waveform-analysis 0.1.75 → 0.1.77

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 (78) hide show
  1. package/README.md +183 -42
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/animation/context.d.ts +1 -0
  6. package/dist/types/components/animation/paginationPresentation.d.ts +7 -7
  7. package/dist/types/components/animation/useWaveformAnimations.d.ts +1 -18
  8. package/dist/types/components/core/amplitudeLimits.d.ts +6 -0
  9. package/dist/types/components/core/layout.d.ts +6 -9
  10. package/dist/types/components/core/trackLayoutBuilder.d.ts +3 -1
  11. package/dist/types/components/core/useWaveformChartController.d.ts +2 -19
  12. package/dist/types/components/core/useWaveformData.d.ts +2 -2
  13. package/dist/types/components/core/useWaveformMeasurements.d.ts +4 -1
  14. package/dist/types/components/core/waveformChartTypes.d.ts +4 -2
  15. package/dist/types/components/core/xAxisTicks.d.ts +2 -0
  16. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  17. package/dist/types/components/core/yAxisTicks.d.ts +6 -0
  18. package/dist/types/components/data/types.d.ts +1 -1
  19. package/dist/types/components/interaction/integerZoom.d.ts +3 -3
  20. package/dist/types/components/interaction/stepZoom.d.ts +5 -0
  21. package/dist/types/components/interaction/useWaveformViewport.d.ts +0 -3
  22. package/dist/types/components/interaction/useWaveformZoom.d.ts +0 -2
  23. package/dist/types/components/rendering/WaveformSeriesLayer.vue.d.ts +0 -1
  24. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +0 -1
  25. package/dist/types/core/data.d.ts +1 -1
  26. package/dist/types/demo/DemoChartHost.vue.d.ts +1 -1
  27. package/dist/types/demo/remoteWaveformLoader.d.ts +23 -0
  28. package/dist/types/demo/types.d.ts +11 -2
  29. package/dist/types/demo/useRemoteWaveformWindow.d.ts +22 -0
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/types/types/chart.d.ts +8 -1
  32. package/dist/types/types/index.d.ts +1 -1
  33. package/dist/types/utils/formatters.d.ts +4 -4
  34. package/dist/types/utils/xAxisNumber.d.ts +2 -0
  35. package/package.json +1 -1
  36. package/src/App.vue +34 -100
  37. package/src/components/WaveformChartView.vue +0 -4
  38. package/src/components/animation/config.ts +1 -1
  39. package/src/components/animation/context.ts +9 -1
  40. package/src/components/animation/paginationPresentation.ts +55 -64
  41. package/src/components/animation/useWaveformAnimations.ts +36 -13
  42. package/src/components/controls/useChartCommands.ts +12 -13
  43. package/src/components/core/amplitudeLimits.ts +44 -0
  44. package/src/components/core/layout.ts +22 -34
  45. package/src/components/core/trackLayoutBuilder.ts +12 -5
  46. package/src/components/core/useWaveformChartController.ts +3 -4
  47. package/src/components/core/useWaveformChartLifecycle.ts +11 -2
  48. package/src/components/core/useWaveformData.ts +11 -5
  49. package/src/components/core/useWaveformLayout.ts +9 -4
  50. package/src/components/core/useWaveformMeasurements.ts +3 -0
  51. package/src/components/core/useWaveformYAxisLayout.ts +2 -0
  52. package/src/components/core/waveformChartTypes.ts +4 -1
  53. package/src/components/core/xAxisTicks.ts +69 -0
  54. package/src/components/core/yAxisLayoutMetrics.ts +4 -0
  55. package/src/components/core/yAxisTicks.ts +39 -0
  56. package/src/components/data/types.ts +2 -0
  57. package/src/components/interaction/integerZoom.ts +31 -12
  58. package/src/components/interaction/remoteViewport.ts +26 -4
  59. package/src/components/interaction/stepZoom.ts +33 -0
  60. package/src/components/interaction/useWaveformViewport.ts +3 -26
  61. package/src/components/interaction/useWaveformZoom.ts +50 -157
  62. package/src/components/interaction/zoomConstraints.ts +5 -5
  63. package/src/components/rendering/WaveformSeriesLayer.vue +1 -2
  64. package/src/components/rendering/WaveformTrack.vue +1 -6
  65. package/src/core/data.ts +5 -2
  66. package/src/data/simulatedWaveforms.ts +0 -10
  67. package/src/demo/DemoChartHost.vue +49 -4
  68. package/src/demo/remoteWaveformLoader.ts +114 -0
  69. package/src/demo/types.ts +11 -1
  70. package/src/demo/useRemoteWaveformWindow.ts +229 -0
  71. package/src/index.ts +2 -0
  72. package/src/types/chart.ts +10 -1
  73. package/src/types/index.ts +2 -0
  74. package/src/utils/formatters.ts +10 -5
  75. package/src/utils/xAxisNumber.ts +36 -0
  76. package/src/views/AnimationDemo.vue +14 -5
  77. package/dist/types/data/sx2Channel.d.ts +0 -10
  78. package/src/data/sx2Channel.ts +0 -315
@@ -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(
@@ -7,7 +7,6 @@ import { waveformLineDasharray, waveformPointSeriesPath } from './seriesStyle'
7
7
 
8
8
  const props = defineProps<{
9
9
  track: TrackLayout
10
- paginationOpacity?: number
11
10
  clipPathId: string
12
11
  }>()
13
12
 
@@ -53,7 +52,7 @@ const renderedSeriesPaths = computed<RenderedSeriesPath[]>(() =>
53
52
  <g
54
53
  v-for="seriesPath in renderedSeriesPaths"
55
54
  :key="seriesPath.presentationKey ?? seriesPath.series.id"
56
- :opacity="(seriesPath.opacity ?? 1) * (paginationOpacity ?? 1)"
55
+ :opacity="seriesPath.opacity ?? 1"
57
56
  :clip-path="seriesPath.animationClip ? `url(#${seriesPath.animationClip.id})` : undefined"
58
57
  class="waveform-track__series-item waveform-chart__series"
59
58
  :data-series-id="seriesPath.series.id"
@@ -12,7 +12,6 @@ import WaveformTrackBackdrop from './WaveformTrackBackdrop.vue'
12
12
 
13
13
  interface Props {
14
14
  track: TrackLayout
15
- paginationOpacity?: number
16
15
  clipPathId: string
17
16
  innerWidth: number
18
17
  zoomable: boolean
@@ -137,11 +136,7 @@ const frameInset = computed(() =>
137
136
  aria-hidden="true"
138
137
  />
139
138
 
140
- <WaveformSeriesLayer
141
- :track="track"
142
- :clip-path-id="clipPathId"
143
- :pagination-opacity="paginationOpacity"
144
- />
139
+ <WaveformSeriesLayer :track="track" :clip-path-id="clipPathId" />
145
140
  <WaveformZeroLines
146
141
  :track="track"
147
142
  :clip-path-id="clipPathId"
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 {
@@ -1,15 +1,20 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
 
4
4
  import { WaveformChart } from '../components'
5
5
  import type { DemoChartModel } from './types'
6
+ import { applyXDomainStrategy } from '../components/core/xDomain'
6
7
 
7
8
  const model = defineModel<DemoChartModel>('model', { required: true })
9
+ const xDomainStrategy = { type: 'integer-ms' as const, includeExplicit: true }
10
+ const panXDomain = computed(() =>
11
+ applyXDomainStrategy(model.value.recordDomain, xDomainStrategy, true),
12
+ )
8
13
 
9
14
  const waveformChartRef = ref<{ resetViewport: (trackIndex?: number) => void }>()
10
15
 
11
- function resetViewport() {
12
- waveformChartRef.value?.resetViewport()
16
+ function resetViewport(trackIndex?: number) {
17
+ waveformChartRef.value?.resetViewport(trackIndex)
13
18
  }
14
19
 
15
20
  defineExpose({ resetViewport })
@@ -17,6 +22,17 @@ defineExpose({ resetViewport })
17
22
 
18
23
  <template>
19
24
  <section class="chart-panel">
25
+ <div
26
+ v-if="model.loading || model.errors.length"
27
+ class="remote-window-status"
28
+ aria-live="polite"
29
+ >
30
+ <span v-if="model.loading" role="status">正在加载当前时间范围…</span>
31
+ <template v-if="model.errors.length">
32
+ <span role="alert">{{ model.errors.join(';') }}</span>
33
+ <button type="button" :disabled="model.loading" @click="model.retry()">重试</button>
34
+ </template>
35
+ </div>
20
36
  <WaveformChart
21
37
  ref="waveformChartRef"
22
38
  layout-preset="edge-compact"
@@ -25,15 +41,19 @@ defineExpose({ resetViewport })
25
41
  v-model:hidden-series-ids="model.hiddenSeriesIds"
26
42
  :data="model.data"
27
43
  :min-visible-points="2"
44
+ :min-zoom-span="model.minZoomSpan"
45
+ :max-zoom-scale="null"
46
+ :pan-x-domain="panXDomain"
28
47
  :integer-zoom="model.integerZoom"
29
48
  :initial-x-domain="model.initialXDomain"
30
- :x-domain-strategy="{ type: 'integer-ms', includeExplicit: true }"
49
+ :x-domain-strategy="xDomainStrategy"
31
50
  :display-mode="model.displayMode"
32
51
  :overlay-mode="model.overlayMode"
33
52
  :grid="{
34
53
  rowCount: model.rowCount,
35
54
  columnCount: model.columnCount,
36
55
  showPagination: true,
56
+ fillIncompleteLastRow: true,
37
57
  trackLines: model.gridTrackLines,
38
58
  }"
39
59
  :title="model.title"
@@ -55,8 +75,33 @@ defineExpose({ resetViewport })
55
75
  v-model:interaction-mode="model.interactionMode"
56
76
  :toolbar="model.toolbarVisible"
57
77
  pannable
78
+ @zoom-intent="model.handleZoomIntent"
79
+ @zoom-change="model.handleZoomChange"
58
80
  @zoom-end="model.handleZoomEnd"
81
+ @pan-end="model.handlePanEnd"
59
82
  @zoom-reset="model.resetWaveformViewport"
60
83
  />
61
84
  </section>
62
85
  </template>
86
+
87
+ <style scoped>
88
+ .chart-panel {
89
+ position: relative;
90
+ }
91
+
92
+ .remote-window-status {
93
+ position: absolute;
94
+ top: 6px;
95
+ right: 12px;
96
+ z-index: 2;
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 8px;
100
+ max-width: calc(100% - 24px);
101
+ padding: 6px 10px;
102
+ border: 1px solid #d0d5dd;
103
+ border-radius: 4px;
104
+ background: #fff;
105
+ color: #344054;
106
+ }
107
+ </style>
@@ -0,0 +1,114 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import { normalizeWaveformSeries } from '../core'
3
+ import { normalizeWaveformSeriesSources } from '../core/data'
4
+ import type { WaveformData } from '../types'
5
+
6
+ export interface RemoteWindowRequest {
7
+ start: number
8
+ end: number
9
+ seriesIds?: string[]
10
+ }
11
+
12
+ export type RemoteWindowLoader = (
13
+ request: RemoteWindowRequest,
14
+ signal: AbortSignal,
15
+ ) => Promise<WaveformData>
16
+
17
+ export const remoteWindowLoaderKey: InjectionKey<RemoteWindowLoader> = Symbol('remoteWindowLoader')
18
+
19
+ export interface MockWindowOptions {
20
+ delayMs?: number | ((request: RemoteWindowRequest) => number)
21
+ empty?: boolean | ((request: RemoteWindowRequest) => boolean)
22
+ fail?: boolean | ((request: RemoteWindowRequest) => boolean)
23
+ }
24
+
25
+ /** Preserve series identities even when a window has no samples. */
26
+ export function selectWaveformWindow(
27
+ data: WaveformData,
28
+ request: RemoteWindowRequest,
29
+ empty = false,
30
+ ): WaveformData {
31
+ const series = normalizeWaveformSeriesSources(data, true)
32
+ const selected = request.seriesIds ? new Set(request.seriesIds) : undefined
33
+ const windows = series
34
+ .filter((item) => !selected || selected.has(item.id))
35
+ .map(({ source, ...item }) => {
36
+ const range = source.visibleRange([request.start, request.end])
37
+ return {
38
+ ...item,
39
+ data: {
40
+ kind: 'points' as const,
41
+ points: empty ? [] : source.pointsInRange(range.start, range.end),
42
+ },
43
+ }
44
+ })
45
+ return data.kind === 'series'
46
+ ? { kind: 'series', series: windows }
47
+ : (windows[0]?.data ?? { kind: 'points', points: [] })
48
+ }
49
+
50
+ export function mergeWaveformWindow(
51
+ current: WaveformData,
52
+ response: WaveformData,
53
+ seriesIds?: string[],
54
+ ): WaveformData {
55
+ if (!seriesIds || current.kind !== 'series' || response.kind !== 'series') return response
56
+ const selected = new Set(seriesIds)
57
+ const byId = new Map(response.series.map((series) => [series.id, series]))
58
+ return {
59
+ kind: 'series',
60
+ series: current.series.map((series) =>
61
+ series.id && selected.has(series.id) ? (byId.get(series.id) ?? series) : series,
62
+ ),
63
+ }
64
+ }
65
+
66
+ /** Metadata is derived from the complete record, never from a loaded window. */
67
+ export function waveformRecordMetadata(data: WaveformData) {
68
+ let minimum = Infinity
69
+ let maximum = -Infinity
70
+ let minZoomSpan = Infinity
71
+ for (const series of normalizeWaveformSeries(data)) {
72
+ for (let index = 0; index < series.points.length; index++) {
73
+ const x = series.points[index].x
74
+ minimum = Math.min(minimum, x)
75
+ maximum = Math.max(maximum, x)
76
+ const previous = series.points[index - 1]?.x
77
+ if (previous !== undefined && x > previous) {
78
+ minZoomSpan = Math.min(minZoomSpan, x - previous)
79
+ }
80
+ }
81
+ }
82
+ return {
83
+ domain: (minimum < maximum ? [minimum, maximum] : [0, 1]) as [number, number],
84
+ minZoomSpan: Number.isFinite(minZoomSpan) ? minZoomSpan : undefined,
85
+ }
86
+ }
87
+
88
+ export function createMockWindowLoader(
89
+ source: () => WaveformData,
90
+ options: MockWindowOptions = {},
91
+ ): RemoteWindowLoader {
92
+ return (request, signal) =>
93
+ new Promise((resolve, reject) => {
94
+ const abort = () => {
95
+ clearTimeout(timer)
96
+ signal.removeEventListener('abort', abort)
97
+ reject(new DOMException('Request cancelled', 'AbortError'))
98
+ }
99
+ const delay =
100
+ typeof options.delayMs === 'function' ? options.delayMs(request) : options.delayMs
101
+ const timer = setTimeout(() => {
102
+ signal.removeEventListener('abort', abort)
103
+ const fail = typeof options.fail === 'function' ? options.fail(request) : options.fail
104
+ if (fail) {
105
+ reject(new Error('模拟数据加载失败'))
106
+ return
107
+ }
108
+ const empty = typeof options.empty === 'function' ? options.empty(request) : options.empty
109
+ resolve(selectWaveformWindow(source(), request, empty))
110
+ }, delay ?? 80)
111
+ signal.addEventListener('abort', abort, { once: true })
112
+ if (signal.aborted) abort()
113
+ })
114
+ }
package/src/demo/types.ts CHANGED
@@ -14,7 +14,9 @@ import type {
14
14
  WaveformTitleOptions,
15
15
  WaveformZeroLineOptions,
16
16
  WaveformZoomEndPayload,
17
+ WaveformZoomIntentPayload,
17
18
  } from '../components'
19
+ import type { WaveformZoomResetPayload, WaveformPanEndPayload } from '../types'
18
20
  import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
19
21
 
20
22
  interface SelectOption<T> {
@@ -97,6 +99,11 @@ export interface DemoChartModel {
97
99
  toolbarVisible: boolean
98
100
  data: WaveformData
99
101
  initialXDomain?: [number, number]
102
+ recordDomain: [number, number]
103
+ minZoomSpan?: number
104
+ loading: boolean
105
+ errors: string[]
106
+ retry: () => Promise<void[]>
100
107
  displayMode: WaveformDisplayMode
101
108
  overlayMode: WaveformOverlayMode
102
109
  rowCount: number
@@ -120,5 +127,8 @@ export interface DemoChartModel {
120
127
  interactionMode: WaveformInteractionMode
121
128
  hiddenSeriesIds: string[]
122
129
  handleZoomEnd: (payload: WaveformZoomEndPayload) => void | Promise<void>
123
- resetWaveformViewport: () => void
130
+ handleZoomIntent: (payload: WaveformZoomIntentPayload) => void
131
+ handleZoomChange: (domain: [number, number]) => void
132
+ handlePanEnd: (payload: WaveformPanEndPayload) => void | Promise<void>
133
+ resetWaveformViewport: (payload?: WaveformZoomResetPayload) => void | Promise<void>
124
134
  }