waveform-analysis 0.1.55 → 0.1.58

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.
Files changed (29) hide show
  1. package/README.md +3 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +565 -539
  4. package/dist/types/components/core/grid.d.ts +3 -0
  5. package/dist/types/components/core/layout.d.ts +18 -0
  6. package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -0
  7. package/dist/types/components/core/trackPagination.d.ts +4 -0
  8. package/dist/types/components/core/useWaveformChartController.d.ts +3 -1
  9. package/dist/types/components/core/useWaveformLayout.d.ts +2 -1
  10. package/dist/types/components/core/waveformChartTypes.d.ts +2 -0
  11. package/dist/types/components/core/yAxisConstants.d.ts +1 -0
  12. package/package.json +1 -1
  13. package/src/App.test.ts +2 -1
  14. package/src/components/WaveformChartView.vue +3 -3
  15. package/src/components/core/grid.test.ts +3 -0
  16. package/src/components/core/grid.ts +4 -0
  17. package/src/components/core/layout.test.ts +1 -1
  18. package/src/components/core/layout.ts +63 -0
  19. package/src/components/core/trackLayoutBuilder.ts +24 -4
  20. package/src/components/core/trackPagination.ts +40 -0
  21. package/src/components/core/useWaveformLayout.ts +28 -31
  22. package/src/components/core/waveformChartTypes.ts +2 -0
  23. package/src/components/core/yAxisConstants.ts +1 -0
  24. package/src/components/core/yAxisSlotLayout.test.ts +129 -0
  25. package/src/components/waveformChartCases/layoutAndDomains.test.ts +3 -5
  26. package/src/components/waveformChartCases/multiAxisLabelAlignment.test.ts +61 -0
  27. package/src/components/waveformChartCases/trackOrder.test.ts +62 -0
  28. package/src/data/simulatedWaveforms.test.ts +4 -4
  29. package/src/data/simulatedWaveforms.ts +12 -0
@@ -9,6 +9,8 @@ export interface WaveformGridOptions {
9
9
  fillIncompleteLastRow?: boolean;
10
10
  /** Stable grid slots. Empty IDs remain as empty chart frames and keep their page position. */
11
11
  trackOrder?: string[];
12
+ /** Omits ordered tracks without waveform series from layout and pagination. */
13
+ hideEmptyTracks?: boolean;
12
14
  trackLines?: WaveformGridTrackLines;
13
15
  }
14
16
  export interface WaveformGridLineOptions {
@@ -32,6 +34,7 @@ export interface NormalizedWaveformGridOptions {
32
34
  showPagination: boolean;
33
35
  fillIncompleteLastRow: boolean;
34
36
  trackOrder?: string[];
37
+ hideEmptyTracks?: boolean;
35
38
  trackLines: Record<string, NormalizedWaveformGridLineOptions>;
36
39
  }
37
40
  export interface GridCellGeometry {
@@ -10,6 +10,13 @@ export interface YAxisSeriesGroup {
10
10
  domain: [number, number];
11
11
  fixed: boolean;
12
12
  }
13
+ export interface YAxisSlot {
14
+ side: 'left' | 'right';
15
+ sideIndex: number;
16
+ axisOffset: number;
17
+ labelOffset: number;
18
+ clearance: number;
19
+ }
13
20
  export declare function buildYAxisSeriesGroups(track: DisplayTrack, overlayMode: WaveformOverlayMode): YAxisSeriesGroup[];
14
21
  export declare function resolveYAxisSeriesGroups(track: DisplayTrack, overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>): YAxisSeriesGroup[];
15
22
  export declare function axisTextMetrics(domain: [number, number], nice?: boolean, tickValues?: number[], unit?: string, tickCount?: number): {
@@ -20,6 +27,17 @@ export declare function measureTrackYAxisClearance(track: DisplayTrack, overlayM
20
27
  left: number;
21
28
  right: number;
22
29
  };
30
+ /**
31
+ * Reserves stable Y-axis positions for every visible track on the current page.
32
+ * Slots are ordered from the plot edge outward for each side.
33
+ */
34
+ export declare function buildYAxisSlots(tracks: readonly DisplayTrack[], overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>, tickCount?: number, nice?: boolean): {
35
+ slots: YAxisSlot[];
36
+ clearance: {
37
+ left: number;
38
+ right: number;
39
+ };
40
+ };
23
41
  type PositionedTrack = Pick<TrackLayout, 'left' | 'top' | 'width' | 'height'>;
24
42
  export declare function findClosestTrackAtPointer<T extends PositionedTrack>(tracks: readonly T[], pointerX: number, pointerY: number): T | undefined;
25
43
  export { buildTrackLayouts, type BuildTrackLayoutsOptions } from './trackLayoutBuilder';
@@ -2,6 +2,7 @@ import { type ZoomTransform } from 'd3';
2
2
  import { type ResolvedWaveformRenderingOptions } from '../../core/rendering';
3
3
  import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint, WaveformXDomainStrategy, WaveformXAxisLabelFormatter } from '../../types';
4
4
  import { type GridCellGeometry, type NormalizedWaveformGridOptions } from './grid';
5
+ import { type YAxisSlot } from './layout';
5
6
  import type { DisplayTrack, TrackLayout } from './types';
6
7
  interface SeriesGridCell extends GridCellGeometry {
7
8
  series?: DisplayTrack;
@@ -28,6 +29,7 @@ export interface BuildTrackLayoutsOptions {
28
29
  linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>;
29
30
  hideSecondaryLabels: boolean;
30
31
  yAxisLabelX: number;
32
+ yAxisSlots?: readonly YAxisSlot[];
31
33
  showCompactEmptyTracks: boolean;
32
34
  }
33
35
  export declare function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[];
@@ -0,0 +1,4 @@
1
+ import type { DisplayTrack } from './types';
2
+ export declare const resolvePageableTracks: (tracks: DisplayTrack[], hideEmptyTracks?: boolean) => DisplayTrack[];
3
+ export declare const resolveTrackFrameNumber: (frameNumber: string | number | undefined, frameNumbers: Record<string, string | number> | undefined, trackCount: number, trackIndex: number, trackId: string) => string | number | undefined;
4
+ export declare const createFrameNumberResolver: (getFrameNumber: () => string | number | undefined, getFrameNumbers: () => Record<string, string | number> | undefined, getTracks: () => DisplayTrack[]) => (trackId: string) => string | number | undefined;
@@ -246,6 +246,7 @@ export declare function useWaveformChartController(props: ResolvedWaveformChartP
246
246
  confirmAnnotation: (annotation: import("..").WaveformAnnotation) => void;
247
247
  chartSeries: import("./types").DisplaySeries[];
248
248
  chartTracks: import("./types").DisplayTrack[];
249
+ pageableTracks: import("./types").DisplayTrack[];
249
250
  renderingOptions: import("../..").ResolvedWaveformRenderingOptions;
250
251
  gridOptions: import("./grid").NormalizedWaveformGridOptions;
251
252
  pageCount: number;
@@ -281,7 +282,7 @@ export declare function useWaveformChartController(props: ResolvedWaveformChartP
281
282
  annotationTrackLayouts: import("../annotation").AnnotationTrackLayout[];
282
283
  renderedAnnotations: import("../annotation").RenderedAnnotation[];
283
284
  editorSeries: import("../annotation").AnnotationSeriesInfo | undefined;
284
- resolveFrameNumber: (trackIndex: number) => string | number | undefined;
285
+ resolveFrameNumber: (trackId: string) => string | number | undefined;
285
286
  fixedWidth: number | undefined;
286
287
  fixedHeight: number | undefined;
287
288
  chartWidth: number;
@@ -341,6 +342,7 @@ export declare function useWaveformChartController(props: ResolvedWaveformChartP
341
342
  yDomains?: Record<string, [number, number]> | undefined;
342
343
  timeUnit: "s" | "ms";
343
344
  frameNumber?: string | number | undefined;
345
+ frameNumbers?: Record<string, string | number> | undefined;
344
346
  frameStyle?: import("..").WaveformFrameStyle | undefined;
345
347
  axes?: import("..").WaveformAxesOptions | undefined;
346
348
  annotations: import("..").WaveformAnnotation[];
@@ -23,6 +23,7 @@ interface LayoutContext {
23
23
  export declare function useWaveformLayout(context: LayoutContext): {
24
24
  chartSeries: ComputedRef<DisplaySeries[]>;
25
25
  chartTracks: ComputedRef<DisplayTrack[]>;
26
+ pageableTracks: ComputedRef<DisplayTrack[]>;
26
27
  renderingOptions: ComputedRef<import("../..").ResolvedWaveformRenderingOptions>;
27
28
  gridOptions: ComputedRef<import("./grid").NormalizedWaveformGridOptions>;
28
29
  pageCount: ComputedRef<number>;
@@ -58,6 +59,6 @@ export declare function useWaveformLayout(context: LayoutContext): {
58
59
  annotationTrackLayouts: ComputedRef<AnnotationTrackLayout[]>;
59
60
  renderedAnnotations: ComputedRef<import("../annotation").RenderedAnnotation[]>;
60
61
  editorSeries: ComputedRef<AnnotationSeriesInfo | undefined>;
61
- resolveFrameNumber: (trackIndex: number) => string | number | undefined;
62
+ resolveFrameNumber: (trackId: string) => string | number | undefined;
62
63
  };
63
64
  export {};
@@ -22,6 +22,8 @@ export interface WaveformChartProps {
22
22
  yDomains?: Record<string, [number, number]>;
23
23
  timeUnit?: 's' | 'ms';
24
24
  frameNumber?: string | number;
25
+ /** Per-track frame watermark overrides, keyed by stable trackId. */
26
+ frameNumbers?: Record<string, string | number>;
25
27
  frameStyle?: WaveformFrameStyle;
26
28
  axes?: WaveformAxesOptions;
27
29
  annotations?: WaveformAnnotation[];
@@ -3,4 +3,5 @@ export declare const Y_AXIS_TICK_PADDING = 7;
3
3
  export declare const Y_AXIS_OUTER_PADDING = 4;
4
4
  export declare const Y_AXIS_LABEL_GAP = 0;
5
5
  export declare const Y_AXIS_LABEL_BAND_WIDTH = 12;
6
+ export declare const Y_AXIS_RIGHT_LABEL_OFFSET = 10;
6
7
  export declare function resolveYAxisSides(axisCount: number): Array<'left' | 'right'>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waveform-analysis",
3
- "version": "0.1.55",
3
+ "version": "0.1.58",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
package/src/App.test.ts CHANGED
@@ -231,7 +231,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
231
231
  tracks.map((track) =>
232
232
  track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')),
233
233
  ),
234
- ).toEqual([['正弦基波'], ['谐波扰动', '谐波对比'], ['阻尼振荡'], ['阶跃响应']])
234
+ ).toEqual([['正弦基波'], ['谐波扰动', '谐波对比', '谐波校正'], ['阻尼振荡'], ['阶跃响应']])
235
235
  expect(
236
236
  tracks
237
237
  .slice(1)
@@ -250,6 +250,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
250
250
  '正弦基波',
251
251
  '谐波扰动',
252
252
  '谐波对比',
253
+ '谐波校正',
253
254
  '阻尼振荡',
254
255
  '阶跃响应',
255
256
  '脉冲响应',
@@ -80,7 +80,7 @@ const {
80
80
  pageCount,
81
81
  currentPage,
82
82
  getPageSize,
83
- chartTracks,
83
+ pageableTracks,
84
84
  goToPage,
85
85
  annotationInteraction,
86
86
  editorDraft,
@@ -230,7 +230,7 @@ function handleChartPointerLeave() {
230
230
  :interactive="!isPresentationMode"
231
231
  :display-mode="displayMode"
232
232
  :interaction-mode="activeInteractionMode"
233
- :frame-number="resolveFrameNumber(track.index)"
233
+ :frame-number="resolveFrameNumber(track.id)"
234
234
  :frame-style="frameStyle"
235
235
  :axes="axes"
236
236
  :clean-view="isCleanView"
@@ -326,7 +326,7 @@ function handleChartPointerLeave() {
326
326
  aria-label="波形分页"
327
327
  :current="currentPage"
328
328
  :page-size="getPageSize(gridOptions)"
329
- :total="chartTracks.length"
329
+ :total="pageableTracks.length"
330
330
  :show-size-changer="false"
331
331
  :show-quick-jumper="false"
332
332
  @change="goToPage"
@@ -17,6 +17,7 @@ describe('waveform grid helpers', () => {
17
17
  showPagination: true,
18
18
  fillIncompleteLastRow: false,
19
19
  trackOrder: [],
20
+ hideEmptyTracks: false,
20
21
  trackLines: {},
21
22
  })
22
23
  expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
@@ -25,8 +26,10 @@ describe('waveform grid helpers', () => {
25
26
  showPagination: true,
26
27
  fillIncompleteLastRow: false,
27
28
  trackOrder: [],
29
+ hideEmptyTracks: false,
28
30
  trackLines: {},
29
31
  })
32
+ expect(normalizeGridOptions({ hideEmptyTracks: true }).hideEmptyTracks).toBe(true)
30
33
  })
31
34
 
32
35
  it('normalizes track order by trimming, dropping blanks, and removing duplicates', () => {
@@ -11,6 +11,8 @@ export interface WaveformGridOptions {
11
11
  fillIncompleteLastRow?: boolean
12
12
  /** Stable grid slots. Empty IDs remain as empty chart frames and keep their page position. */
13
13
  trackOrder?: string[]
14
+ /** Omits ordered tracks without waveform series from layout and pagination. */
15
+ hideEmptyTracks?: boolean
14
16
  trackLines?: WaveformGridTrackLines
15
17
  }
16
18
 
@@ -38,6 +40,7 @@ export interface NormalizedWaveformGridOptions {
38
40
  showPagination: boolean
39
41
  fillIncompleteLastRow: boolean
40
42
  trackOrder?: string[]
43
+ hideEmptyTracks?: boolean
41
44
  trackLines: Record<string, NormalizedWaveformGridLineOptions>
42
45
  }
43
46
 
@@ -95,6 +98,7 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
95
98
  showPagination: options?.showPagination ?? true,
96
99
  fillIncompleteLastRow: options?.fillIncompleteLastRow ?? false,
97
100
  trackOrder: normalizeTrackOrder(options?.trackOrder),
101
+ hideEmptyTracks: options?.hideEmptyTracks ?? false,
98
102
  trackLines,
99
103
  }
100
104
  }
@@ -254,7 +254,7 @@ describe('multi-value Y-axis grouping', () => {
254
254
  })),
255
255
  ).toEqual([
256
256
  { side: 'left', labelOffset: 55 },
257
- { side: 'right', labelOffset: 55 },
257
+ { side: 'right', labelOffset: 45 },
258
258
  ])
259
259
  })
260
260
 
@@ -35,6 +35,14 @@ export interface YAxisSeriesGroup {
35
35
  fixed: boolean
36
36
  }
37
37
 
38
+ export interface YAxisSlot {
39
+ side: 'left' | 'right'
40
+ sideIndex: number
41
+ axisOffset: number
42
+ labelOffset: number
43
+ clearance: number
44
+ }
45
+
38
46
  function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
39
47
  if (axisCount >= 4) return ['left', 'left', 'right', 'right']
40
48
  if (axisCount === 3) return ['left', 'right', 'right']
@@ -181,6 +189,61 @@ export function measureTrackYAxisClearance(
181
189
  )
182
190
  }
183
191
 
192
+ /**
193
+ * Reserves stable Y-axis positions for every visible track on the current page.
194
+ * Slots are ordered from the plot edge outward for each side.
195
+ */
196
+ export function buildYAxisSlots(
197
+ tracks: readonly DisplayTrack[],
198
+ overlayMode: WaveformOverlayMode,
199
+ yDomain?: WaveformYDomain,
200
+ yDomains?: Record<string, WaveformYDomain>,
201
+ tickCount?: number,
202
+ nice = true,
203
+ ): { slots: YAxisSlot[]; clearance: { left: number; right: number } } {
204
+ const widths = new Map<string, number>()
205
+ tracks.forEach((track) => {
206
+ const sideIndexes = { left: 0, right: 0 }
207
+ resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).forEach((group) => {
208
+ const sideIndex = sideIndexes[group.side]++
209
+ const key = `${group.side}:${sideIndex}`
210
+ const width = axisTextMetrics(
211
+ group.domain,
212
+ nice,
213
+ undefined,
214
+ group.seriesList[0]?.unit,
215
+ tickCount,
216
+ ).tickTextWidth
217
+ widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, width))
218
+ })
219
+ })
220
+
221
+ const slots: YAxisSlot[] = []
222
+ const clearance = { left: 0, right: 0 }
223
+ ;(['left', 'right'] as const).forEach((side) => {
224
+ const sideIndexes = Array.from(widths.keys())
225
+ .filter((key) => key.startsWith(`${side}:`))
226
+ .map((key) => Number(key.slice(side.length + 1)))
227
+ .sort((first, second) => first - second)
228
+ sideIndexes.forEach((sideIndex) => {
229
+ const tickTextWidth = widths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
230
+ const axisOffset = side === 'left' && clearance.left > 0 ? -clearance.left : clearance[side]
231
+ const labelDistance =
232
+ tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
233
+ const labelOffset = axisOffset + labelDistance * (side === 'left' ? -1 : 1)
234
+ const fullClearance =
235
+ tickTextWidth +
236
+ Y_AXIS_TICK_PADDING +
237
+ Y_AXIS_LABEL_GAP +
238
+ Y_AXIS_LABEL_BAND_WIDTH +
239
+ Y_AXIS_OUTER_PADDING
240
+ slots.push({ side, sideIndex, axisOffset, labelOffset, clearance: fullClearance })
241
+ clearance[side] += fullClearance
242
+ })
243
+ })
244
+ return { slots, clearance }
245
+ }
246
+
184
247
  type PositionedTrack = Pick<TrackLayout, 'left' | 'top' | 'width' | 'height'>
185
248
 
186
249
  export function findClosestTrackAtPointer<T extends PositionedTrack>(
@@ -25,13 +25,19 @@ import {
25
25
  type GridCellGeometry,
26
26
  type NormalizedWaveformGridOptions,
27
27
  } from './grid'
28
- import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
28
+ import {
29
+ axisTextMetrics,
30
+ resolveYAxisSeriesGroups,
31
+ resolveYAxisTickCount,
32
+ type YAxisSlot,
33
+ } from './layout'
29
34
  import type { DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
30
35
  import { applyXDomainStrategy } from './xDomain'
31
36
  import {
32
37
  Y_AXIS_LABEL_BAND_WIDTH,
33
38
  Y_AXIS_LABEL_GAP,
34
39
  Y_AXIS_OUTER_PADDING,
40
+ Y_AXIS_RIGHT_LABEL_OFFSET,
35
41
  Y_AXIS_TICK_PADDING,
36
42
  } from './yAxisConstants'
37
43
 
@@ -61,6 +67,7 @@ export interface BuildTrackLayoutsOptions {
61
67
  linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>
62
68
  hideSecondaryLabels: boolean
63
69
  yAxisLabelX: number
70
+ yAxisSlots?: readonly YAxisSlot[]
64
71
  showCompactEmptyTracks: boolean
65
72
  }
66
73
 
@@ -117,8 +124,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
117
124
  ).map((group) =>
118
125
  !group.fixed && configuredYDomain ? { ...group, domain: configuredYDomain } : group,
119
126
  )
127
+ const sideIndexes = { left: 0, right: 0 }
120
128
  const sideOffsets = { left: 0, right: 0 }
121
129
  const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
130
+ const sideIndex = sideIndexes[group.side]++
122
131
  const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
123
132
  const niceCount = Math.max(1, tickCount - 1)
124
133
  const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
@@ -144,11 +153,22 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
144
153
  Y_AXIS_LABEL_GAP +
145
154
  Y_AXIS_LABEL_BAND_WIDTH +
146
155
  Y_AXIS_OUTER_PADDING
147
- const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
156
+ const slot = options.yAxisSlots?.find(
157
+ (candidate) => candidate.side === group.side && candidate.sideIndex === sideIndex,
158
+ )
159
+ const x = slot
160
+ ? (group.side === 'left' ? 0 : cell.width) + slot.axisOffset
161
+ : group.side === 'left'
162
+ ? -sideOffsets.left
163
+ : cell.width + sideOffsets.right
148
164
  const labelDistance =
149
165
  tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
150
- const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
151
- sideOffsets[group.side] += clearance
166
+ const labelX =
167
+ (slot
168
+ ? (group.side === 'left' ? 0 : cell.width) + slot.labelOffset
169
+ : x + (group.side === 'left' ? -labelDistance : labelDistance)) -
170
+ (group.side === 'right' ? Y_AXIS_RIGHT_LABEL_OFFSET : 0)
171
+ if (!slot) sideOffsets[group.side] += clearance
152
172
  return {
153
173
  index: group.index,
154
174
  side: group.side,
@@ -0,0 +1,40 @@
1
+ import type { DisplayTrack } from './types'
2
+
3
+ export const resolvePageableTracks = (
4
+ tracks: DisplayTrack[],
5
+ hideEmptyTracks?: boolean,
6
+ ): DisplayTrack[] => (hideEmptyTracks ? tracks.filter((track) => track.series.length > 0) : tracks)
7
+
8
+ export const resolveTrackFrameNumber = (
9
+ frameNumber: string | number | undefined,
10
+ frameNumbers: Record<string, string | number> | undefined,
11
+ trackCount: number,
12
+ trackIndex: number,
13
+ trackId: string,
14
+ ): string | number | undefined => {
15
+ const mappedFrameNumber = frameNumbers?.[trackId]
16
+ if (mappedFrameNumber !== undefined) return mappedFrameNumber
17
+ if (frameNumber === undefined) return undefined
18
+ if (trackCount === 1) return frameNumber
19
+ return typeof frameNumber === 'number'
20
+ ? frameNumber + trackIndex
21
+ : `${frameNumber}-${trackIndex + 1}`
22
+ }
23
+
24
+ export const createFrameNumberResolver =
25
+ (
26
+ getFrameNumber: () => string | number | undefined,
27
+ getFrameNumbers: () => Record<string, string | number> | undefined,
28
+ getTracks: () => DisplayTrack[],
29
+ ) =>
30
+ (trackId: string): string | number | undefined => {
31
+ const tracks = getTracks()
32
+ const trackIndex = tracks.findIndex((track) => track.id === trackId)
33
+ return resolveTrackFrameNumber(
34
+ getFrameNumber(),
35
+ getFrameNumbers(),
36
+ tracks.length,
37
+ trackIndex >= 0 ? trackIndex : 0,
38
+ trackId,
39
+ )
40
+ }
@@ -27,10 +27,11 @@ import {
27
27
  import {
28
28
  buildTrackLayouts,
29
29
  axisTextMetrics,
30
- measureTrackYAxisClearance,
30
+ buildYAxisSlots,
31
31
  resolveYAxisSeriesGroups,
32
32
  } from './layout'
33
33
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
34
+ import { createFrameNumberResolver, resolvePageableTracks } from './trackPagination'
34
35
  import type { PreparedWaveformSeries } from './useWaveformData'
35
36
  import type { ResolvedWaveformChartProps } from './waveformChartTypes'
36
37
  import { applyXDomainStrategy } from './xDomain'
@@ -107,9 +108,12 @@ export function useWaveformLayout(context: LayoutContext) {
107
108
  })
108
109
  })
109
110
  const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
110
- const pageCount = computed(() => getPageCount(chartTracks.value.length, gridOptions.value))
111
+ const pageableTracks = computed(() =>
112
+ resolvePageableTracks(chartTracks.value, gridOptions.value.hideEmptyTracks),
113
+ )
114
+ const pageCount = computed(() => getPageCount(pageableTracks.value.length, gridOptions.value))
111
115
  const pagedTracks = computed(() =>
112
- paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
116
+ paginateSeries(pageableTracks.value, currentPage.value, gridOptions.value),
113
117
  )
114
118
  const layoutTracks = computed(() => {
115
119
  const tracksWithSeries = pagedTracks.value.filter((track) => track.series.length > 0)
@@ -169,33 +173,24 @@ export function useWaveformLayout(context: LayoutContext) {
169
173
  : 0,
170
174
  ),
171
175
  )
172
- const multiAxisClearance = computed(() =>
173
- chartTracks.value.reduce(
174
- (maximum, track) => {
175
- const clearance = measureTrackYAxisClearance(
176
- track,
177
- props.overlayMode,
178
- props.yDomain,
179
- props.yDomains,
180
- props.axes?.y?.splitNumber,
181
- props.axes?.y?.nice !== false,
182
- )
183
- return {
184
- left: Math.max(maximum.left, clearance.left),
185
- right: Math.max(maximum.right, clearance.right),
186
- }
187
- },
188
- { left: 0, right: 0 },
176
+ const yAxisSlots = computed(() =>
177
+ buildYAxisSlots(
178
+ layoutTracks.value.filter((track) => track.visibleSeries.length > 0),
179
+ props.overlayMode,
180
+ props.yDomain,
181
+ props.yDomains,
182
+ props.axes?.y?.splitNumber,
183
+ props.axes?.y?.nice !== false,
189
184
  ),
190
185
  )
191
186
  const resolvedChartLeftMargin = computed(() =>
192
187
  props.overlayMode === 'multi-axis'
193
- ? Math.max(chartLeftMargin.value, multiAxisClearance.value.left)
188
+ ? Math.max(chartLeftMargin.value, yAxisSlots.value.clearance.left)
194
189
  : chartLeftMargin.value,
195
190
  )
196
191
  const chartRightMargin = computed(() =>
197
192
  props.overlayMode === 'multi-axis'
198
- ? Math.max(margin.right, multiAxisClearance.value.right)
193
+ ? Math.max(margin.right, yAxisSlots.value.clearance.right)
199
194
  : margin.right,
200
195
  )
201
196
  const innerWidth = computed(() =>
@@ -212,7 +207,7 @@ export function useWaveformLayout(context: LayoutContext) {
212
207
  return {
213
208
  horizontalGap:
214
209
  props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
215
- ? Math.max(baseGap, multiAxisClearance.value.left + multiAxisClearance.value.right)
210
+ ? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
216
211
  : hasMultipleColumns && hasVisibleWaveformData.value
217
212
  ? hasYAxisLabels.value && canReserveLabelClearance
218
213
  ? fullGap
@@ -323,7 +318,10 @@ export function useWaveformLayout(context: LayoutContext) {
323
318
  rendering: renderingOptions.value,
324
319
  linePointOverrides: linePointOverrides?.value,
325
320
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
326
- yAxisLabelX: yAxisMetrics.value.labelCenterX,
321
+ yAxisLabelX:
322
+ yAxisSlots.value.slots.find((slot) => slot.side === 'left' && slot.sideIndex === 0)
323
+ ?.labelOffset ?? yAxisMetrics.value.labelCenterX,
324
+ yAxisSlots: props.overlayMode === 'multi-axis' ? yAxisSlots.value.slots : undefined,
327
325
  showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
328
326
  }),
329
327
  )
@@ -363,16 +361,15 @@ export function useWaveformLayout(context: LayoutContext) {
363
361
  }
364
362
  : undefined
365
363
  })
366
- const resolveFrameNumber = (trackIndex: number): string | number | undefined => {
367
- if (props.frameNumber === undefined || props.frameNumber === null) return undefined
368
- if (chartTracks.value.length === 1) return props.frameNumber
369
- return typeof props.frameNumber === 'number'
370
- ? props.frameNumber + trackIndex
371
- : `${props.frameNumber}-${trackIndex + 1}`
372
- }
364
+ const resolveFrameNumber = createFrameNumberResolver(
365
+ () => props.frameNumber,
366
+ () => props.frameNumbers,
367
+ () => chartTracks.value,
368
+ )
373
369
  return {
374
370
  chartSeries,
375
371
  chartTracks,
372
+ pageableTracks,
376
373
  renderingOptions,
377
374
  gridOptions,
378
375
  pageCount,
@@ -43,6 +43,8 @@ export interface WaveformChartProps {
43
43
  yDomains?: Record<string, [number, number]>
44
44
  timeUnit?: 's' | 'ms'
45
45
  frameNumber?: string | number
46
+ /** Per-track frame watermark overrides, keyed by stable trackId. */
47
+ frameNumbers?: Record<string, string | number>
46
48
  frameStyle?: WaveformFrameStyle
47
49
  axes?: WaveformAxesOptions
48
50
  annotations?: WaveformAnnotation[]
@@ -3,6 +3,7 @@ export const Y_AXIS_TICK_PADDING = 7
3
3
  export const Y_AXIS_OUTER_PADDING = 4
4
4
  export const Y_AXIS_LABEL_GAP = 0
5
5
  export const Y_AXIS_LABEL_BAND_WIDTH = 12
6
+ export const Y_AXIS_RIGHT_LABEL_OFFSET = 10
6
7
 
7
8
  export function resolveYAxisSides(axisCount: number): Array<'left' | 'right'> {
8
9
  if (axisCount >= 4) return ['left', 'left', 'right', 'right']