waveform-analysis 0.1.29 → 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 (65) hide show
  1. package/README.md +8 -0
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +937 -714
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/WaveformChart.vue.d.ts +1 -0
  6. package/dist/types/components/core/constants.d.ts +2 -0
  7. package/dist/types/components/core/useWaveformChartController.d.ts +8 -15
  8. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
  9. package/dist/types/components/core/useWaveformData.d.ts +1 -1
  10. package/dist/types/components/core/useWaveformLayout.d.ts +0 -1
  11. package/dist/types/components/core/useWaveformPresentation.d.ts +5 -0
  12. package/dist/types/components/core/waveformChartTypes.d.ts +9 -5
  13. package/dist/types/components/data/types.d.ts +1 -1
  14. package/dist/types/components/index.d.ts +1 -1
  15. package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
  16. package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
  17. package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
  18. package/dist/types/components/waveform.d.ts +1 -1
  19. package/dist/types/core/rendering.d.ts +1 -4
  20. package/dist/types/core/renderingStrategies.d.ts +22 -0
  21. package/dist/types/demo/types.d.ts +4 -2
  22. package/dist/types/index.d.ts +1 -1
  23. package/dist/types/types/chart.d.ts +7 -0
  24. package/dist/types/types/index.d.ts +1 -1
  25. package/package.json +5 -1
  26. package/src/App.vue +10 -4
  27. package/src/components/WaveformChart.vue +1 -0
  28. package/src/components/WaveformChartView.vue +15 -12
  29. package/src/components/core/constants.ts +3 -0
  30. package/src/components/core/useWaveformChartController.ts +7 -2
  31. package/src/components/core/useWaveformChartLifecycle.ts +47 -3
  32. package/src/components/core/useWaveformData.ts +6 -1
  33. package/src/components/core/useWaveformLayout.ts +0 -3
  34. package/src/components/core/useWaveformPresentation.ts +17 -2
  35. package/src/components/core/waveformChartTypes.ts +7 -3
  36. package/src/components/data/types.ts +1 -0
  37. package/src/components/index.ts +1 -0
  38. package/src/components/interaction/WaveformTooltip.test.ts +39 -0
  39. package/src/components/interaction/WaveformTooltip.vue +72 -22
  40. package/src/components/interaction/pointerCapture.ts +10 -0
  41. package/src/components/interaction/useWaveformHover.ts +1 -1
  42. package/src/components/interaction/useWaveformViewport.ts +86 -74
  43. package/src/components/interaction/useWaveformZoom.ts +46 -14
  44. package/src/components/interaction/viewportInteractionState.test.ts +101 -0
  45. package/src/components/interaction/viewportInteractionState.ts +89 -0
  46. package/src/components/interaction/zoomConstraints.ts +167 -0
  47. package/src/components/waveform.ts +1 -0
  48. package/src/components/waveformChartCases/sizingAndTitles.test.ts +59 -0
  49. package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
  50. package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
  51. package/src/core/rendering.ts +11 -81
  52. package/src/core/renderingStrategies.test.ts +90 -0
  53. package/src/core/renderingStrategies.ts +131 -0
  54. package/src/data/simulatedWaveforms.ts +9 -10
  55. package/src/demo/DemoChartHost.vue +2 -2
  56. package/src/demo/DemoGridFrameControls.vue +29 -0
  57. package/src/demo/plotMarginControls.test.ts +29 -0
  58. package/src/demo/types.ts +4 -1
  59. package/src/index.ts +1 -0
  60. package/src/styles.css +1 -0
  61. package/src/types/chart.ts +8 -0
  62. package/src/types/index.ts +1 -0
  63. package/src/utils/formatters.test.ts +4 -3
  64. package/src/utils/formatters.ts +1 -2
  65. package/src/utils/sampling.ts +2 -1
@@ -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
+ })
@@ -0,0 +1,89 @@
1
+ import type { ViewportSelectionState } from '../core/waveformChartTypes'
2
+
3
+ export interface ViewportGestureStart {
4
+ trackIndex: number
5
+ independent: boolean
6
+ startX: number
7
+ startY: number
8
+ pointerId: number
9
+ kind: 'box' | 'pan'
10
+ xDomain: [number, number]
11
+ yDomains: Record<string, [number, number]>
12
+ }
13
+
14
+ export interface ViewportGesturePosition {
15
+ currentX: number
16
+ currentY: number
17
+ }
18
+
19
+ export type ViewportInteractionEvent =
20
+ | { type: 'begin'; gesture: ViewportGestureStart }
21
+ | { type: 'move'; pointerId: number; position: ViewportGesturePosition }
22
+ | { type: 'finish'; pointerId: number; position: ViewportGesturePosition }
23
+ | { type: 'cancel'; pointerId?: number }
24
+ | { type: 'reset' }
25
+
26
+ export interface ViewportInteractionTransition {
27
+ state: ViewportSelectionState | null
28
+ accepted: boolean
29
+ completed: ViewportSelectionState | null
30
+ }
31
+
32
+ function createSelectionState(input: ViewportGestureStart): ViewportSelectionState {
33
+ return {
34
+ ...input,
35
+ currentX: input.startX,
36
+ currentY: input.startY,
37
+ xDomain: [...input.xDomain],
38
+ yDomains: Object.fromEntries(
39
+ Object.entries(input.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
40
+ ),
41
+ }
42
+ }
43
+
44
+ function withPosition(
45
+ state: ViewportSelectionState,
46
+ position: ViewportGesturePosition,
47
+ ): ViewportSelectionState {
48
+ return { ...state, ...position }
49
+ }
50
+
51
+ function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition {
52
+ return { state, accepted: false, completed: null }
53
+ }
54
+
55
+ export function transitionViewportInteraction(
56
+ state: ViewportSelectionState | null,
57
+ event: ViewportInteractionEvent,
58
+ ): ViewportInteractionTransition {
59
+ switch (event.type) {
60
+ case 'begin':
61
+ return state
62
+ ? rejectedTransition(state)
63
+ : { state: createSelectionState(event.gesture), accepted: true, completed: null }
64
+ case 'move':
65
+ if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
66
+ return { state: withPosition(state, event.position), accepted: true, completed: null }
67
+ case 'finish':
68
+ if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
69
+ return {
70
+ state: null,
71
+ accepted: true,
72
+ completed: withPosition(state, event.position),
73
+ }
74
+ case 'cancel':
75
+ if (!state || (event.pointerId !== undefined && state.pointerId !== event.pointerId)) {
76
+ return rejectedTransition(state)
77
+ }
78
+ return { state: null, accepted: true, completed: null }
79
+ case 'reset':
80
+ return { state: null, accepted: true, completed: null }
81
+ }
82
+ }
83
+
84
+ export function reduceViewportInteraction(
85
+ state: ViewportSelectionState | null,
86
+ event: ViewportInteractionEvent,
87
+ ): ViewportSelectionState | null {
88
+ return transitionViewportInteraction(state, event).state
89
+ }