waveform-analysis 0.1.71 → 0.1.72

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 (117) hide show
  1. package/README.md +241 -39
  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 +2922 -1910
  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/controls/WaveformToolbar.vue.d.ts +7 -0
  9. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  10. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  11. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  12. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  13. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  14. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  15. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  16. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  17. package/dist/types/components/core/layout.d.ts +5 -3
  18. package/dist/types/components/core/niceScale.d.ts +34 -0
  19. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  20. package/dist/types/components/core/trackPagination.d.ts +1 -1
  21. package/dist/types/components/core/types.d.ts +3 -0
  22. package/dist/types/components/core/useWaveformChartController.d.ts +24 -5
  23. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  24. package/dist/types/components/core/useWaveformData.d.ts +1 -0
  25. package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
  26. package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
  27. package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
  28. package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
  29. package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
  30. package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
  31. package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
  32. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  33. package/dist/types/components/core/yAxisPadding.d.ts +7 -0
  34. package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
  35. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
  36. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  37. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  38. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +0 -1
  39. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  40. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  41. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  42. package/dist/types/core/lineBoundary.d.ts +4 -0
  43. package/dist/types/core/rendering.d.ts +4 -0
  44. package/dist/types/core/renderingStrategies.d.ts +2 -0
  45. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  46. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  47. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  48. package/dist/types/demo/types.d.ts +6 -0
  49. package/dist/types/index.d.ts +2 -1
  50. package/dist/types/types/chart.d.ts +30 -8
  51. package/dist/types/types/controls.d.ts +69 -0
  52. package/dist/types/types/index.d.ts +2 -1
  53. package/dist/types/utils/formatters.d.ts +1 -0
  54. package/package.json +7 -6
  55. package/src/App.vue +58 -14
  56. package/src/components/WaveformChart.css +10 -1
  57. package/src/components/WaveformChart.vue +6 -0
  58. package/src/components/WaveformChartView.vue +27 -23
  59. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  60. package/src/components/annotation/useWaveformChartAnnotations.ts +6 -1
  61. package/src/components/controls/WaveformToolbar.vue +248 -0
  62. package/src/components/controls/chartExportOptions.ts +32 -0
  63. package/src/components/controls/chartSnapshot.ts +132 -0
  64. package/src/components/controls/snapshotTitle.ts +73 -0
  65. package/src/components/controls/toolbarRegistry.ts +92 -0
  66. package/src/components/controls/useChartCommands.ts +294 -0
  67. package/src/components/controls/useChartImageExport.ts +155 -0
  68. package/src/components/controls/useControlMode.ts +30 -0
  69. package/src/components/core/latestTaskScheduler.ts +14 -2
  70. package/src/components/core/layout.ts +35 -34
  71. package/src/components/core/niceScale.ts +151 -0
  72. package/src/components/core/trackLayoutBuilder.ts +34 -17
  73. package/src/components/core/trackPagination.ts +3 -3
  74. package/src/components/core/types.ts +3 -0
  75. package/src/components/core/useWaveformChartController.ts +94 -3
  76. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  77. package/src/components/core/useWaveformData.ts +2 -0
  78. package/src/components/core/useWaveformLayout.ts +17 -5
  79. package/src/components/core/useWaveformMeasurements.ts +63 -0
  80. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  81. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  82. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  83. package/src/components/core/waveformChartTypes.ts +5 -0
  84. package/src/components/core/waveformSamplingSession.ts +49 -0
  85. package/src/components/core/workerSamplingClient.ts +38 -6
  86. package/src/components/core/yAxisLabelStyle.ts +10 -0
  87. package/src/components/core/yAxisLayoutMetrics.ts +10 -9
  88. package/src/components/core/yAxisPadding.ts +45 -0
  89. package/src/components/core/yAxisTitleLayout.ts +11 -12
  90. package/src/components/interaction/useWaveformViewport.ts +11 -2
  91. package/src/components/rendering/WaveformTrack.css +1 -2
  92. package/src/components/rendering/WaveformTrack.vue +4 -12
  93. package/src/components/rendering/WaveformTrackAxes.vue +48 -19
  94. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  95. package/src/components/rendering/compactFrame.ts +31 -0
  96. package/src/components/rendering/yAxisTickVisibility.ts +95 -0
  97. package/src/core/lineBoundary.ts +24 -0
  98. package/src/core/rendering.ts +48 -25
  99. package/src/core/renderingStrategies.ts +19 -13
  100. package/src/core/waveformPointSource.ts +23 -1
  101. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  102. package/src/core/workerSampling/repository.ts +26 -18
  103. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  104. package/src/core/workerSampling/workerRuntime.ts +13 -3
  105. package/src/data/simulatedWaveforms.ts +57 -2
  106. package/src/demo/DemoChartHost.vue +5 -1
  107. package/src/demo/DemoGridFrameControls.vue +2 -0
  108. package/src/demo/DemoViewControls.vue +5 -2
  109. package/src/demo/DemoYAxisPaddingControls.vue +46 -0
  110. package/src/demo/embedBridge.ts +85 -0
  111. package/src/demo/types.ts +6 -0
  112. package/src/index.ts +18 -0
  113. package/src/types/chart.ts +32 -8
  114. package/src/types/controls.ts +80 -0
  115. package/src/types/index.ts +18 -0
  116. package/src/utils/formatters.ts +2 -1
  117. package/src/views/WasmSamplingDemo.vue +8 -1
@@ -0,0 +1,132 @@
1
+ import { snapshotTitle } from './snapshotTitle'
2
+ const NS = 'http://www.w3.org/2000/svg'
3
+ function svgNode(name: string, attributes: Record<string, string | number> = {}) {
4
+ const node = document.createElementNS(NS, name)
5
+ Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value)))
6
+ return node
7
+ }
8
+ const styleKeys = [
9
+ 'fill',
10
+ 'fill-opacity',
11
+ 'stroke',
12
+ 'stroke-width',
13
+ 'stroke-opacity',
14
+ 'stroke-dasharray',
15
+ 'stroke-linecap',
16
+ 'stroke-linejoin',
17
+ 'opacity',
18
+ 'font-family',
19
+ 'font-size',
20
+ 'font-weight',
21
+ 'font-style',
22
+ 'text-decoration',
23
+ 'letter-spacing',
24
+ 'text-anchor',
25
+ 'dominant-baseline',
26
+ 'visibility',
27
+ 'display',
28
+ ]
29
+ export function copySvgStyles(source: Element, clone: Element) {
30
+ const originals = [source, ...source.querySelectorAll('*')]
31
+ const copies = [clone, ...clone.querySelectorAll('*')]
32
+ originals.forEach((node, i) => {
33
+ const target = copies[i] as SVGElement
34
+ const style = getComputedStyle(node)
35
+ styleKeys.forEach((key) => {
36
+ const value = style.getPropertyValue(key)
37
+ if (value) target.style.setProperty(key, value)
38
+ })
39
+ // Make fragment references independent of the host URL.
40
+ for (const attr of ['clip-path', 'mask', 'filter']) {
41
+ const value = target.getAttribute(attr)
42
+ if (value?.includes('#'))
43
+ target.setAttribute(attr, `url(#${value.split('#')[1]!.replace(/[)'"].*$/, '')})`)
44
+ }
45
+ })
46
+ }
47
+ function replaceLegends(source: SVGSVGElement, clone: SVGSVGElement) {
48
+ const originals = source.querySelectorAll('foreignObject')
49
+ clone.querySelectorAll('foreignObject').forEach((foreign, index) => {
50
+ const original = originals[index]!
51
+ const origin = original.getBoundingClientRect()
52
+ const group = svgNode('g', { class: 'waveform-export-legend' })
53
+ const width = Number(original.getAttribute('width')) || origin.width
54
+ const ratio = origin.width > 0 ? width / origin.width : 1
55
+ const panel = original.querySelector('.waveform-legend__panel')
56
+ if (panel) {
57
+ const rect = panel.getBoundingClientRect()
58
+ group.appendChild(
59
+ svgNode('rect', {
60
+ x: (rect.left - origin.left) * ratio,
61
+ y: (rect.top - origin.top) * ratio,
62
+ width: rect.width * ratio,
63
+ height: rect.height * ratio,
64
+ rx: 4,
65
+ fill: getComputedStyle(panel).backgroundColor,
66
+ stroke: '#d0d5dd',
67
+ }),
68
+ )
69
+ }
70
+ original.querySelectorAll('.waveform-legend__item').forEach((item) => {
71
+ const rect = item.getBoundingClientRect()
72
+ const row = svgNode('g', {
73
+ transform: `translate(${(rect.left - origin.left) * ratio},${(rect.top - origin.top) * ratio})`,
74
+ opacity: getComputedStyle(item).opacity,
75
+ })
76
+ const swatch = item.querySelector('svg')
77
+ if (swatch) {
78
+ const copy = swatch.cloneNode(true) as SVGSVGElement
79
+ copySvgStyles(swatch, copy)
80
+ copy.setAttribute('width', '26')
81
+ copy.setAttribute('height', '16')
82
+ row.appendChild(copy)
83
+ }
84
+ const label = item.querySelector('.waveform-legend__label')
85
+ if (label) {
86
+ const text = svgNode('text', {
87
+ x: 32,
88
+ y: 12,
89
+ fill: getComputedStyle(label).color,
90
+ 'font-size': 12,
91
+ 'font-family': 'sans-serif',
92
+ 'text-decoration': getComputedStyle(label).textDecorationLine,
93
+ })
94
+ text.textContent = label.textContent
95
+ row.appendChild(text)
96
+ }
97
+ group.appendChild(row)
98
+ })
99
+ foreign.replaceWith(group)
100
+ })
101
+ }
102
+ export function createChartSnapshot(
103
+ container: HTMLElement,
104
+ source: SVGSVGElement,
105
+ width: number,
106
+ height: number,
107
+ titleHeight: number,
108
+ background?: string,
109
+ ) {
110
+ const root = svgNode('svg', {
111
+ width,
112
+ height,
113
+ viewBox: `0 0 ${width} ${height}`,
114
+ }) as SVGSVGElement
115
+ const color = background ?? getComputedStyle(container).backgroundColor
116
+ root.appendChild(svgNode('rect', { width, height, fill: color || '#fff' }))
117
+ const clone = source.cloneNode(true) as SVGSVGElement
118
+ copySvgStyles(source, clone)
119
+ replaceLegends(source, clone)
120
+ clone
121
+ .querySelectorAll(
122
+ '.waveform-chart__overlay, .waveform-chart__hover-layer, .waveform-chart__zoom-selection',
123
+ )
124
+ .forEach((n) => n.remove())
125
+ clone.setAttribute('y', String(titleHeight))
126
+ clone.removeAttribute('role')
127
+ clone.removeAttribute('aria-label')
128
+ root.appendChild(clone)
129
+ const title = snapshotTitle(container, titleHeight, width)
130
+ if (title) root.appendChild(title)
131
+ return new XMLSerializer().serializeToString(root)
132
+ }
@@ -0,0 +1,73 @@
1
+ const NS = 'http://www.w3.org/2000/svg'
2
+ /** Recreate the HTML title in SVG, retaining wrapping, rotation and fitted scale. */
3
+ export function snapshotTitle(container: HTMLElement, titleHeight: number, width: number) {
4
+ const title = container.querySelector<HTMLElement>('.waveform-chart__title-text')
5
+ if (!title) return undefined
6
+ const style = getComputedStyle(title)
7
+ const rect = title.getBoundingClientRect()
8
+ const origin = container.getBoundingClientRect()
9
+ const ratio = origin.width ? width / origin.width : 1
10
+ const group = document.createElementNS(NS, 'g')
11
+ const matrix =
12
+ typeof DOMMatrix !== 'undefined' && style.transform !== 'none' && style.transform
13
+ ? new DOMMatrix(style.transform)
14
+ : undefined
15
+ const angle = matrix ? (Math.atan2(matrix.b, matrix.a) * 180) / Math.PI : 0
16
+ const scale = matrix ? Math.hypot(matrix.a, matrix.b) : 1
17
+ group.setAttribute(
18
+ 'transform',
19
+ `translate(${(rect.left - origin.left + rect.width / 2) * ratio},${titleHeight / 2}) rotate(${angle}) scale(${scale})`,
20
+ )
21
+ const text = document.createElementNS(NS, 'text')
22
+ text.setAttribute('fill', style.color)
23
+ for (const key of [
24
+ 'font-family',
25
+ 'font-size',
26
+ 'font-weight',
27
+ 'font-style',
28
+ 'text-decoration',
29
+ 'letter-spacing',
30
+ ])
31
+ text.style.setProperty(key, style.getPropertyValue(key))
32
+ const fontSize = parseFloat(style.fontSize) || 14
33
+ const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.4
34
+ const logicalWidth = title.clientWidth || parseFloat(style.width) || rect.width || width
35
+ const canvas = document.createElement('canvas')
36
+ const ctx = canvas.getContext('2d')
37
+ if (ctx) ctx.font = `${style.fontStyle} ${style.fontWeight} ${fontSize}px ${style.fontFamily}`
38
+ const lines: string[] = []
39
+ let line = ''
40
+ for (const char of title.textContent ?? '') {
41
+ if (char === '\n') {
42
+ lines.push(line)
43
+ line = ''
44
+ continue
45
+ }
46
+ const measured = ctx?.measureText(line + char).width ?? (line.length + 1) * fontSize
47
+ if (style.whiteSpace !== 'nowrap' && line && measured > logicalWidth) {
48
+ lines.push(line)
49
+ line = char
50
+ } else line += char
51
+ }
52
+ lines.push(line)
53
+ const align = style.textAlign
54
+ text.setAttribute(
55
+ 'text-anchor',
56
+ align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle',
57
+ )
58
+ lines.forEach((value, index) => {
59
+ const span = document.createElementNS(NS, 'tspan')
60
+ span.setAttribute(
61
+ 'x',
62
+ String(align === 'left' ? -logicalWidth / 2 : align === 'right' ? logicalWidth / 2 : 0),
63
+ )
64
+ span.setAttribute('y', String((index - (lines.length - 1) / 2) * lineHeight))
65
+ span.setAttribute('dominant-baseline', 'middle')
66
+ span.textContent = value
67
+ text.appendChild(span)
68
+ })
69
+ canvas.width = 0
70
+ canvas.height = 0
71
+ group.appendChild(text)
72
+ return group
73
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ WaveformControlState,
3
+ WaveformToolbarItem,
4
+ WaveformToolbarOptions,
5
+ } from '../../types/controls'
6
+ import type { useChartCommands } from './useChartCommands'
7
+
8
+ type Commands = Pick<ReturnType<typeof useChartCommands>, 'execute' | 'setInteractionMode'>
9
+ interface ButtonDefinition {
10
+ id: WaveformToolbarItem
11
+ label: string
12
+ icon: string
13
+ group: 'mode' | 'viewport' | 'export'
14
+ active: (state: WaveformControlState) => boolean
15
+ enabled: (state: WaveformControlState, trackId?: string) => boolean
16
+ run: (commands: Commands, trackId?: string) => void
17
+ }
18
+ const paths = {
19
+ 'zoom-box': 'M3 3h6M3 3v6M21 3h-6M21 3v6M3 21h6M3 21v-6M21 21h-6M21 21v-6',
20
+ pan: 'M12 2v20M2 12h20M8 6l4-4 4 4M8 18l4 4 4-4M6 8l-4 4 4 4M18 8l4 4-4 4',
21
+ annotate: 'M4 4h16v12H9l-5 4V4M8 8h8M8 12h5',
22
+ 'zoom-in': 'M12 5v14M5 12h14',
23
+ 'zoom-out': 'M5 12h14',
24
+ reset: 'M4 11a8 8 0 1 1 1 6M4 4v7h7',
25
+ fit: 'M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5M8 8h8v8H8z',
26
+ export: 'M12 3v12M7 10l5 5 5-5M4 15v6h16v-6',
27
+ }
28
+ const modeItems = { 'zoom-box': 'zoom', pan: 'pan', annotate: 'annotation' } as const
29
+ const labels = {
30
+ 'zoom-box': '框选缩放',
31
+ pan: '平移',
32
+ annotate: '注解',
33
+ 'zoom-in': '放大',
34
+ 'zoom-out': '缩小',
35
+ reset: '重置',
36
+ fit: '显示全部',
37
+ export: '导出图片',
38
+ }
39
+ export const defaultToolbarItems: WaveformToolbarItem[] = [
40
+ 'zoom-box',
41
+ 'pan',
42
+ 'annotate',
43
+ 'zoom-in',
44
+ 'zoom-out',
45
+ 'reset',
46
+ 'fit',
47
+ 'export',
48
+ ]
49
+ export const toolbarRegistry = Object.fromEntries(
50
+ defaultToolbarItems.map((id) => {
51
+ const mode = modeItems[id as keyof typeof modeItems]
52
+ const action =
53
+ id === 'zoom-in' || id === 'zoom-out' || id === 'reset' || id === 'fit' ? id : undefined
54
+ return [
55
+ id,
56
+ {
57
+ id,
58
+ label: labels[id],
59
+ icon: paths[id],
60
+ group: mode ? 'mode' : action ? 'viewport' : 'export',
61
+ active: (state: WaveformControlState) => !!mode && state.mode === mode,
62
+ enabled: (state: WaveformControlState, trackId?: string) =>
63
+ action && trackId
64
+ ? !!state.targets.find((t) => t.trackId === trackId)?.available[action]
65
+ : state.available[id],
66
+ run: (commands: Commands, trackId?: string) => {
67
+ if (mode) commands.setInteractionMode(mode)
68
+ else if (action)
69
+ commands.execute(action, trackId ? { trackId } : {}, undefined, 'toolbar')
70
+ },
71
+ } satisfies ButtonDefinition,
72
+ ]
73
+ }),
74
+ ) as Record<WaveformToolbarItem, ButtonDefinition>
75
+ export function resolveToolbar(options?: boolean | WaveformToolbarOptions) {
76
+ const config = typeof options === 'object' ? options : {}
77
+ const items = [...new Set(config.items ?? defaultToolbarItems)].filter(
78
+ (id) => toolbarRegistry[id],
79
+ )
80
+ const groups: ButtonDefinition[][] = []
81
+ items.forEach((id) => {
82
+ const item = toolbarRegistry[id]
83
+ if (groups.at(-1)?.[0]?.group === item.group) groups.at(-1)!.push(item)
84
+ else groups.push([item])
85
+ })
86
+ return {
87
+ visible: !!options && config.visible !== false && items.length > 0,
88
+ display: config.display ?? 'hover',
89
+ position: config.position ?? 'top-right',
90
+ groups,
91
+ }
92
+ }
@@ -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
+ }