waveform-analysis 0.1.70 → 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.
- package/README.md +244 -45
- package/dist/assets/workerRuntime-5p-EI-GL.js +1 -0
- package/dist/index.cjs +2 -1
- package/dist/index.js +2924 -1915
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +24 -6
- package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
- package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
- package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
- package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
- package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
- package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
- package/dist/types/components/controls/useChartCommands.d.ts +42 -0
- package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
- package/dist/types/components/controls/useControlMode.d.ts +8 -0
- package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
- package/dist/types/components/core/layout.d.ts +5 -3
- package/dist/types/components/core/niceScale.d.ts +34 -0
- package/dist/types/components/core/pagination.d.ts +2 -2
- package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
- package/dist/types/components/core/trackPagination.d.ts +1 -1
- package/dist/types/components/core/types.d.ts +3 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +24 -5
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
- package/dist/types/components/core/useWaveformData.d.ts +1 -0
- package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
- package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
- package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
- package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
- package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
- package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
- package/dist/types/components/core/yAxisPadding.d.ts +7 -0
- package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
- package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
- package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
- package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +0 -1
- package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
- package/dist/types/components/rendering/compactFrame.d.ts +5 -0
- package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
- package/dist/types/core/lineBoundary.d.ts +4 -0
- package/dist/types/core/rendering.d.ts +4 -0
- package/dist/types/core/renderingStrategies.d.ts +2 -0
- package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
- package/dist/types/data/simulatedWaveforms.d.ts +1 -1
- package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
- package/dist/types/demo/types.d.ts +6 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types/chart.d.ts +30 -8
- package/dist/types/types/controls.d.ts +69 -0
- package/dist/types/types/index.d.ts +2 -1
- package/dist/types/utils/formatters.d.ts +1 -0
- package/package.json +7 -6
- package/src/App.vue +58 -14
- package/src/components/WaveformChart.css +10 -1
- package/src/components/WaveformChart.vue +6 -0
- package/src/components/WaveformChartView.vue +27 -23
- package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
- package/src/components/annotation/useWaveformChartAnnotations.ts +6 -1
- package/src/components/controls/WaveformToolbar.vue +248 -0
- package/src/components/controls/chartExportOptions.ts +32 -0
- package/src/components/controls/chartSnapshot.ts +132 -0
- package/src/components/controls/snapshotTitle.ts +73 -0
- package/src/components/controls/toolbarRegistry.ts +92 -0
- package/src/components/controls/useChartCommands.ts +294 -0
- package/src/components/controls/useChartImageExport.ts +155 -0
- package/src/components/controls/useControlMode.ts +30 -0
- package/src/components/core/latestTaskScheduler.ts +14 -2
- package/src/components/core/layout.ts +35 -34
- package/src/components/core/niceScale.ts +151 -0
- package/src/components/core/pagination.ts +3 -16
- package/src/components/core/trackLayoutBuilder.ts +34 -17
- package/src/components/core/trackPagination.ts +3 -3
- package/src/components/core/types.ts +3 -0
- package/src/components/core/useWaveformChartController.ts +95 -10
- package/src/components/core/useWaveformChartLifecycle.ts +11 -111
- package/src/components/core/useWaveformData.ts +2 -0
- package/src/components/core/useWaveformLayout.ts +17 -5
- package/src/components/core/useWaveformMeasurements.ts +63 -0
- package/src/components/core/useWaveformRenderSampling.ts +80 -59
- package/src/components/core/useWaveformViewportRestore.ts +105 -0
- package/src/components/core/useWaveformYAxisLayout.ts +3 -2
- package/src/components/core/waveformChartTypes.ts +5 -0
- package/src/components/core/waveformSamplingSession.ts +49 -0
- package/src/components/core/workerSamplingClient.ts +38 -6
- package/src/components/core/yAxisLabelStyle.ts +10 -0
- package/src/components/core/yAxisLayoutMetrics.ts +10 -9
- package/src/components/core/yAxisPadding.ts +45 -0
- package/src/components/core/yAxisTitleLayout.ts +11 -12
- package/src/components/interaction/useWaveformViewport.ts +11 -2
- package/src/components/rendering/WaveformTrack.css +1 -2
- package/src/components/rendering/WaveformTrack.vue +4 -12
- package/src/components/rendering/WaveformTrackAxes.vue +48 -19
- package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
- package/src/components/rendering/compactFrame.ts +31 -0
- package/src/components/rendering/yAxisTickVisibility.ts +95 -0
- package/src/core/lineBoundary.ts +24 -0
- package/src/core/rendering.ts +48 -25
- package/src/core/renderingStrategies.ts +19 -13
- package/src/core/waveformPointSource.ts +23 -1
- package/src/core/workerSampling/outputBoundaries.ts +32 -0
- package/src/core/workerSampling/repository.ts +26 -18
- package/src/core/workerSampling/repositoryDataset.ts +33 -1
- package/src/core/workerSampling/workerRuntime.ts +13 -3
- package/src/data/simulatedWaveforms.ts +57 -2
- package/src/demo/DemoChartHost.vue +5 -1
- package/src/demo/DemoGridFrameControls.vue +2 -0
- package/src/demo/DemoViewControls.vue +5 -2
- package/src/demo/DemoYAxisPaddingControls.vue +46 -0
- package/src/demo/embedBridge.ts +85 -0
- package/src/demo/types.ts +6 -0
- package/src/index.ts +18 -0
- package/src/types/chart.ts +32 -8
- package/src/types/controls.ts +80 -0
- package/src/types/index.ts +18 -0
- package/src/utils/formatters.ts +2 -1
- package/src/views/WasmSamplingDemo.vue +8 -1
- package/dist/assets/workerRuntime-BcYng9TR.js +0 -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
|
+
}
|