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.
- package/README.md +260 -40
- package/dist/assets/{workerRuntime-o5An0M-W.js → workerRuntime-5p-EI-GL.js} +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +3874 -2778
- 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/annotation/useWaveformChartAnnotations.d.ts +0 -1
- 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 +6 -3
- package/dist/types/components/core/niceScale.d.ts +34 -0
- 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 +5 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +24 -6
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
- package/dist/types/components/core/useWaveformData.d.ts +2 -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/useExtremaTickViewport.d.ts +5 -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 +1 -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/data/sx2Channel.d.ts +10 -0
- package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
- package/dist/types/demo/types.d.ts +7 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types/chart.d.ts +32 -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 +61 -14
- package/src/components/WaveformChart.css +10 -1
- package/src/components/WaveformChart.vue +6 -0
- package/src/components/WaveformChartView.vue +27 -26
- package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
- package/src/components/annotation/useWaveformChartAnnotations.ts +7 -18
- package/src/components/controls/WaveformToolbar.vue +247 -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 +49 -36
- package/src/components/core/niceScale.ts +151 -0
- package/src/components/core/trackLayoutBuilder.ts +41 -17
- package/src/components/core/trackPagination.ts +3 -3
- package/src/components/core/types.ts +5 -0
- package/src/components/core/useWaveformChartController.ts +94 -3
- package/src/components/core/useWaveformChartLifecycle.ts +11 -111
- package/src/components/core/useWaveformData.ts +4 -0
- package/src/components/core/useWaveformLayout.ts +25 -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 +11 -9
- package/src/components/core/yAxisPadding.ts +60 -0
- package/src/components/core/yAxisTitleLayout.ts +11 -12
- package/src/components/interaction/useExtremaTickViewport.ts +43 -0
- package/src/components/interaction/useWaveformViewport.ts +11 -2
- package/src/components/rendering/WaveformTrack.css +1 -2
- package/src/components/rendering/WaveformTrack.vue +5 -12
- package/src/components/rendering/WaveformTrackAxes.vue +66 -19
- package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
- package/src/components/rendering/compactFrame.ts +31 -0
- package/src/components/rendering/yAxisTickVisibility.ts +156 -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 +67 -2
- package/src/data/sx2Channel.ts +315 -0
- 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 +53 -0
- package/src/demo/embedBridge.ts +85 -0
- package/src/demo/types.ts +7 -0
- package/src/index.ts +19 -0
- package/src/types/chart.ts +34 -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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import type { WaveformChartController } from '../core/useWaveformChartController'
|
|
4
|
+
import { resolveToolbar } from './toolbarRegistry'
|
|
5
|
+
const props = defineProps<{ controller: WaveformChartController }>()
|
|
6
|
+
const config = computed(() => resolveToolbar(props.controller.toolbar))
|
|
7
|
+
const compact = computed(() => props.controller.chartWidth < 440)
|
|
8
|
+
const expanded = ref(false)
|
|
9
|
+
const selected = ref('')
|
|
10
|
+
const menu = ref(false)
|
|
11
|
+
const busy = ref(false)
|
|
12
|
+
const feedback = ref('')
|
|
13
|
+
let disposed = false
|
|
14
|
+
onBeforeUnmount(() => {
|
|
15
|
+
disposed = true
|
|
16
|
+
})
|
|
17
|
+
watch(
|
|
18
|
+
() => props.controller.controlState,
|
|
19
|
+
(state) => {
|
|
20
|
+
if (!state.independent || !state.targets.some((t) => t.trackId === selected.value))
|
|
21
|
+
selected.value = ''
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
watch(
|
|
25
|
+
() => config.value.visible,
|
|
26
|
+
() => {
|
|
27
|
+
menu.value = false
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
async function download(format: 'png' | 'svg') {
|
|
31
|
+
menu.value = false
|
|
32
|
+
busy.value = true
|
|
33
|
+
feedback.value = ''
|
|
34
|
+
try {
|
|
35
|
+
const blob = await props.controller.exportImage({ format })
|
|
36
|
+
if (disposed) return
|
|
37
|
+
const url = URL.createObjectURL(blob)
|
|
38
|
+
const link = document.createElement('a')
|
|
39
|
+
try {
|
|
40
|
+
link.href = url
|
|
41
|
+
link.download = `waveform.${format}`
|
|
42
|
+
document.body.appendChild(link)
|
|
43
|
+
link.click()
|
|
44
|
+
} finally {
|
|
45
|
+
link.remove()
|
|
46
|
+
URL.revokeObjectURL(url)
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!disposed) feedback.value = error instanceof Error ? error.message : '导出失败,请重试'
|
|
50
|
+
} finally {
|
|
51
|
+
busy.value = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function toggleExpanded() {
|
|
55
|
+
expanded.value = !expanded.value
|
|
56
|
+
menu.value = false
|
|
57
|
+
}
|
|
58
|
+
function escape() {
|
|
59
|
+
menu.value = false
|
|
60
|
+
expanded.value = false
|
|
61
|
+
props.controller.cancelViewportDrag()
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
<template>
|
|
65
|
+
<div
|
|
66
|
+
v-if="config.visible"
|
|
67
|
+
class="waveform-toolbar"
|
|
68
|
+
:class="[
|
|
69
|
+
`waveform-toolbar--${config.display}`,
|
|
70
|
+
`waveform-toolbar--${config.position}`,
|
|
71
|
+
{ 'waveform-toolbar--collapsed': compact && !expanded },
|
|
72
|
+
]"
|
|
73
|
+
:style="{ top: `${controller.titleAreaHeight + 2}px` }"
|
|
74
|
+
role="toolbar"
|
|
75
|
+
aria-label="波形工具栏"
|
|
76
|
+
@pointerdown.stop
|
|
77
|
+
@click.stop
|
|
78
|
+
@dblclick.stop
|
|
79
|
+
@wheel.stop
|
|
80
|
+
@contextmenu.stop
|
|
81
|
+
@keydown.esc.stop="escape"
|
|
82
|
+
>
|
|
83
|
+
<button
|
|
84
|
+
v-if="compact"
|
|
85
|
+
type="button"
|
|
86
|
+
class="waveform-toolbar__toggle"
|
|
87
|
+
aria-label="图表工具"
|
|
88
|
+
:aria-expanded="expanded"
|
|
89
|
+
@click="toggleExpanded"
|
|
90
|
+
>
|
|
91
|
+
工具
|
|
92
|
+
</button>
|
|
93
|
+
<select v-if="controller.controlState.independent" v-model="selected" aria-label="操作图框">
|
|
94
|
+
<option value="">当前页全部</option>
|
|
95
|
+
<option
|
|
96
|
+
v-for="target in controller.controlState.targets"
|
|
97
|
+
:key="target.trackId"
|
|
98
|
+
:value="target.trackId"
|
|
99
|
+
>
|
|
100
|
+
{{ target.label }}
|
|
101
|
+
</option>
|
|
102
|
+
</select>
|
|
103
|
+
<span v-else class="waveform-toolbar__scope">共享范围</span>
|
|
104
|
+
<div
|
|
105
|
+
v-for="(group, index) in config.groups"
|
|
106
|
+
:key="index"
|
|
107
|
+
class="waveform-toolbar__group"
|
|
108
|
+
:data-group="group[0]?.group"
|
|
109
|
+
>
|
|
110
|
+
<button
|
|
111
|
+
v-for="item in group"
|
|
112
|
+
:key="item.id"
|
|
113
|
+
type="button"
|
|
114
|
+
:data-command="item.id"
|
|
115
|
+
:title="item.label"
|
|
116
|
+
:aria-label="item.label"
|
|
117
|
+
:aria-pressed="item.group === 'mode' ? item.active(controller.controlState) : undefined"
|
|
118
|
+
:aria-expanded="item.id === 'export' ? menu : undefined"
|
|
119
|
+
:disabled="
|
|
120
|
+
!item.enabled(controller.controlState, selected || undefined) ||
|
|
121
|
+
(item.id === 'export' && busy)
|
|
122
|
+
"
|
|
123
|
+
@click="item.id === 'export' ? (menu = !menu) : item.run(controller, selected || undefined)"
|
|
124
|
+
>
|
|
125
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
<div v-if="menu" class="waveform-toolbar__menu" role="group" aria-label="图片格式">
|
|
129
|
+
<button type="button" @click="download('png')">下载 PNG</button>
|
|
130
|
+
<button type="button" @click="download('svg')">下载 SVG</button>
|
|
131
|
+
</div>
|
|
132
|
+
<span v-if="busy || feedback" class="waveform-toolbar__feedback" role="status">{{
|
|
133
|
+
busy ? '正在导出…' : feedback
|
|
134
|
+
}}</span>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
137
|
+
<style scoped>
|
|
138
|
+
.waveform-toolbar {
|
|
139
|
+
position: absolute;
|
|
140
|
+
z-index: 10;
|
|
141
|
+
top: 2px;
|
|
142
|
+
right: 2px;
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-wrap: wrap;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 0;
|
|
147
|
+
max-width: calc(100% - 4px);
|
|
148
|
+
background: rgb(255 255 255 / 96%);
|
|
149
|
+
}
|
|
150
|
+
.waveform-toolbar--collapsed > :not(.waveform-toolbar__toggle):not(.waveform-toolbar__feedback) {
|
|
151
|
+
display: none;
|
|
152
|
+
}
|
|
153
|
+
.waveform-toolbar--top-left {
|
|
154
|
+
left: 2px;
|
|
155
|
+
right: auto;
|
|
156
|
+
}
|
|
157
|
+
.waveform-toolbar--hover {
|
|
158
|
+
opacity: 0;
|
|
159
|
+
pointer-events: none;
|
|
160
|
+
}
|
|
161
|
+
.waveform-chart:hover .waveform-toolbar--hover,
|
|
162
|
+
.waveform-toolbar:focus-within,
|
|
163
|
+
.waveform-toolbar:has(.waveform-toolbar__menu),
|
|
164
|
+
.waveform-toolbar:has(.waveform-toolbar__feedback) {
|
|
165
|
+
opacity: 1;
|
|
166
|
+
pointer-events: auto;
|
|
167
|
+
}
|
|
168
|
+
.waveform-toolbar__group {
|
|
169
|
+
display: flex;
|
|
170
|
+
padding-left: 8px;
|
|
171
|
+
}
|
|
172
|
+
button {
|
|
173
|
+
display: inline-flex;
|
|
174
|
+
align-items: center;
|
|
175
|
+
justify-content: center;
|
|
176
|
+
box-sizing: border-box;
|
|
177
|
+
min-width: 24px;
|
|
178
|
+
height: 22px;
|
|
179
|
+
padding: 3px 4px;
|
|
180
|
+
font-size: 12px;
|
|
181
|
+
line-height: normal;
|
|
182
|
+
border: 0;
|
|
183
|
+
border-radius: 4px;
|
|
184
|
+
background: transparent;
|
|
185
|
+
color: #344054;
|
|
186
|
+
cursor: pointer;
|
|
187
|
+
}
|
|
188
|
+
button:hover,
|
|
189
|
+
button[aria-pressed='true'] {
|
|
190
|
+
background: #e6f4ff;
|
|
191
|
+
color: #0960bd;
|
|
192
|
+
}
|
|
193
|
+
button:disabled {
|
|
194
|
+
opacity: 0.35;
|
|
195
|
+
cursor: default;
|
|
196
|
+
}
|
|
197
|
+
button:focus-visible,
|
|
198
|
+
select:focus-visible {
|
|
199
|
+
outline: 2px solid #0960bd;
|
|
200
|
+
outline-offset: 1px;
|
|
201
|
+
}
|
|
202
|
+
svg {
|
|
203
|
+
width: 16px;
|
|
204
|
+
height: 16px;
|
|
205
|
+
fill: none;
|
|
206
|
+
stroke: currentColor;
|
|
207
|
+
stroke-width: 1.6;
|
|
208
|
+
stroke-linecap: round;
|
|
209
|
+
stroke-linejoin: round;
|
|
210
|
+
}
|
|
211
|
+
select {
|
|
212
|
+
min-width: 0;
|
|
213
|
+
max-width: 120px;
|
|
214
|
+
box-sizing: border-box;
|
|
215
|
+
height: 22px;
|
|
216
|
+
padding: 0 2px;
|
|
217
|
+
font-size: 12px;
|
|
218
|
+
border: 1px solid #d0d5dd;
|
|
219
|
+
border-radius: 4px;
|
|
220
|
+
background: white;
|
|
221
|
+
}
|
|
222
|
+
.waveform-toolbar__scope {
|
|
223
|
+
font-size: 12px;
|
|
224
|
+
color: #667085;
|
|
225
|
+
}
|
|
226
|
+
.waveform-toolbar__menu {
|
|
227
|
+
position: absolute;
|
|
228
|
+
right: 0;
|
|
229
|
+
top: 100%;
|
|
230
|
+
padding: 6px;
|
|
231
|
+
border: 1px solid #d0d5dd;
|
|
232
|
+
border-radius: 4px;
|
|
233
|
+
background: white;
|
|
234
|
+
display: flex;
|
|
235
|
+
}
|
|
236
|
+
.waveform-toolbar__feedback {
|
|
237
|
+
flex-basis: 100%;
|
|
238
|
+
font-size: 12px;
|
|
239
|
+
max-width: 300px;
|
|
240
|
+
}
|
|
241
|
+
@media (hover: none) {
|
|
242
|
+
.waveform-toolbar--hover {
|
|
243
|
+
opacity: 1;
|
|
244
|
+
pointer-events: auto;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
|
|
2
|
+
|
|
3
|
+
/** Presentation changes invalidate an in-flight image snapshot, without serializing input data. */
|
|
4
|
+
export function chartExportOptions(props: ResolvedWaveformChartProps) {
|
|
5
|
+
const keys = [
|
|
6
|
+
'annotations',
|
|
7
|
+
'annotationsVisible',
|
|
8
|
+
'title',
|
|
9
|
+
'axes',
|
|
10
|
+
'frameStyle',
|
|
11
|
+
'frameNumber',
|
|
12
|
+
'frameNumbers',
|
|
13
|
+
'legend',
|
|
14
|
+
'cleanView',
|
|
15
|
+
'rendering',
|
|
16
|
+
'displayMode',
|
|
17
|
+
'overlayMode',
|
|
18
|
+
'initialXDomain',
|
|
19
|
+
'initialXDomains',
|
|
20
|
+
'yDomain',
|
|
21
|
+
'yDomains',
|
|
22
|
+
'grid',
|
|
23
|
+
'plotMargin',
|
|
24
|
+
'xLabel',
|
|
25
|
+
'yLabel',
|
|
26
|
+
'timeUnit',
|
|
27
|
+
'zeroLine',
|
|
28
|
+
'layoutPreset',
|
|
29
|
+
'unitDisplayMode',
|
|
30
|
+
] as const satisfies readonly (keyof ResolvedWaveformChartProps)[]
|
|
31
|
+
return Object.fromEntries(keys.map((key) => [key, props[key]]))
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|