Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions apps/sim/lib/charts/annotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,98 @@ describe('chart annotations', () => {
)
).toThrow('Use highlights and thresholds instead of markArea or markLine on the series')
})

it('measures thresholds on the axes the first series is plotted on', () => {
const option = applyChartAnnotations(
{
xAxis: { type: 'time' },
yAxis: [{ type: 'category' }, { id: 'latency', type: 'value' }],
series: [{ type: 'line', yAxisIndex: 1 }],
},
{ thresholds: [{ value: 5 }] },
palette
)
expect((option.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] })
expect(() =>
applyChartAnnotations(
{
xAxis: { type: 'time' },
yAxis: [{ type: 'value' }, { id: 'stage', type: 'category' }],
series: [{ type: 'line', yAxisId: 'stage' }],
},
{ thresholds: [{ value: 5 }] },
palette
)
).toThrow('Thresholds require a value axis')
Comment thread
TheodoreSpeaks marked this conversation as resolved.
})

it('resolves a value axis selected by id, including a value x-axis on horizontal bars', () => {
const byId = applyChartAnnotations(
{
xAxis: { type: 'time' },
yAxis: [{ type: 'category' }, { id: 'latency', type: 'value' }],
series: [{ type: 'line', yAxisId: 'latency' }],
},
{ thresholds: [{ value: 5 }] },
palette
)
expect((byId.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] })
const horizontal = applyChartAnnotations(
{
xAxis: [{ type: 'category' }, { id: 'count', type: 'value' }],
yAxis: [{ type: 'value' }, { id: 'stage', type: 'category', inverse: true }],
series: [{ type: 'bar', xAxisId: 'count', yAxisIndex: 1 }],
},
{ thresholds: [{ value: 5, label: 'Limit' }] },
palette
)
const [first, labels] = horizontal.series as Series[]
expect(first.markLine).toMatchObject({ data: [{ xAxis: 5 }] })
expect(labels.markLine).toMatchObject({ data: [{ xAxis: 5, label: { position: 'start' } }] })
})

it('prefers the axis index over the axis id, as ECharts does', () => {
const option = applyChartAnnotations(
{
xAxis: { type: 'time' },
yAxis: [
{ id: 'stage', type: 'category' },
{ id: 7, type: 'value' },
],
series: [{ type: 'line', yAxisIndex: 1, yAxisId: 'stage' }],
},
{ thresholds: [{ value: 5 }] },
palette
)
expect((option.series as Series[])[0].markLine).toMatchObject({ data: [{ yAxis: 5 }] })
expect(() =>
applyChartAnnotations(
{ ...option, series: [{ type: 'line', yAxisId: '7' }] },
{ thresholds: [{ value: 5 }] },
palette
)
).not.toThrow()
})

it('rejects a first series that references an axis the chart does not define', () => {
for (const reference of [{ yAxisIndex: 1 }, { yAxisIndex: -1 }, { yAxisIndex: 0.5 }])
expect(() =>
applyChartAnnotations(
{
xAxis: { type: 'time' },
yAxis: { type: 'value' },
series: [{ type: 'line', ...reference }],
},
{ thresholds: [{ value: 5 }] },
palette
)
).toThrow('The first series references a missing yAxis')
expect(() =>
applyChartAnnotations(
{ xAxis: { type: 'time' }, yAxis: [], series: [{ type: 'line' }] },
{ thresholds: [{ value: 5 }] },
palette
)
).toThrow('The first series references a missing yAxis')
})
})
38 changes: 32 additions & 6 deletions apps/sim/lib/charts/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,43 @@ export interface ChartAnnotations {

const BAND_OPACITY = 0.08

function firstAxis(axis: unknown): Record<string, unknown> {
return toRecord(Array.isArray(axis) ? axis[0] : axis)
function firstSeries(option: Record<string, unknown>): Record<string, unknown> {
return toRecord(Array.isArray(option.series) ? option.series[0] : option.series)
}

/** The axis a threshold is measured on; ECharts defaults an unspecified yAxis to a value axis. */
/**
* The axis the first series is plotted on. Like ECharts, `*AxisIndex` wins over `*AxisId`, and ids
* match across string and number.
*/
function seriesAxis(
option: Record<string, unknown>,
key: 'xAxis' | 'yAxis'
): Record<string, unknown> {
const axes = Array.isArray(option[key]) ? (option[key] as unknown[]) : [option[key]]
const series = firstSeries(option)
const id = series[`${key}Id`]
if (series[`${key}Index`] === undefined && id !== undefined) {
const axis = axes.find((candidate) => String(toRecord(candidate).id) === String(id))
if (axis === undefined) throw new Error(`The first series references a missing ${key} "${id}"`)
return toRecord(axis)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}
const index = series[`${key}Index`] ?? 0
if (option[key] === undefined && index === 0) return {}
if (!Number.isInteger(index) || axes[index as number] === undefined)
throw new Error(`The first series references a missing ${key} at index ${String(index)}`)
return toRecord(axes[index as number])
}

/**
* The axis a threshold is measured on, among the axes the first series is plotted on; ECharts
* defaults an unspecified yAxis to a value axis.
*/
export function valueAxisKey(option: Record<string, unknown>): 'xAxis' | 'yAxis' {
if (option.xAxis === undefined && option.yAxis === undefined)
throw new Error('Thresholds require a value axis')
const y = firstAxis(option.yAxis)
const y = seriesAxis(option, 'yAxis')
if (y.type === undefined || y.type === 'value' || y.type === 'log') return 'yAxis'
const x = firstAxis(option.xAxis)
const x = seriesAxis(option, 'xAxis')
if (x.type === 'value' || x.type === 'log') return 'xAxis'
throw new Error('Thresholds require a value axis')
}
Expand All @@ -47,7 +73,7 @@ export function valueAxisKey(option: Record<string, unknown>): 'xAxis' | 'yAxis'
* y axis is inverted (as horizontal bar charts usually are).
*/
function verticalTop(option: Record<string, unknown>): 'start' | 'end' {
return firstAxis(option.yAxis).inverse === true ? 'start' : 'end'
return seriesAxis(option, 'yAxis').inverse === true ? 'start' : 'end'
}

/** A chart annotations can draw on: a first series with no hand-written marks to collide with. */
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/lib/dashboards/time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
dashboardAxisFormatter,
dashboardRangeFromCalendar,
dashboardRangeText,
dashboardTimeLabel,
dashboardZoomRange,
parseDashboardCustomRange,
Expand Down Expand Up @@ -47,4 +48,21 @@ describe('dashboard time interactions', () => {
expect(dashboardTimeLabel(stamp, 'America/Los_Angeles')).toContain('PDT')
expect(dashboardTimeLabel('2026-12-20T02:30:00Z', 'America/Los_Angeles')).toContain('PST')
})

it('keeps both ends of a range that repeats the same wall-clock minute across a DST fall-back', () => {
const text = dashboardRangeText(
{ from: '2026-11-01T05:30:00.000Z', to: '2026-11-01T06:31:00.000Z' },
'America/New_York'
)
expect(text).toBe('Nov 1, 01:30:00 EDT – Nov 1, 01:30:59 EST')
})

it('shows distinct endpoints for a sub-minute zoom range', () => {
expect(
dashboardRangeText(
{ from: '2026-09-20T14:30:00.000Z', to: '2026-09-20T14:30:01.000Z' },
'UTC'
)
).toBe('Sep 20, 14:30:00.000 UTC – Sep 20, 14:30:00.999 UTC')
})
})
19 changes: 19 additions & 0 deletions apps/sim/lib/dashboards/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ export function dashboardRangeText(range: DashboardTimeRange, timeZone: string):
const to = new Date(Date.parse(range.to) - 1)
const fromLocal = zonedWallClock(from, timeZone)
const toLocal = zonedWallClock(to, timeZone)
if (fromLocal === toLocal) {
// Same wall-clock minute (e.g. a DST fall-back repeat): formatRange would collapse both ends,
// so show each inclusive end exactly, down to milliseconds if seconds still collide.
const exact = (fractionalSecondDigits?: 3) =>
new Intl.DateTimeFormat('en-US', {
timeZone,
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits,
hourCycle: 'h23',
timeZoneName: 'short',
})
const seconds = exact()
const format = seconds.format(from) === seconds.format(to) ? exact(3) : seconds
return `${format.format(from)} – ${format.format(to)}`
}
const sameDay = fromLocal.slice(0, 10) === toLocal.slice(0, 10)
return new Intl.DateTimeFormat('en-US', {
timeZone,
Expand Down
Loading