waveform-analysis 0.1.30 → 0.1.31

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 (41) hide show
  1. package/README.md +8 -0
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +815 -601
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/core/useWaveformChartController.d.ts +2 -14
  6. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
  7. package/dist/types/components/core/useWaveformData.d.ts +1 -1
  8. package/dist/types/components/core/waveformChartTypes.d.ts +6 -3
  9. package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
  10. package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
  11. package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
  12. package/dist/types/core/rendering.d.ts +1 -4
  13. package/dist/types/core/renderingStrategies.d.ts +22 -0
  14. package/dist/types/demo/types.d.ts +0 -1
  15. package/package.json +5 -1
  16. package/src/App.vue +0 -4
  17. package/src/components/WaveformChartView.vue +2 -2
  18. package/src/components/core/useWaveformChartController.ts +7 -2
  19. package/src/components/core/useWaveformChartLifecycle.ts +47 -3
  20. package/src/components/core/useWaveformData.ts +6 -1
  21. package/src/components/core/waveformChartTypes.ts +4 -3
  22. package/src/components/interaction/WaveformTooltip.test.ts +39 -0
  23. package/src/components/interaction/WaveformTooltip.vue +72 -22
  24. package/src/components/interaction/pointerCapture.ts +10 -0
  25. package/src/components/interaction/useWaveformHover.ts +1 -1
  26. package/src/components/interaction/useWaveformViewport.ts +86 -74
  27. package/src/components/interaction/useWaveformZoom.ts +46 -14
  28. package/src/components/interaction/viewportInteractionState.test.ts +101 -0
  29. package/src/components/interaction/viewportInteractionState.ts +89 -0
  30. package/src/components/interaction/zoomConstraints.ts +167 -0
  31. package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
  32. package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
  33. package/src/core/rendering.ts +11 -81
  34. package/src/core/renderingStrategies.test.ts +90 -0
  35. package/src/core/renderingStrategies.ts +131 -0
  36. package/src/data/simulatedWaveforms.ts +9 -10
  37. package/src/demo/DemoChartHost.vue +1 -2
  38. package/src/demo/types.ts +0 -1
  39. package/src/utils/formatters.test.ts +4 -3
  40. package/src/utils/formatters.ts +1 -2
  41. package/src/utils/sampling.ts +2 -1
@@ -33,24 +33,68 @@ const props = defineProps<Props>()
33
33
 
34
34
  const tooltipGap = 12
35
35
  const containerPadding = 8
36
- const tooltipMaxWidth = 238
36
+ const tooltipPlacementWidth = 238
37
+ const tooltipMaxWidth = 320
38
+ const tooltipHorizontalPadding = 20
39
+ const tooltipLineHeight = 20
40
+
41
+ function estimateLineCount(text: string, width: number): number {
42
+ const contentWidth = Math.max(1, width - tooltipHorizontalPadding - 14)
43
+ const charactersPerLine = Math.max(1, Math.floor(contentWidth / 7.2))
44
+ return Math.max(1, Math.ceil([...text].length / charactersPerLine))
45
+ }
46
+
47
+ function formatSeriesText(seriesPoint: SeriesPoint): string {
48
+ const error = formatError(seriesPoint.point)
49
+ return `${seriesPoint.name ? `${seriesPoint.name}: ` : ''}${formatTooltipNumber(seriesPoint.point.y)}${
50
+ seriesPoint.unit ? ` ${seriesPoint.unit}` : ''
51
+ }${error ? ` ${error}` : ''}`
52
+ }
53
+
54
+ function estimateTooltipHeight(width: number, timeText: string): number {
55
+ const seriesLines = props.seriesPoints.reduce(
56
+ (total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
57
+ 0,
58
+ )
59
+ return 16 + tooltipLineHeight * (estimateLineCount(timeText, width) + seriesLines) + 5
60
+ }
37
61
 
38
62
  const tooltipStyle = computed(() => {
39
63
  if (!props.visible || !props.hoveredPoint) return { display: 'none' }
40
64
 
41
- const estimatedHeight = 44 + props.seriesPoints.length * 22
42
65
  const rightPlacement = props.position.x + tooltipGap
43
- const leftPlacement = props.position.x - tooltipGap - tooltipMaxWidth
66
+ const leftPlacement = props.position.x - tooltipGap - tooltipPlacementWidth
67
+ const rightAvailableWidth = props.containerWidth - containerPadding - rightPlacement
68
+ const leftAvailableWidth = props.position.x - tooltipGap - containerPadding
69
+ const availableWidth = Math.max(
70
+ 1,
71
+ Math.min(tooltipMaxWidth, props.containerWidth - containerPadding * 2),
72
+ )
44
73
  const horizontalStyle =
45
- rightPlacement + tooltipMaxWidth <= props.containerWidth - containerPadding
46
- ? { left: `${rightPlacement}px` }
74
+ rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
75
+ ? {
76
+ left: `${rightPlacement}px`,
77
+ maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
78
+ }
47
79
  : leftPlacement >= containerPadding
48
- ? { right: `${props.containerWidth - props.position.x + tooltipGap}px` }
49
- : { left: `${containerPadding}px` }
80
+ ? {
81
+ right: `${props.containerWidth - props.position.x + tooltipGap}px`,
82
+ maxWidth: `${Math.min(tooltipMaxWidth, leftAvailableWidth)}px`,
83
+ }
84
+ : { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
85
+
86
+ const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
87
+ const timeText = `${props.timeUnit}: ${formatTooltipTime(props.hoveredPoint.x, props.timeUnit)}`
50
88
 
51
89
  return {
52
90
  ...horizontalStyle,
53
- top: `${Math.max(8, Math.min(props.position.y - 18, props.containerHeight - estimatedHeight - 8))}px`,
91
+ top: `${Math.max(
92
+ 8,
93
+ Math.min(
94
+ props.position.y - 18,
95
+ props.containerHeight - estimateTooltipHeight(maxWidth, timeText) - 8,
96
+ ),
97
+ )}px`,
54
98
  }
55
99
  })
56
100
 
@@ -76,11 +120,13 @@ function formatError(point: WaveformPoint): string | null {
76
120
  class="waveform-tooltip__series waveform-chart__tooltip-series"
77
121
  >
78
122
  <i :style="{ backgroundColor: seriesPoint.color }" />
79
- <strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
80
- <span class="waveform-tooltip__value">
81
- {{ formatTooltipNumber(seriesPoint.point.y)
82
- }}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
83
- <small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
123
+ <span class="waveform-tooltip__series-content">
124
+ <strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
125
+ <span class="waveform-tooltip__value">
126
+ {{ formatTooltipNumber(seriesPoint.point.y)
127
+ }}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
128
+ <small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
129
+ </span>
84
130
  </span>
85
131
  </span>
86
132
  </div>
@@ -93,8 +139,8 @@ function formatError(point: WaveformPoint): string | null {
93
139
  z-index: 2;
94
140
  display: grid;
95
141
  gap: 3px;
96
- min-width: 180px;
97
- max-width: 238px;
142
+ width: max-content;
143
+ max-width: min(320px, calc(100% - 16px));
98
144
  padding: 8px 10px;
99
145
  color: #333;
100
146
  font:
@@ -116,30 +162,34 @@ function formatError(point: WaveformPoint): string | null {
116
162
 
117
163
  .waveform-tooltip__series {
118
164
  display: grid;
119
- grid-template-columns: 8px minmax(0, 1fr) auto;
165
+ grid-template-columns: 8px minmax(0, 1fr);
120
166
  gap: 6px;
121
- align-items: center;
167
+ align-items: start;
122
168
  }
123
169
 
124
170
  .waveform-tooltip__series i {
171
+ flex: 0 0 auto;
125
172
  width: 8px;
126
173
  height: 8px;
174
+ margin-top: 4px;
127
175
  border-radius: 50%;
128
176
  }
129
177
 
178
+ .waveform-tooltip__series-content {
179
+ min-width: 0;
180
+ overflow-wrap: anywhere;
181
+ white-space: normal;
182
+ }
183
+
130
184
  .waveform-tooltip__series strong {
131
- overflow: hidden;
132
185
  font-weight: 600;
133
- text-overflow: ellipsis;
134
- white-space: nowrap;
135
186
  }
136
187
 
137
188
  .waveform-tooltip__value {
138
- white-space: nowrap;
189
+ overflow-wrap: anywhere;
139
190
  }
140
191
 
141
192
  .waveform-tooltip__series small {
142
193
  color: #667085;
143
- white-space: nowrap;
144
194
  }
145
195
  </style>
@@ -0,0 +1,10 @@
1
+ export function tryReleasePointerCapture(
2
+ target: SVGRectElement | null | undefined,
3
+ pointerId: number,
4
+ ): void {
5
+ try {
6
+ target?.releasePointerCapture?.(pointerId)
7
+ } catch {
8
+ // The target may already be detached or have released the pointer.
9
+ }
10
+ }
@@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) {
136
136
  }
137
137
  const handleSharedPointerMove = (event: PointerEvent) => {
138
138
  if (isPresentationMode.value) return
139
- if (selection.value?.overlay === event.currentTarget) {
139
+ if (selection.value && !selection.value.independent) {
140
140
  updateViewportDrag(event)
141
141
  return
142
142
  }
@@ -1,6 +1,5 @@
1
- import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
2
- import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
3
-
1
+ import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
2
+ import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
4
3
  import { MINIMUM_SELECTION_SIZE } from '../core/constants'
5
4
  import type { DisplayTrack, TrackLayout } from '../core/types'
6
5
  import { hasFixedYDomainForTrack } from '../core/yDomain'
@@ -10,7 +9,9 @@ import type {
10
9
  WaveformChartEmit,
11
10
  } from '../core/waveformChartTypes'
12
11
  import type { AnnotationSeriesCandidate } from '../annotation'
13
-
12
+ import { tryReleasePointerCapture } from './pointerCapture'
13
+ import { transitionViewportInteraction } from './viewportInteractionState'
14
+ import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
14
15
  interface ViewportContext {
15
16
  props: ResolvedWaveformChartProps
16
17
  emit: WaveformChartEmit
@@ -37,7 +38,6 @@ interface ViewportContext {
37
38
  clearHover: () => void
38
39
  resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
39
40
  }
40
-
41
41
  export function useWaveformViewport(context: ViewportContext) {
42
42
  const {
43
43
  props,
@@ -65,6 +65,23 @@ export function useWaveformViewport(context: ViewportContext) {
65
65
  clearHover,
66
66
  resolveTrackAtPointer,
67
67
  } = context
68
+ const activeOverlay = shallowRef<SVGRectElement>()
69
+ const releasePointerCapture = (pointerId: number, event?: PointerEvent) => {
70
+ const overlay = activeOverlay.value
71
+ const eventTarget = event?.currentTarget as SVGRectElement | null
72
+ tryReleasePointerCapture(overlay, pointerId)
73
+ if (eventTarget && eventTarget !== overlay) tryReleasePointerCapture(eventTarget, pointerId)
74
+ }
75
+ const cleanupViewportDrag = (pointerId: number, event?: PointerEvent) => {
76
+ const active = selection.value
77
+ if (!active || active.pointerId !== pointerId) return
78
+ releasePointerCapture(pointerId, event)
79
+ selection.value = transitionViewportInteraction(selection.value, {
80
+ type: 'cancel',
81
+ pointerId,
82
+ }).state
83
+ activeOverlay.value = undefined
84
+ }
68
85
  const selectionBox = computed(() => {
69
86
  const active = selection.value
70
87
  if (!active) return null
@@ -76,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
76
93
  height: Math.abs(active.currentY - active.startY),
77
94
  }
78
95
  })
79
- const transformForDomain = (
80
- domain: [number, number],
81
- baseDomain: [number, number],
82
- width: number,
83
- ): ZoomTransform => {
84
- const baseSpan = baseDomain[1] - baseDomain[0]
85
- const span = domain[1] - domain[0]
86
- if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
87
- return zoomIdentity
88
- }
89
- const scale = baseSpan / span
90
- const baseScale = scaleLinear(baseDomain, [0, width])
91
- return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
92
- }
93
- const resolveMinimumZoomSpan = (boundary: [number, number]): number => {
94
- const boundarySpan = Math.abs(boundary[1] - boundary[0])
95
- if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
96
- const configured = props.minZoomSpan
97
- if (Number.isFinite(configured) && (configured ?? 0) > 0) {
98
- return Math.min(boundarySpan, configured as number)
99
- }
100
- return boundarySpan / 40
101
- }
102
- const constrainZoomDomain = (
103
- domain: [number, number],
104
- boundary: [number, number],
105
- ): [number, number] => {
106
- const normalizedBoundary: [number, number] =
107
- boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
108
- const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0]
109
- if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary
110
- const requestedStart = Math.min(domain[0], domain[1])
111
- const requestedEnd = Math.max(domain[0], domain[1])
112
- const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary)
113
- const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart))
114
- const center = (requestedStart + requestedEnd) / 2
115
- const start = Math.max(
116
- normalizedBoundary[0],
117
- Math.min(center - span / 2, normalizedBoundary[1] - span),
118
- )
119
- return [start, start + span]
120
- }
121
96
  const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
122
97
  const span = domain[1] - domain[0]
123
98
  const boundarySpan = boundary[1] - boundary[0]
@@ -143,19 +118,22 @@ export function useWaveformViewport(context: ViewportContext) {
143
118
  const [rawX, rawY] = pointer(event, overlay)
144
119
  const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
145
120
  const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
146
- selection.value = {
147
- trackIndex,
148
- independent,
149
- overlay,
150
- startX: x,
151
- startY: y,
152
- currentX: x,
153
- currentY: y,
154
- pointerId: event.pointerId,
155
- mode: panRequested ? 'pan' : 'box',
156
- xDomain: track.xScale.domain() as [number, number],
157
- yDomains: currentYDomains(),
158
- }
121
+ const started = transitionViewportInteraction(selection.value, {
122
+ type: 'begin',
123
+ gesture: {
124
+ trackIndex,
125
+ independent,
126
+ startX: x,
127
+ startY: y,
128
+ pointerId: event.pointerId,
129
+ kind: panRequested ? 'pan' : 'box',
130
+ xDomain: track.xScale.domain() as [number, number],
131
+ yDomains: currentYDomains(),
132
+ },
133
+ })
134
+ if (!started.accepted) return
135
+ selection.value = started.state
136
+ activeOverlay.value = overlay
159
137
  overlay.setPointerCapture?.(event.pointerId)
160
138
  clearHover()
161
139
  event.preventDefault()
@@ -213,27 +191,34 @@ export function useWaveformViewport(context: ViewportContext) {
213
191
  const updateViewportDrag = (event: PointerEvent) => {
214
192
  if (isPresentationMode.value) return
215
193
  const active = selection.value
216
- if (!active || event.pointerId !== active.pointerId) return
194
+ const overlay = activeOverlay.value
195
+ if (!active || !overlay || event.pointerId !== active.pointerId) return
217
196
  const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
218
197
  if (!track) return
219
- const [rawX, rawY] = pointer(event, active.overlay)
220
- active.currentX = Math.max(
198
+ const [rawX, rawY] = pointer(event, overlay)
199
+ const currentX = Math.max(
221
200
  0,
222
201
  Math.min(active.independent ? track.width : innerWidth.value, rawX),
223
202
  )
224
- active.currentY = Math.max(
203
+ const currentY = Math.max(
225
204
  0,
226
205
  Math.min(active.independent ? track.height : innerHeight.value, rawY),
227
206
  )
228
- selection.value = { ...active }
229
- if (active.mode === 'pan') applyPan(active, track)
207
+ const transition = transitionViewportInteraction(selection.value, {
208
+ type: 'move',
209
+ pointerId: event.pointerId,
210
+ position: { currentX, currentY },
211
+ })
212
+ if (!transition.accepted || !transition.state) return
213
+ const next = transition.state
214
+ selection.value = next
215
+ if (next.kind === 'pan') applyPan(next, track)
230
216
  event.preventDefault()
231
217
  }
232
218
  const cancelViewportDrag = (event?: PointerEvent) => {
233
219
  const active = selection.value
234
220
  if (!active || (event && event.pointerId !== active.pointerId)) return
235
- active.overlay.releasePointerCapture?.(active.pointerId)
236
- selection.value = null
221
+ cleanupViewportDrag(active.pointerId, event)
237
222
  }
238
223
  const applyBoxZoom = (active: ViewportSelectionState) => {
239
224
  const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
@@ -249,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
249
234
  )
250
235
  if (right - left < MINIMUM_SELECTION_SIZE) return
251
236
  const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
237
+ const groups = active.independent
238
+ ? [track.seriesList]
239
+ : trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
252
240
  const xDomain = constrainZoomDomain(
253
241
  [track.xScale.invert(left), track.xScale.invert(right)],
254
242
  baseXDomain,
243
+ groups,
244
+ props,
255
245
  )
256
246
  if (active.independent) {
257
247
  const next = [...independentTransforms.value]
@@ -300,15 +290,38 @@ export function useWaveformViewport(context: ViewportContext) {
300
290
  }
301
291
  const active = selection.value
302
292
  if (!active || event.pointerId !== active.pointerId) return
303
- updateViewportDrag(event)
304
- active.overlay.releasePointerCapture?.(active.pointerId)
293
+ const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
294
+ const overlay = activeOverlay.value
295
+ if (!track || !overlay || !overlay.parentNode) {
296
+ cleanupViewportDrag(active.pointerId, event)
297
+ return
298
+ }
299
+ const [rawX, rawY] = pointer(event, overlay)
300
+ const currentX = Math.max(
301
+ 0,
302
+ Math.min(active.independent ? track.width : innerWidth.value, rawX),
303
+ )
304
+ const currentY = Math.max(
305
+ 0,
306
+ Math.min(active.independent ? track.height : innerHeight.value, rawY),
307
+ )
308
+ const completed = transitionViewportInteraction(selection.value, {
309
+ type: 'finish',
310
+ pointerId: event.pointerId,
311
+ position: { currentX, currentY },
312
+ }).completed
313
+ if (!completed) return
305
314
  selection.value = null
306
- if (active.mode === 'pan') {
315
+ releasePointerCapture(completed.pointerId, event)
316
+ activeOverlay.value = undefined
317
+ event.preventDefault()
318
+ if (completed.kind === 'pan') {
319
+ applyPan(completed, track)
307
320
  void nextTick(configureZoom)
308
321
  return
309
322
  }
310
- if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
311
- applyBoxZoom(active)
323
+ if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
324
+ applyBoxZoom(completed)
312
325
  }
313
326
  }
314
327
  const resetViewport = (trackIndex?: number) => {
@@ -337,7 +350,6 @@ export function useWaveformViewport(context: ViewportContext) {
337
350
  resetViewport()
338
351
  emit('zoom-reset')
339
352
  }
340
-
341
353
  return {
342
354
  selectionBox,
343
355
  beginViewportDrag,
@@ -15,6 +15,12 @@ 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
24
 
19
25
  interface ZoomContext {
20
26
  props: ResolvedWaveformChartProps
@@ -216,24 +222,38 @@ export function useWaveformZoom(context: ZoomContext) {
216
222
  .forEach((overlay) => select(overlay).on('.zoom', null))
217
223
  zoomBehaviors.clear()
218
224
  }
219
- const resolveMaximumZoomScale = (domain: [number, number]): number => {
220
- const minZoomSpan = props.minZoomSpan
221
- if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
222
- return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
223
- }
225
+ const resolveMaximumZoomScale = (
226
+ domain: [number, number],
227
+ groups: readonly ZoomSeriesGroup[],
228
+ ): number => {
224
229
  const domainSpan = Math.abs(domain[1] - domain[0])
225
230
  if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
226
- return Math.min(
227
- ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
228
- Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
231
+ const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
232
+ return Math.max(
233
+ ZOOM_CONSTRAINTS.MIN_SCALE,
234
+ minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
229
235
  )
230
236
  }
231
- const canZoomTrack = (track: TrackLayout): boolean =>
232
- hasMinimumVisibleXValues(
237
+ const constrainTransform = (
238
+ transform: ZoomTransform,
239
+ domain: [number, number],
240
+ width: number,
241
+ groups: readonly ZoomSeriesGroup[],
242
+ ): ZoomTransform => {
243
+ const requested = transform.rescaleX(scaleLinear(domain, [0, width])).domain() as [
244
+ number,
245
+ number,
246
+ ]
247
+ return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
248
+ }
249
+ const canZoomTrack = (track: TrackLayout): boolean => {
250
+ const minimum = Number(props.minVisiblePoints)
251
+ return hasMinimumVisibleXValues(
233
252
  track.seriesList,
234
253
  track.xScale.domain() as [number, number],
235
- Number(props.minVisiblePoints),
254
+ Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
236
255
  )
256
+ }
237
257
  const canZoomSharedTracks = (): boolean => {
238
258
  const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
239
259
  return tracks.length > 0 && tracks.every(canZoomTrack)
@@ -265,9 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
265
285
  )
266
286
  if (!overlay) return
267
287
  const dataDomain = resolveInitialTrackDomain(track)
288
+ const groups = [track.seriesList]
268
289
  const behavior = zoom<SVGRectElement, unknown>()
269
- .filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
270
- .scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
290
+ .filter((event) => {
291
+ const currentTrack =
292
+ trackLayouts.value.find((item) => item.index === track.index) ?? track
293
+ return canHandleWheelZoom(event, canZoomTrack(currentTrack))
294
+ })
295
+ .scaleExtent([1, resolveMaximumZoomScale(dataDomain, groups)])
296
+ .constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
271
297
  .extent([
272
298
  [0, 0],
273
299
  [track.width, track.height],
@@ -293,9 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
293
319
  }
294
320
  const overlay = sharedOverlayElement.value
295
321
  if (!overlay) return
322
+ const groups = trackLayouts.value
323
+ .filter((track) => track.hasVisibleSeries)
324
+ .map((track) => track.seriesList)
296
325
  const behavior = zoom<SVGRectElement, unknown>()
297
326
  .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
298
- .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
327
+ .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
328
+ .constrain((transform) =>
329
+ constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
330
+ )
299
331
  .extent([
300
332
  [0, 0],
301
333
  [innerWidth.value, innerHeight.value],
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ reduceViewportInteraction,
5
+ transitionViewportInteraction,
6
+ type ViewportInteractionEvent,
7
+ } from './viewportInteractionState'
8
+
9
+ const gesture = {
10
+ trackIndex: 2,
11
+ independent: true,
12
+ startX: 10,
13
+ startY: 20,
14
+ pointerId: 7,
15
+ kind: 'box' as const,
16
+ xDomain: [0, 100] as [number, number],
17
+ yDomains: { track: [-1, 1] as [number, number] },
18
+ }
19
+
20
+ describe('viewport interaction reducer', () => {
21
+ it('accepts one begin and rejects a conflicting begin', () => {
22
+ const started = transitionViewportInteraction(null, { type: 'begin', gesture })
23
+ const conflicting = transitionViewportInteraction(started.state, {
24
+ type: 'begin',
25
+ gesture: { ...gesture, pointerId: 8 },
26
+ })
27
+
28
+ expect(started.accepted).toBe(true)
29
+ expect(started.state).toMatchObject({ kind: 'box', pointerId: 7 })
30
+ expect(conflicting.accepted).toBe(false)
31
+ expect(conflicting.state).toMatchObject({ kind: 'box', pointerId: 7 })
32
+ })
33
+
34
+ it('rejects move and finish from a different pointer without changing state', () => {
35
+ const state = reduceViewportInteraction(null, { type: 'begin', gesture })
36
+ const move = transitionViewportInteraction(state, {
37
+ type: 'move',
38
+ pointerId: 8,
39
+ position: { currentX: 30, currentY: 40 },
40
+ })
41
+ const finish = transitionViewportInteraction(move.state, {
42
+ type: 'finish',
43
+ pointerId: 8,
44
+ position: { currentX: 30, currentY: 40 },
45
+ })
46
+
47
+ expect(move.accepted).toBe(false)
48
+ expect(finish.accepted).toBe(false)
49
+ expect(finish.state).toMatchObject({ currentX: 10, currentY: 20 })
50
+ })
51
+
52
+ it('updates a valid pointer and returns the completed snapshot on finish', () => {
53
+ const state = reduceViewportInteraction(null, {
54
+ type: 'begin',
55
+ gesture: { ...gesture, kind: 'pan' },
56
+ })
57
+ const moved = transitionViewportInteraction(state, {
58
+ type: 'move',
59
+ pointerId: 7,
60
+ position: { currentX: 30, currentY: 40 },
61
+ })
62
+ const finished = transitionViewportInteraction(moved.state, {
63
+ type: 'finish',
64
+ pointerId: 7,
65
+ position: { currentX: 50, currentY: 60 },
66
+ })
67
+
68
+ expect(moved.state).toMatchObject({ kind: 'pan', currentX: 30, currentY: 40 })
69
+ expect(finished.completed).toMatchObject({ kind: 'pan', currentX: 50, currentY: 60 })
70
+ expect(finished.state).toBeNull()
71
+ })
72
+
73
+ it('cancels only the owning pointer and supports cancel/reset events', () => {
74
+ const state = reduceViewportInteraction(null, { type: 'begin', gesture })
75
+ const rejectedCancel = transitionViewportInteraction(state, { type: 'cancel', pointerId: 8 })
76
+ const cancelled = transitionViewportInteraction(rejectedCancel.state, {
77
+ type: 'cancel',
78
+ pointerId: 7,
79
+ })
80
+ const restarted = reduceViewportInteraction(null, { type: 'begin', gesture })
81
+ const reset = transitionViewportInteraction(restarted, { type: 'reset' })
82
+
83
+ expect(rejectedCancel.accepted).toBe(false)
84
+ expect(rejectedCancel.state).not.toBeNull()
85
+ expect(cancelled.accepted).toBe(true)
86
+ expect(cancelled.state).toBeNull()
87
+ expect(reset.accepted).toBe(true)
88
+ expect(reset.state).toBeNull()
89
+ })
90
+
91
+ it('keeps events discriminated and does not mutate the begin input', () => {
92
+ const event: ViewportInteractionEvent = { type: 'begin', gesture }
93
+ const state = reduceViewportInteraction(null, event)
94
+
95
+ expect(state).not.toBeNull()
96
+ expect(state?.xDomain).toEqual([0, 100])
97
+ expect(state?.yDomains.track).toEqual([-1, 1])
98
+ expect(event.gesture.xDomain).toEqual([0, 100])
99
+ expect(event.gesture.yDomains.track).toEqual([-1, 1])
100
+ })
101
+ })