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.
- package/README.md +8 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +815 -601
- package/dist/style.css +1 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +2 -14
- 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/waveformChartTypes.d.ts +6 -3
- 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/core/rendering.d.ts +1 -4
- package/dist/types/core/renderingStrategies.d.ts +22 -0
- package/dist/types/demo/types.d.ts +0 -1
- package/package.json +5 -1
- package/src/App.vue +0 -4
- package/src/components/WaveformChartView.vue +2 -2
- 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/waveformChartTypes.ts +4 -3
- 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/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 +1 -2
- package/src/demo/types.ts +0 -1
- package/src/utils/formatters.test.ts +4 -3
- package/src/utils/formatters.ts +1 -2
- 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
|
|
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 -
|
|
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 +
|
|
46
|
-
? {
|
|
74
|
+
rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
|
|
75
|
+
? {
|
|
76
|
+
left: `${rightPlacement}px`,
|
|
77
|
+
maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
|
|
78
|
+
}
|
|
47
79
|
: leftPlacement >= containerPadding
|
|
48
|
-
? {
|
|
49
|
-
|
|
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(
|
|
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
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
97
|
-
max-width:
|
|
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)
|
|
165
|
+
grid-template-columns: 8px minmax(0, 1fr);
|
|
120
166
|
gap: 6px;
|
|
121
|
-
align-items:
|
|
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
|
-
|
|
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
|
|
139
|
+
if (selection.value && !selection.value.independent) {
|
|
140
140
|
updateViewportDrag(event)
|
|
141
141
|
return
|
|
142
142
|
}
|
|
@@ -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
|
+
})
|