waveform-analysis 0.1.64 → 0.1.66

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 (55) hide show
  1. package/README.md +33 -4
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +724 -621
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/WaveformChart.vue.d.ts +3 -2
  6. package/dist/types/components/core/constants.d.ts +3 -1
  7. package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -0
  8. package/dist/types/components/core/types.d.ts +2 -1
  9. package/dist/types/components/core/useWaveformChartController.d.ts +3 -0
  10. package/dist/types/components/core/useWaveformPresentation.d.ts +2 -0
  11. package/dist/types/components/core/waveformChartTypes.d.ts +3 -1
  12. package/dist/types/components/core/yAxisConstants.d.ts +1 -1
  13. package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
  14. package/dist/types/components/interaction/integerZoom.d.ts +12 -0
  15. package/dist/types/components/interaction/viewportDomain.d.ts +1 -0
  16. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
  17. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -6
  18. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +0 -4
  19. package/dist/types/components/rendering/WaveformZeroLines.vue.d.ts +12 -0
  20. package/dist/types/demo/types.d.ts +3 -0
  21. package/dist/types/index.d.ts +1 -0
  22. package/dist/types/types/chart.d.ts +8 -0
  23. package/package.json +1 -1
  24. package/src/App.vue +8 -2
  25. package/src/components/WaveformChart.css +4 -0
  26. package/src/components/WaveformChart.vue +2 -1
  27. package/src/components/core/constants.ts +5 -3
  28. package/src/components/core/layout.ts +1 -1
  29. package/src/components/core/trackLayoutBuilder.ts +30 -19
  30. package/src/components/core/types.ts +2 -0
  31. package/src/components/core/useWaveformChartLifecycle.ts +1 -1
  32. package/src/components/core/useWaveformLayout.ts +34 -17
  33. package/src/components/core/useWaveformPresentation.ts +11 -1
  34. package/src/components/core/waveformChartTypes.ts +3 -0
  35. package/src/components/core/yAxisConstants.ts +1 -1
  36. package/src/components/core/yAxisTitleLayout.ts +78 -0
  37. package/src/components/interaction/integerZoom.ts +64 -0
  38. package/src/components/interaction/useWaveformViewport.ts +8 -12
  39. package/src/components/interaction/useWaveformZoom.ts +6 -4
  40. package/src/components/interaction/viewportDomain.ts +12 -0
  41. package/src/components/interaction/zoomConstraints.ts +7 -5
  42. package/src/components/rendering/WaveformLegend.vue +1 -1
  43. package/src/components/rendering/WaveformSeriesLayer.vue +1 -1
  44. package/src/components/rendering/WaveformTrack.css +5 -2
  45. package/src/components/rendering/WaveformTrack.vue +18 -5
  46. package/src/components/rendering/WaveformTrackAxes.vue +9 -3
  47. package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
  48. package/src/components/rendering/WaveformZeroLines.vue +53 -0
  49. package/src/demo/DemoChartHost.vue +1 -0
  50. package/src/demo/DemoGridFrameControls.vue +12 -0
  51. package/src/demo/DemoViewControls.vue +4 -0
  52. package/src/demo/types.ts +3 -0
  53. package/src/index.ts +1 -0
  54. package/src/styles.css +3 -3
  55. package/src/types/chart.ts +8 -0
@@ -18,7 +18,7 @@ const Y_AXIS_CHARACTER_WIDTH = 7
18
18
  const Y_AXIS_TICK_PADDING = 7
19
19
  const Y_AXIS_OUTER_PADDING = 4
20
20
  const Y_AXIS_LABEL_GAP = 0
21
- const Y_AXIS_LABEL_BAND_WIDTH = 12
21
+ const Y_AXIS_LABEL_BAND_WIDTH = 20
22
22
 
23
23
  export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
24
24
  if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
@@ -34,6 +34,13 @@ import {
34
34
  } from './layout'
35
35
  import type { DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
36
36
  import { applyXDomainStrategy } from './xDomain'
37
+ import { alignLeftYAxisTitles } from './yAxisTitleLayout'
38
+ import {
39
+ alignIntegerZoomDomain,
40
+ normalizeIntegerZoomDomain,
41
+ integerZoomTicks,
42
+ formatIntegerZoomLabel,
43
+ } from '../interaction/integerZoom'
37
44
  import {
38
45
  Y_AXIS_LABEL_BAND_WIDTH,
39
46
  Y_AXIS_LABEL_GAP,
@@ -56,6 +63,7 @@ export interface BuildTrackLayoutsOptions {
56
63
  initialXDomain?: [number, number]
57
64
  initialXDomains?: Record<string, [number, number]>
58
65
  xDomainStrategy?: WaveformXDomainStrategy
66
+ integerZoom?: boolean
59
67
  fixedYDomain?: [number, number]
60
68
  fixedYDomains?: Record<string, [number, number]>
61
69
  yDomains?: Record<string, [number, number]>
@@ -68,6 +76,7 @@ export interface BuildTrackLayoutsOptions {
68
76
  linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>
69
77
  hideSecondaryLabels: boolean
70
78
  yAxisLabelX: number
79
+ yLabel?: string
71
80
  yAxisSlots?: readonly YAxisSlot[]
72
81
  showCompactEmptyTracks: boolean
73
82
  }
@@ -82,16 +91,19 @@ function resolveIndependentXDomain(
82
91
  options.initialXDomains?.[track.id] ??
83
92
  options.initialXDomains?.[seriesId] ??
84
93
  options.initialXDomain
85
- return explicitDomain
86
- ? applyXDomainStrategy(explicitDomain, strategy, true)
87
- : applyXDomainStrategy(track.xDomain, strategy)
94
+ return alignIntegerZoomDomain(
95
+ explicitDomain
96
+ ? applyXDomainStrategy(explicitDomain, strategy, true)
97
+ : applyXDomainStrategy(track.xDomain, strategy),
98
+ options,
99
+ )
88
100
  }
89
101
 
90
102
  export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
91
103
  const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
92
104
  const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
93
105
 
94
- return visibleCells.flatMap((cell, index) => {
106
+ const layouts: TrackLayout[] = visibleCells.flatMap((cell, index) => {
95
107
  const isEmpty = !cell.series || cell.series.series.length === 0
96
108
  if (!cell.series && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks))
97
109
  return []
@@ -116,6 +128,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
116
128
  ? (options.independentTransforms[index] ?? zoomIdentity)
117
129
  : zoomIdentity
118
130
  const xScale = transform.rescaleX(baseXScale)
131
+ xScale.domain(normalizeIntegerZoomDomain(xScale.domain() as [number, number], options))
119
132
  const yAxisGroups = resolveRenderedYAxisSeriesGroups(
120
133
  displayTrack,
121
134
  options.overlayMode,
@@ -178,25 +191,19 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
178
191
  const fallbackYScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0])
179
192
  if (options.yAxisNice !== false) fallbackYScale.nice()
180
193
  const yScale = yAxes[0]?.scale ?? fallbackYScale
181
- const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
194
+ const xMajorTicks = integerZoomTicks(
195
+ xScale.domain() as [number, number],
196
+ Math.max(2, Math.floor(cell.width / 100)),
197
+ options,
198
+ )
199
+ const xAxisLabelFormatter =
200
+ options.xAxisLabelFormatter ?? (options.integerZoom ? formatIntegerZoomLabel : undefined)
182
201
  const yMajorTicks = yAxes[0]?.majorTicks ?? []
183
202
  const yAxisTickValues = yAxes[0]?.tickValues ?? []
184
203
  const domain = xScale.domain() as [number, number]
185
204
  const endpointLabels = {
186
- start: formatXAxisLabel(
187
- domain[0],
188
- domain,
189
- options.timeUnit,
190
- 'start',
191
- options.xAxisLabelFormatter,
192
- ),
193
- end: formatXAxisLabel(
194
- domain[1],
195
- domain,
196
- options.timeUnit,
197
- 'end',
198
- options.xAxisLabelFormatter,
199
- ),
205
+ start: formatXAxisLabel(domain[0], domain, options.timeUnit, 'start', xAxisLabelFormatter),
206
+ end: formatXAxisLabel(domain[1], domain, options.timeUnit, 'end', xAxisLabelFormatter),
200
207
  }
201
208
  const leftClearance = endpointLabels.start.length * 7 + 10
202
209
  const rightClearance = endpointLabels.end.length * 7 + 10
@@ -259,6 +266,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
259
266
  yScale,
260
267
  yAxes,
261
268
  xMajorTicks,
269
+ xAxisLabelFormatter,
262
270
  xMinorTicks: buildMinorTicks(xMajorTicks, 3, domain),
263
271
  yMajorTicks,
264
272
  yMinorTicks: yAxes[0]?.minorTicks ?? [],
@@ -279,4 +287,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
279
287
  : bottomCells.has(cell.slotIndex))),
280
288
  }
281
289
  })
290
+
291
+ alignLeftYAxisTitles(layouts, options.yLabel)
292
+ return layouts
282
293
  }
@@ -5,6 +5,7 @@ import type {
5
5
  WaveformLineStyle,
6
6
  WaveformPoint,
7
7
  WaveformPointType,
8
+ WaveformXAxisLabelFormatter,
8
9
  } from '../../types'
9
10
  import type { WaveformPointSource } from '../../core/waveformPointSource'
10
11
  import type { NormalizedWaveformGridLineOptions } from './grid'
@@ -114,6 +115,7 @@ export interface TrackLayout {
114
115
  yMinorTicks: number[]
115
116
  yAxisTickValues: number[]
116
117
  xAxisTickValues: number[]
118
+ xAxisLabelFormatter?: WaveformXAxisLabelFormatter
117
119
  endpointLabels: { start: string; end: string }
118
120
  path: string | null
119
121
  seriesPaths: TrackSeriesPath[]
@@ -239,7 +239,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
239
239
  )
240
240
 
241
241
  watch(
242
- () => props.displayMode,
242
+ () => [props.displayMode, props.integerZoom, props.integerZoom ? props.timeUnit : undefined],
243
243
  () => {
244
244
  const previousPage = currentPage.value
245
245
  currentPage.value = 1
@@ -21,6 +21,7 @@ import { createFrameNumberResolver, resolvePageableTracks } from './trackPaginat
21
21
  import type { PreparedWaveformSeries } from './useWaveformData'
22
22
  import type { ResolvedWaveformChartProps } from './waveformChartTypes'
23
23
  import { applyXDomainStrategy } from './xDomain'
24
+ import { alignIntegerZoomDomain, normalizeIntegerZoomDomain } from '../interaction/integerZoom'
24
25
  import { resolveYAxisLayoutMetrics, resolveViewportYDomains } from './yAxisLayoutMetrics'
25
26
  import type { useWaveformAnnotationInteraction } from '../annotation'
26
27
  interface LayoutContext {
@@ -220,13 +221,19 @@ export function useWaveformLayout(context: LayoutContext) {
220
221
  Number.isFinite(domain[1]) &&
221
222
  domain[0] !== domain[1]
222
223
  ) {
223
- return applyXDomainStrategy(
224
- domain[0] < domain[1] ? domain : [domain[1], domain[0]],
225
- props.xDomainStrategy,
226
- true,
224
+ return alignIntegerZoomDomain(
225
+ applyXDomainStrategy(
226
+ domain[0] < domain[1] ? domain : [domain[1], domain[0]],
227
+ props.xDomainStrategy,
228
+ true,
229
+ ),
230
+ props,
227
231
  )
228
232
  }
229
- return applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy)
233
+ return alignIntegerZoomDomain(
234
+ applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy),
235
+ props,
236
+ )
230
237
  })
231
238
  const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
232
239
  const configuredDomain =
@@ -239,24 +246,32 @@ export function useWaveformLayout(context: LayoutContext) {
239
246
  Number.isFinite(configuredDomain[1]) &&
240
247
  configuredDomain[0] !== configuredDomain[1]
241
248
  ) {
242
- return applyXDomainStrategy(
243
- configuredDomain[0] < configuredDomain[1]
244
- ? configuredDomain
245
- : [configuredDomain[1], configuredDomain[0]],
246
- props.xDomainStrategy,
247
- true,
249
+ return alignIntegerZoomDomain(
250
+ applyXDomainStrategy(
251
+ configuredDomain[0] < configuredDomain[1]
252
+ ? configuredDomain
253
+ : [configuredDomain[1], configuredDomain[0]],
254
+ props.xDomainStrategy,
255
+ true,
256
+ ),
257
+ props,
248
258
  )
249
259
  }
250
- return applyXDomainStrategy(
251
- paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
252
- props.xDomainStrategy,
260
+ return alignIntegerZoomDomain(
261
+ applyXDomainStrategy(
262
+ paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
263
+ props.xDomainStrategy,
264
+ ),
265
+ props,
253
266
  )
254
267
  }
255
- const sharedZoomDomain = computed(
256
- () =>
268
+ const sharedZoomDomain = computed(() =>
269
+ normalizeIntegerZoomDomain(
257
270
  sharedTransform.value
258
271
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
259
272
  .domain() as [number, number],
273
+ props,
274
+ ),
260
275
  )
261
276
  const gridCells = computed(() => {
262
277
  const cells = resolveGridCellGeometry(
@@ -280,6 +295,7 @@ export function useWaveformLayout(context: LayoutContext) {
280
295
  initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
281
296
  initialXDomains: props.initialXDomains,
282
297
  xDomainStrategy: props.xDomainStrategy,
298
+ integerZoom: props.integerZoom,
283
299
  fixedYDomain: props.yDomain,
284
300
  fixedYDomains: props.yDomains,
285
301
  yDomains: viewportYDomains.value,
@@ -290,10 +306,11 @@ export function useWaveformLayout(context: LayoutContext) {
290
306
  rendering: renderingOptions.value,
291
307
  linePointOverrides: linePointOverrides?.value,
292
308
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
309
+ yLabel: props.yLabel,
293
310
  yAxisLabelX:
294
311
  yAxisSlots.value.slots.find((slot) => slot.side === 'left' && slot.sideIndex === 0)
295
312
  ?.labelOffset ?? yAxisMetrics.value.labelCenterX,
296
- yAxisSlots: props.overlayMode === 'multi-axis' ? yAxisSlots.value.slots : undefined,
313
+ yAxisSlots: yAxisSlots.value.slots,
297
314
  showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
298
315
  }),
299
316
  )
@@ -53,14 +53,24 @@ export function useWaveformPresentation(context: PresentationContext) {
53
53
  const isPresentationMode = computed(() => props.presentationMode === true)
54
54
  const resolvedZeroLine = computed(() => {
55
55
  const width = props.zeroLine.width
56
+ const opacity = props.zeroLine.opacity
57
+ const threshold = props.zeroLine.boundaryThreshold
56
58
  return {
57
- visible: props.zeroLine.visible === true,
59
+ visible: props.zeroLine.visible !== false,
58
60
  color: props.zeroLine.color || ZERO_LINE_DEFAULTS.COLOR,
59
61
  width:
60
62
  typeof width === 'number' && Number.isFinite(width) && width > 0
61
63
  ? width
62
64
  : ZERO_LINE_DEFAULTS.WIDTH,
63
65
  dash: props.zeroLine.dash ?? ZERO_LINE_DEFAULTS.DASH,
66
+ opacity:
67
+ typeof opacity === 'number' && Number.isFinite(opacity) && opacity >= 0 && opacity <= 1
68
+ ? opacity
69
+ : ZERO_LINE_DEFAULTS.OPACITY,
70
+ boundaryThreshold:
71
+ typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 0
72
+ ? threshold
73
+ : ZERO_LINE_DEFAULTS.BOUNDARY_THRESHOLD,
64
74
  }
65
75
  })
66
76
  const legendBackgroundColor = computed(
@@ -33,6 +33,8 @@ export interface WaveformChartProps {
33
33
  lineColor?: string
34
34
  showTooltip?: boolean
35
35
  zoomable?: boolean
36
+ /** Snap viewport endpoints outwards to integer X-axis display units. */
37
+ integerZoom?: boolean
36
38
  pannable?: boolean
37
39
  minZoomSpan?: number
38
40
  minVisiblePoints?: number
@@ -70,6 +72,7 @@ type DefaultedProp =
70
72
  | 'lineColor'
71
73
  | 'showTooltip'
72
74
  | 'zoomable'
75
+ | 'integerZoom'
73
76
  | 'pannable'
74
77
  | 'minVisiblePoints'
75
78
  | 'xDomainStrategy'
@@ -2,7 +2,7 @@ export const Y_AXIS_CHARACTER_WIDTH = 7
2
2
  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
- export const Y_AXIS_LABEL_BAND_WIDTH = 12
5
+ export const Y_AXIS_LABEL_BAND_WIDTH = 20
6
6
  export const Y_AXIS_RIGHT_LABEL_OFFSET = 10
7
7
 
8
8
  export function resolveYAxisSides(axisCount: number): Array<'left' | 'right'> {
@@ -0,0 +1,78 @@
1
+ import { formatYAxisTickLabel } from './layout'
2
+ import type { TrackLayout, WaveformYAxisLayout } from './types'
3
+ import {
4
+ Y_AXIS_CHARACTER_WIDTH,
5
+ Y_AXIS_LABEL_BAND_WIDTH,
6
+ Y_AXIS_TICK_PADDING,
7
+ } from './yAxisConstants'
8
+
9
+ // Match the existing 14px channel names and 11px ticks in WaveformTrack.css.
10
+ const TITLE_FONT_SIZE = 14
11
+ const TICK_FONT_SIZE = 11
12
+ const TEXT_GAP = 2
13
+
14
+ function estimateTitleWidth(title: string): number {
15
+ return Array.from(title).reduce((width, character) => {
16
+ // Latin labels average well below one em; full-width characters occupy one em.
17
+ const em = /[\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60]/u.test(character)
18
+ ? 1
19
+ : /[MW@%]/u.test(character)
20
+ ? 0.95
21
+ : /[ilI1., :;|'!]/u.test(character)
22
+ ? 0.4
23
+ : 0.65
24
+ return width + em * TITLE_FONT_SIZE
25
+ }, 0)
26
+ }
27
+
28
+ type TitleTrack = Pick<TrackLayout, 'height' | 'column' | 'yAxes'>
29
+
30
+ function titleDistance(track: TitleTrack, axis: WaveformYAxisLayout, yLabel?: string): number {
31
+ const series = axis.seriesList[0]
32
+ const title = series?.name.trim() || yLabel || ''
33
+ const halfTitleHeight = estimateTitleWidth(title) / 2
34
+ const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
35
+ const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
36
+ const lowestTick = Math.min(...axis.tickValues)
37
+ const tickWidths = axis.tickValues.flatMap((value) => {
38
+ // D3 places ticks at scale(value) + 0.5. The lowest label is bottom-aligned;
39
+ // all other labels are vertically centered using dy="0.32em".
40
+ const y = axis.scale(value) + 0.5
41
+ const top = y - (value === lowestTick ? TICK_FONT_SIZE : TICK_FONT_SIZE / 2)
42
+ const bottom = value === lowestTick ? y : y + TICK_FONT_SIZE / 2
43
+ if (bottom < titleTop || top > titleBottom) return []
44
+ return [
45
+ formatYAxisTickLabel(
46
+ value,
47
+ axis.scale.domain() as [number, number],
48
+ axis.tickValues,
49
+ series?.unit,
50
+ ).length * Y_AXIS_CHARACTER_WIDTH,
51
+ ]
52
+ })
53
+ // The 20px title band already leaves 3px beside a 14px glyph.
54
+ return (
55
+ Math.max(Y_AXIS_CHARACTER_WIDTH, ...tickWidths) +
56
+ Y_AXIS_TICK_PADDING +
57
+ Y_AXIS_LABEL_BAND_WIDTH / 2
58
+ )
59
+ }
60
+
61
+ export function alignLeftYAxisTitles(layouts: TitleTrack[], yLabel?: string): void {
62
+ const distances = new Map<string, number>()
63
+ for (const track of layouts) {
64
+ let leftIndex = 0
65
+ for (const axis of track.yAxes) {
66
+ if (axis.side !== 'left') continue
67
+ const key = `${track.column}:${leftIndex++}`
68
+ distances.set(key, Math.max(distances.get(key) ?? 0, titleDistance(track, axis, yLabel)))
69
+ }
70
+ }
71
+ for (const track of layouts) {
72
+ let leftIndex = 0
73
+ for (const axis of track.yAxes) {
74
+ if (axis.side !== 'left') continue
75
+ axis.labelX = axis.x - distances.get(`${track.column}:${leftIndex++}`)!
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,64 @@
1
+ import { scaleLinear } from 'd3'
2
+
3
+ export interface IntegerZoomOptions {
4
+ integerZoom?: boolean
5
+ timeUnit?: 's' | 'ms'
6
+ }
7
+
8
+ export function integerZoomTicks(
9
+ domain: [number, number],
10
+ count: number,
11
+ options: IntegerZoomOptions,
12
+ ) {
13
+ if (!options.integerZoom) return scaleLinear().domain(domain).ticks(count)
14
+ const factor = options.timeUnit === 's' ? 1 : 1000
15
+ return scaleLinear()
16
+ .domain(domain.map((value) => value * factor))
17
+ .ticks(count)
18
+ .filter(Number.isInteger)
19
+ .map((value) => value / factor)
20
+ }
21
+
22
+ export function formatIntegerZoomLabel(value: number): string {
23
+ return String(Math.round(value))
24
+ }
25
+
26
+ function stableInteger(value: number): number {
27
+ const rounded = Math.round(value)
28
+ return Math.abs(value - rounded) <= Number.EPSILON * Math.max(1, Math.abs(value)) * 16
29
+ ? rounded
30
+ : value
31
+ }
32
+
33
+ /** Align outwards in display units; all returned coordinates remain in seconds. */
34
+ export function alignIntegerZoomDomain(
35
+ domain: [number, number],
36
+ options: IntegerZoomOptions,
37
+ ): [number, number] {
38
+ if (!options.integerZoom) return domain
39
+ const factor = options.timeUnit === 's' ? 1 : 1000
40
+ const start = stableInteger(Math.min(domain[0], domain[1]) * factor)
41
+ const end = stableInteger(Math.max(domain[0], domain[1]) * factor)
42
+ if (!Number.isSafeInteger(Math.floor(start)) || !Number.isSafeInteger(Math.ceil(end)))
43
+ return domain
44
+ const left = Math.floor(start)
45
+ return [left / factor, Math.max(left + 1, Math.ceil(end)) / factor]
46
+ }
47
+
48
+ /** Recover aligned endpoints after a D3 transform, tolerating floating-point round-off. */
49
+ export function normalizeIntegerZoomDomain(
50
+ domain: [number, number],
51
+ options: IntegerZoomOptions,
52
+ ): [number, number] {
53
+ return alignIntegerZoomDomain(domain, options)
54
+ }
55
+
56
+ /** Translation snaps to the nearest unit so panning preserves the visible span. */
57
+ export function alignIntegerPanDomain(
58
+ domain: [number, number],
59
+ options: IntegerZoomOptions,
60
+ ): [number, number] {
61
+ if (!options.integerZoom) return domain
62
+ const factor = options.timeUnit === 's' ? 1 : 1000
63
+ return [Math.round(domain[0] * factor) / factor, Math.round(domain[1] * factor) / factor]
64
+ }
@@ -8,7 +8,8 @@ import type { WaveformChartEmit } from '../core/waveformChartTypes'
8
8
  import type { AnnotationSeriesCandidate } from '../annotation'
9
9
  import { tryReleasePointerCapture } from './pointerCapture'
10
10
  import { transitionViewportInteraction } from './viewportInteractionState'
11
- import { createViewportDomainSetter } from './viewportDomain'
11
+ import { clampViewportDomain as clampDomain, createViewportDomainSetter } from './viewportDomain'
12
+ import { alignIntegerPanDomain } from './integerZoom'
12
13
  import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
13
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
14
15
  interface ViewportContext {
@@ -92,14 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
92
93
  height: Math.abs(active.currentY - active.startY),
93
94
  }
94
95
  })
95
- const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
96
- const span = domain[1] - domain[0]
97
- const boundarySpan = boundary[1] - boundary[0]
98
- if (span >= boundarySpan) return [...boundary]
99
- if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
100
- if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
101
- return domain
102
- }
103
96
  const currentYDomains = (): Record<string, [number, number]> =>
104
97
  Object.fromEntries(
105
98
  trackLayouts.value
@@ -157,9 +150,12 @@ export function useWaveformViewport(context: ViewportContext) {
157
150
  const sourceXDomain = active.independent
158
151
  ? resolveInitialTrackDomain(track)
159
152
  : initialXDomain.value
160
- const nextX = clampDomain(
161
- [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
162
- sourceXDomain,
153
+ const nextX = alignIntegerPanDomain(
154
+ clampDomain(
155
+ [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
156
+ sourceXDomain,
157
+ ),
158
+ props,
163
159
  )
164
160
  if (active.independent) {
165
161
  const nextTransforms = [...independentTransforms.value]
@@ -15,6 +15,7 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
15
15
  import type { TrackLayout } from '../core/types'
16
16
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
17
17
  import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
18
+ import { normalizeIntegerZoomDomain } from './integerZoom'
18
19
  import {
19
20
  constrainZoomDomain,
20
21
  resolveMinimumZoomSpan,
@@ -90,7 +91,8 @@ export function useWaveformZoom(context: ZoomContext) {
90
91
  const domain = event.transform
91
92
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
92
93
  .domain()
93
- emit('zoom-intent', { start: domain[0], end: domain[1], gesture: 'wheel' })
94
+ const [start, end] = normalizeIntegerZoomDomain(domain as [number, number], props)
95
+ emit('zoom-intent', { start, end, gesture: 'wheel' })
94
96
  scheduleZoomCommit()
95
97
  }
96
98
  const handleIndependentZoom = (
@@ -108,8 +110,8 @@ export function useWaveformZoom(context: ZoomContext) {
108
110
  .rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
109
111
  .domain()
110
112
  emit('zoom-intent', {
111
- start: domain[0],
112
- end: domain[1],
113
+ start: normalizeIntegerZoomDomain(domain as [number, number], props)[0],
114
+ end: normalizeIntegerZoomDomain(domain as [number, number], props)[1],
113
115
  trackIndex,
114
116
  seriesIds: track.seriesList.map((series) => series.id),
115
117
  gesture: 'wheel',
@@ -131,7 +133,7 @@ export function useWaveformZoom(context: ZoomContext) {
131
133
  const domain = transform
132
134
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
133
135
  .domain()
134
- emit('zoom-change', [domain[0], domain[1]])
136
+ emit('zoom-change', normalizeIntegerZoomDomain([domain[0], domain[1]], props))
135
137
  }
136
138
  if (!pendingIndependentZoomTransforms.size) return
137
139
  const nextTransforms = [...independentTransforms.value]
@@ -21,6 +21,18 @@ interface ViewportDomainContext {
21
21
  configureZoom: () => void
22
22
  }
23
23
 
24
+ export function clampViewportDomain(
25
+ domain: [number, number],
26
+ boundary: [number, number],
27
+ ): [number, number] {
28
+ const span = domain[1] - domain[0]
29
+ const boundarySpan = boundary[1] - boundary[0]
30
+ if (span >= boundarySpan) return [...boundary]
31
+ if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
32
+ if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
33
+ return domain
34
+ }
35
+
24
36
  export function createViewportDomainSetter(context: ViewportDomainContext) {
25
37
  return (domain: [number, number], trackIndex?: number) => {
26
38
  if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
@@ -3,6 +3,7 @@ import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
3
3
  import type { WaveformPoint } from '../../types'
4
4
  import { pointSourceFromPoints, type WaveformPointSource } from '../../core/waveformPointSource'
5
5
  import { ZOOM_CONSTRAINTS } from '../core/constants'
6
+ import { alignIntegerZoomDomain, type IntegerZoomOptions } from './integerZoom'
6
7
 
7
8
  interface PointSeriesSource {
8
9
  points: WaveformPoint[]
@@ -11,7 +12,7 @@ interface PointSeriesSource {
11
12
 
12
13
  export type ZoomSeriesGroup = readonly PointSeriesSource[]
13
14
 
14
- interface ZoomConstraintOptions {
15
+ interface ZoomConstraintOptions extends IntegerZoomOptions {
15
16
  minZoomSpan?: number
16
17
  minVisiblePoints?: number
17
18
  maxZoomScale?: number | null
@@ -72,7 +73,7 @@ export function resolveMinimumZoomSpan(
72
73
  groups: readonly ZoomSeriesGroup[],
73
74
  options: ZoomConstraintOptions,
74
75
  ): number {
75
- const normalized = normalizedBoundary(boundary)
76
+ const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
76
77
  const boundarySpan = normalized[1] - normalized[0]
77
78
  if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
78
79
  const configuredSpan =
@@ -88,7 +89,8 @@ export function resolveMinimumZoomSpan(
88
89
  : options.maxZoomScale !== null && configuredSpan === 0 && required === 0
89
90
  ? boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
90
91
  : 0
91
- return Math.max(configuredSpan, pointSpan, scaleSpan)
92
+ const integerSpan = options.integerZoom ? (options.timeUnit === 's' ? 1 : 0.001) : 0
93
+ return Math.max(configuredSpan, pointSpan, scaleSpan, integerSpan)
92
94
  }
93
95
 
94
96
  function expandToMinimumPoints(
@@ -139,7 +141,7 @@ export function constrainZoomDomain(
139
141
  groups: readonly ZoomSeriesGroup[],
140
142
  options: ZoomConstraintOptions,
141
143
  ): [number, number] {
142
- const normalized = normalizedBoundary(boundary)
144
+ const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
143
145
  const boundarySpan = normalized[1] - normalized[0]
144
146
  if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
145
147
  if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
@@ -157,7 +159,7 @@ export function constrainZoomDomain(
157
159
  const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
158
160
  const center = (expanded[0] + expanded[1]) / 2
159
161
  const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
160
- return [start, start + span]
162
+ return alignIntegerZoomDomain([start, start + span], options)
161
163
  }
162
164
 
163
165
  export function transformForDomain(
@@ -93,7 +93,7 @@ function toggleSeries(seriesId: string) {
93
93
  :d="waveformLegendLinePath(item.lineType) ?? undefined"
94
94
  :stroke="item.color"
95
95
  :stroke-dasharray="waveformLineDasharray(item.lineStyle)"
96
- stroke-width="2.5"
96
+ stroke-width="1"
97
97
  fill="none"
98
98
  />
99
99
  <path
@@ -103,7 +103,7 @@ const renderedSeriesPaths = computed<RenderedSeriesPath[]>(() =>
103
103
  <style scoped>
104
104
  .waveform-track__line {
105
105
  fill: none;
106
- stroke-width: 2.5;
106
+ stroke-width: 1;
107
107
  stroke-linejoin: round;
108
108
  stroke-linecap: round;
109
109
  }
@@ -10,8 +10,8 @@
10
10
  }
11
11
 
12
12
  .waveform-track__y-axis-label {
13
- font-size: 12px;
14
- font-weight: 500;
13
+ font-size: 14px;
14
+ font-weight: 600;
15
15
  pointer-events: none;
16
16
  }
17
17
 
@@ -83,6 +83,7 @@
83
83
  .waveform-track__axis-endpoint {
84
84
  fill: #667085;
85
85
  font-size: 11px;
86
+ font-weight: 700;
86
87
  pointer-events: none;
87
88
  }
88
89
 
@@ -90,6 +91,7 @@
90
91
  fill: #666;
91
92
  font-family: sans-serif;
92
93
  font-size: 10px;
94
+ font-weight: 700;
93
95
  }
94
96
 
95
97
  .waveform-track__axis path,
@@ -100,4 +102,5 @@
100
102
  .waveform-track__axis text {
101
103
  fill: #667085;
102
104
  font-size: 11px;
105
+ font-weight: 700;
103
106
  }