waveform-analysis 0.1.71 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +260 -40
  2. package/dist/assets/{workerRuntime-o5An0M-W.js → workerRuntime-5p-EI-GL.js} +1 -1
  3. package/dist/index.cjs +2 -1
  4. package/dist/index.js +3874 -2778
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +24 -6
  7. package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
  8. package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +0 -1
  9. package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
  10. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  11. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  12. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  13. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  14. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  15. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  16. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  17. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  18. package/dist/types/components/core/layout.d.ts +6 -3
  19. package/dist/types/components/core/niceScale.d.ts +34 -0
  20. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  21. package/dist/types/components/core/trackPagination.d.ts +1 -1
  22. package/dist/types/components/core/types.d.ts +5 -0
  23. package/dist/types/components/core/useWaveformChartController.d.ts +24 -6
  24. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  25. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  26. package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
  27. package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
  28. package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
  29. package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
  30. package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
  31. package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
  32. package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
  33. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  34. package/dist/types/components/core/yAxisPadding.d.ts +7 -0
  35. package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
  36. package/dist/types/components/interaction/useExtremaTickViewport.d.ts +5 -0
  37. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
  38. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  39. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  40. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -1
  41. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  42. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  43. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  44. package/dist/types/core/lineBoundary.d.ts +4 -0
  45. package/dist/types/core/rendering.d.ts +4 -0
  46. package/dist/types/core/renderingStrategies.d.ts +2 -0
  47. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  48. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  49. package/dist/types/data/sx2Channel.d.ts +10 -0
  50. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  51. package/dist/types/demo/types.d.ts +7 -0
  52. package/dist/types/index.d.ts +2 -1
  53. package/dist/types/types/chart.d.ts +32 -8
  54. package/dist/types/types/controls.d.ts +69 -0
  55. package/dist/types/types/index.d.ts +2 -1
  56. package/dist/types/utils/formatters.d.ts +1 -0
  57. package/package.json +7 -6
  58. package/src/App.vue +61 -14
  59. package/src/components/WaveformChart.css +10 -1
  60. package/src/components/WaveformChart.vue +6 -0
  61. package/src/components/WaveformChartView.vue +27 -26
  62. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  63. package/src/components/annotation/useWaveformChartAnnotations.ts +7 -18
  64. package/src/components/controls/WaveformToolbar.vue +247 -0
  65. package/src/components/controls/chartExportOptions.ts +32 -0
  66. package/src/components/controls/chartSnapshot.ts +132 -0
  67. package/src/components/controls/snapshotTitle.ts +73 -0
  68. package/src/components/controls/toolbarRegistry.ts +92 -0
  69. package/src/components/controls/useChartCommands.ts +294 -0
  70. package/src/components/controls/useChartImageExport.ts +155 -0
  71. package/src/components/controls/useControlMode.ts +30 -0
  72. package/src/components/core/latestTaskScheduler.ts +14 -2
  73. package/src/components/core/layout.ts +49 -36
  74. package/src/components/core/niceScale.ts +151 -0
  75. package/src/components/core/trackLayoutBuilder.ts +41 -17
  76. package/src/components/core/trackPagination.ts +3 -3
  77. package/src/components/core/types.ts +5 -0
  78. package/src/components/core/useWaveformChartController.ts +94 -3
  79. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  80. package/src/components/core/useWaveformData.ts +4 -0
  81. package/src/components/core/useWaveformLayout.ts +25 -5
  82. package/src/components/core/useWaveformMeasurements.ts +63 -0
  83. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  84. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  85. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  86. package/src/components/core/waveformChartTypes.ts +5 -0
  87. package/src/components/core/waveformSamplingSession.ts +49 -0
  88. package/src/components/core/workerSamplingClient.ts +38 -6
  89. package/src/components/core/yAxisLabelStyle.ts +10 -0
  90. package/src/components/core/yAxisLayoutMetrics.ts +11 -9
  91. package/src/components/core/yAxisPadding.ts +60 -0
  92. package/src/components/core/yAxisTitleLayout.ts +11 -12
  93. package/src/components/interaction/useExtremaTickViewport.ts +43 -0
  94. package/src/components/interaction/useWaveformViewport.ts +11 -2
  95. package/src/components/rendering/WaveformTrack.css +1 -2
  96. package/src/components/rendering/WaveformTrack.vue +5 -12
  97. package/src/components/rendering/WaveformTrackAxes.vue +66 -19
  98. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  99. package/src/components/rendering/compactFrame.ts +31 -0
  100. package/src/components/rendering/yAxisTickVisibility.ts +156 -0
  101. package/src/core/lineBoundary.ts +24 -0
  102. package/src/core/rendering.ts +48 -25
  103. package/src/core/renderingStrategies.ts +19 -13
  104. package/src/core/waveformPointSource.ts +23 -1
  105. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  106. package/src/core/workerSampling/repository.ts +26 -18
  107. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  108. package/src/core/workerSampling/workerRuntime.ts +13 -3
  109. package/src/data/simulatedWaveforms.ts +67 -2
  110. package/src/data/sx2Channel.ts +315 -0
  111. package/src/demo/DemoChartHost.vue +5 -1
  112. package/src/demo/DemoGridFrameControls.vue +2 -0
  113. package/src/demo/DemoViewControls.vue +5 -2
  114. package/src/demo/DemoYAxisPaddingControls.vue +53 -0
  115. package/src/demo/embedBridge.ts +85 -0
  116. package/src/demo/types.ts +7 -0
  117. package/src/index.ts +19 -0
  118. package/src/types/chart.ts +34 -8
  119. package/src/types/controls.ts +80 -0
  120. package/src/types/index.ts +18 -0
  121. package/src/utils/formatters.ts +2 -1
  122. package/src/views/WasmSamplingDemo.vue +8 -1
@@ -0,0 +1,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
+ }