waveform-analysis 0.1.65 → 0.1.67

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 (81) hide show
  1. package/README.md +146 -5
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1326 -1118
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/WaveformChart.vue.d.ts +7 -2
  6. package/dist/types/components/core/constants.d.ts +2 -0
  7. package/dist/types/components/core/grid.d.ts +1 -1
  8. package/dist/types/components/core/layout.d.ts +2 -2
  9. package/dist/types/components/core/pagination.d.ts +2 -0
  10. package/dist/types/components/core/title.d.ts +2 -1
  11. package/dist/types/components/core/trackLayoutBuilder.d.ts +5 -0
  12. package/dist/types/components/core/types.d.ts +2 -1
  13. package/dist/types/components/core/useWaveformChartController.d.ts +9 -0
  14. package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
  15. package/dist/types/components/core/useWaveformPresentation.d.ts +3 -0
  16. package/dist/types/components/core/useWaveformYAxisLayout.d.ts +31 -0
  17. package/dist/types/components/core/waveformChartTypes.d.ts +11 -2
  18. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +1 -1
  19. package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
  20. package/dist/types/components/data/types.d.ts +1 -1
  21. package/dist/types/components/interaction/integerZoom.d.ts +12 -0
  22. package/dist/types/components/interaction/interactionTarget.d.ts +1 -0
  23. package/dist/types/components/interaction/remoteViewport.d.ts +8 -0
  24. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -2
  25. package/dist/types/components/interaction/viewportDomain.d.ts +1 -1
  26. package/dist/types/components/interaction/viewportPan.d.ts +4 -0
  27. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
  28. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -0
  29. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +6 -6
  30. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +5 -1
  31. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +0 -4
  32. package/dist/types/components/rendering/WaveformZeroLines.vue.d.ts +12 -0
  33. package/dist/types/data/recordedDampedWaveform.d.ts +1 -0
  34. package/dist/types/demo/types.d.ts +3 -0
  35. package/dist/types/index.d.ts +2 -1
  36. package/dist/types/types/chart.d.ts +17 -0
  37. package/dist/types/types/index.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/src/App.vue +6 -0
  40. package/src/components/WaveformChart.css +5 -0
  41. package/src/components/WaveformChart.vue +3 -0
  42. package/src/components/WaveformChartView.vue +14 -3
  43. package/src/components/core/constants.ts +2 -0
  44. package/src/components/core/grid.ts +4 -1
  45. package/src/components/core/layout.ts +16 -2
  46. package/src/components/core/pagination.ts +17 -0
  47. package/src/components/core/title.ts +7 -3
  48. package/src/components/core/trackLayoutBuilder.ts +39 -59
  49. package/src/components/core/types.ts +2 -0
  50. package/src/components/core/useWaveformChartController.ts +17 -1
  51. package/src/components/core/useWaveformChartLifecycle.ts +11 -17
  52. package/src/components/core/useWaveformLayout.ts +67 -107
  53. package/src/components/core/useWaveformPresentation.ts +27 -3
  54. package/src/components/core/useWaveformYAxisLayout.ts +140 -0
  55. package/src/components/core/waveformChartTypes.ts +15 -0
  56. package/src/components/core/yAxisLayoutMetrics.ts +11 -2
  57. package/src/components/core/yAxisTitleLayout.ts +78 -0
  58. package/src/components/data/types.ts +3 -0
  59. package/src/components/interaction/integerZoom.ts +64 -0
  60. package/src/components/interaction/interactionTarget.ts +10 -0
  61. package/src/components/interaction/remoteViewport.ts +53 -0
  62. package/src/components/interaction/useWaveformViewport.ts +16 -58
  63. package/src/components/interaction/useWaveformZoom.ts +13 -11
  64. package/src/components/interaction/viewportDomain.ts +11 -3
  65. package/src/components/interaction/viewportPan.ts +82 -0
  66. package/src/components/interaction/zoomConstraints.ts +7 -5
  67. package/src/components/rendering/WaveformLegend.vue +10 -1
  68. package/src/components/rendering/WaveformTrack.vue +24 -6
  69. package/src/components/rendering/WaveformTrackAxes.vue +16 -5
  70. package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
  71. package/src/components/rendering/WaveformZeroLines.vue +53 -0
  72. package/src/data/recordedDampedWaveform.ts +3 -0
  73. package/src/data/simulatedWaveforms.ts +12 -10
  74. package/src/demo/DemoChartHost.vue +1 -0
  75. package/src/demo/DemoGridFrameControls.vue +12 -0
  76. package/src/demo/DemoViewControls.vue +4 -0
  77. package/src/demo/types.ts +3 -0
  78. package/src/index.ts +4 -0
  79. package/src/styles.css +3 -3
  80. package/src/types/chart.ts +20 -0
  81. package/src/types/index.ts +3 -0
@@ -0,0 +1,140 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+
3
+ import { margin, MINIMUM_PLOT_WIDTH } from './constants'
4
+ import { getGridGap, type NormalizedWaveformGridOptions } from './grid'
5
+ import { buildYAxisSlots } from './layout'
6
+ import type { DisplayTrack } from './types'
7
+ import type { ResolvedWaveformChartProps } from './waveformChartTypes'
8
+ import { resolveYAxisLayoutMetrics } from './yAxisLayoutMetrics'
9
+ import { Y_AXIS_OUTER_PADDING } from './yAxisConstants'
10
+
11
+ interface YAxisLayoutContext {
12
+ props: ResolvedWaveformChartProps
13
+ chartTracks: ComputedRef<DisplayTrack[]>
14
+ layoutTracks: ComputedRef<DisplayTrack[]>
15
+ chartWidth: ComputedRef<number>
16
+ gridOptions: ComputedRef<NormalizedWaveformGridOptions>
17
+ viewportYDomains: ComputedRef<Record<string, [number, number]>>
18
+ yAxisTickCount: ComputedRef<number>
19
+ }
20
+
21
+ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
22
+ const {
23
+ props,
24
+ chartTracks,
25
+ layoutTracks,
26
+ chartWidth,
27
+ gridOptions,
28
+ viewportYDomains,
29
+ yAxisTickCount,
30
+ } = context
31
+ const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
32
+ const showUnits = computed(() => !isEdgeCompact.value || props.unitDisplayMode === 'axis')
33
+ const yAxisMetrics = computed(() =>
34
+ resolveYAxisLayoutMetrics(
35
+ chartTracks.value,
36
+ props.overlayMode,
37
+ props.yDomain,
38
+ props.yDomains,
39
+ viewportYDomains.value,
40
+ props.axes?.y?.nice !== false,
41
+ yAxisTickCount.value,
42
+ props.displayMode === 'compact',
43
+ props.axes?.y?.upperPaddingRatio,
44
+ showUnits.value,
45
+ ),
46
+ )
47
+ const hasYAxisLabels = computed(() =>
48
+ chartTracks.value.some(
49
+ (track) =>
50
+ track.visibleSeries.length === 1 &&
51
+ Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
52
+ ),
53
+ )
54
+ const hasVisibleWaveformData = computed(() =>
55
+ chartTracks.value.some((track) => track.visibleSeries.length > 0),
56
+ )
57
+ const chartLeftMargin = computed(() =>
58
+ Math.max(
59
+ margin.left,
60
+ hasYAxisLabels.value
61
+ ? yAxisMetrics.value.fullClearance
62
+ : hasVisibleWaveformData.value
63
+ ? yAxisMetrics.value.tickClearance
64
+ : 0,
65
+ ),
66
+ )
67
+ const yAxisSlots = computed(() =>
68
+ buildYAxisSlots(
69
+ layoutTracks.value.filter((track) => track.visibleSeries.length > 0),
70
+ props.overlayMode,
71
+ props.yDomain,
72
+ props.yDomains,
73
+ yAxisTickCount.value,
74
+ props.axes?.y?.nice !== false,
75
+ props.displayMode === 'compact',
76
+ viewportYDomains.value,
77
+ props.axes?.y?.upperPaddingRatio,
78
+ showUnits.value,
79
+ ),
80
+ )
81
+ const resolvedChartLeftMargin = computed(() =>
82
+ isEdgeCompact.value
83
+ ? margin.right + Math.max(0, compactLeftClearance.value - Y_AXIS_OUTER_PADDING)
84
+ : props.overlayMode === 'multi-axis'
85
+ ? Math.max(chartLeftMargin.value, yAxisSlots.value.clearance.left)
86
+ : chartLeftMargin.value,
87
+ )
88
+ // The same slot places the title and reserves its entire 20px band. The outer
89
+ // padding belongs to the chart edge, rather than the old fixed 48px minimum.
90
+ const compactLeftClearance = computed(() =>
91
+ !hasVisibleWaveformData.value
92
+ ? 0
93
+ : props.overlayMode === 'multi-axis' || hasYAxisLabels.value
94
+ ? yAxisSlots.value.clearance.left
95
+ : yAxisMetrics.value.tickClearance,
96
+ )
97
+ const chartRightMargin = computed(() =>
98
+ props.overlayMode === 'multi-axis'
99
+ ? Math.max(margin.right, yAxisSlots.value.clearance.right)
100
+ : margin.right,
101
+ )
102
+ const innerWidth = computed(() =>
103
+ Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
104
+ )
105
+ const yAxisLayout = computed(() => {
106
+ const baseGap = getGridGap(props.displayMode)
107
+ const columnCount = gridOptions.value.columnCount
108
+ const hasMultipleColumns = columnCount > 1
109
+ const fullGap = Math.max(
110
+ baseGap,
111
+ isEdgeCompact.value ? yAxisSlots.value.clearance.left : yAxisMetrics.value.fullClearance,
112
+ )
113
+ const tickGap = Math.max(baseGap, yAxisMetrics.value.tickClearance)
114
+ const plotWidth = (innerWidth.value - fullGap * Math.max(0, columnCount - 1)) / columnCount
115
+ const canReserveLabelClearance = plotWidth >= MINIMUM_PLOT_WIDTH
116
+ return {
117
+ horizontalGap:
118
+ props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
119
+ ? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
120
+ : hasMultipleColumns && hasVisibleWaveformData.value
121
+ ? hasYAxisLabels.value && canReserveLabelClearance
122
+ ? fullGap
123
+ : tickGap
124
+ : baseGap,
125
+ hideSecondaryLabels:
126
+ props.overlayMode !== 'multi-axis' &&
127
+ hasMultipleColumns &&
128
+ hasYAxisLabels.value &&
129
+ !canReserveLabelClearance,
130
+ }
131
+ })
132
+ return {
133
+ yAxisMetrics,
134
+ yAxisSlots,
135
+ resolvedChartLeftMargin,
136
+ innerWidth,
137
+ yAxisLayout,
138
+ hasVisibleWaveformData,
139
+ }
140
+ }
@@ -3,6 +3,8 @@ import type {
3
3
  WaveformAxesOptions,
4
4
  WaveformData,
5
5
  WaveformDisplayMode,
6
+ WaveformUnitDisplayMode,
7
+ WaveformLayoutPreset,
6
8
  WaveformFrameStyle,
7
9
  WaveformInteractionMode,
8
10
  WaveformLegendOptions,
@@ -18,6 +20,7 @@ import type {
18
20
  WaveformZeroLineOptions,
19
21
  WaveformZoomIntentPayload,
20
22
  WaveformZoomEndPayload,
23
+ WaveformPanEndPayload,
21
24
  WaveformZoomResetPayload,
22
25
  } from '../data/types'
23
26
  import type { WaveformGridOptions } from './grid'
@@ -26,6 +29,10 @@ export interface WaveformChartProps {
26
29
  data: WaveformData
27
30
  displayMode?: WaveformDisplayMode
28
31
  overlayMode?: WaveformOverlayMode
32
+ /** Default axis units; optional chart-wide single-series legend units. */
33
+ unitDisplayMode?: WaveformUnitDisplayMode
34
+ /** Default spacing or compact title/time labels with a separate pagination band. */
35
+ layoutPreset?: WaveformLayoutPreset
29
36
  width?: number
30
37
  height?: number
31
38
  xLabel?: string
@@ -33,7 +40,11 @@ export interface WaveformChartProps {
33
40
  lineColor?: string
34
41
  showTooltip?: boolean
35
42
  zoomable?: boolean
43
+ /** Snap viewport endpoints outwards to integer X-axis display units. */
44
+ integerZoom?: boolean
36
45
  pannable?: boolean
46
+ /** Global record bounds in seconds; enables horizontal panning beyond loaded data. */
47
+ panXDomain?: [number, number]
37
48
  minZoomSpan?: number
38
49
  minVisiblePoints?: number
39
50
  maxZoomScale?: number | null
@@ -66,10 +77,13 @@ export interface WaveformChartProps {
66
77
  type DefaultedProp =
67
78
  | 'displayMode'
68
79
  | 'overlayMode'
80
+ | 'unitDisplayMode'
81
+ | 'layoutPreset'
69
82
  | 'yLabel'
70
83
  | 'lineColor'
71
84
  | 'showTooltip'
72
85
  | 'zoomable'
86
+ | 'integerZoom'
73
87
  | 'pannable'
74
88
  | 'minVisiblePoints'
75
89
  | 'xDomainStrategy'
@@ -94,6 +108,7 @@ export interface WaveformChartEmit {
94
108
  (event: 'zoom-intent', payload: WaveformZoomIntentPayload): void
95
109
  (event: 'zoom-change', domain: [number, number]): void
96
110
  (event: 'zoom-end', payload: WaveformZoomEndPayload): void
111
+ (event: 'pan-end', payload: WaveformPanEndPayload): void
97
112
  (event: 'zoom-reset', payload: WaveformZoomResetPayload): void
98
113
  (event: 'update:annotations', annotations: WaveformAnnotation[]): void
99
114
  (event: 'update:hidden-series-ids', ids: string[]): void
@@ -40,11 +40,20 @@ export function resolveYAxisLayoutMetrics(
40
40
  nice: boolean,
41
41
  tickCount: number,
42
42
  compact: boolean,
43
+ upperPaddingRatio = 0,
44
+ showUnits = true,
43
45
  ): YAxisLayoutMetrics {
44
46
  const axisText = chartTracks
45
47
  .filter((track) => track.visibleSeries.length > 0)
46
48
  .flatMap((track) =>
47
- resolveRenderedYAxisSeriesGroups(track, overlayMode, yDomain, yDomains, viewportYDomains),
49
+ resolveRenderedYAxisSeriesGroups(
50
+ track,
51
+ overlayMode,
52
+ yDomain,
53
+ yDomains,
54
+ viewportYDomains,
55
+ upperPaddingRatio,
56
+ ),
48
57
  )
49
58
  .map(
50
59
  (group) =>
@@ -52,7 +61,7 @@ export function resolveYAxisLayoutMetrics(
52
61
  group.domain,
53
62
  nice,
54
63
  undefined,
55
- group.seriesList[0]?.unit,
64
+ showUnits ? group.seriesList[0]?.unit : undefined,
56
65
  tickCount,
57
66
  compact,
58
67
  ).tickTextWidth,
@@ -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
+ }
@@ -7,11 +7,14 @@
7
7
  export type {
8
8
  WaveformPoint,
9
9
  WaveformDisplayMode,
10
+ WaveformUnitDisplayMode,
11
+ WaveformLayoutPreset,
10
12
  WaveformOverlayMode,
11
13
  WaveformInteractionMode,
12
14
  WaveformXDomainStrategy,
13
15
  WaveformZoomIntentPayload,
14
16
  WaveformZoomEndPayload,
17
+ WaveformPanEndPayload,
15
18
  WaveformZoomResetPayload,
16
19
  WaveformAnnotationStyle,
17
20
  WaveformAnnotation,
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ export function isEditableTarget(target: EventTarget | null): boolean {
2
+ return (
3
+ target instanceof Element &&
4
+ Boolean(
5
+ target.closest(
6
+ 'button, input, select, textarea, [contenteditable]:not([contenteditable="false"])',
7
+ ),
8
+ )
9
+ )
10
+ }
@@ -0,0 +1,53 @@
1
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+ import {
3
+ constrainZoomDomain,
4
+ resolveMinimumZoomSpan,
5
+ type ZoomSeriesGroup,
6
+ } from './zoomConstraints'
7
+
8
+ export function clampViewportDomain(
9
+ domain: [number, number],
10
+ boundary: [number, number],
11
+ ): [number, number] {
12
+ const span = domain[1] - domain[0]
13
+ const boundarySpan = boundary[1] - boundary[0]
14
+ if (span >= boundarySpan) return [...boundary]
15
+ if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
16
+ if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
17
+ return domain
18
+ }
19
+
20
+ export function remotePanBoundary(props: Pick<ResolvedWaveformChartProps, 'panXDomain'>) {
21
+ const domain = props.panXDomain
22
+ return domain && domain.every(Number.isFinite) && domain[0] < domain[1] ? domain : undefined
23
+ }
24
+
25
+ /** Restore remote windows without requiring points inside a not-yet-loaded interval. */
26
+ export function restoreViewportDomain(
27
+ domain: [number, number],
28
+ baseDomain: [number, number],
29
+ groups: readonly ZoomSeriesGroup[],
30
+ props: ResolvedWaveformChartProps,
31
+ ): [number, number] {
32
+ const boundary = remotePanBoundary(props)
33
+ return boundary
34
+ ? clampViewportDomain(domain, boundary)
35
+ : constrainZoomDomain(domain, baseDomain, groups, props)
36
+ }
37
+
38
+ /** Wheel/box zoom keeps the remote position and uses loaded data for scale limits. */
39
+ export function constrainViewportZoom(
40
+ domain: [number, number],
41
+ baseDomain: [number, number],
42
+ groups: readonly ZoomSeriesGroup[],
43
+ props: ResolvedWaveformChartProps,
44
+ ) {
45
+ const boundary = remotePanBoundary(props)
46
+ if (!boundary) return constrainZoomDomain(domain, baseDomain, groups, props)
47
+ return constrainZoomDomain(domain, boundary, [], {
48
+ ...props,
49
+ minVisiblePoints: 0,
50
+ maxZoomScale: null,
51
+ minZoomSpan: resolveMinimumZoomSpan(baseDomain, groups, props),
52
+ })
53
+ }
@@ -2,16 +2,17 @@ import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
2
2
  import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
3
3
  import { MINIMUM_SELECTION_SIZE } from '../core/constants'
4
4
  import type { DisplayTrack, TrackLayout } from '../core/types'
5
- import { hasFixedYDomainForTrack } from '../core/yDomain'
6
5
  import type { ResolvedWaveformChartProps, ViewportSelectionState } from '../core/waveformChartTypes'
7
6
  import type { WaveformChartEmit } from '../core/waveformChartTypes'
8
7
  import type { AnnotationSeriesCandidate } from '../annotation'
9
8
  import { tryReleasePointerCapture } from './pointerCapture'
10
9
  import { transitionViewportInteraction } from './viewportInteractionState'
11
10
  import { createViewportDomainSetter } from './viewportDomain'
12
- import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
11
+ import { constrainViewportZoom } from './remoteViewport'
12
+ import { createViewportPan } from './viewportPan'
13
+ import { transformForDomain } from './zoomConstraints'
13
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
14
- interface ViewportContext {
15
+ export interface ViewportContext {
15
16
  props: ResolvedWaveformChartProps
16
17
  emit: WaveformChartEmit
17
18
  selection: Ref<ViewportSelectionState | null>
@@ -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
@@ -148,51 +141,7 @@ export function useWaveformViewport(context: ViewportContext) {
148
141
  trackLayouts.value.find((item) => item.hasVisibleSeries)
149
142
  if (track) beginViewportDrag(event, track.index, false)
150
143
  }
151
- const applyPan = (active: ViewportSelectionState, track: TrackLayout) => {
152
- const width = track.width || 1
153
- const height = track.height || 1
154
- const dx = active.currentX - active.startX
155
- const dy = active.currentY - active.startY
156
- const xSpan = active.xDomain[1] - active.xDomain[0]
157
- const sourceXDomain = active.independent
158
- ? resolveInitialTrackDomain(track)
159
- : initialXDomain.value
160
- const nextX = clampDomain(
161
- [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
162
- sourceXDomain,
163
- )
164
- if (active.independent) {
165
- const nextTransforms = [...independentTransforms.value]
166
- nextTransforms[track.index] = transformForDomain(nextX, sourceXDomain, width)
167
- independentTransforms.value = nextTransforms
168
- } else {
169
- sharedTransform.value = transformForDomain(nextX, sourceXDomain, innerWidth.value)
170
- }
171
- const targets = active.independent
172
- ? [track]
173
- : trackLayouts.value.filter((target) => target.hasVisibleSeries)
174
- const nextIndependentDomains = { ...independentYDomains.value }
175
- const nextSharedDomains = { ...sharedYDomains.value }
176
- targets.forEach((target) => {
177
- const chartTrack = chartTracks.value.find((item) => item.id === target.id)
178
- if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
179
- return
180
- }
181
- const key = target.series?.trackId ?? target.series?.id ?? target.id
182
- const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
183
- const boundary = chartTrack?.yDomain ?? source
184
- const ySpan = source[1] - source[0]
185
- const nextY = clampDomain(
186
- [source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
187
- boundary,
188
- )
189
- if (active.independent) nextIndependentDomains[target.index] = nextY
190
- else nextSharedDomains[key] = nextY
191
- })
192
- if (active.independent) independentYDomains.value = nextIndependentDomains
193
- else sharedYDomains.value = nextSharedDomains
194
- emit('zoom-change', nextX)
195
- }
144
+ const applyPan = createViewportPan(context)
196
145
  const updateViewportDrag = (event: PointerEvent) => {
197
146
  if (isPresentationMode.value) return
198
147
  const active = selection.value
@@ -242,7 +191,7 @@ export function useWaveformViewport(context: ViewportContext) {
242
191
  const groups = active.independent
243
192
  ? [track.seriesList]
244
193
  : trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
245
- const xDomain = constrainZoomDomain(
194
+ const xDomain = constrainViewportZoom(
246
195
  [track.xScale.invert(left), track.xScale.invert(right)],
247
196
  baseXDomain,
248
197
  groups,
@@ -324,7 +273,16 @@ export function useWaveformViewport(context: ViewportContext) {
324
273
  activeOverlay.value = undefined
325
274
  event.preventDefault()
326
275
  if (completed.kind === 'pan') {
327
- applyPan(completed, track)
276
+ const domain = applyPan(completed, track)
277
+ if (domain.some((value, index) => Math.abs(value - completed.xDomain[index]) > 1e-12)) {
278
+ emit('pan-end', {
279
+ start: domain[0],
280
+ end: domain[1],
281
+ ...(completed.independent
282
+ ? { trackIndex: track.index, seriesIds: track.seriesList.map((series) => series.id) }
283
+ : {}),
284
+ })
285
+ }
328
286
  void nextTick(configureZoom)
329
287
  return
330
288
  }
@@ -15,12 +15,9 @@ 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 {
19
- constrainZoomDomain,
20
- resolveMinimumZoomSpan,
21
- transformForDomain,
22
- type ZoomSeriesGroup,
23
- } from './zoomConstraints'
18
+ import { constrainViewportZoom } from './remoteViewport'
19
+ import { normalizeIntegerZoomDomain } from './integerZoom'
20
+ import { resolveMinimumZoomSpan, transformForDomain, type ZoomSeriesGroup } from './zoomConstraints'
24
21
  import {
25
22
  emitIndependentWheelZoomEnds,
26
23
  emitSharedWheelZoomEnd,
@@ -90,7 +87,8 @@ export function useWaveformZoom(context: ZoomContext) {
90
87
  const domain = event.transform
91
88
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
92
89
  .domain()
93
- emit('zoom-intent', { start: domain[0], end: domain[1], gesture: 'wheel' })
90
+ const [start, end] = normalizeIntegerZoomDomain(domain as [number, number], props)
91
+ emit('zoom-intent', { start, end, gesture: 'wheel' })
94
92
  scheduleZoomCommit()
95
93
  }
96
94
  const handleIndependentZoom = (
@@ -108,8 +106,8 @@ export function useWaveformZoom(context: ZoomContext) {
108
106
  .rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
109
107
  .domain()
110
108
  emit('zoom-intent', {
111
- start: domain[0],
112
- end: domain[1],
109
+ start: normalizeIntegerZoomDomain(domain as [number, number], props)[0],
110
+ end: normalizeIntegerZoomDomain(domain as [number, number], props)[1],
113
111
  trackIndex,
114
112
  seriesIds: track.seriesList.map((series) => series.id),
115
113
  gesture: 'wheel',
@@ -131,7 +129,7 @@ export function useWaveformZoom(context: ZoomContext) {
131
129
  const domain = transform
132
130
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
133
131
  .domain()
134
- emit('zoom-change', [domain[0], domain[1]])
132
+ emit('zoom-change', normalizeIntegerZoomDomain([domain[0], domain[1]], props))
135
133
  }
136
134
  if (!pendingIndependentZoomTransforms.size) return
137
135
  const nextTransforms = [...independentTransforms.value]
@@ -238,7 +236,11 @@ export function useWaveformZoom(context: ZoomContext) {
238
236
  number,
239
237
  number,
240
238
  ]
241
- return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
239
+ return transformForDomain(
240
+ constrainViewportZoom(requested, domain, groups, props),
241
+ domain,
242
+ width,
243
+ )
242
244
  }
243
245
  const canZoomTrack = (track: TrackLayout): boolean => {
244
246
  const minimum = Number(props.minVisiblePoints)
@@ -4,7 +4,8 @@ import type { ZoomTransform } from 'd3'
4
4
  import type { TrackLayout } from '../core/types'
5
5
  import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
6
6
  import type { AnnotationSeriesCandidate } from '../annotation'
7
- import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
7
+ import { transformForDomain } from './zoomConstraints'
8
+ import { constrainViewportZoom } from './remoteViewport'
8
9
 
9
10
  interface ViewportDomainContext {
10
11
  props: ResolvedWaveformChartProps
@@ -21,6 +22,8 @@ interface ViewportDomainContext {
21
22
  configureZoom: () => void
22
23
  }
23
24
 
25
+ export { clampViewportDomain } from './remoteViewport'
26
+
24
27
  export function createViewportDomainSetter(context: ViewportDomainContext) {
25
28
  return (domain: [number, number], trackIndex?: number) => {
26
29
  if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
@@ -37,7 +40,12 @@ export function createViewportDomainSetter(context: ViewportDomainContext) {
37
40
  const track = context.trackLayouts.value.find((item) => item.index === index)
38
41
  if (!track) return
39
42
  const boundary = context.resolveInitialTrackDomain(track)
40
- const constrained = constrainZoomDomain(domain, boundary, [track.seriesList], context.props)
43
+ const constrained = constrainViewportZoom(
44
+ domain,
45
+ boundary,
46
+ [track.seriesList],
47
+ context.props,
48
+ )
41
49
  nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
42
50
  })
43
51
  context.independentTransforms.value = nextTransforms
@@ -46,7 +54,7 @@ export function createViewportDomainSetter(context: ViewportDomainContext) {
46
54
  const groups = context.trackLayouts.value
47
55
  .filter((track) => track.hasVisibleSeries)
48
56
  .map((track) => track.seriesList)
49
- const constrained = constrainZoomDomain(domain, boundary, groups, context.props)
57
+ const constrained = constrainViewportZoom(domain, boundary, groups, context.props)
50
58
  context.sharedTransform.value = transformForDomain(
51
59
  constrained,
52
60
  boundary,