waveform-analysis 0.1.46 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,8 @@ export interface WaveformGridOptions {
7
7
  columnCount?: number;
8
8
  showPagination?: boolean;
9
9
  fillIncompleteLastRow?: boolean;
10
+ /** Stable grid slots. Empty IDs remain as empty chart frames and keep their page position. */
11
+ trackOrder?: string[];
10
12
  trackLines?: WaveformGridTrackLines;
11
13
  }
12
14
  export interface WaveformGridLineOptions {
@@ -29,6 +31,7 @@ export interface NormalizedWaveformGridOptions {
29
31
  columnCount: number;
30
32
  showPagination: boolean;
31
33
  fillIncompleteLastRow: boolean;
34
+ trackOrder?: string[];
32
35
  trackLines: Record<string, NormalizedWaveformGridLineOptions>;
33
36
  }
34
37
  export interface GridCellGeometry {
@@ -15,8 +15,8 @@ export declare function resolveYAxisSeriesGroups(track: DisplayTrack, overlayMod
15
15
  export declare function axisTextMetrics(domain: [number, number], nice?: boolean, tickValues?: number[], unit?: string, tickCount?: number): {
16
16
  tickTextWidth: number;
17
17
  };
18
- export declare function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number;
19
- export declare function measureTrackYAxisClearance(track: DisplayTrack, overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>, tickCount?: number): {
18
+ export declare function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number, nice?: boolean): number;
19
+ export declare function measureTrackYAxisClearance(track: DisplayTrack, overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>, tickCount?: number, nice?: boolean): {
20
20
  left: number;
21
21
  right: number;
22
22
  };
@@ -22,6 +22,7 @@ export interface BuildTrackLayoutsOptions {
22
22
  timeUnit: 's' | 'ms';
23
23
  xAxisLabelFormatter?: WaveformXAxisLabelFormatter;
24
24
  yAxisSplitNumber?: number;
25
+ yAxisNice?: boolean;
25
26
  rendering: ResolvedWaveformRenderingOptions;
26
27
  hideSecondaryLabels: boolean;
27
28
  yAxisLabelX: number;
@@ -76,7 +76,8 @@ export interface TrackLayout {
76
76
  /** Stable track key derived from trackId, or from the series id when trackId is omitted. */
77
77
  id: string;
78
78
  index: number;
79
- series: DisplaySeries;
79
+ /** Primary visible series, if this grid slot has waveform data. */
80
+ series: DisplaySeries | null;
80
81
  /** Visible series used by rendering and interaction code. */
81
82
  seriesList: DisplaySeries[];
82
83
  /** Complete series list used by the legend. */
@@ -1,7 +1,8 @@
1
1
  import { type ZoomTransform } from 'd3';
2
2
  import { type ComputedRef, type Ref, type ShallowRef } from 'vue';
3
3
  import type { DisplayTrack, TrackLayout } from '../core/types';
4
- import type { ResolvedWaveformChartProps, ViewportSelectionState, WaveformChartEmit } from '../core/waveformChartTypes';
4
+ import type { ResolvedWaveformChartProps, ViewportSelectionState } from '../core/waveformChartTypes';
5
+ import type { WaveformChartEmit } from '../core/waveformChartTypes';
5
6
  import type { AnnotationSeriesCandidate } from '../annotation';
6
7
  interface ViewportContext {
7
8
  props: ResolvedWaveformChartProps;
@@ -152,6 +152,8 @@ export interface WaveformAxesOptions {
152
152
  lineVisible?: boolean;
153
153
  /** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
154
154
  splitNumber?: number;
155
+ /** Expands Y-axis domains to equal, human-friendly intervals. Defaults to true. */
156
+ nice?: boolean;
155
157
  };
156
158
  }
157
159
  /** Styling and visibility options for the horizontal zero-value reference line. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waveform-analysis",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -222,7 +222,7 @@ function handleChartPointerLeave() {
222
222
  <!-- 轨道渲染 -->
223
223
  <WaveformTrack
224
224
  v-for="track in trackLayouts"
225
- :key="`${track.index}-${track.series.name}`"
225
+ :key="`${track.index}-${track.id}`"
226
226
  :track="track"
227
227
  :clip-path-id="clipPathId"
228
228
  :inner-width="innerWidth"
@@ -277,7 +277,7 @@ function handleChartPointerLeave() {
277
277
  <g v-if="!isCleanView" class="waveform-chart__legend-layer">
278
278
  <g
279
279
  v-for="track in trackLayouts"
280
- :key="`legend-${track.index}-${track.series.name}`"
280
+ :key="`legend-${track.index}-${track.id}`"
281
281
  class="waveform-chart__legend-track"
282
282
  :data-legend-track-index="track.index"
283
283
  :data-legend-track-id="track.id"
@@ -16,6 +16,7 @@ describe('waveform grid helpers', () => {
16
16
  columnCount: 1,
17
17
  showPagination: true,
18
18
  fillIncompleteLastRow: false,
19
+ trackOrder: [],
19
20
  trackLines: {},
20
21
  })
21
22
  expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
@@ -23,10 +24,17 @@ describe('waveform grid helpers', () => {
23
24
  columnCount: 10,
24
25
  showPagination: true,
25
26
  fillIncompleteLastRow: false,
27
+ trackOrder: [],
26
28
  trackLines: {},
27
29
  })
28
30
  })
29
31
 
32
+ it('normalizes track order by trimming, dropping blanks, and removing duplicates', () => {
33
+ expect(
34
+ normalizeGridOptions({ trackOrder: [' voltage ', '', 'current', 'voltage', ' '] }),
35
+ ).toMatchObject({ trackOrder: ['voltage', 'current'] })
36
+ })
37
+
30
38
  it('normalizes per-track grid line visibility with visible defaults', () => {
31
39
  expect(
32
40
  normalizeGridOptions({
@@ -170,7 +178,11 @@ describe('waveform grid helpers', () => {
170
178
 
171
179
  it('keeps full pages and the disabled option byte-for-byte compatible', () => {
172
180
  const base = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
173
- const filled = normalizeGridOptions({ rowCount: 2, columnCount: 2, fillIncompleteLastRow: true })
181
+ const filled = normalizeGridOptions({
182
+ rowCount: 2,
183
+ columnCount: 2,
184
+ fillIncompleteLastRow: true,
185
+ })
174
186
  const fullSlots = [true, true, true, true]
175
187
  expect(resolveGridCellGeometry(400, 300, filled, 'independent', fullSlots)).toEqual(
176
188
  resolveGridCellGeometry(400, 300, base, 'independent', fullSlots),
@@ -9,6 +9,8 @@ export interface WaveformGridOptions {
9
9
  columnCount?: number
10
10
  showPagination?: boolean
11
11
  fillIncompleteLastRow?: boolean
12
+ /** Stable grid slots. Empty IDs remain as empty chart frames and keep their page position. */
13
+ trackOrder?: string[]
12
14
  trackLines?: WaveformGridTrackLines
13
15
  }
14
16
 
@@ -35,6 +37,7 @@ export interface NormalizedWaveformGridOptions {
35
37
  columnCount: number
36
38
  showPagination: boolean
37
39
  fillIncompleteLastRow: boolean
40
+ trackOrder?: string[]
38
41
  trackLines: Record<string, NormalizedWaveformGridLineOptions>
39
42
  }
40
43
 
@@ -59,6 +62,15 @@ const normalizeCount = (value: unknown, fallback: number) => {
59
62
  return Math.min(GRID_MAX_COUNT, Math.max(GRID_MIN_COUNT, Math.floor(numeric)))
60
63
  }
61
64
 
65
+ const normalizeTrackOrder = (trackOrder: unknown): string[] => {
66
+ if (!Array.isArray(trackOrder)) return []
67
+ const normalized = trackOrder
68
+ .filter((trackId): trackId is string => typeof trackId === 'string')
69
+ .map((trackId) => trackId.trim())
70
+ .filter(Boolean)
71
+ return Array.from(new Set(normalized))
72
+ }
73
+
62
74
  export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedWaveformGridOptions {
63
75
  const trackLines = Object.fromEntries(
64
76
  Object.entries(options?.trackLines ?? {}).map(([trackId, lines]) => [
@@ -82,6 +94,7 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
82
94
  columnCount: normalizeCount(options?.columnCount, 1),
83
95
  showPagination: options?.showPagination ?? true,
84
96
  fillIncompleteLastRow: options?.fillIncompleteLastRow ?? false,
97
+ trackOrder: normalizeTrackOrder(options?.trackOrder),
85
98
  trackLines,
86
99
  }
87
100
  }
@@ -148,10 +161,7 @@ export function resolveGridCellGeometry(
148
161
  }
149
162
  const totalVerticalGap = Math.max(0, rowCount - 1) * defaultGap
150
163
  const totalAxisBand = axisRows.size * X_AXIS_BAND
151
- const plotHeight = Math.max(
152
- 1,
153
- (innerHeight - totalVerticalGap - totalAxisBand) / rowCount,
154
- )
164
+ const plotHeight = Math.max(1, (innerHeight - totalVerticalGap - totalAxisBand) / rowCount)
155
165
 
156
166
  return Array.from({ length: filledLastRow ? seriesCount : pageSize }, (_, slotIndex) => {
157
167
  const row = Math.floor(slotIndex / options.columnCount)
@@ -133,9 +133,13 @@ export function axisTextMetrics(
133
133
  }
134
134
  }
135
135
 
136
- export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number {
136
+ export function measureYAxisGroupClearance(
137
+ group: YAxisSeriesGroup,
138
+ tickCount?: number,
139
+ nice = true,
140
+ ): number {
137
141
  return (
138
- axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
142
+ axisTextMetrics(group.domain, nice, undefined, group.seriesList[0]?.unit, tickCount)
139
143
  .tickTextWidth +
140
144
  Y_AXIS_TICK_PADDING +
141
145
  Y_AXIS_LABEL_GAP +
@@ -144,9 +148,13 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?:
144
148
  )
145
149
  }
146
150
 
147
- function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number {
151
+ function measureYAxisGroupTickClearance(
152
+ group: YAxisSeriesGroup,
153
+ tickCount?: number,
154
+ nice = true,
155
+ ): number {
148
156
  return (
149
- axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
157
+ axisTextMetrics(group.domain, nice, undefined, group.seriesList[0]?.unit, tickCount)
150
158
  .tickTextWidth +
151
159
  Y_AXIS_TICK_PADDING +
152
160
  Y_AXIS_OUTER_PADDING
@@ -159,13 +167,14 @@ export function measureTrackYAxisClearance(
159
167
  yDomain?: WaveformYDomain,
160
168
  yDomains?: Record<string, WaveformYDomain>,
161
169
  tickCount?: number,
170
+ nice = true,
162
171
  ): { left: number; right: number } {
163
172
  return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
164
173
  (clearance, group) => {
165
174
  clearance[group.side] +=
166
175
  overlayMode === 'multi-axis' || track.visibleSeries.length === 1
167
- ? measureYAxisGroupClearance(group, tickCount)
168
- : measureYAxisGroupTickClearance(group, tickCount)
176
+ ? measureYAxisGroupClearance(group, tickCount, nice)
177
+ : measureYAxisGroupTickClearance(group, tickCount, nice)
169
178
  return clearance
170
179
  },
171
180
  { left: 0, right: 0 },
@@ -26,7 +26,7 @@ import {
26
26
  type NormalizedWaveformGridOptions,
27
27
  } from './grid'
28
28
  import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
29
- import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
29
+ import type { DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
30
30
  import { applyXDomainStrategy } from './xDomain'
31
31
  import {
32
32
  Y_AXIS_LABEL_BAND_WIDTH,
@@ -55,6 +55,7 @@ export interface BuildTrackLayoutsOptions {
55
55
  timeUnit: 's' | 'ms'
56
56
  xAxisLabelFormatter?: WaveformXAxisLabelFormatter
57
57
  yAxisSplitNumber?: number
58
+ yAxisNice?: boolean
58
59
  rendering: ResolvedWaveformRenderingOptions
59
60
  hideSecondaryLabels: boolean
60
61
  yAxisLabelX: number
@@ -81,33 +82,24 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
81
82
  const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
82
83
 
83
84
  return visibleCells.flatMap((cell, index) => {
84
- const isEmpty = !cell.series
85
- if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return []
86
- const emptySeries: DisplaySeries = {
85
+ const isEmpty = !cell.series || cell.series.series.length === 0
86
+ if (!cell.series && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks))
87
+ return []
88
+ const displayTrack: DisplayTrack = cell.series ?? {
87
89
  id: `empty-grid-slot-${cell.slotIndex}`,
88
- name: '',
89
- color: 'transparent',
90
- lineType: 'linear',
91
- lineStyle: 'solid',
92
- pointType: 'none',
93
- errorBar: { visible: false, width: 1.5, capWidth: 8 },
94
- points: [],
90
+ series: [],
91
+ visibleSeries: [],
95
92
  xDomain: [0, 1],
96
93
  yDomain: [0, 1],
97
- hasErrorPoints: false,
98
- }
99
- const displayTrack: DisplayTrack = cell.series ?? {
100
- id: emptySeries.id,
101
- series: [emptySeries],
102
- visibleSeries: [emptySeries],
103
- xDomain: emptySeries.xDomain,
104
- yDomain: emptySeries.yDomain,
105
94
  }
106
95
  const hasVisibleSeries = !isEmpty && displayTrack.visibleSeries.length > 0
107
- const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
96
+ const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? null
108
97
  const baseXScale =
109
98
  options.displayMode === 'independent'
110
- ? scaleLinear(resolveIndependentXDomain(displayTrack, series.id, options), [0, cell.width])
99
+ ? scaleLinear(
100
+ resolveIndependentXDomain(displayTrack, series?.id ?? displayTrack.id, options),
101
+ [0, cell.width],
102
+ )
111
103
  : scaleLinear(options.sharedZoomDomain, [0, cell.width])
112
104
  const transform =
113
105
  options.displayMode === 'independent'
@@ -128,7 +120,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
128
120
  const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
129
121
  const niceCount = Math.max(1, tickCount - 1)
130
122
  const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
131
- scale.nice(niceCount)
123
+ if (options.yAxisNice !== false) scale.nice(niceCount)
132
124
  const [axisStart, axisEnd] = scale.domain()
133
125
  const majorTicks = Array.from(
134
126
  { length: tickCount },
@@ -167,7 +159,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
167
159
  seriesList: group.seriesList,
168
160
  }
169
161
  })
170
- const yScale = yAxes[0]?.scale ?? scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
162
+ const fallbackYScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0])
163
+ if (options.yAxisNice !== false) fallbackYScale.nice()
164
+ const yScale = yAxes[0]?.scale ?? fallbackYScale
171
165
  const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
172
166
  const yMajorTicks = yAxes[0]?.majorTicks ?? []
173
167
  const yAxisTickValues = yAxes[0]?.tickValues ?? []
@@ -86,7 +86,8 @@ export interface TrackLayout {
86
86
  /** Stable track key derived from trackId, or from the series id when trackId is omitted. */
87
87
  id: string
88
88
  index: number
89
- series: DisplaySeries
89
+ /** Primary visible series, if this grid slot has waveform data. */
90
+ series: DisplaySeries | null
90
91
  /** Visible series used by rendering and interaction code. */
91
92
  seriesList: DisplaySeries[]
92
93
  /** Complete series list used by the legend. */
@@ -75,6 +75,7 @@ export function useWaveformLayout(context: LayoutContext) {
75
75
  (index === 0 ? props.lineColor : channelColors[index % channelColors.length]),
76
76
  })),
77
77
  )
78
+ const gridOptions = computed(() => normalizeGridOptions(props.grid))
78
79
  const chartTracks = computed<DisplayTrack[]>(() => {
79
80
  const groupedSeries = new Map<string, DisplaySeries[]>()
80
81
  chartSeries.value.forEach((series) => {
@@ -83,7 +84,13 @@ export function useWaveformLayout(context: LayoutContext) {
83
84
  if (trackSeries) trackSeries.push(series)
84
85
  else groupedSeries.set(trackId, [series])
85
86
  })
86
- return Array.from(groupedSeries, ([id, series]) => {
87
+ const trackOrder = gridOptions.value.trackOrder ?? []
88
+ const orderedTrackIds = [
89
+ ...trackOrder,
90
+ ...Array.from(groupedSeries.keys()).filter((trackId) => !trackOrder.includes(trackId)),
91
+ ]
92
+ return orderedTrackIds.map((id) => {
93
+ const series = groupedSeries.get(id) ?? []
87
94
  const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id))
88
95
  const xDomainValues: number[] = []
89
96
  const yDomainValues: number[] = []
@@ -95,17 +102,22 @@ export function useWaveformLayout(context: LayoutContext) {
95
102
  id,
96
103
  series,
97
104
  visibleSeries,
98
- xDomain: paddedDomain(xDomainValues),
99
- yDomain: paddedDomain(yDomainValues),
105
+ xDomain: xDomainValues.length ? paddedDomain(xDomainValues) : [0, 1],
106
+ yDomain: yDomainValues.length ? paddedDomain(yDomainValues) : [0, 1],
100
107
  }
101
108
  })
102
109
  })
103
- const gridOptions = computed(() => normalizeGridOptions(props.grid))
104
110
  const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
105
111
  const pageCount = computed(() => getPageCount(chartTracks.value.length, gridOptions.value))
106
112
  const pagedTracks = computed(() =>
107
113
  paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
108
114
  )
115
+ const layoutTracks = computed(() => {
116
+ const tracksWithSeries = pagedTracks.value.filter((track) => track.series.length > 0)
117
+ return gridOptions.value.fillIncompleteLastRow && tracksWithSeries.length > 0
118
+ ? tracksWithSeries
119
+ : pagedTracks.value
120
+ })
109
121
  const yAxisMetrics = computed(() => {
110
122
  const axisText = chartTracks.value
111
123
  .filter((track) => track.visibleSeries.length > 0)
@@ -116,7 +128,7 @@ export function useWaveformLayout(context: LayoutContext) {
116
128
  (group) =>
117
129
  axisTextMetrics(
118
130
  group.domain,
119
- true,
131
+ props.axes?.y?.nice !== false,
120
132
  undefined,
121
133
  group.seriesList[0]?.unit,
122
134
  props.axes?.y?.splitNumber,
@@ -167,6 +179,7 @@ export function useWaveformLayout(context: LayoutContext) {
167
179
  props.yDomain,
168
180
  props.yDomains,
169
181
  props.axes?.y?.splitNumber,
182
+ props.axes?.y?.nice !== false,
170
183
  )
171
184
  return {
172
185
  left: Math.max(maximum.left, clearance.left),
@@ -217,9 +230,7 @@ export function useWaveformLayout(context: LayoutContext) {
217
230
  const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
218
231
  const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`)
219
232
  const activeInteractionMode = computed(() => props.interactionMode)
220
- const isZoomMode = computed(
221
- () => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined,
222
- )
233
+ const isZoomMode = computed(() => activeInteractionMode.value !== 'annotation')
223
234
  const sharedXDomain = computed(() => {
224
235
  const values: number[] = []
225
236
  chartTracks.value.forEach((track) => {
@@ -245,8 +256,8 @@ export function useWaveformLayout(context: LayoutContext) {
245
256
  })
246
257
  const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
247
258
  const configuredDomain =
248
- props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
249
- props.initialXDomains?.[track.series.id] ??
259
+ props.initialXDomains?.[track.series?.trackId ?? track.series?.id ?? track.id] ??
260
+ (track.series ? props.initialXDomains?.[track.series.id] : undefined) ??
250
261
  props.initialXDomain
251
262
  if (
252
263
  configuredDomain &&
@@ -279,10 +290,10 @@ export function useWaveformLayout(context: LayoutContext) {
279
290
  innerHeight.value,
280
291
  gridOptions.value,
281
292
  props.displayMode,
282
- pagedTracks.value.map(Boolean),
293
+ layoutTracks.value.map(Boolean),
283
294
  yAxisLayout.value.horizontalGap,
284
295
  )
285
- return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
296
+ return cells.map((cell, index) => ({ ...cell, series: layoutTracks.value[index] }))
286
297
  })
287
298
  const trackLayouts = computed<TrackLayout[]>(() =>
288
299
  buildTrackLayouts({
@@ -309,6 +320,7 @@ export function useWaveformLayout(context: LayoutContext) {
309
320
  timeUnit: props.timeUnit,
310
321
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
311
322
  yAxisSplitNumber: props.axes?.y?.splitNumber,
323
+ yAxisNice: props.axes?.y?.nice,
312
324
  rendering: renderingOptions.value,
313
325
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
314
326
  yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -0,0 +1,174 @@
1
+ import { zoomIdentity } from 'd3'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
5
+ import type { WaveformData } from '../../types'
6
+ import { buildTrackLayouts } from './trackLayoutBuilder'
7
+ import type { DisplaySeries, DisplayTrack } from './types'
8
+ import { prepareWaveformSeries } from './useWaveformData'
9
+
10
+ function displaySeries(id: string, domain: [number, number]): DisplaySeries {
11
+ return {
12
+ id,
13
+ name: id,
14
+ color: '#1677ff',
15
+ lineType: 'linear',
16
+ lineStyle: 'solid',
17
+ pointType: 'none',
18
+ errorBar: { visible: false, width: 1.5, capWidth: 8 },
19
+ points: [
20
+ { x: 0, y: domain[0] },
21
+ { x: 1, y: domain[1] },
22
+ ],
23
+ xDomain: [0, 1],
24
+ yDomain: domain,
25
+ hasErrorPoints: false,
26
+ }
27
+ }
28
+
29
+ function layout(
30
+ series: DisplaySeries[],
31
+ options: { overlayMode?: 'single-axis' | 'multi-axis'; fixedYDomain?: [number, number] } = {},
32
+ ) {
33
+ const track: DisplayTrack = {
34
+ id: 'track',
35
+ series,
36
+ visibleSeries: series,
37
+ xDomain: [0, 1],
38
+ yDomain: [
39
+ Math.min(...series.map((item) => item.yDomain[0])),
40
+ Math.max(...series.map((item) => item.yDomain[1])),
41
+ ],
42
+ }
43
+ return buildTrackLayouts({
44
+ cells: [
45
+ {
46
+ slotIndex: 0,
47
+ row: 0,
48
+ column: 0,
49
+ left: 0,
50
+ top: 0,
51
+ width: 120,
52
+ height: 100,
53
+ plotHeight: 100,
54
+ cellHeight: 130,
55
+ xAxisBand: 30,
56
+ series: track,
57
+ },
58
+ ],
59
+ grid: {
60
+ rowCount: 1,
61
+ columnCount: 1,
62
+ showPagination: false,
63
+ fillIncompleteLastRow: false,
64
+ trackLines: {},
65
+ },
66
+ displayMode: 'independent',
67
+ overlayMode: options.overlayMode ?? 'single-axis',
68
+ independentTransforms: [zoomIdentity],
69
+ sharedZoomDomain: [0, 1],
70
+ fixedYDomain: options.fixedYDomain,
71
+ timeUnit: 'ms',
72
+ rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
73
+ hideSecondaryLabels: false,
74
+ yAxisLabelX: -50,
75
+ yAxisNice: false,
76
+ showCompactEmptyTracks: false,
77
+ })[0]!
78
+ }
79
+
80
+ describe('Y-axis nice domains', () => {
81
+ it('defaults to a nice domain and can preserve the actual data endpoints', () => {
82
+ const source = displaySeries('source', [3, 97])
83
+ const track: DisplayTrack = {
84
+ id: 'track',
85
+ series: [source],
86
+ visibleSeries: [source],
87
+ xDomain: [0, 1],
88
+ yDomain: [3, 97],
89
+ }
90
+ const defaults = buildTrackLayouts({
91
+ cells: [
92
+ {
93
+ slotIndex: 0,
94
+ row: 0,
95
+ column: 0,
96
+ left: 0,
97
+ top: 0,
98
+ width: 120,
99
+ height: 100,
100
+ plotHeight: 100,
101
+ cellHeight: 130,
102
+ xAxisBand: 30,
103
+ series: track,
104
+ },
105
+ ],
106
+ grid: {
107
+ rowCount: 1,
108
+ columnCount: 1,
109
+ showPagination: false,
110
+ fillIncompleteLastRow: false,
111
+ trackLines: {},
112
+ },
113
+ displayMode: 'independent',
114
+ overlayMode: 'single-axis',
115
+ independentTransforms: [zoomIdentity],
116
+ sharedZoomDomain: [0, 1],
117
+ timeUnit: 'ms',
118
+ rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
119
+ hideSecondaryLabels: false,
120
+ yAxisLabelX: -50,
121
+ showCompactEmptyTracks: false,
122
+ })[0]!
123
+
124
+ expect(defaults.yScale.domain()).toEqual([0, 100])
125
+ expect(layout([source]).yScale.domain()).toEqual([3, 97])
126
+ })
127
+
128
+ it('merges visible single-axis series but keeps multi-axis series independent', () => {
129
+ const first = displaySeries('first', [3, 40])
130
+ const second = displaySeries('second', [10, 97])
131
+
132
+ expect(layout([first, second]).yScale.domain()).toEqual([3, 97])
133
+ expect(
134
+ layout([first, second], { overlayMode: 'multi-axis' }).yAxes.map((axis) =>
135
+ axis.scale.domain(),
136
+ ),
137
+ ).toEqual([
138
+ [3, 40],
139
+ [10, 97],
140
+ ])
141
+ })
142
+
143
+ it('keeps explicit domains and constant-data safety ranges unchanged', () => {
144
+ expect(
145
+ layout([displaySeries('source', [3, 97])], { fixedYDomain: [3, 97] }).yScale.domain(),
146
+ ).toEqual([3, 97])
147
+ expect(layout([displaySeries('constant', [9.5, 10.5])]).yScale.domain()).toEqual([9.5, 10.5])
148
+ })
149
+
150
+ it('continues to include visible error-bar bounds in the preserved domain', () => {
151
+ const input: WaveformData = {
152
+ kind: 'series',
153
+ series: [
154
+ {
155
+ id: 'error-series',
156
+ name: 'error series',
157
+ errorBar: { visible: true },
158
+ data: {
159
+ kind: 'points',
160
+ points: [
161
+ { x: 0, y: 3, lowerError: 2 },
162
+ { x: 1, y: 7, upperError: 4 },
163
+ ],
164
+ },
165
+ },
166
+ ],
167
+ }
168
+ const [prepared] = prepareWaveformSeries(input)
169
+ const errorSeries: DisplaySeries = { ...prepared!, color: prepared!.color ?? '#1677ff' }
170
+
171
+ expect(prepared?.yDomain).toEqual([1, 11])
172
+ expect(layout([errorSeries]).yScale.domain()).toEqual([1, 11])
173
+ })
174
+ })
@@ -153,7 +153,8 @@ export function useWaveformHover(context: HoverContext) {
153
153
  scheduleHover(() => {
154
154
  const resolvedTrack = resolveTrackAtPointer(pointerX, pointerY)
155
155
  const referenceTrack =
156
- resolvedTrack ?? trackLayouts.value.find((track) => track.hasVisibleSeries)
156
+ (resolvedTrack?.hasVisibleSeries ? resolvedTrack : undefined) ??
157
+ trackLayouts.value.find((track) => track.hasVisibleSeries)
157
158
  if (!referenceTrack) return
158
159
  const localPointerX = Math.max(
159
160
  0,