waveform-analysis 0.1.75 → 0.1.76

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 +178 -23
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/types/components/core/amplitudeLimits.d.ts +6 -0
  5. package/dist/types/components/core/layout.d.ts +6 -9
  6. package/dist/types/components/core/trackLayoutBuilder.d.ts +3 -1
  7. package/dist/types/components/core/useWaveformChartController.d.ts +1 -1
  8. package/dist/types/components/core/useWaveformData.d.ts +2 -2
  9. package/dist/types/components/core/useWaveformMeasurements.d.ts +4 -1
  10. package/dist/types/components/core/waveformChartTypes.d.ts +4 -2
  11. package/dist/types/components/core/xAxisTicks.d.ts +2 -0
  12. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  13. package/dist/types/components/core/yAxisTicks.d.ts +6 -0
  14. package/dist/types/components/data/types.d.ts +1 -1
  15. package/dist/types/components/interaction/integerZoom.d.ts +3 -3
  16. package/dist/types/components/interaction/stepZoom.d.ts +5 -0
  17. package/dist/types/components/interaction/useWaveformViewport.d.ts +0 -3
  18. package/dist/types/components/interaction/useWaveformZoom.d.ts +0 -2
  19. package/dist/types/core/data.d.ts +1 -1
  20. package/dist/types/demo/DemoChartHost.vue.d.ts +1 -1
  21. package/dist/types/demo/remoteWaveformLoader.d.ts +23 -0
  22. package/dist/types/demo/types.d.ts +11 -2
  23. package/dist/types/demo/useRemoteWaveformWindow.d.ts +22 -0
  24. package/dist/types/index.d.ts +1 -1
  25. package/dist/types/types/chart.d.ts +8 -1
  26. package/dist/types/types/index.d.ts +1 -1
  27. package/dist/types/utils/formatters.d.ts +4 -4
  28. package/dist/types/utils/xAxisNumber.d.ts +2 -0
  29. package/package.json +1 -1
  30. package/src/App.vue +34 -100
  31. package/src/components/WaveformChartView.vue +0 -2
  32. package/src/components/controls/useChartCommands.ts +12 -13
  33. package/src/components/core/amplitudeLimits.ts +44 -0
  34. package/src/components/core/layout.ts +22 -34
  35. package/src/components/core/trackLayoutBuilder.ts +12 -5
  36. package/src/components/core/useWaveformChartController.ts +3 -3
  37. package/src/components/core/useWaveformChartLifecycle.ts +11 -2
  38. package/src/components/core/useWaveformData.ts +11 -5
  39. package/src/components/core/useWaveformLayout.ts +9 -4
  40. package/src/components/core/useWaveformMeasurements.ts +3 -0
  41. package/src/components/core/useWaveformYAxisLayout.ts +2 -0
  42. package/src/components/core/waveformChartTypes.ts +4 -1
  43. package/src/components/core/xAxisTicks.ts +69 -0
  44. package/src/components/core/yAxisLayoutMetrics.ts +4 -0
  45. package/src/components/core/yAxisTicks.ts +39 -0
  46. package/src/components/data/types.ts +2 -0
  47. package/src/components/interaction/integerZoom.ts +31 -12
  48. package/src/components/interaction/remoteViewport.ts +26 -4
  49. package/src/components/interaction/stepZoom.ts +33 -0
  50. package/src/components/interaction/useWaveformViewport.ts +3 -26
  51. package/src/components/interaction/useWaveformZoom.ts +50 -157
  52. package/src/components/interaction/zoomConstraints.ts +5 -5
  53. package/src/core/data.ts +5 -2
  54. package/src/data/simulatedWaveforms.ts +0 -10
  55. package/src/demo/DemoChartHost.vue +49 -4
  56. package/src/demo/remoteWaveformLoader.ts +114 -0
  57. package/src/demo/types.ts +11 -1
  58. package/src/demo/useRemoteWaveformWindow.ts +229 -0
  59. package/src/index.ts +2 -0
  60. package/src/types/chart.ts +10 -1
  61. package/src/types/index.ts +2 -0
  62. package/src/utils/formatters.ts +10 -5
  63. package/src/utils/xAxisNumber.ts +36 -0
  64. package/dist/types/data/sx2Channel.d.ts +0 -10
  65. package/src/data/sx2Channel.ts +0 -315
@@ -1,4 +1,5 @@
1
- import { scaleLinear } from 'd3'
1
+ import { resolveXAxisTicks } from '../core/xAxisTicks'
2
+ import { formatPlainNumber } from '../../utils/formatters'
2
3
 
3
4
  export interface IntegerZoomOptions {
4
5
  integerZoom?: boolean
@@ -10,17 +11,20 @@ export function integerZoomTicks(
10
11
  count: number,
11
12
  options: IntegerZoomOptions,
12
13
  ) {
13
- if (!options.integerZoom) return scaleLinear().domain(domain).ticks(count)
14
+ if (!options.integerZoom) return resolveXAxisTicks(domain, count)
14
15
  const factor = options.timeUnit === 's' ? 1 : 1000
15
- return scaleLinear()
16
- .domain(domain.map((value) => value * factor))
17
- .ticks(count)
18
- .filter(Number.isInteger)
16
+ const fractional = hasFractionalZoomDomain(domain, options)
17
+ return resolveXAxisTicks(
18
+ domain.map((value) => value * factor) as [number, number],
19
+ count,
20
+ fractional ? 0 : 1,
21
+ )
22
+ .filter((value) => fractional || Number.isInteger(value))
19
23
  .map((value) => value / factor)
20
24
  }
21
25
 
22
26
  export function formatIntegerZoomLabel(value: number): string {
23
- return String(Math.round(value))
27
+ return formatPlainNumber(Number(stableInteger(value).toPrecision(15)))
24
28
  }
25
29
 
26
30
  function stableInteger(value: number): number {
@@ -30,7 +34,17 @@ function stableInteger(value: number): number {
30
34
  : value
31
35
  }
32
36
 
33
- /** Align outwards in display units; all returned coordinates remain in seconds. */
37
+ function displayDomain(domain: [number, number], options: IntegerZoomOptions): [number, number] {
38
+ const factor = options.timeUnit === 's' ? 1 : 1000
39
+ return [stableInteger(domain[0] * factor), stableInteger(domain[1] * factor)]
40
+ }
41
+
42
+ /** Fractional viewports, including those panned across an integer, need decimal ticks. */
43
+ function hasFractionalZoomDomain(domain: [number, number], options: IntegerZoomOptions): boolean {
44
+ return displayDomain(domain, options).some((value) => !Number.isInteger(value))
45
+ }
46
+
47
+ /** Align outwards only when the displayed span reaches one unit. */
34
48
  export function alignIntegerZoomDomain(
35
49
  domain: [number, number],
36
50
  options: IntegerZoomOptions,
@@ -41,24 +55,29 @@ export function alignIntegerZoomDomain(
41
55
  const end = stableInteger(Math.max(domain[0], domain[1]) * factor)
42
56
  if (!Number.isSafeInteger(Math.floor(start)) || !Number.isSafeInteger(Math.ceil(end)))
43
57
  return domain
58
+ if (end > start && end - start < 1) return [start / factor, end / factor]
44
59
  const left = Math.floor(start)
45
60
  return [left / factor, Math.max(left + 1, Math.ceil(end)) / factor]
46
61
  }
47
62
 
48
- /** Recover aligned endpoints after a D3 transform, tolerating floating-point round-off. */
63
+ /** Recover endpoints after a D3 transform without applying a new zoom or changing pan span. */
49
64
  export function normalizeIntegerZoomDomain(
50
65
  domain: [number, number],
51
66
  options: IntegerZoomOptions,
52
67
  ): [number, number] {
53
- return alignIntegerZoomDomain(domain, options)
68
+ if (!options.integerZoom) return domain
69
+ const factor = options.timeUnit === 's' ? 1 : 1000
70
+ return displayDomain(domain, options).map((value) => value / factor) as [number, number]
54
71
  }
55
72
 
56
- /** Translation snaps to the nearest unit so panning preserves the visible span. */
73
+ /** Integer viewports snap translation; sub-unit viewports pan freely across integers. */
57
74
  export function alignIntegerPanDomain(
58
75
  domain: [number, number],
59
76
  options: IntegerZoomOptions,
60
77
  ): [number, number] {
61
78
  if (!options.integerZoom) return domain
62
79
  const factor = options.timeUnit === 's' ? 1 : 1000
63
- return [Math.round(domain[0] * factor) / factor, Math.round(domain[1] * factor) / factor]
80
+ if (Math.abs(domain[1] - domain[0]) * factor < 1) return domain
81
+ const start = Math.round(domain[0] * factor) / factor
82
+ return [start, start + (domain[1] - domain[0])]
64
83
  }
@@ -1,4 +1,6 @@
1
1
  import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+ import { hasMinimumVisibleXValues } from '../../core/rendering'
3
+ import { normalizeIntegerZoomDomain } from './integerZoom'
2
4
  import {
3
5
  constrainZoomDomain,
4
6
  resolveMinimumZoomSpan,
@@ -32,7 +34,13 @@ export function restoreViewportDomain(
32
34
  const boundary = remotePanBoundary(props)
33
35
  return boundary
34
36
  ? clampViewportDomain(domain, boundary)
35
- : constrainZoomDomain(domain, baseDomain, groups, props)
37
+ : normalizeIntegerZoomDomain(
38
+ constrainZoomDomain(domain, baseDomain, groups, {
39
+ ...props,
40
+ integerZoom: false,
41
+ }),
42
+ props,
43
+ )
36
44
  }
37
45
 
38
46
  /** Wheel/box zoom keeps the remote position and uses loaded data for scale limits. */
@@ -44,10 +52,24 @@ export function constrainViewportZoom(
44
52
  ) {
45
53
  const boundary = remotePanBoundary(props)
46
54
  if (!boundary) return constrainZoomDomain(domain, baseDomain, groups, props)
47
- return constrainZoomDomain(domain, boundary, [], {
55
+ const availableGroups = groups.filter((group) =>
56
+ hasMinimumVisibleXValues(group, boundary, props.minVisiblePoints),
57
+ )
58
+ // Only constrain against loaded groups that overlap the requested window. An
59
+ // unloaded remote window must remain free to move beyond the cached samples.
60
+ const loadedGroups = availableGroups.filter((group) =>
61
+ group.some((series) => {
62
+ const source = series.source
63
+ const length = source?.length ?? series.points.length
64
+ if (!length) return false
65
+ const first = source ? source.pointAt(0)! : series.points[0]
66
+ const last = source ? source.pointAt(length - 1)! : series.points[length - 1]
67
+ return first.x <= Math.max(...domain) && last.x >= Math.min(...domain)
68
+ }),
69
+ )
70
+ return constrainZoomDomain(domain, boundary, loadedGroups, {
48
71
  ...props,
49
- minVisiblePoints: 0,
50
72
  maxZoomScale: null,
51
- minZoomSpan: resolveMinimumZoomSpan(baseDomain, groups, props),
73
+ minZoomSpan: resolveMinimumZoomSpan(baseDomain, availableGroups, props),
52
74
  })
53
75
  }
@@ -0,0 +1,33 @@
1
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+ import { normalizeIntegerZoomDomain } from './integerZoom'
3
+ import { constrainViewportZoom } from './remoteViewport'
4
+ import type { ZoomSeriesGroup } from './zoomConstraints'
5
+
6
+ export const sameZoomDomain = (a: [number, number], b: [number, number]) =>
7
+ a.every((value, index) => Math.abs(value - b[index]!) < 1e-12)
8
+
9
+ /** Toolbar and wheel steps share their scale factor and constraints. */
10
+ export function resolveStepZoomDomain(
11
+ current: [number, number],
12
+ action: 'zoom-in' | 'zoom-out',
13
+ boundary: [number, number],
14
+ groups: readonly ZoomSeriesGroup[],
15
+ props: ResolvedWaveformChartProps,
16
+ anchorRatio = 0.5,
17
+ ): [number, number] {
18
+ const [start, end] = current
19
+ const ratio = Math.max(0, Math.min(1, anchorRatio))
20
+ const anchor = start + (end - start) * ratio
21
+ const span = (end - start) * (action === 'zoom-in' ? 0.5 : 2)
22
+ const constrained = constrainViewportZoom(
23
+ normalizeIntegerZoomDomain([anchor - span * ratio, anchor + span * (1 - ratio)], props),
24
+ boundary,
25
+ groups,
26
+ props,
27
+ )
28
+ // An unsatisfiable point constraint may expand to the entire boundary.
29
+ // A zoom-in step must not turn into a zoom-out operation.
30
+ return action === 'zoom-in' && constrained[1] - constrained[0] > end - start
31
+ ? [...current]
32
+ : constrained
33
+ }
@@ -12,6 +12,7 @@ import { constrainViewportZoom } from './remoteViewport'
12
12
  import { createViewportPan } from './viewportPan'
13
13
  import { transformForDomain } from './zoomConstraints'
14
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
15
+ import { sameZoomDomain } from './stepZoom'
15
16
  export interface ViewportContext {
16
17
  clearBoundaries?: (trackIndex?: number) => void
17
18
  props: ResolvedWaveformChartProps
@@ -32,8 +33,6 @@ export interface ViewportContext {
32
33
  isPresentationMode: ComputedRef<boolean>
33
34
  editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
34
35
  resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
35
- canZoomTrack: (track: TrackLayout) => boolean
36
- canZoomSharedTracks: () => boolean
37
36
  configureZoom: () => void
38
37
  cancelPendingZoom: () => void
39
38
  clearHover: () => void
@@ -59,8 +58,6 @@ export function useWaveformViewport(context: ViewportContext) {
59
58
  isPresentationMode,
60
59
  editorSeriesOptions,
61
60
  resolveInitialTrackDomain,
62
- canZoomTrack,
63
- canZoomSharedTracks,
64
61
  configureZoom,
65
62
  cancelPendingZoom,
66
63
  clearHover,
@@ -184,7 +181,7 @@ export function useWaveformViewport(context: ViewportContext) {
184
181
  }
185
182
  const applyBoxZoom = (active: ViewportSelectionState) => {
186
183
  const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
187
- if (!track || (active.independent ? !canZoomTrack(track) : !canZoomSharedTracks())) return
184
+ if (!track) return
188
185
  const offsetX = active.independent ? 0 : track.left
189
186
  const left = Math.max(
190
187
  0,
@@ -205,6 +202,7 @@ export function useWaveformViewport(context: ViewportContext) {
205
202
  groups,
206
203
  props,
207
204
  )
205
+ if (sameZoomDomain(track.xScale.domain() as [number, number], xDomain)) return
208
206
  if (active.independent) {
209
207
  const next = [...independentTransforms.value]
210
208
  next[track.index] = transformForDomain(xDomain, baseXDomain, track.width)
@@ -333,26 +331,6 @@ export function useWaveformViewport(context: ViewportContext) {
333
331
  editorSeriesOptions,
334
332
  configureZoom,
335
333
  })
336
- const requestViewportReset = (event: MouseEvent) => {
337
- if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
338
- if (props.displayMode === 'independent') {
339
- const target = event.target instanceof Element ? event.target : null
340
- const overlay = target?.closest('[data-independent-overlay-index]')
341
- const trackIndex = Number(overlay?.getAttribute('data-independent-overlay-index'))
342
- const track = trackLayouts.value.find((item) => item.index === trackIndex)
343
- if (!track) return
344
- event.preventDefault()
345
- resetViewport(trackIndex)
346
- emit('zoom-reset', {
347
- trackIndex,
348
- seriesIds: track.legendSeries.map((series) => series.id),
349
- })
350
- return
351
- }
352
- event.preventDefault()
353
- resetViewport()
354
- emit('zoom-reset', {})
355
- }
356
334
  return {
357
335
  selectionBox,
358
336
  beginViewportDrag,
@@ -362,6 +340,5 @@ export function useWaveformViewport(context: ViewportContext) {
362
340
  cancelViewportDrag,
363
341
  resetViewport,
364
342
  setViewportDomain,
365
- requestViewportReset,
366
343
  }
367
344
  }
@@ -1,23 +1,13 @@
1
- import {
2
- scaleLinear,
3
- select,
4
- zoom,
5
- zoomIdentity,
6
- zoomTransform,
7
- type D3ZoomEvent,
8
- type ZoomBehavior,
9
- type ZoomTransform,
10
- } from 'd3'
1
+ import { pointer, scaleLinear, select, zoomIdentity, zoomTransform, type ZoomTransform } from 'd3'
11
2
  import type { ComputedRef, ShallowRef } from 'vue'
12
3
 
13
- import { hasMinimumVisibleXValues } from '../../core/rendering'
14
- import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
4
+ import { WHEEL_ZOOM_DEBOUNCE_MS } from '../core/constants'
15
5
  import type { TrackLayout } from '../core/types'
16
6
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
17
7
  import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
18
- import { constrainViewportZoom, remotePanBoundary } from './remoteViewport'
8
+ import { resolveStepZoomDomain, sameZoomDomain } from './stepZoom'
19
9
  import { normalizeIntegerZoomDomain } from './integerZoom'
20
- import { resolveMinimumZoomSpan, transformForDomain, type ZoomSeriesGroup } from './zoomConstraints'
10
+ import { transformForDomain, type ZoomSeriesGroup } from './zoomConstraints'
21
11
  import {
22
12
  emitIndependentWheelZoomEnds,
23
13
  emitSharedWheelZoomEnd,
@@ -57,16 +47,13 @@ export function useWaveformZoom(context: ZoomContext) {
57
47
  initialXDomain,
58
48
  sharedZoomDomain,
59
49
  innerWidth,
60
- innerHeight,
61
50
  hasChartArea,
62
51
  isZoomMode,
63
52
  isPresentationMode,
64
53
  resolveInitialTrackDomain,
65
54
  cancelPendingHover,
66
55
  } = context
67
- const zoomBehaviors = new Map<number | 'shared', ZoomBehavior<SVGRectElement, unknown>>()
68
56
  const zoomThrottle = useAnimationFrameThrottle()
69
- let synchronizingZoomTransform = false
70
57
  let pendingSharedZoomTransform: ZoomTransform | null = null
71
58
  let pendingSharedZoomGesture: ZoomGestureKind | null = null
72
59
  let lastSharedZoomGesture: ZoomGestureKind | null = null
@@ -75,11 +62,10 @@ export function useWaveformZoom(context: ZoomContext) {
75
62
  const lastIndependentWheelSeriesIds = new Map<string, string[]>()
76
63
  let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
77
64
  let preservePendingWheelDuringDataRebind = false
78
- let zoomBindingEpoch = 0
79
65
 
80
66
  const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom)
81
- const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => {
82
- if (synchronizingZoomTransform || isPresentationMode.value) return
67
+ const handleSharedZoom = (event: { transform: ZoomTransform }) => {
68
+ if (isPresentationMode.value) return
83
69
  cancelPendingHover()
84
70
  pendingSharedZoomTransform = event.transform
85
71
  pendingSharedZoomGesture = 'wheel'
@@ -91,11 +77,8 @@ export function useWaveformZoom(context: ZoomContext) {
91
77
  emit('zoom-intent', { start, end, gesture: 'wheel' })
92
78
  scheduleZoomCommit()
93
79
  }
94
- const handleIndependentZoom = (
95
- event: D3ZoomEvent<SVGRectElement, unknown>,
96
- trackIndex: number,
97
- ) => {
98
- if (synchronizingZoomTransform || isPresentationMode.value) return
80
+ const handleIndependentZoom = (event: { transform: ZoomTransform }, trackIndex: number) => {
81
+ if (isPresentationMode.value) return
99
82
  cancelPendingHover()
100
83
  pendingIndependentZoomTransforms.set(trackIndex, event.transform)
101
84
  pendingIndependentZoomGestures.set(trackIndex, 'wheel')
@@ -166,16 +149,6 @@ export function useWaveformZoom(context: ZoomContext) {
166
149
  emitSharedWheelZoomEnd(emit, sharedZoomDomain.value, trackLayouts.value)
167
150
  lastSharedZoomGesture = null
168
151
  }
169
- const flushPendingZoom = () => {
170
- zoomThrottle.flush()
171
- commitPendingZoom()
172
- if (lastSharedZoomGesture === 'wheel' || lastIndependentWheelSeriesIds.size) return
173
- emitZoomEnd()
174
- }
175
- const handleD3ZoomEnd = (bindingEpoch: number) => {
176
- if (bindingEpoch !== zoomBindingEpoch) return
177
- flushPendingZoom()
178
- }
179
152
  function scheduleWheelZoomEnd() {
180
153
  if (wheelZoomEndTimer !== undefined) clearTimeout(wheelZoomEndTimer)
181
154
  wheelZoomEndTimer = setTimeout(() => {
@@ -207,81 +180,50 @@ export function useWaveformZoom(context: ZoomContext) {
207
180
  }
208
181
  }
209
182
  const clearZoomBindings = (preservePendingWheel = false) => {
210
- zoomBindingEpoch += 1
211
183
  if (!preservePendingWheel) cancelPendingZoom()
212
184
  svgElement.value
213
185
  ?.querySelectorAll<SVGRectElement>('.waveform-chart__overlay')
214
186
  .forEach((overlay) => select(overlay).on('.zoom', null))
215
- zoomBehaviors.clear()
216
187
  }
217
- const resolveMaximumZoomScale = (
218
- domain: [number, number],
219
- groups: readonly ZoomSeriesGroup[],
220
- ): number => {
221
- const domainSpan = Math.abs(domain[1] - domain[0])
222
- if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
223
- const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
224
- return Math.max(
225
- ZOOM_CONSTRAINTS.MIN_SCALE,
226
- minimumSpan > 0 ? domainSpan / minimumSpan : Number.POSITIVE_INFINITY,
227
- )
228
- }
229
- const constrainTransform = (
230
- transform: ZoomTransform,
231
- domain: [number, number],
188
+ const bindWheelZoom = (
189
+ overlay: SVGRectElement,
190
+ baseDomain: [number, number],
232
191
  width: number,
233
192
  groups: readonly ZoomSeriesGroup[],
234
- ): ZoomTransform => {
235
- const requested = transform.rescaleX(scaleLinear(domain, [0, width])).domain() as [
236
- number,
237
- number,
238
- ]
239
- return transformForDomain(
240
- constrainViewportZoom(requested, domain, groups, props),
241
- domain,
242
- width,
243
- )
244
- }
245
- const canZoomTrack = (track: TrackLayout): boolean => {
246
- const minimum = Number(props.minVisiblePoints)
247
- return hasMinimumVisibleXValues(
248
- track.seriesList,
249
- track.xScale.domain() as [number, number],
250
- Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
251
- )
252
- }
253
- const canZoomSharedTracks = (): boolean => {
254
- const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
255
- return tracks.length > 0 && tracks.every(canZoomTrack)
256
- }
257
- const resolveMinimumZoomScale = (domain: [number, number]): number => {
258
- const boundary = remotePanBoundary(props)
259
- return boundary
260
- ? Math.min(1, (domain[1] - domain[0]) / (boundary[1] - boundary[0]))
261
- : ZOOM_CONSTRAINTS.MIN_SCALE
262
- }
263
- const canHandleWheelZoom = (event: Event, canZoomIn: boolean, minimumScale: number): boolean => {
264
- if (event.type !== 'wheel') return false
265
- const deltaY = (event as WheelEvent).deltaY
266
- const currentScale = zoomTransform(event.currentTarget as Element).k
267
-
268
- // 放大操作:需要检查是否还有足够的数据点支持继续放大
269
- if (deltaY < 0) return canZoomIn
270
-
271
- // 缩小操作:只要当前缩放级别大于最小比例就允许
272
- // 即使已达到最小可见点数限制,用户也应该能够缩小视图
273
- if (deltaY > 0) {
274
- // 使用小的容差避免浮点精度问题导致无法缩小到边界
275
- const scaleTolerance = 1e-6
276
- return currentScale > minimumScale * (1 + scaleTolerance)
277
- }
278
-
279
- return false
193
+ transform: ZoomTransform,
194
+ trackIndex?: number,
195
+ ) => {
196
+ select(overlay)
197
+ .property('__zoom', transform)
198
+ .on(
199
+ 'wheel.zoom',
200
+ (event: WheelEvent) => {
201
+ if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return
202
+ const current = zoomTransform(overlay)
203
+ .rescaleX(scaleLinear(baseDomain, [0, width]))
204
+ .domain() as [number, number]
205
+ const next = resolveStepZoomDomain(
206
+ current,
207
+ event.deltaY < 0 ? 'zoom-in' : 'zoom-out',
208
+ baseDomain,
209
+ groups,
210
+ props,
211
+ pointer(event, overlay)[0] / width,
212
+ )
213
+ if (sameZoomDomain(current, next)) return
214
+ event.preventDefault()
215
+ event.stopImmediatePropagation()
216
+ const transform = transformForDomain(next, baseDomain, width)
217
+ select(overlay).property('__zoom', transform)
218
+ if (trackIndex === undefined) handleSharedZoom({ transform })
219
+ else handleIndependentZoom({ transform }, trackIndex)
220
+ },
221
+ { passive: false },
222
+ )
280
223
  }
281
224
  const configureZoom = () => {
282
225
  const preservePendingWheel = preservePendingWheelDuringDataRebind
283
226
  clearZoomBindings(preservePendingWheel)
284
- const bindingEpoch = zoomBindingEpoch
285
227
  if (
286
228
  isPresentationMode.value ||
287
229
  !props.zoomable ||
@@ -299,35 +241,14 @@ export function useWaveformZoom(context: ZoomContext) {
299
241
  if (!overlay) return
300
242
  const dataDomain = resolveInitialTrackDomain(track)
301
243
  const groups = [track.seriesList]
302
- const minimumScale = resolveMinimumZoomScale(dataDomain)
303
- const behavior = zoom<SVGRectElement, unknown>()
304
- .filter((event) => {
305
- const currentTrack =
306
- trackLayouts.value.find((item) => item.index === track.index) ?? track
307
- return canHandleWheelZoom(event, canZoomTrack(currentTrack), minimumScale)
308
- })
309
- .scaleExtent([minimumScale, resolveMaximumZoomScale(dataDomain, groups)])
310
- .constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
311
- .extent([
312
- [0, 0],
313
- [track.width, track.height],
314
- ])
315
- .translateExtent([
316
- [0, 0],
317
- [track.width, track.height],
318
- ])
319
- .on('zoom', (event) => handleIndependentZoom(event, track.index))
320
- .on('end', () => handleD3ZoomEnd(bindingEpoch))
321
- zoomBehaviors.set(track.index, behavior)
322
- synchronizingZoomTransform = true
323
- try {
324
- select(overlay)
325
- .call(behavior)
326
- .on('dblclick.zoom', null)
327
- .call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity)
328
- } finally {
329
- synchronizingZoomTransform = false
330
- }
244
+ bindWheelZoom(
245
+ overlay,
246
+ dataDomain,
247
+ track.width,
248
+ groups,
249
+ independentTransforms.value[track.index] ?? zoomIdentity,
250
+ track.index,
251
+ )
331
252
  })
332
253
  return
333
254
  }
@@ -336,40 +257,12 @@ export function useWaveformZoom(context: ZoomContext) {
336
257
  const groups = trackLayouts.value
337
258
  .filter((track) => track.hasVisibleSeries)
338
259
  .map((track) => track.seriesList)
339
- const minimumScale = resolveMinimumZoomScale(initialXDomain.value)
340
- const behavior = zoom<SVGRectElement, unknown>()
341
- .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks(), minimumScale))
342
- .scaleExtent([minimumScale, resolveMaximumZoomScale(initialXDomain.value, groups)])
343
- .constrain((transform) =>
344
- constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
345
- )
346
- .extent([
347
- [0, 0],
348
- [innerWidth.value, innerHeight.value],
349
- ])
350
- .translateExtent([
351
- [0, 0],
352
- [innerWidth.value, innerHeight.value],
353
- ])
354
- .on('zoom', handleSharedZoom)
355
- .on('end', () => handleD3ZoomEnd(bindingEpoch))
356
- zoomBehaviors.set('shared', behavior)
357
- synchronizingZoomTransform = true
358
- try {
359
- select(overlay)
360
- .call(behavior)
361
- .on('dblclick.zoom', null)
362
- .call(behavior.transform, sharedTransform.value)
363
- } finally {
364
- synchronizingZoomTransform = false
365
- }
260
+ bindWheelZoom(overlay, initialXDomain.value, innerWidth.value, groups, sharedTransform.value)
366
261
  }
367
262
 
368
263
  return {
369
264
  cancelPendingZoom,
370
265
  clearZoomBindings,
371
- canZoomTrack,
372
- canZoomSharedTracks,
373
266
  configureZoom,
374
267
  prepareForDataChange,
375
268
  }
@@ -73,7 +73,7 @@ export function resolveMinimumZoomSpan(
73
73
  groups: readonly ZoomSeriesGroup[],
74
74
  options: ZoomConstraintOptions,
75
75
  ): number {
76
- const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
76
+ const normalized = normalizedBoundary(boundary)
77
77
  const boundarySpan = normalized[1] - normalized[0]
78
78
  if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
79
79
  const configuredSpan =
@@ -89,8 +89,7 @@ export function resolveMinimumZoomSpan(
89
89
  : options.maxZoomScale !== null && configuredSpan === 0 && required === 0
90
90
  ? boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
91
91
  : 0
92
- const integerSpan = options.integerZoom ? (options.timeUnit === 's' ? 1 : 0.001) : 0
93
- return Math.max(configuredSpan, pointSpan, scaleSpan, integerSpan)
92
+ return Math.max(configuredSpan, pointSpan, scaleSpan)
94
93
  }
95
94
 
96
95
  function expandToMinimumPoints(
@@ -141,7 +140,7 @@ export function constrainZoomDomain(
141
140
  groups: readonly ZoomSeriesGroup[],
142
141
  options: ZoomConstraintOptions,
143
142
  ): [number, number] {
144
- const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
143
+ const normalized = normalizedBoundary(boundary)
145
144
  const boundarySpan = normalized[1] - normalized[0]
146
145
  if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
147
146
  if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
@@ -159,7 +158,8 @@ export function constrainZoomDomain(
159
158
  const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
160
159
  const center = (expanded[0] + expanded[1]) / 2
161
160
  const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
162
- return alignIntegerZoomDomain([start, start + span], options)
161
+ const aligned = alignIntegerZoomDomain([start, start + span], options)
162
+ return [Math.max(normalized[0], aligned[0]), Math.min(normalized[1], aligned[1])]
163
163
  }
164
164
 
165
165
  export function transformForDomain(
package/src/core/data.ts CHANGED
@@ -215,10 +215,11 @@ function normalizedSeriesOptions(
215
215
  /** Internal normalized series which preserves compact TypedArray storage for chart rendering. */
216
216
  export function normalizeWaveformSeriesSources(
217
217
  data: WaveformData,
218
+ retainEmpty = false,
218
219
  ): NormalizedWaveformSourceSeries[] {
219
220
  if (data.kind !== 'series') {
220
221
  const source = createWaveformPointSource(data, normalizeWaveformData)
221
- return source.length
222
+ return source.length || retainEmpty
222
223
  ? [
223
224
  {
224
225
  ...normalizedSeriesOptions('series-0', { name: '' }),
@@ -239,7 +240,9 @@ export function normalizeWaveformSeriesSources(
239
240
  }
240
241
  usedIds.add(uniqueId)
241
242
  const source = createWaveformPointSource(series.data, normalizeWaveformData)
242
- return source.length ? [{ ...normalizedSeriesOptions(uniqueId, series), source }] : []
243
+ return source.length || retainEmpty
244
+ ? [{ ...normalizedSeriesOptions(uniqueId, series), source }]
245
+ : []
243
246
  })
244
247
  }
245
248
 
@@ -1,6 +1,5 @@
1
1
  import type { WaveformData, WaveformPoint, WaveformSeries } from '../types'
2
2
  import { recordedDampedPoints } from './recordedDampedWaveform'
3
- import { sx2Channel } from './sx2Channel'
4
3
 
5
4
  const POINT_COUNT = 1_000
6
5
  const START_TIME = -5
@@ -177,15 +176,6 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
177
176
  pointType: 'none',
178
177
  signal: (time) => 6e-9 * Math.exp(-(time + 5) * 0.2) * Math.cos(TWO_PI * 0.8 * time),
179
178
  },
180
- {
181
- id: `recorded-${sx2Channel.chnl_id}`,
182
- shotNo: String(sx2Channel.shot),
183
- name: sx2Channel.chnl,
184
- unit: sx2Channel.dat_unit,
185
- lineType: 'linear',
186
- pointType: 'none',
187
- signal: sx2Channel.data.map((value, index) => [sx2Channel.time[index]!, value]),
188
- },
189
179
  ]
190
180
 
191
181
  export function createSimulatedWaveformData(trackCount?: number): WaveformData {