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