waveform-analysis 0.1.58 → 0.1.60

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 (55) hide show
  1. package/README.md +9 -6
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +894 -817
  4. package/dist/types/components/WaveformChart.vue.d.ts +2 -0
  5. package/dist/types/components/core/layout.d.ts +9 -2
  6. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +1 -2
  7. package/dist/types/components/core/waveformChartTypes.d.ts +2 -1
  8. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +10 -0
  9. package/dist/types/components/data/types.d.ts +1 -1
  10. package/dist/types/components/index.d.ts +1 -1
  11. package/dist/types/components/interaction/useWaveformZoom.d.ts +2 -1
  12. package/dist/types/components/interaction/zoomEventPayload.d.ts +7 -0
  13. package/dist/types/core/rendering.d.ts +1 -1
  14. package/dist/types/core/renderingOptions.d.ts +1 -1
  15. package/dist/types/core/wasmSampling.d.ts +1 -1
  16. package/dist/types/core/workerSampling/multiresolution.d.ts +1 -1
  17. package/dist/types/core/workerSampling/protocol.d.ts +1 -1
  18. package/dist/types/core/workerSampling/repositoryDataset.d.ts +1 -1
  19. package/dist/types/core/workerSampling/repositorySupport.d.ts +1 -1
  20. package/dist/types/index.d.ts +1 -1
  21. package/dist/types/types/chart.d.ts +8 -0
  22. package/dist/types/types/index.d.ts +1 -1
  23. package/dist/types/utils/geometry.d.ts +1 -1
  24. package/dist/types/wasm/waveform_sampling_wasm.d.ts +74 -0
  25. package/dist/types/wasm/waveform_sampling_wasm_bg.wasm.d.ts +19 -0
  26. package/package.json +5 -4
  27. package/src/components/core/layout.test.ts +24 -0
  28. package/src/components/core/layout.ts +75 -13
  29. package/src/components/core/trackLayoutBuilder.ts +7 -13
  30. package/src/components/core/useWaveformChartController.ts +6 -1
  31. package/src/components/core/useWaveformChartLifecycle.ts +15 -9
  32. package/src/components/core/useWaveformLayout.ts +30 -58
  33. package/src/components/core/waveformChartTypes.ts +2 -0
  34. package/src/components/core/yAxisLayoutMetrics.ts +75 -0
  35. package/src/components/core/yAxisSlotLayout.test.ts +37 -1
  36. package/src/components/data/types.ts +1 -0
  37. package/src/components/index.ts +1 -0
  38. package/src/components/interaction/useWaveformViewport.ts +2 -0
  39. package/src/components/interaction/useWaveformZoom.ts +58 -61
  40. package/src/components/interaction/zoomEventPayload.ts +88 -0
  41. package/src/components/rendering/WaveformTrackAxes.vue +8 -4
  42. package/src/components/waveformChartCases/layoutAndDomains.test.ts +23 -24
  43. package/src/components/waveformChartCases/yAxisLayout.test.ts +103 -0
  44. package/src/components/waveformChartCases/zoomIntentAndDataRebind.test.ts +213 -0
  45. package/src/core/rendering.ts +1 -1
  46. package/src/core/renderingOptions.ts +1 -1
  47. package/src/core/workerSampling/multiresolution.ts +1 -1
  48. package/src/core/workerSampling/protocol.ts +1 -1
  49. package/src/core/workerSampling/repository.ts +1 -1
  50. package/src/core/workerSampling/repositoryDataset.ts +1 -1
  51. package/src/core/workerSampling/repositorySupport.ts +1 -1
  52. package/src/index.ts +1 -0
  53. package/src/types/chart.ts +9 -0
  54. package/src/types/index.ts +1 -0
  55. package/src/utils/geometry.ts +1 -1
@@ -9,12 +9,12 @@ import {
9
9
  type ShallowRef,
10
10
  } from 'vue'
11
11
 
12
- import type { AnnotationSeriesCandidate } from '../annotation'
13
- import type { useWaveformAnnotationInteraction } from '../annotation'
12
+ import type { AnnotationSeriesCandidate, useWaveformAnnotationInteraction } from '../annotation'
14
13
  import type { NormalizedWaveformGridOptions } from './grid'
15
14
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
16
15
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
17
16
  import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
17
+ import { seriesIdentity } from '../interaction/zoomEventPayload'
18
18
 
19
19
  interface LifecycleContext {
20
20
  props: ResolvedWaveformChartProps
@@ -152,16 +152,20 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
152
152
  }
153
153
 
154
154
  let pendingSharedXDomain: [number, number] | undefined
155
- let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
155
+ let pendingIndependentXDomains: Map<string, [number, number]> | undefined
156
156
 
157
157
  function handleBeforeDataReferenceChange() {
158
158
  if (props.displayMode === 'independent') {
159
159
  pendingSharedXDomain = undefined
160
- pendingIndependentXDomains = trackLayouts.value.map((track) => {
161
- const current = track.xScale.domain() as [number, number]
162
- const boundary = resolveInitialTrackDomain(track)
163
- return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
164
- })
160
+ pendingIndependentXDomains = new Map(
161
+ trackLayouts.value.flatMap((track) => {
162
+ const current = track.xScale.domain() as [number, number]
163
+ const boundary = resolveInitialTrackDomain(track)
164
+ return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
165
+ ? [[seriesIdentity(track.seriesList.map((series) => series.id)), current]]
166
+ : []
167
+ }),
168
+ )
165
169
  return
166
170
  }
167
171
  pendingIndependentXDomains = undefined
@@ -184,7 +188,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
184
188
  if (props.displayMode === 'independent' && pendingIndependentXDomains) {
185
189
  const nextTransforms = chartTracks.value.map(() => zoomIdentity)
186
190
  trackLayouts.value.forEach((track) => {
187
- const previousDomain = pendingIndependentXDomains?.[track.index]
191
+ const previousDomain = pendingIndependentXDomains?.get(
192
+ seriesIdentity(track.seriesList.map((series) => series.id)),
193
+ )
188
194
  if (!previousDomain) return
189
195
  const boundary = resolveInitialTrackDomain(track)
190
196
  const domain = constrainZoomDomain(previousDomain, boundary, [track.seriesList], props)
@@ -7,16 +7,7 @@ import {
7
7
  type AnnotationSeriesInfo,
8
8
  type AnnotationTrackLayout,
9
9
  } from '../annotation'
10
- import {
11
- channelColors,
12
- margin,
13
- MINIMUM_PLOT_WIDTH,
14
- Y_AXIS_CHARACTER_WIDTH,
15
- Y_AXIS_LABEL_BAND_WIDTH,
16
- Y_AXIS_LABEL_GAP,
17
- Y_AXIS_OUTER_PADDING,
18
- Y_AXIS_TICK_PADDING,
19
- } from './constants'
10
+ import { channelColors, margin, MINIMUM_PLOT_WIDTH } from './constants'
20
11
  import {
21
12
  getGridGap,
22
13
  getPageCount,
@@ -24,17 +15,13 @@ import {
24
15
  paginateSeries,
25
16
  resolveGridCellGeometry,
26
17
  } from './grid'
27
- import {
28
- buildTrackLayouts,
29
- axisTextMetrics,
30
- buildYAxisSlots,
31
- resolveYAxisSeriesGroups,
32
- } from './layout'
18
+ import { buildTrackLayouts, buildYAxisSlots, resolveYAxisTickCount } from './layout'
33
19
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
34
20
  import { createFrameNumberResolver, resolvePageableTracks } from './trackPagination'
35
21
  import type { PreparedWaveformSeries } from './useWaveformData'
36
22
  import type { ResolvedWaveformChartProps } from './waveformChartTypes'
37
23
  import { applyXDomainStrategy } from './xDomain'
24
+ import { resolveYAxisLayoutMetrics, resolveViewportYDomains } from './yAxisLayoutMetrics'
38
25
  import type { useWaveformAnnotationInteraction } from '../annotation'
39
26
  interface LayoutContext {
40
27
  props: ResolvedWaveformChartProps
@@ -121,38 +108,29 @@ export function useWaveformLayout(context: LayoutContext) {
121
108
  ? tracksWithSeries
122
109
  : pagedTracks.value
123
110
  })
124
- const yAxisMetrics = computed(() => {
125
- const axisText = chartTracks.value
126
- .filter((track) => track.visibleSeries.length > 0)
127
- .flatMap((track) =>
128
- resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
129
- )
130
- .map(
131
- (group) =>
132
- axisTextMetrics(
133
- group.domain,
134
- props.axes?.y?.nice !== false,
135
- undefined,
136
- group.seriesList[0]?.unit,
137
- props.axes?.y?.splitNumber,
138
- ).tickTextWidth,
139
- )
140
- const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
141
- const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
142
- const labelCenterX = -(
143
- Y_AXIS_TICK_PADDING +
144
- tickTextWidth +
145
- Y_AXIS_LABEL_GAP +
146
- Y_AXIS_LABEL_BAND_WIDTH / 2
147
- )
148
- const fullClearance =
149
- tickTextWidth +
150
- Y_AXIS_TICK_PADDING +
151
- Y_AXIS_LABEL_GAP +
152
- Y_AXIS_LABEL_BAND_WIDTH +
153
- Y_AXIS_OUTER_PADDING
154
- return { tickClearance, fullClearance, labelCenterX }
155
- })
111
+ const yAxisTickCount = computed(() =>
112
+ resolveYAxisTickCount(innerHeight.value, props.axes?.y?.splitNumber),
113
+ )
114
+ const viewportYDomains = computed(() =>
115
+ resolveViewportYDomains(
116
+ props.displayMode,
117
+ chartTracks.value,
118
+ sharedYDomains.value,
119
+ independentYDomains.value,
120
+ ),
121
+ )
122
+ const yAxisMetrics = computed(() =>
123
+ resolveYAxisLayoutMetrics(
124
+ chartTracks.value,
125
+ props.overlayMode,
126
+ props.yDomain,
127
+ props.yDomains,
128
+ viewportYDomains.value,
129
+ props.axes?.y?.nice !== false,
130
+ yAxisTickCount.value,
131
+ props.displayMode === 'compact',
132
+ ),
133
+ )
156
134
  const hasYAxisLabels = computed(() =>
157
135
  chartTracks.value.some(
158
136
  (track) =>
@@ -179,8 +157,10 @@ export function useWaveformLayout(context: LayoutContext) {
179
157
  props.overlayMode,
180
158
  props.yDomain,
181
159
  props.yDomains,
182
- props.axes?.y?.splitNumber,
160
+ yAxisTickCount.value,
183
161
  props.axes?.y?.nice !== false,
162
+ props.displayMode === 'compact',
163
+ viewportYDomains.value,
184
164
  ),
185
165
  )
186
166
  const resolvedChartLeftMargin = computed(() =>
@@ -302,15 +282,7 @@ export function useWaveformLayout(context: LayoutContext) {
302
282
  xDomainStrategy: props.xDomainStrategy,
303
283
  fixedYDomain: props.yDomain,
304
284
  fixedYDomains: props.yDomains,
305
- yDomains:
306
- props.displayMode === 'independent'
307
- ? Object.fromEntries(
308
- chartTracks.value.flatMap((track, index) => {
309
- const domain = independentYDomains.value[index]
310
- return domain ? [[track.id, domain]] : []
311
- }),
312
- )
313
- : sharedYDomains.value,
285
+ yDomains: viewportYDomains.value,
314
286
  timeUnit: props.timeUnit,
315
287
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
316
288
  yAxisSplitNumber: props.axes?.y?.splitNumber,
@@ -16,6 +16,7 @@ import type {
16
16
  WaveformTitleOptions,
17
17
  WaveformXDomainStrategy,
18
18
  WaveformZeroLineOptions,
19
+ WaveformZoomIntentPayload,
19
20
  WaveformZoomEndPayload,
20
21
  WaveformZoomResetPayload,
21
22
  } from '../data/types'
@@ -90,6 +91,7 @@ export type ResolvedWaveformChartProps = Readonly<
90
91
 
91
92
  export interface WaveformChartEmit {
92
93
  (event: 'point-hover', point: WaveformPoint | null): void
94
+ (event: 'zoom-intent', payload: WaveformZoomIntentPayload): void
93
95
  (event: 'zoom-change', domain: [number, number]): void
94
96
  (event: 'zoom-end', payload: WaveformZoomEndPayload): void
95
97
  (event: 'zoom-reset', payload: WaveformZoomResetPayload): void
@@ -0,0 +1,75 @@
1
+ import type { WaveformOverlayMode } from '../data/types'
2
+ import {
3
+ Y_AXIS_CHARACTER_WIDTH,
4
+ Y_AXIS_LABEL_BAND_WIDTH,
5
+ Y_AXIS_LABEL_GAP,
6
+ Y_AXIS_OUTER_PADDING,
7
+ Y_AXIS_TICK_PADDING,
8
+ } from './constants'
9
+ import { axisTextMetrics, resolveRenderedYAxisSeriesGroups } from './layout'
10
+ import type { DisplayTrack } from './types'
11
+ import type { WaveformYDomain } from './yDomain'
12
+
13
+ export interface YAxisLayoutMetrics {
14
+ tickClearance: number
15
+ fullClearance: number
16
+ labelCenterX: number
17
+ }
18
+
19
+ export function resolveViewportYDomains(
20
+ displayMode: 'independent' | 'separated' | 'compact',
21
+ chartTracks: DisplayTrack[],
22
+ sharedYDomains: Record<string, [number, number]>,
23
+ independentYDomains: Record<number, [number, number]>,
24
+ ): Record<string, [number, number]> {
25
+ if (displayMode !== 'independent') return sharedYDomains
26
+ return Object.fromEntries(
27
+ chartTracks.flatMap((track, index) => {
28
+ const domain = independentYDomains[index]
29
+ return domain ? [[track.id, domain]] : []
30
+ }),
31
+ )
32
+ }
33
+
34
+ export function resolveYAxisLayoutMetrics(
35
+ chartTracks: DisplayTrack[],
36
+ overlayMode: WaveformOverlayMode,
37
+ yDomain: WaveformYDomain | undefined,
38
+ yDomains: Record<string, WaveformYDomain> | undefined,
39
+ viewportYDomains: Record<string, [number, number]>,
40
+ nice: boolean,
41
+ tickCount: number,
42
+ compact: boolean,
43
+ ): YAxisLayoutMetrics {
44
+ const axisText = chartTracks
45
+ .filter((track) => track.visibleSeries.length > 0)
46
+ .flatMap((track) =>
47
+ resolveRenderedYAxisSeriesGroups(track, overlayMode, yDomain, yDomains, viewportYDomains),
48
+ )
49
+ .map(
50
+ (group) =>
51
+ axisTextMetrics(
52
+ group.domain,
53
+ nice,
54
+ undefined,
55
+ group.seriesList[0]?.unit,
56
+ tickCount,
57
+ compact,
58
+ ).tickTextWidth,
59
+ )
60
+ const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
61
+ const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
62
+ const labelCenterX = -(
63
+ Y_AXIS_TICK_PADDING +
64
+ tickTextWidth +
65
+ Y_AXIS_LABEL_GAP +
66
+ Y_AXIS_LABEL_BAND_WIDTH / 2
67
+ )
68
+ const fullClearance =
69
+ tickTextWidth +
70
+ Y_AXIS_TICK_PADDING +
71
+ Y_AXIS_LABEL_GAP +
72
+ Y_AXIS_LABEL_BAND_WIDTH +
73
+ Y_AXIS_OUTER_PADDING
74
+ return { tickClearance, fullClearance, labelCenterX }
75
+ }
@@ -2,7 +2,7 @@ import { zoomIdentity } from 'd3'
2
2
  import { describe, expect, it } from 'vitest'
3
3
 
4
4
  import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
5
- import { buildTrackLayouts, buildYAxisSlots } from './layout'
5
+ import { buildTrackLayouts, buildYAxisSlots, resolveRenderedYAxisSeriesGroups } from './layout'
6
6
  import type { DisplaySeries, DisplayTrack } from './types'
7
7
  import { Y_AXIS_RIGHT_LABEL_OFFSET } from './yAxisConstants'
8
8
 
@@ -126,4 +126,40 @@ describe('Y-axis slots', () => {
126
126
  expect(fourAxisSlots.slots[1]!.axisOffset).toBeLessThan(0)
127
127
  expect(fourAxisSlots.slots[3]!.axisOffset).toBeGreaterThan(0)
128
128
  })
129
+
130
+ it('uses dynamic Y domains when measuring multi-axis slots', () => {
131
+ const negativeVoltage = series('negative-voltage', 0, 1)
132
+ negativeVoltage.unit = 'V'
133
+ const longUnit = series('long-unit', 0, 1)
134
+ longUnit.unit = 'volt-amperes-per-phase'
135
+ const sourceTrack = track([negativeVoltage, longUnit])
136
+ const viewportYDomains = { [sourceTrack.id]: [-0.7434, -0.3434] as [number, number] }
137
+
138
+ expect(
139
+ resolveRenderedYAxisSeriesGroups(
140
+ sourceTrack,
141
+ 'multi-axis',
142
+ undefined,
143
+ undefined,
144
+ viewportYDomains,
145
+ ).map((group) => group.domain),
146
+ ).toEqual([
147
+ [-0.7434, -0.3434],
148
+ [-0.7434, -0.3434],
149
+ ])
150
+
151
+ const slots = buildYAxisSlots(
152
+ [sourceTrack],
153
+ 'multi-axis',
154
+ undefined,
155
+ undefined,
156
+ 5,
157
+ false,
158
+ false,
159
+ viewportYDomains,
160
+ )
161
+
162
+ expect(slots.clearance.left).toBe(100)
163
+ expect(slots.clearance.right).toBeGreaterThan(180)
164
+ })
129
165
  })
@@ -10,6 +10,7 @@ export type {
10
10
  WaveformOverlayMode,
11
11
  WaveformInteractionMode,
12
12
  WaveformXDomainStrategy,
13
+ WaveformZoomIntentPayload,
13
14
  WaveformZoomEndPayload,
14
15
  WaveformZoomResetPayload,
15
16
  WaveformAnnotationStyle,
@@ -7,6 +7,7 @@ export type {
7
7
  WaveformDisplayMode,
8
8
  WaveformOverlayMode,
9
9
  WaveformInteractionMode,
10
+ WaveformZoomIntentPayload,
10
11
  WaveformZoomEndPayload,
11
12
  WaveformAnnotationStyle,
12
13
  WaveformAnnotation,
@@ -10,6 +10,7 @@ import { tryReleasePointerCapture } from './pointerCapture'
10
10
  import { transitionViewportInteraction } from './viewportInteractionState'
11
11
  import { createViewportDomainSetter } from './viewportDomain'
12
12
  import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
13
+ import { emitBoxZoomIntent } from './zoomEventPayload'
13
14
  interface ViewportContext {
14
15
  props: ResolvedWaveformChartProps
15
16
  emit: WaveformChartEmit
@@ -254,6 +255,7 @@ export function useWaveformViewport(context: ViewportContext) {
254
255
  } else {
255
256
  sharedTransform.value = transformForDomain(xDomain, baseXDomain, innerWidth.value)
256
257
  }
258
+ emitBoxZoomIntent(emit, xDomain, track, active.independent)
257
259
  const targets = active.independent
258
260
  ? [track]
259
261
  : trackLayouts.value.filter((target) => target.hasVisibleSeries)
@@ -21,6 +21,11 @@ import {
21
21
  transformForDomain,
22
22
  type ZoomSeriesGroup,
23
23
  } from './zoomConstraints'
24
+ import {
25
+ emitIndependentWheelZoomEnds,
26
+ emitSharedWheelZoomEnd,
27
+ seriesIdentity,
28
+ } from './zoomEventPayload'
24
29
 
25
30
  interface ZoomContext {
26
31
  props: ResolvedWaveformChartProps
@@ -70,9 +75,10 @@ export function useWaveformZoom(context: ZoomContext) {
70
75
  let lastSharedZoomGesture: ZoomGestureKind | null = null
71
76
  const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
72
77
  const pendingIndependentZoomGestures = new Map<number, ZoomGestureKind>()
73
- const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
74
- const lastZoomedTrackIndexes = new Set<number>()
78
+ const lastIndependentWheelSeriesIds = new Map<string, string[]>()
75
79
  let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
80
+ let preservePendingWheelDuringDataRebind = false
81
+ let zoomBindingEpoch = 0
76
82
 
77
83
  const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom)
78
84
  const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => {
@@ -80,8 +86,12 @@ export function useWaveformZoom(context: ZoomContext) {
80
86
  cancelPendingHover()
81
87
  pendingSharedZoomTransform = event.transform
82
88
  pendingSharedZoomGesture = 'wheel'
83
- scheduleZoomCommit()
84
89
  scheduleWheelZoomEnd()
90
+ const domain = event.transform
91
+ .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
92
+ .domain()
93
+ emit('zoom-intent', { start: domain[0], end: domain[1], gesture: 'wheel' })
94
+ scheduleZoomCommit()
85
95
  }
86
96
  const handleIndependentZoom = (
87
97
  event: D3ZoomEvent<SVGRectElement, unknown>,
@@ -91,8 +101,21 @@ export function useWaveformZoom(context: ZoomContext) {
91
101
  cancelPendingHover()
92
102
  pendingIndependentZoomTransforms.set(trackIndex, event.transform)
93
103
  pendingIndependentZoomGestures.set(trackIndex, 'wheel')
94
- scheduleZoomCommit()
95
104
  scheduleWheelZoomEnd()
105
+ const track = trackLayouts.value.find((item) => item.index === trackIndex)
106
+ if (track) {
107
+ const domain = event.transform
108
+ .rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
109
+ .domain()
110
+ emit('zoom-intent', {
111
+ start: domain[0],
112
+ end: domain[1],
113
+ trackIndex,
114
+ seriesIds: track.seriesList.map((series) => series.id),
115
+ gesture: 'wheel',
116
+ })
117
+ }
118
+ scheduleZoomCommit()
96
119
  }
97
120
  function commitPendingZoom() {
98
121
  if (isPresentationMode.value) {
@@ -113,12 +136,7 @@ export function useWaveformZoom(context: ZoomContext) {
113
136
  if (!pendingIndependentZoomTransforms.size) return
114
137
  const nextTransforms = [...independentTransforms.value]
115
138
  const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
116
- lastZoomedTrackIndexes.clear()
117
- changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
118
- changedTrackIndexes.forEach((trackIndex) => {
119
- const gesture = pendingIndependentZoomGestures.get(trackIndex)
120
- if (gesture) lastIndependentZoomGestures.set(trackIndex, gesture)
121
- })
139
+ const changedGestures = new Map(pendingIndependentZoomGestures)
122
140
  pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
123
141
  nextTransforms[trackIndex] = transform
124
142
  })
@@ -129,69 +147,37 @@ export function useWaveformZoom(context: ZoomContext) {
129
147
  changedTrackIndexes.forEach((trackIndex) => {
130
148
  const track = tracksByIndex.get(trackIndex)
131
149
  if (!track) return
150
+ if (changedGestures.get(trackIndex) === 'wheel') {
151
+ const seriesIds = track.seriesList.map((series) => series.id)
152
+ lastIndependentWheelSeriesIds.set(seriesIdentity(seriesIds), seriesIds)
153
+ }
132
154
  const domain = track.xScale.domain()
133
155
  emit('zoom-change', [domain[0], domain[1]])
134
156
  })
135
157
  }
136
158
  const emitZoomEnd = () => {
137
159
  if (props.displayMode === 'independent') {
138
- const tracksByIndex = new Map(trackLayouts.value.map((track) => [track.index, track]))
139
- lastZoomedTrackIndexes.forEach((trackIndex) => {
140
- if (lastIndependentZoomGestures.get(trackIndex) !== 'wheel') return
141
- const track = tracksByIndex.get(trackIndex)
142
- if (!track) return
143
- const domain = track.xScale.domain() as [number, number]
144
- const yDomain = track.yScale.domain()
145
- emit('zoom-end', {
146
- start: domain[0],
147
- end: domain[1],
148
- yStart: yDomain[0],
149
- yEnd: yDomain[1],
150
- trackIndex,
151
- seriesIds: track.seriesList.map((series) => series.id),
152
- gesture: 'wheel',
153
- })
154
- })
155
- lastZoomedTrackIndexes.clear()
156
- lastIndependentZoomGestures.clear()
160
+ emitIndependentWheelZoomEnds(emit, trackLayouts.value, lastIndependentWheelSeriesIds.values())
161
+ lastIndependentWheelSeriesIds.clear()
157
162
  return
158
163
  }
159
164
  if (lastSharedZoomGesture !== 'wheel') {
160
165
  lastSharedZoomGesture = null
161
166
  return
162
167
  }
163
- const domain = sharedZoomDomain.value
164
- const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
165
- if (visibleTracks.length === 1) {
166
- const yDomain = visibleTracks[0]?.yScale.domain()
167
- emit('zoom-end', {
168
- start: domain[0],
169
- end: domain[1],
170
- yStart: yDomain?.[0],
171
- yEnd: yDomain?.[1],
172
- gesture: 'wheel',
173
- })
174
- } else {
175
- emit('zoom-end', {
176
- start: domain[0],
177
- end: domain[1],
178
- yRanges: Object.fromEntries(
179
- visibleTracks.map((track) => [
180
- track.series?.trackId ?? track.series?.id ?? track.id,
181
- track.yScale.domain() as [number, number],
182
- ]),
183
- ),
184
- gesture: 'wheel',
185
- })
186
- }
168
+ emitSharedWheelZoomEnd(emit, sharedZoomDomain.value, trackLayouts.value)
187
169
  lastSharedZoomGesture = null
188
170
  }
189
171
  const flushPendingZoom = () => {
190
172
  zoomThrottle.flush()
191
173
  commitPendingZoom()
192
- if (lastSharedZoomGesture === 'wheel' || lastIndependentZoomGestures.size) return
174
+ if (lastSharedZoomGesture === 'wheel' || lastIndependentWheelSeriesIds.size) return
193
175
  emitZoomEnd()
194
176
  }
177
+ const handleD3ZoomEnd = (bindingEpoch: number) => {
178
+ if (bindingEpoch !== zoomBindingEpoch) return
179
+ flushPendingZoom()
180
+ }
195
181
  function scheduleWheelZoomEnd() {
196
182
  if (wheelZoomEndTimer !== undefined) clearTimeout(wheelZoomEndTimer)
197
183
  wheelZoomEndTimer = setTimeout(() => {
@@ -199,24 +185,32 @@ export function useWaveformZoom(context: ZoomContext) {
199
185
  zoomThrottle.flush()
200
186
  commitPendingZoom()
201
187
  emitZoomEnd()
188
+ preservePendingWheelDuringDataRebind = false
202
189
  }, WHEEL_ZOOM_DEBOUNCE_MS)
203
190
  }
191
+ const prepareForDataChange = () => {
192
+ if (wheelZoomEndTimer === undefined) return
193
+ zoomThrottle.flush()
194
+ commitPendingZoom()
195
+ preservePendingWheelDuringDataRebind = true
196
+ }
204
197
  const cancelPendingZoom = () => {
205
198
  pendingSharedZoomTransform = null
206
199
  pendingSharedZoomGesture = null
207
200
  lastSharedZoomGesture = null
208
201
  pendingIndependentZoomTransforms.clear()
209
202
  pendingIndependentZoomGestures.clear()
210
- lastZoomedTrackIndexes.clear()
211
- lastIndependentZoomGestures.clear()
203
+ lastIndependentWheelSeriesIds.clear()
212
204
  zoomThrottle.cancel()
205
+ preservePendingWheelDuringDataRebind = false
213
206
  if (wheelZoomEndTimer !== undefined) {
214
207
  clearTimeout(wheelZoomEndTimer)
215
208
  wheelZoomEndTimer = undefined
216
209
  }
217
210
  }
218
- const clearZoomBindings = () => {
219
- cancelPendingZoom()
211
+ const clearZoomBindings = (preservePendingWheel = false) => {
212
+ zoomBindingEpoch += 1
213
+ if (!preservePendingWheel) cancelPendingZoom()
220
214
  svgElement.value
221
215
  ?.querySelectorAll<SVGRectElement>('.waveform-chart__overlay')
222
216
  .forEach((overlay) => select(overlay).on('.zoom', null))
@@ -268,7 +262,9 @@ export function useWaveformZoom(context: ZoomContext) {
268
262
  return false
269
263
  }
270
264
  const configureZoom = () => {
271
- clearZoomBindings()
265
+ const preservePendingWheel = preservePendingWheelDuringDataRebind
266
+ clearZoomBindings(preservePendingWheel)
267
+ const bindingEpoch = zoomBindingEpoch
272
268
  if (
273
269
  isPresentationMode.value ||
274
270
  !props.zoomable ||
@@ -303,7 +299,7 @@ export function useWaveformZoom(context: ZoomContext) {
303
299
  [track.width, track.height],
304
300
  ])
305
301
  .on('zoom', (event) => handleIndependentZoom(event, track.index))
306
- .on('end', flushPendingZoom)
302
+ .on('end', () => handleD3ZoomEnd(bindingEpoch))
307
303
  zoomBehaviors.set(track.index, behavior)
308
304
  synchronizingZoomTransform = true
309
305
  try {
@@ -337,7 +333,7 @@ export function useWaveformZoom(context: ZoomContext) {
337
333
  [innerWidth.value, innerHeight.value],
338
334
  ])
339
335
  .on('zoom', handleSharedZoom)
340
- .on('end', flushPendingZoom)
336
+ .on('end', () => handleD3ZoomEnd(bindingEpoch))
341
337
  zoomBehaviors.set('shared', behavior)
342
338
  synchronizingZoomTransform = true
343
339
  try {
@@ -356,5 +352,6 @@ export function useWaveformZoom(context: ZoomContext) {
356
352
  canZoomTrack,
357
353
  canZoomSharedTracks,
358
354
  configureZoom,
355
+ prepareForDataChange,
359
356
  }
360
357
  }
@@ -0,0 +1,88 @@
1
+ import type { TrackLayout } from '../core/types'
2
+ import type { WaveformChartEmit } from '../core/waveformChartTypes'
3
+
4
+ export function seriesIdentity(seriesIds: readonly string[]) {
5
+ return [...seriesIds].sort().join('\u0000')
6
+ }
7
+
8
+ export function findTrackBySeriesIds(
9
+ trackLayouts: readonly TrackLayout[],
10
+ seriesIds: readonly string[],
11
+ ) {
12
+ const identity = seriesIdentity(seriesIds)
13
+ return trackLayouts.find(
14
+ (track) => seriesIdentity(track.seriesList.map((series) => series.id)) === identity,
15
+ )
16
+ }
17
+
18
+ export function emitBoxZoomIntent(
19
+ emit: WaveformChartEmit,
20
+ domain: [number, number],
21
+ track: TrackLayout,
22
+ independent: boolean,
23
+ ) {
24
+ emit(
25
+ 'zoom-intent',
26
+ independent
27
+ ? {
28
+ start: domain[0],
29
+ end: domain[1],
30
+ trackIndex: track.index,
31
+ seriesIds: track.seriesList.map((series) => series.id),
32
+ gesture: 'box',
33
+ }
34
+ : { start: domain[0], end: domain[1], gesture: 'box' },
35
+ )
36
+ }
37
+
38
+ export function emitIndependentWheelZoomEnds(
39
+ emit: WaveformChartEmit,
40
+ trackLayouts: readonly TrackLayout[],
41
+ pendingSeriesIds: Iterable<readonly string[]>,
42
+ ) {
43
+ for (const seriesIds of pendingSeriesIds) {
44
+ const track = findTrackBySeriesIds(trackLayouts, seriesIds)
45
+ if (!track) continue
46
+ const domain = track.xScale.domain() as [number, number]
47
+ const yDomain = track.yScale.domain()
48
+ emit('zoom-end', {
49
+ start: domain[0],
50
+ end: domain[1],
51
+ yStart: yDomain[0],
52
+ yEnd: yDomain[1],
53
+ trackIndex: track.index,
54
+ seriesIds: [...seriesIds],
55
+ gesture: 'wheel',
56
+ })
57
+ }
58
+ }
59
+
60
+ export function emitSharedWheelZoomEnd(
61
+ emit: WaveformChartEmit,
62
+ domain: [number, number],
63
+ trackLayouts: readonly TrackLayout[],
64
+ ) {
65
+ const visibleTracks = trackLayouts.filter((track) => track.hasVisibleSeries)
66
+ if (visibleTracks.length === 1) {
67
+ const yDomain = visibleTracks[0]?.yScale.domain()
68
+ emit('zoom-end', {
69
+ start: domain[0],
70
+ end: domain[1],
71
+ yStart: yDomain?.[0],
72
+ yEnd: yDomain?.[1],
73
+ gesture: 'wheel',
74
+ })
75
+ return
76
+ }
77
+ emit('zoom-end', {
78
+ start: domain[0],
79
+ end: domain[1],
80
+ yRanges: Object.fromEntries(
81
+ visibleTracks.map((track) => [
82
+ track.series?.trackId ?? track.series?.id ?? track.id,
83
+ track.yScale.domain() as [number, number],
84
+ ]),
85
+ ),
86
+ gesture: 'wheel',
87
+ })
88
+ }