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.
- package/README.md +8 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +937 -714
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +1 -0
- package/dist/types/components/core/constants.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +8 -15
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
- package/dist/types/components/core/useWaveformData.d.ts +1 -1
- package/dist/types/components/core/useWaveformLayout.d.ts +0 -1
- package/dist/types/components/core/useWaveformPresentation.d.ts +5 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +9 -5
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/index.d.ts +1 -1
- package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
- package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
- package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
- package/dist/types/components/waveform.d.ts +1 -1
- package/dist/types/core/rendering.d.ts +1 -4
- package/dist/types/core/renderingStrategies.d.ts +22 -0
- package/dist/types/demo/types.d.ts +4 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +7 -0
- package/dist/types/types/index.d.ts +1 -1
- package/package.json +5 -1
- package/src/App.vue +10 -4
- package/src/components/WaveformChart.vue +1 -0
- package/src/components/WaveformChartView.vue +15 -12
- package/src/components/core/constants.ts +3 -0
- package/src/components/core/useWaveformChartController.ts +7 -2
- package/src/components/core/useWaveformChartLifecycle.ts +47 -3
- package/src/components/core/useWaveformData.ts +6 -1
- package/src/components/core/useWaveformLayout.ts +0 -3
- package/src/components/core/useWaveformPresentation.ts +17 -2
- package/src/components/core/waveformChartTypes.ts +7 -3
- package/src/components/data/types.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/components/interaction/WaveformTooltip.test.ts +39 -0
- package/src/components/interaction/WaveformTooltip.vue +72 -22
- package/src/components/interaction/pointerCapture.ts +10 -0
- package/src/components/interaction/useWaveformHover.ts +1 -1
- package/src/components/interaction/useWaveformViewport.ts +86 -74
- package/src/components/interaction/useWaveformZoom.ts +46 -14
- package/src/components/interaction/viewportInteractionState.test.ts +101 -0
- package/src/components/interaction/viewportInteractionState.ts +89 -0
- package/src/components/interaction/zoomConstraints.ts +167 -0
- package/src/components/waveform.ts +1 -0
- package/src/components/waveformChartCases/sizingAndTitles.test.ts +59 -0
- package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
- package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
- package/src/core/rendering.ts +11 -81
- package/src/core/renderingStrategies.test.ts +90 -0
- package/src/core/renderingStrategies.ts +131 -0
- package/src/data/simulatedWaveforms.ts +9 -10
- package/src/demo/DemoChartHost.vue +2 -2
- package/src/demo/DemoGridFrameControls.vue +29 -0
- package/src/demo/plotMarginControls.test.ts +29 -0
- package/src/demo/types.ts +4 -1
- package/src/index.ts +1 -0
- package/src/styles.css +1 -0
- package/src/types/chart.ts +8 -0
- package/src/types/index.ts +1 -0
- package/src/utils/formatters.test.ts +4 -3
- package/src/utils/formatters.ts +1 -2
- package/src/utils/sampling.ts +2 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { pointer,
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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,
|
|
220
|
-
|
|
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
|
-
|
|
203
|
+
const currentY = Math.max(
|
|
225
204
|
0,
|
|
226
205
|
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
|
227
206
|
)
|
|
228
|
-
selection.value
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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(
|
|
311
|
-
applyBoxZoom(
|
|
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 = (
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
232
|
-
|
|
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(
|
|
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) =>
|
|
270
|
-
|
|
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
|
+
}
|