waveform-analysis 0.1.71 → 0.1.73

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 (122) hide show
  1. package/README.md +260 -40
  2. package/dist/assets/{workerRuntime-o5An0M-W.js → workerRuntime-5p-EI-GL.js} +1 -1
  3. package/dist/index.cjs +2 -1
  4. package/dist/index.js +3874 -2778
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +24 -6
  7. package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
  8. package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +0 -1
  9. package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
  10. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  11. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  12. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  13. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  14. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  15. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  16. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  17. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  18. package/dist/types/components/core/layout.d.ts +6 -3
  19. package/dist/types/components/core/niceScale.d.ts +34 -0
  20. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  21. package/dist/types/components/core/trackPagination.d.ts +1 -1
  22. package/dist/types/components/core/types.d.ts +5 -0
  23. package/dist/types/components/core/useWaveformChartController.d.ts +24 -6
  24. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  25. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  26. package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
  27. package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
  28. package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
  29. package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
  30. package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
  31. package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
  32. package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
  33. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  34. package/dist/types/components/core/yAxisPadding.d.ts +7 -0
  35. package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
  36. package/dist/types/components/interaction/useExtremaTickViewport.d.ts +5 -0
  37. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
  38. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  39. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  40. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -1
  41. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  42. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  43. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  44. package/dist/types/core/lineBoundary.d.ts +4 -0
  45. package/dist/types/core/rendering.d.ts +4 -0
  46. package/dist/types/core/renderingStrategies.d.ts +2 -0
  47. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  48. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  49. package/dist/types/data/sx2Channel.d.ts +10 -0
  50. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  51. package/dist/types/demo/types.d.ts +7 -0
  52. package/dist/types/index.d.ts +2 -1
  53. package/dist/types/types/chart.d.ts +32 -8
  54. package/dist/types/types/controls.d.ts +69 -0
  55. package/dist/types/types/index.d.ts +2 -1
  56. package/dist/types/utils/formatters.d.ts +1 -0
  57. package/package.json +7 -6
  58. package/src/App.vue +61 -14
  59. package/src/components/WaveformChart.css +10 -1
  60. package/src/components/WaveformChart.vue +6 -0
  61. package/src/components/WaveformChartView.vue +27 -26
  62. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  63. package/src/components/annotation/useWaveformChartAnnotations.ts +7 -18
  64. package/src/components/controls/WaveformToolbar.vue +247 -0
  65. package/src/components/controls/chartExportOptions.ts +32 -0
  66. package/src/components/controls/chartSnapshot.ts +132 -0
  67. package/src/components/controls/snapshotTitle.ts +73 -0
  68. package/src/components/controls/toolbarRegistry.ts +92 -0
  69. package/src/components/controls/useChartCommands.ts +294 -0
  70. package/src/components/controls/useChartImageExport.ts +155 -0
  71. package/src/components/controls/useControlMode.ts +30 -0
  72. package/src/components/core/latestTaskScheduler.ts +14 -2
  73. package/src/components/core/layout.ts +49 -36
  74. package/src/components/core/niceScale.ts +151 -0
  75. package/src/components/core/trackLayoutBuilder.ts +41 -17
  76. package/src/components/core/trackPagination.ts +3 -3
  77. package/src/components/core/types.ts +5 -0
  78. package/src/components/core/useWaveformChartController.ts +94 -3
  79. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  80. package/src/components/core/useWaveformData.ts +4 -0
  81. package/src/components/core/useWaveformLayout.ts +25 -5
  82. package/src/components/core/useWaveformMeasurements.ts +63 -0
  83. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  84. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  85. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  86. package/src/components/core/waveformChartTypes.ts +5 -0
  87. package/src/components/core/waveformSamplingSession.ts +49 -0
  88. package/src/components/core/workerSamplingClient.ts +38 -6
  89. package/src/components/core/yAxisLabelStyle.ts +10 -0
  90. package/src/components/core/yAxisLayoutMetrics.ts +11 -9
  91. package/src/components/core/yAxisPadding.ts +60 -0
  92. package/src/components/core/yAxisTitleLayout.ts +11 -12
  93. package/src/components/interaction/useExtremaTickViewport.ts +43 -0
  94. package/src/components/interaction/useWaveformViewport.ts +11 -2
  95. package/src/components/rendering/WaveformTrack.css +1 -2
  96. package/src/components/rendering/WaveformTrack.vue +5 -12
  97. package/src/components/rendering/WaveformTrackAxes.vue +66 -19
  98. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  99. package/src/components/rendering/compactFrame.ts +31 -0
  100. package/src/components/rendering/yAxisTickVisibility.ts +156 -0
  101. package/src/core/lineBoundary.ts +24 -0
  102. package/src/core/rendering.ts +48 -25
  103. package/src/core/renderingStrategies.ts +19 -13
  104. package/src/core/waveformPointSource.ts +23 -1
  105. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  106. package/src/core/workerSampling/repository.ts +26 -18
  107. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  108. package/src/core/workerSampling/workerRuntime.ts +13 -3
  109. package/src/data/simulatedWaveforms.ts +67 -2
  110. package/src/data/sx2Channel.ts +315 -0
  111. package/src/demo/DemoChartHost.vue +5 -1
  112. package/src/demo/DemoGridFrameControls.vue +2 -0
  113. package/src/demo/DemoViewControls.vue +5 -2
  114. package/src/demo/DemoYAxisPaddingControls.vue +53 -0
  115. package/src/demo/embedBridge.ts +85 -0
  116. package/src/demo/types.ts +7 -0
  117. package/src/index.ts +19 -0
  118. package/src/types/chart.ts +34 -8
  119. package/src/types/controls.ts +80 -0
  120. package/src/types/index.ts +18 -0
  121. package/src/utils/formatters.ts +2 -1
  122. package/src/views/WasmSamplingDemo.vue +8 -1
@@ -0,0 +1,294 @@
1
+ import { computed, nextTick, watch, type Ref, type ShallowRef } from 'vue'
2
+ import type { ZoomTransform } from 'd3'
3
+ import type {
4
+ WaveformControlAction,
5
+ WaveformControlResult,
6
+ WaveformControlState,
7
+ WaveformControlTarget,
8
+ WaveformTargetResult,
9
+ WaveformViewportSnapshot,
10
+ } from '../../types/controls'
11
+ import type { TrackLayout } from '../core/types'
12
+ import type { useWaveformLayout } from '../core/useWaveformLayout'
13
+ import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
14
+ import { applyXDomainStrategy } from '../core/xDomain'
15
+ import { paddedDomain } from '../../utils'
16
+ import { transformForDomain } from '../interaction/zoomConstraints'
17
+ import { constrainViewportZoom } from '../interaction/remoteViewport'
18
+ import { normalizeIntegerZoomDomain } from '../interaction/integerZoom'
19
+ import type { useControlMode } from './useControlMode'
20
+
21
+ type Domain = [number, number]
22
+ interface Context {
23
+ props: ResolvedWaveformChartProps
24
+ emit: WaveformChartEmit
25
+ layout: ReturnType<typeof useWaveformLayout>
26
+ mode: ReturnType<typeof useControlMode>
27
+ boundaries: ShallowRef<Record<string, Domain>>
28
+ independentTransforms: ShallowRef<ZoomTransform[]>
29
+ sharedTransform: ShallowRef<ZoomTransform>
30
+ independentYDomains: Ref<Record<number, Domain>>
31
+ sharedYDomains: Ref<Record<string, Domain>>
32
+ cancel: () => void
33
+ configure: () => void
34
+ legacyReset: (index?: number) => void
35
+ legacySet: (domain: Domain, index?: number) => void
36
+ }
37
+ const same = (a: Domain, b: Domain) => a.every((v, i) => Math.abs(v - b[i]!) < 1e-12)
38
+ export function useChartCommands(c: Context) {
39
+ let sequence = 0
40
+ const independent = computed(() => c.props.displayMode === 'independent')
41
+ const groups = () =>
42
+ independent.value
43
+ ? c.layout.trackLayouts.value.map((t) => ({ id: t.id, tracks: [t] }))
44
+ : [{ id: 'shared', tracks: c.layout.trackLayouts.value }]
45
+ const snapshot = (tracks: TrackLayout[]): WaveformViewportSnapshot => ({
46
+ xDomain: [
47
+ ...(independent.value ? tracks[0]!.xScale.domain() : c.layout.sharedZoomDomain.value),
48
+ ] as Domain,
49
+ yDomains: Object.fromEntries(
50
+ tracks.flatMap((t) => t.seriesPaths.map((p) => [p.series.id, p.yScale.domain() as Domain])),
51
+ ),
52
+ })
53
+ const boundary = (tracks: TrackLayout[], original = false): Domain =>
54
+ independent.value
55
+ ? (original ? c.layout.resolveOriginalTrackDomain : c.layout.resolveInitialTrackDomain)(
56
+ tracks[0]!,
57
+ )
58
+ : (original ? c.layout.originalXDomain : c.layout.initialXDomain).value
59
+ const clearBoundaries = (index?: number) => {
60
+ if (!independent.value || index === undefined) c.boundaries.value = {}
61
+ else {
62
+ const id = c.layout.trackLayouts.value.find((t) => t.index === index)?.id
63
+ const next = { ...c.boundaries.value }
64
+ if (id) delete next[id]
65
+ c.boundaries.value = next
66
+ }
67
+ }
68
+ function restoreInitialBoundaries() {
69
+ if (!Object.keys(c.boundaries.value).length) return
70
+ const previous = groups()
71
+ .filter((g) => g.tracks.length)
72
+ .map((g) => ({
73
+ index: independent.value ? g.tracks[0]!.index : undefined,
74
+ domain: snapshot(g.tracks).xDomain,
75
+ }))
76
+ clearBoundaries()
77
+ previous.forEach((p) => c.legacySet(p.domain, p.index))
78
+ }
79
+ function plan(
80
+ action: WaveformControlAction,
81
+ target: WaveformControlTarget = {},
82
+ domain?: Domain,
83
+ ) {
84
+ const all = groups()
85
+ if (
86
+ target.trackId !== undefined &&
87
+ (!independent.value || !all.some((g) => g.id === target.trackId))
88
+ )
89
+ return { error: 'invalid-target' as const, entries: [] }
90
+ if (
91
+ action === 'set-domain' &&
92
+ (!domain || !domain.every(Number.isFinite) || domain[0] === domain[1])
93
+ )
94
+ return { error: 'invalid-domain' as const, entries: [] }
95
+ const selected = target.trackId === undefined ? all : all.filter((g) => g.id === target.trackId)
96
+ const entries = selected.map(({ id, tracks }) => {
97
+ const nonempty = tracks.some((t) =>
98
+ t.seriesList.some((s) => (s.source?.length ?? s.points.length) > 0),
99
+ )
100
+ const before = tracks.length ? snapshot(tracks) : undefined
101
+ let bounds: Domain = tracks.length ? boundary(tracks, action === 'reset') : [0, 1]
102
+ if (action === 'fit')
103
+ bounds = applyXDomainStrategy(
104
+ paddedDomain(tracks.flatMap((t) => t.seriesList.flatMap((s) => s.xDomain))),
105
+ c.props.xDomainStrategy,
106
+ )
107
+ let requested: Domain = domain ?? bounds
108
+ if (before && (action === 'zoom-in' || action === 'zoom-out')) {
109
+ const [a, b] = before.xDomain
110
+ const half = (b - a) * (action === 'zoom-in' ? 0.25 : 1)
111
+ requested = [(a + b) / 2 - half, (a + b) / 2 + half]
112
+ }
113
+ const xDomain = constrainViewportZoom(
114
+ normalizeIntegerZoomDomain(requested, c.props),
115
+ bounds,
116
+ tracks.filter((t) => t.hasVisibleSeries).map((t) => t.seriesList),
117
+ c.props,
118
+ )
119
+ const resetsY = action === 'reset' || action === 'fit'
120
+ const hasY =
121
+ resetsY &&
122
+ (independent.value
123
+ ? tracks.some((t) => c.independentYDomains.value[t.index])
124
+ : Object.keys(c.sharedYDomains.value).length > 0)
125
+ const boundaryChanged = tracks.length && !same(bounds, boundary(tracks))
126
+ const status: WaveformTargetResult['status'] = !nonempty
127
+ ? 'empty-data'
128
+ : c.props.presentationMode || !c.props.zoomable
129
+ ? 'disabled'
130
+ : before && same(before.xDomain, xDomain) && !hasY && !boundaryChanged
131
+ ? 'unchanged'
132
+ : 'applied'
133
+ return { id, tracks, before, bounds, xDomain, status, resetsY }
134
+ })
135
+ return { entries }
136
+ }
137
+ function execute(
138
+ action: WaveformControlAction,
139
+ target: WaveformControlTarget = {},
140
+ domain?: Domain,
141
+ source: 'toolbar' | 'api' = 'api',
142
+ ): WaveformControlResult {
143
+ const planned = plan(action, target, domain)
144
+ if (planned.error) return { kind: 'viewport', status: planned.error, targets: [] }
145
+ const { entries } = planned
146
+ const isIndependent = independent.value
147
+ const metadata = { commandId: `command-${++sequence}`, source, action }
148
+ // Capture identities before host event handlers can replace the data.
149
+ const identities = entries.map((e) => ({
150
+ trackIndex: isIndependent ? e.tracks[0]?.index : undefined,
151
+ seriesIds: e.tracks.flatMap((t) => t.seriesList.map((s) => s.id)),
152
+ }))
153
+ if (
154
+ entries.some(
155
+ (e) => e.status === 'applied' || (action === 'reset' && e.status === 'unchanged'),
156
+ )
157
+ )
158
+ c.cancel()
159
+ const transforms = [...c.independentTransforms.value]
160
+ const bounds = { ...c.boundaries.value }
161
+ const ys = { ...c.independentYDomains.value }
162
+ for (const e of entries) {
163
+ if (e.status !== 'applied') continue
164
+ if (action === 'fit') bounds[e.id] = e.bounds
165
+ if (action === 'reset') delete bounds[e.id]
166
+ if (isIndependent) {
167
+ const track = e.tracks[0]!
168
+ transforms[track.index] = transformForDomain(e.xDomain, e.bounds, track.width)
169
+ if (e.resetsY) delete ys[track.index]
170
+ } else {
171
+ c.sharedTransform.value = transformForDomain(e.xDomain, e.bounds, c.layout.innerWidth.value)
172
+ if (e.resetsY) c.sharedYDomains.value = {}
173
+ }
174
+ }
175
+ if (entries.some((e) => e.status === 'applied')) {
176
+ c.boundaries.value = bounds
177
+ c.independentTransforms.value = transforms
178
+ c.independentYDomains.value = ys
179
+ }
180
+ const current = groups()
181
+ const targets: WaveformTargetResult[] = entries.map((e) => ({
182
+ trackId: e.id,
183
+ status: e.status,
184
+ before: e.before,
185
+ after: current.find((g) => g.id === e.id)?.tracks.length
186
+ ? snapshot(current.find((g) => g.id === e.id)!.tracks)
187
+ : undefined,
188
+ }))
189
+ const yPayloads = entries.map((e) => {
190
+ const tracks = current.find((g) => g.id === e.id)?.tracks ?? []
191
+ const ranges = Object.fromEntries(
192
+ tracks.map((t) => [t.series?.trackId ?? t.series?.id ?? t.id, t.yScale.domain()]),
193
+ )
194
+ const range = Object.values(ranges)[0]
195
+ return isIndependent || tracks.length === 1
196
+ ? { yStart: range?.[0], yEnd: range?.[1] }
197
+ : { yRanges: ranges as Record<string, Domain> }
198
+ })
199
+ targets.forEach((result, index) => {
200
+ if (action === 'reset' && (result.status === 'applied' || result.status === 'unchanged')) {
201
+ c.emit('zoom-reset', { ...metadata, ...identities[index] })
202
+ } else if (result.status === 'applied' && result.after) {
203
+ const [start, end] = result.after.xDomain
204
+ const payload = {
205
+ ...metadata,
206
+ ...identities[index],
207
+ start,
208
+ end,
209
+ gesture: 'command' as const,
210
+ ...yPayloads[index],
211
+ }
212
+ if (action !== 'fit') c.emit('zoom-intent', payload)
213
+ c.emit('zoom-change', [start, end])
214
+ c.emit('zoom-end', payload)
215
+ }
216
+ })
217
+ if (targets.some((t) => t.status === 'applied')) void nextTick(c.configure)
218
+ const status =
219
+ (['applied', 'unchanged', 'disabled', 'empty-data'] as const).find((value) =>
220
+ targets.some((t) => t.status === value),
221
+ ) ?? 'empty-data'
222
+ return { kind: 'viewport', status, targets }
223
+ }
224
+ function resetViewport(index?: number): void
225
+ function resetViewport(target: WaveformControlTarget): WaveformControlResult
226
+ function resetViewport(target?: number | WaveformControlTarget) {
227
+ if (typeof target === 'object') return execute('reset', target)
228
+ c.legacyReset(target)
229
+ }
230
+ function setViewportDomain(domain: Domain, index?: number): void
231
+ function setViewportDomain(domain: Domain, target: WaveformControlTarget): WaveformControlResult
232
+ function setViewportDomain(domain: Domain, target?: number | WaveformControlTarget) {
233
+ if (typeof target === 'object') return execute('set-domain', target, domain)
234
+ if (domain.every(Number.isFinite) && domain[0] !== domain[1]) clearBoundaries(target)
235
+ c.legacySet(domain, target)
236
+ }
237
+ const state = computed<WaveformControlState>(() => {
238
+ const actions: WaveformControlAction[] = ['zoom-in', 'zoom-out', 'reset', 'fit', 'set-domain']
239
+ const targets = groups()
240
+ .filter((g) => g.tracks.length)
241
+ .map((g) => ({
242
+ trackId: g.id,
243
+ label: independent.value ? g.tracks[0]!.series?.name || g.id : '共享范围',
244
+ viewport: snapshot(g.tracks),
245
+ available: Object.fromEntries(
246
+ actions.map((action) => {
247
+ const p = plan(
248
+ action,
249
+ independent.value ? { trackId: g.id } : {},
250
+ snapshot(g.tracks).xDomain,
251
+ ).entries[0]
252
+ return [
253
+ action,
254
+ p?.status === 'applied' ||
255
+ ((action === 'reset' || action === 'set-domain') && p?.status === 'unchanged'),
256
+ ]
257
+ }),
258
+ ) as Record<WaveformControlAction, boolean>,
259
+ }))
260
+ return {
261
+ mode: c.mode.mode.value,
262
+ independent: independent.value,
263
+ targets,
264
+ available: {
265
+ 'zoom-box': c.mode.canSetMode('zoom'),
266
+ pan: c.mode.canSetMode('pan'),
267
+ annotate: c.mode.canSetMode('annotation'),
268
+ export: true,
269
+ 'zoom-in': targets.some((t) => t.available['zoom-in']),
270
+ 'zoom-out': targets.some((t) => t.available['zoom-out']),
271
+ reset: targets.some((t) => t.available.reset),
272
+ fit: targets.some((t) => t.available.fit),
273
+ },
274
+ }
275
+ })
276
+ const getControlState = () => JSON.parse(JSON.stringify(state.value)) as WaveformControlState
277
+ watch(
278
+ () => JSON.stringify(state.value),
279
+ () => c.emit('control-state-change', getControlState()),
280
+ )
281
+ return {
282
+ controlState: state,
283
+ getControlState,
284
+ execute,
285
+ clearBoundaries,
286
+ restoreInitialBoundaries,
287
+ resetViewport,
288
+ setViewportDomain,
289
+ zoomIn: (target?: WaveformControlTarget) => execute('zoom-in', target),
290
+ zoomOut: (target?: WaveformControlTarget) => execute('zoom-out', target),
291
+ fitToData: (target?: WaveformControlTarget) => execute('fit', target),
292
+ setInteractionMode: c.mode.setInteractionMode,
293
+ }
294
+ }
@@ -0,0 +1,155 @@
1
+ import { nextTick, onBeforeUnmount, watch } from 'vue'
2
+ import type {
3
+ WaveformImageExportError,
4
+ WaveformImageExportErrorCode,
5
+ WaveformImageExportOptions,
6
+ } from '../../types/controls'
7
+ import { updateMaximumTickVisibility } from '../rendering/yAxisTickVisibility'
8
+ import { createChartSnapshot } from './chartSnapshot'
9
+ export function imageExportError(code: WaveformImageExportErrorCode): WaveformImageExportError {
10
+ const messages = {
11
+ 'export-invalid-options': '图片导出参数无效',
12
+ 'export-unavailable': '图表尚未具备有效尺寸',
13
+ 'export-busy': '已有导出正在执行',
14
+ 'export-cancelled': '图表已卸载,导出取消',
15
+ 'export-render-failed': '图片编码失败',
16
+ 'export-stale': '图表已变化,请重新导出',
17
+ 'export-timeout': '等待图表稳定超时,请重试',
18
+ }
19
+ return Object.assign(new Error(messages[code]), { code })
20
+ }
21
+ interface Context {
22
+ container: () => HTMLElement | undefined
23
+ svg: () => SVGSVGElement | undefined
24
+ size: () => { width: number; height: number; titleHeight: number }
25
+ version: () => unknown
26
+ ready: () => boolean
27
+ }
28
+ export function useChartImageExport(c: Context) {
29
+ let busy = false
30
+ let disposed = false
31
+ let revision = 0
32
+ let abort: ((error: WaveformImageExportError) => void) | undefined
33
+ watch(
34
+ c.version,
35
+ () => {
36
+ revision++
37
+ abort?.(imageExportError('export-stale'))
38
+ },
39
+ { flush: 'sync' },
40
+ )
41
+ onBeforeUnmount(() => {
42
+ disposed = true
43
+ abort?.(imageExportError('export-cancelled'))
44
+ })
45
+ async function exportImage(options: WaveformImageExportOptions = {}): Promise<Blob> {
46
+ const format = options.format ?? 'png'
47
+ const scale = options.scale ?? 1
48
+ if (
49
+ !['png', 'svg'].includes(format) ||
50
+ !Number.isFinite(scale) ||
51
+ scale <= 0 ||
52
+ scale > 4 ||
53
+ (format === 'svg' && scale !== 1) ||
54
+ (options.backgroundColor !== undefined &&
55
+ (typeof options.backgroundColor !== 'string' ||
56
+ !CSS.supports('color', options.backgroundColor)))
57
+ )
58
+ throw imageExportError('export-invalid-options')
59
+ if (disposed) throw imageExportError('export-cancelled')
60
+ if (busy) throw imageExportError('export-busy')
61
+ const container = c.container(),
62
+ svg = c.svg(),
63
+ size = c.size()
64
+ if (!container || !svg || size.width <= 0 || size.height <= 0)
65
+ throw imageExportError('export-unavailable')
66
+ busy = true
67
+ const captured = revision
68
+ let timer: ReturnType<typeof setTimeout> | undefined
69
+ let poll: ReturnType<typeof setTimeout> | undefined
70
+ let url: string | undefined
71
+ let img: HTMLImageElement | undefined
72
+ let canvas: HTMLCanvasElement | undefined
73
+ let stopped = false
74
+ const check = () => {
75
+ if (disposed) throw imageExportError('export-cancelled')
76
+ if (captured !== revision) throw imageExportError('export-stale')
77
+ if (stopped) throw imageExportError('export-timeout')
78
+ }
79
+ const cancelled = new Promise<never>((_, reject) => {
80
+ abort = reject
81
+ timer = setTimeout(() => reject(imageExportError('export-timeout')), 5000)
82
+ })
83
+ const generate = async () => {
84
+ await nextTick()
85
+ check()
86
+ await new Promise<void>((resolve) => {
87
+ const test = () => {
88
+ if (stopped) return
89
+ if (c.ready()) resolve()
90
+ else poll = setTimeout(test, 16)
91
+ }
92
+ test()
93
+ })
94
+ await document.fonts?.ready
95
+ await nextTick()
96
+ check()
97
+ updateMaximumTickVisibility(svg)
98
+ const snapshot = createChartSnapshot(
99
+ container,
100
+ svg,
101
+ size.width,
102
+ size.height,
103
+ size.titleHeight,
104
+ options.backgroundColor,
105
+ )
106
+ const blob = new Blob([snapshot], { type: 'image/svg+xml' })
107
+ if (format === 'svg') return blob
108
+ url = URL.createObjectURL(blob)
109
+ img = new Image()
110
+ await new Promise<void>((resolve, reject) => {
111
+ img!.onload = () => resolve()
112
+ img!.onerror = () => reject(imageExportError('export-render-failed'))
113
+ img!.src = url!
114
+ })
115
+ check()
116
+ canvas = document.createElement('canvas')
117
+ canvas.width = Math.round(size.width * scale)
118
+ canvas.height = Math.round(size.height * scale)
119
+ const ctx = canvas.getContext('2d')
120
+ if (!ctx) throw imageExportError('export-render-failed')
121
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
122
+ const png = await new Promise<Blob>((resolve, reject) =>
123
+ canvas!.toBlob(
124
+ (result) => (result ? resolve(result) : reject(imageExportError('export-render-failed'))),
125
+ 'image/png',
126
+ ),
127
+ )
128
+ check()
129
+ return png
130
+ }
131
+ try {
132
+ return await Promise.race([cancelled, generate()])
133
+ } catch (error) {
134
+ if (error instanceof Error && 'code' in error) throw error
135
+ throw imageExportError('export-render-failed')
136
+ } finally {
137
+ stopped = true
138
+ clearTimeout(timer)
139
+ clearTimeout(poll)
140
+ if (img) {
141
+ img.onload = null
142
+ img.onerror = null
143
+ img.src = ''
144
+ }
145
+ if (url) URL.revokeObjectURL(url)
146
+ if (canvas) {
147
+ canvas.width = 0
148
+ canvas.height = 0
149
+ }
150
+ abort = undefined
151
+ busy = false
152
+ }
153
+ }
154
+ return { exportImage }
155
+ }
@@ -0,0 +1,30 @@
1
+ import { computed, ref, watch } from 'vue'
2
+ import type { WaveformControlResult } from '../../types/controls'
3
+ import type { WaveformInteractionMode } from '../../types'
4
+ import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
5
+
6
+ export function useControlMode(props: ResolvedWaveformChartProps, emit: WaveformChartEmit) {
7
+ const internalMode = ref<WaveformInteractionMode>('zoom')
8
+ const mode = computed(() => props.interactionMode ?? internalMode.value)
9
+ const canSetMode = (value: WaveformInteractionMode) =>
10
+ !props.presentationMode &&
11
+ (value === 'none' ||
12
+ (value === 'zoom' && props.zoomable) ||
13
+ (value === 'pan' && props.pannable) ||
14
+ (value === 'annotation' && props.annotationsVisible))
15
+ function setInteractionMode(value: WaveformInteractionMode): WaveformControlResult {
16
+ let status: Extract<WaveformControlResult, { kind: 'mode' }>['status']
17
+ if (!canSetMode(value)) status = 'disabled'
18
+ else if (mode.value === value) status = 'unchanged'
19
+ else if (props.interactionMode !== undefined) {
20
+ emit('update:interactionMode', value)
21
+ status = 'requested'
22
+ } else {
23
+ internalMode.value = value
24
+ status = 'applied'
25
+ }
26
+ return { kind: 'mode', status, targets: [] }
27
+ }
28
+ watch(mode, (value) => emit('interaction-mode-change', value))
29
+ return { mode, canSetMode, setInteractionMode }
30
+ }
@@ -26,7 +26,9 @@ interface ScheduledTask {
26
26
  }
27
27
 
28
28
  /** Runs at most one task at a time and retains only the newest pending task. */
29
- export function createLatestTaskScheduler() {
29
+ export function createLatestTaskScheduler(
30
+ onError: (error: unknown, generation: number) => void = () => undefined,
31
+ ) {
30
32
  let generation = 0
31
33
  let running = false
32
34
  let pending: ScheduledTask | undefined
@@ -40,7 +42,17 @@ export function createLatestTaskScheduler() {
40
42
  while (pending && !disposed) {
41
43
  const task = pending
42
44
  pending = undefined
43
- await task.run(task.generation)
45
+ try {
46
+ await task.run(task.generation)
47
+ } catch (error) {
48
+ if (!disposed && task.generation === generation) {
49
+ try {
50
+ onError(error, task.generation)
51
+ } catch {
52
+ /* Reporting must not stall the queue. */
53
+ }
54
+ }
55
+ }
44
56
  }
45
57
  } finally {
46
58
  running = false
@@ -1,5 +1,3 @@
1
- import { scaleLinear } from 'd3'
2
-
3
1
  import type { WaveformOverlayMode } from '../../types'
4
2
  import { formatScientificAxisLabel, formatScientificAxisExponent, paddedDomain } from '../../utils'
5
3
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
@@ -10,6 +8,9 @@ import {
10
8
  resolveTrackFixedYDomain,
11
9
  type WaveformYDomain,
12
10
  } from './yDomain'
11
+ import { resolveYAxisTicksECharts } from './niceScale'
12
+ import { applyYAxisPadding, withMaximumTick, type YAxisPaddingOptions } from './yAxisPadding'
13
+ import { resolveYAxisLabelBandWidth } from './yAxisLabelStyle'
13
14
 
14
15
  // 导出常量供外部使用
15
16
  export { MAX_MULTI_Y_AXIS_COUNT } from './constants'
@@ -37,21 +38,24 @@ export function resolveYAxisTicks(
37
38
  tickCount = 5,
38
39
  nice = true,
39
40
  ): ResolvedYAxisTicks {
40
- const effectiveTickCount =
41
- typeof tickCount === 'number' && Number.isFinite(tickCount)
42
- ? Math.max(2, Math.floor(tickCount))
43
- : 5
44
- const scale = scaleLinear(domain, [1, 0])
45
- if (nice) scale.nice(Math.max(1, effectiveTickCount - 1))
46
- const resolvedDomain = scale.domain() as [number, number]
47
- const [axisStart, axisEnd] = resolvedDomain
48
- return {
49
- domain: resolvedDomain,
50
- values: Array.from(
51
- { length: effectiveTickCount },
52
- (_, index) => axisStart + ((axisEnd - axisStart) * index) / (effectiveTickCount - 1),
53
- ),
41
+ if (!nice) {
42
+ // 如果不需要 nice,使用简单的线性分割
43
+ const effectiveTickCount =
44
+ typeof tickCount === 'number' && Number.isFinite(tickCount)
45
+ ? Math.max(2, Math.floor(tickCount))
46
+ : 5
47
+ const [axisStart, axisEnd] = domain
48
+ return {
49
+ domain,
50
+ values: Array.from(
51
+ { length: effectiveTickCount },
52
+ (_, index) => axisStart + ((axisEnd - axisStart) * index) / (effectiveTickCount - 1),
53
+ ),
54
+ }
54
55
  }
56
+
57
+ // 使用 ECharts 兼容的 1/2/5 系列算法
58
+ return resolveYAxisTicksECharts(domain, tickCount)
55
59
  }
56
60
 
57
61
  export function formatYAxisTickLabel(
@@ -71,7 +75,8 @@ export function formatYAxisTickLabel(
71
75
  })
72
76
  if (suffixMetadata) {
73
77
  const unitLabel = unit?.trim()
74
- return [label, formatScientificAxisExponent(...domain), unitLabel ? `(${unitLabel})` : null]
78
+ const exponent = formatScientificAxisExponent(...domain)
79
+ return [label, exponent, !exponent && unitLabel ? `(${unitLabel})` : null]
75
80
  .filter(Boolean)
76
81
  .join(' ')
77
82
  }
@@ -96,6 +101,8 @@ export interface YAxisSeriesGroup {
96
101
  seriesList: DisplaySeries[]
97
102
  domain: [number, number]
98
103
  fixed: boolean
104
+ maximumTick?: number
105
+ minimumTick?: number
99
106
  }
100
107
 
101
108
  export interface YAxisSlot {
@@ -183,26 +190,19 @@ export function resolveRenderedYAxisSeriesGroups(
183
190
  yDomain?: WaveformYDomain,
184
191
  yDomains?: Record<string, WaveformYDomain>,
185
192
  viewportYDomains?: Record<string, [number, number]>,
186
- upperPaddingRatio = 0,
193
+ padding?: YAxisPaddingOptions | number,
187
194
  ): YAxisSeriesGroup[] {
188
195
  const viewportYDomain = viewportYDomains?.[track.id]
189
196
  const axisTrack =
190
197
  track.visibleSeries.length || !track.series.length
191
198
  ? track
192
199
  : { ...track, visibleSeries: track.series }
193
- return resolveYAxisSeriesGroups(axisTrack, overlayMode, yDomain, yDomains).map((group) =>
194
- !group.fixed && viewportYDomain
195
- ? { ...group, domain: viewportYDomain }
196
- : !group.fixed && Number.isFinite(upperPaddingRatio) && upperPaddingRatio > 0
197
- ? {
198
- ...group,
199
- domain: [
200
- group.domain[0],
201
- group.domain[1] + (group.domain[1] - group.domain[0]) * upperPaddingRatio,
202
- ] as WaveformYDomain,
203
- }
204
- : group,
205
- )
200
+ return resolveYAxisSeriesGroups(axisTrack, overlayMode, yDomain, yDomains).map((group) => {
201
+ const padded = applyYAxisPadding(group, viewportYDomain, padding)
202
+ return track.visibleSeries.length
203
+ ? padded
204
+ : { ...padded, maximumTick: undefined, minimumTick: undefined }
205
+ })
206
206
  }
207
207
 
208
208
  export function axisTextMetrics(
@@ -213,11 +213,19 @@ export function axisTextMetrics(
213
213
  tickCount = 5,
214
214
  includeWithoutLastTick = false,
215
215
  measureTextWidth?: (text: string) => number,
216
+ maximumTick?: number,
217
+ minimumTick?: number,
216
218
  ): { tickTextWidth: number } {
217
219
  const resolvedTicks = tickValues
218
220
  ? { domain, values: tickValues }
219
221
  : resolveYAxisTicks(domain, tickCount, nice)
220
- const tickValueSets = [resolvedTicks.values]
222
+ const inRange = (value?: number) =>
223
+ value !== undefined && value >= resolvedTicks.domain[0] && value <= resolvedTicks.domain[1]
224
+ ? value
225
+ : undefined
226
+ const tickValueSets = [
227
+ withMaximumTick(resolvedTicks.values, inRange(maximumTick), inRange(minimumTick)),
228
+ ]
221
229
  if (includeWithoutLastTick && resolvedTicks.values.length > 1) {
222
230
  tickValueSets.push(resolvedTicks.values.slice(0, -1))
223
231
  }
@@ -296,10 +304,13 @@ export function buildYAxisSlots(
296
304
  nice = true,
297
305
  includeWithoutLastTick = false,
298
306
  viewportYDomains?: Record<string, [number, number]>,
299
- upperPaddingRatio = 0,
307
+ padding?: YAxisPaddingOptions | number,
300
308
  showUnits = true,
301
309
  measureTextWidth?: (text: string) => number,
302
310
  ): { slots: YAxisSlot[]; clearance: { left: number; right: number } } {
311
+ const labelBandWidth = resolveYAxisLabelBandWidth(
312
+ typeof padding === 'object' ? padding.labelTextStyle : undefined,
313
+ )
303
314
  const widths = new Map<string, number>()
304
315
  tracks.forEach((track) => {
305
316
  const sideIndexes = { left: 0, right: 0 }
@@ -309,7 +320,7 @@ export function buildYAxisSlots(
309
320
  yDomain,
310
321
  yDomains,
311
322
  viewportYDomains,
312
- upperPaddingRatio,
323
+ padding,
313
324
  ).forEach((group) => {
314
325
  const sideIndex = sideIndexes[group.side]++
315
326
  const key = `${group.side}:${sideIndex}`
@@ -321,6 +332,8 @@ export function buildYAxisSlots(
321
332
  tickCount,
322
333
  includeWithoutLastTick,
323
334
  measureTextWidth,
335
+ group.maximumTick,
336
+ group.minimumTick,
324
337
  ).tickTextWidth
325
338
  widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, width))
326
339
  })
@@ -337,13 +350,13 @@ export function buildYAxisSlots(
337
350
  const tickTextWidth = widths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
338
351
  const axisOffset = side === 'left' && clearance.left > 0 ? -clearance.left : clearance[side]
339
352
  const labelDistance =
340
- tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
353
+ tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth / 2
341
354
  const labelOffset = axisOffset + labelDistance * (side === 'left' ? -1 : 1)
342
355
  const fullClearance =
343
356
  tickTextWidth +
344
357
  Y_AXIS_TICK_PADDING +
345
358
  Y_AXIS_LABEL_GAP +
346
- Y_AXIS_LABEL_BAND_WIDTH +
359
+ labelBandWidth +
347
360
  Y_AXIS_OUTER_PADDING
348
361
  slots.push({ side, sideIndex, axisOffset, labelOffset, clearance: fullClearance })
349
362
  clearance[side] += fullClearance