waveform-analysis 0.1.73 → 0.1.75
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 +256 -46
- package/dist/assets/workerRuntime-BJiRmIZD.js +2446 -0
- package/dist/index.cjs +1 -2
- package/dist/index.js +1 -7115
- package/dist/style.css +1 -1
- package/dist/types/animation/WaveformAnimationController.d.ts +30 -0
- package/dist/types/animation/core/Animation.d.ts +13 -0
- package/dist/types/animation/core/Animator.d.ts +23 -0
- package/dist/types/animation/core/easing.d.ts +3 -0
- package/dist/types/animation/core/errors.d.ts +4 -0
- package/dist/types/animation/core/handle.d.ts +7 -0
- package/dist/types/animation/core/owner.d.ts +3 -0
- package/dist/types/animation/core/propertyGroups.d.ts +17 -0
- package/dist/types/animation/core/task.d.ts +29 -0
- package/dist/types/animation/core/types.d.ts +74 -0
- package/dist/types/animation/core/validation.d.ts +30 -0
- package/dist/types/animation/core/values.d.ts +8 -0
- package/dist/types/animation/presets/annotation.d.ts +4 -0
- package/dist/types/animation/presets/lineDrawing.d.ts +7 -0
- package/dist/types/animation/presets/shared.d.ts +14 -0
- package/dist/types/animation/presets/transitions.d.ts +4 -0
- package/dist/types/animation/types.d.ts +84 -0
- package/dist/types/components/WaveformChart.vue.d.ts +3 -2
- package/dist/types/components/animation/annotationPresentation.d.ts +17 -0
- package/dist/types/components/animation/chartExportVersion.d.ts +2 -0
- package/dist/types/components/animation/config.d.ts +31 -0
- package/dist/types/components/animation/context.d.ts +20 -0
- package/dist/types/components/animation/geometry.d.ts +10 -0
- package/dist/types/components/animation/paginationPresentation.d.ts +13 -0
- package/dist/types/components/animation/pathBuffer.d.ts +4 -0
- package/dist/types/components/animation/trackPresentation.d.ts +35 -0
- package/dist/types/components/animation/useWaveformAnimations.d.ts +27 -0
- package/dist/types/components/annotation/WaveformAnnotationContextMenu.vue.d.ts +2 -2
- package/dist/types/components/annotation/types.d.ts +7 -0
- package/dist/types/components/controls/useChartImageExport.d.ts +2 -0
- package/dist/types/components/core/layout.d.ts +2 -1
- package/dist/types/components/core/types.d.ts +8 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +25 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +2 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +2 -1
- package/dist/types/components/core/yAxisTitleLayout.d.ts +1 -1
- package/dist/types/components/interaction/useExtremaTickViewport.d.ts +1 -1
- package/dist/types/components/rendering/WaveformSeriesLayer.vue.d.ts +1 -0
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +4 -3
- package/dist/types/components/rendering/updateAxis.d.ts +5 -0
- package/dist/types/components/rendering/updateGrid.d.ts +3 -0
- package/dist/types/components/rendering/yAxisTickVisibility.d.ts +2 -0
- package/dist/types/components/utils/assignElement.d.ts +2 -0
- package/dist/types/demo/types.d.ts +2 -1
- package/dist/types/index.d.ts +9 -0
- package/dist/types/types/chart.d.ts +4 -2
- package/dist/types/views/AnimationDemo.vue.d.ts +3 -0
- package/package.json +5 -2
- package/src/App.vue +6 -3
- package/src/DemoRouterApp.vue +1 -0
- package/src/animation/WaveformAnimationController.ts +328 -0
- package/src/animation/core/Animation.ts +37 -0
- package/src/animation/core/Animator.ts +200 -0
- package/src/animation/core/easing.ts +74 -0
- package/src/animation/core/errors.ts +24 -0
- package/src/animation/core/handle.ts +34 -0
- package/src/animation/core/owner.ts +9 -0
- package/src/animation/core/propertyGroups.ts +143 -0
- package/src/animation/core/task.ts +153 -0
- package/src/animation/core/types.ts +81 -0
- package/src/animation/core/validation.ts +173 -0
- package/src/animation/core/values.ts +38 -0
- package/src/animation/presets/annotation.ts +66 -0
- package/src/animation/presets/lineDrawing.ts +51 -0
- package/src/animation/presets/shared.ts +58 -0
- package/src/animation/presets/transitions.ts +77 -0
- package/src/animation/types.ts +81 -0
- package/src/components/WaveformChart.vue +1 -0
- package/src/components/WaveformChartView.vue +13 -4
- package/src/components/animation/annotationPresentation.ts +145 -0
- package/src/components/animation/chartExportVersion.ts +5 -0
- package/src/components/animation/config.ts +87 -0
- package/src/components/animation/context.ts +56 -0
- package/src/components/animation/geometry.ts +148 -0
- package/src/components/animation/paginationPresentation.ts +114 -0
- package/src/components/animation/pathBuffer.ts +61 -0
- package/src/components/animation/trackPresentation.ts +172 -0
- package/src/components/animation/useWaveformAnimations.ts +420 -0
- package/src/components/annotation/WaveformAnnotationLayer.vue +43 -30
- package/src/components/annotation/types.ts +3 -0
- package/src/components/controls/useChartImageExport.ts +4 -0
- package/src/components/core/layout.ts +36 -22
- package/src/components/core/trackLayoutBuilder.ts +17 -23
- package/src/components/core/types.ts +4 -0
- package/src/components/core/useWaveformChartController.ts +24 -20
- package/src/components/core/useWaveformLayout.ts +3 -1
- package/src/components/core/useWaveformRenderSampling.ts +23 -1
- package/src/components/core/waveformChartTypes.ts +2 -1
- package/src/components/core/yAxisLayoutMetrics.ts +24 -16
- package/src/components/core/yAxisPadding.ts +10 -5
- package/src/components/core/yAxisTitleLayout.ts +9 -48
- package/src/components/interaction/useExtremaTickViewport.ts +1 -1
- package/src/components/rendering/WaveformSeriesLayer.vue +13 -1
- package/src/components/rendering/WaveformTrack.vue +6 -1
- package/src/components/rendering/WaveformTrackAxes.vue +64 -51
- package/src/components/rendering/WaveformTrackBackdrop.vue +35 -49
- package/src/components/rendering/updateAxis.ts +57 -0
- package/src/components/rendering/updateGrid.ts +42 -0
- package/src/components/rendering/yAxisTickVisibility.ts +45 -18
- package/src/components/utils/assignElement.ts +7 -0
- package/src/demo/DemoYAxisPaddingControls.vue +11 -3
- package/src/demo/types.ts +2 -1
- package/src/index.ts +53 -1
- package/src/router.ts +1 -0
- package/src/types/chart.ts +4 -2
- package/src/views/AnimationDemo.vue +129 -0
- package/dist/assets/workerRuntime-5p-EI-GL.js +0 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { computed, shallowRef, watch, onScopeDispose } from 'vue'
|
|
2
|
+
import { WaveformAnimationController } from '@/animation/WaveformAnimationController'
|
|
3
|
+
import { resolveAnimationProps } from './config'
|
|
4
|
+
import { createAnnotationPresentation } from './annotationPresentation'
|
|
5
|
+
import {
|
|
6
|
+
staticTrack,
|
|
7
|
+
transitionTrack,
|
|
8
|
+
projectTrack,
|
|
9
|
+
hasThirdGeometry,
|
|
10
|
+
type TrackPresentation,
|
|
11
|
+
} from './trackPresentation'
|
|
12
|
+
import { chartExportOptions } from '../controls/chartExportOptions'
|
|
13
|
+
import { layoutAnnotations } from '../annotation/annotationLayout'
|
|
14
|
+
import { useWaveformInstanceId } from '@/utils/waveformId'
|
|
15
|
+
|
|
16
|
+
import { animationLayoutSignature, wrapAnimationCommands, type AnimationContext } from './context'
|
|
17
|
+
import { createPaginationPresentation } from './paginationPresentation'
|
|
18
|
+
|
|
19
|
+
export function useWaveformAnimations(context: AnimationContext) {
|
|
20
|
+
const { props, tracks, preparedSeries, sampling } = context
|
|
21
|
+
const config = computed(() => resolveAnimationProps(props))
|
|
22
|
+
const revision = shallowRef(0)
|
|
23
|
+
const invalidate = () => revision.value++
|
|
24
|
+
const states = new Map<string, TrackPresentation>()
|
|
25
|
+
const entering = new Set<string>()
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
const prefix = useWaveformInstanceId('waveform-animation')
|
|
28
|
+
let clipSequence = 0
|
|
29
|
+
let previousAnnotations = props.annotations
|
|
30
|
+
let previousData = props.data
|
|
31
|
+
let previousLayout = ''
|
|
32
|
+
let previousOptions = ''
|
|
33
|
+
let previousTracks = tracks.value
|
|
34
|
+
let previousSamplingState = sampling.state.value
|
|
35
|
+
let pendingFrom: Map<string, TrackPresentation> | undefined
|
|
36
|
+
let skipSignature: string | undefined
|
|
37
|
+
const exportSuppressed = shallowRef(false)
|
|
38
|
+
let disposed = false
|
|
39
|
+
const media =
|
|
40
|
+
typeof globalThis.matchMedia === 'function'
|
|
41
|
+
? globalThis.matchMedia('(prefers-reduced-motion: reduce)')
|
|
42
|
+
: undefined
|
|
43
|
+
const reduced = shallowRef(media?.matches ?? false)
|
|
44
|
+
const totalPoints = computed(() =>
|
|
45
|
+
preparedSeries.value.reduce((sum, series) => sum + series.source.length, 0),
|
|
46
|
+
)
|
|
47
|
+
const effective = computed(
|
|
48
|
+
() =>
|
|
49
|
+
config.value.animationEnabled &&
|
|
50
|
+
!reduced.value &&
|
|
51
|
+
!exportSuppressed.value &&
|
|
52
|
+
typeof globalThis.requestAnimationFrame === 'function' &&
|
|
53
|
+
typeof globalThis.cancelAnimationFrame === 'function' &&
|
|
54
|
+
totalPoints.value < config.value.animationThreshold,
|
|
55
|
+
)
|
|
56
|
+
const visibleAnnotations = () =>
|
|
57
|
+
props.annotationsVisible && !props.cleanView ? props.annotations : []
|
|
58
|
+
const controller = new WaveformAnimationController({ commitFinalState: () => commit() })
|
|
59
|
+
const annotations = createAnnotationPresentation(controller, invalidate)
|
|
60
|
+
const presentationTracks = computed(() => {
|
|
61
|
+
void revision.value
|
|
62
|
+
return tracks.value.map((track) =>
|
|
63
|
+
states.has(track.id) ? projectTrack(states.get(track.id)!) : track,
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
const pagination = createPaginationPresentation(context, controller, config, effective, () =>
|
|
67
|
+
commit(true),
|
|
68
|
+
)
|
|
69
|
+
const projectedAnnotations = computed(() => {
|
|
70
|
+
const anchors = new Map([...states.values()].flatMap((state) => [...state.anchors]))
|
|
71
|
+
const values = visibleAnnotations().flatMap((annotation) => {
|
|
72
|
+
const anchor = anchors.get(annotation.id)
|
|
73
|
+
const target = [...states.values()].find((state) => state.series.has(annotation.seriesId))
|
|
74
|
+
const domain = target?.target.xScale.domain()
|
|
75
|
+
return anchor && domain && annotation.x >= domain[0] && annotation.x <= domain[1]
|
|
76
|
+
? [{ ...annotation, x: anchor.x, y: anchor.y }]
|
|
77
|
+
: []
|
|
78
|
+
})
|
|
79
|
+
const annotationTracks = presentationTracks.value.flatMap((track) =>
|
|
80
|
+
track.seriesList.map((series) => ({
|
|
81
|
+
...track,
|
|
82
|
+
series,
|
|
83
|
+
yScale:
|
|
84
|
+
track.seriesPaths.find(
|
|
85
|
+
(path) => path.series.id === series.id && path.presentationKey !== `${series.id}:old`,
|
|
86
|
+
)?.yScale ?? track.yScale,
|
|
87
|
+
})),
|
|
88
|
+
)
|
|
89
|
+
return layoutAnnotations(
|
|
90
|
+
values,
|
|
91
|
+
annotationTracks,
|
|
92
|
+
context.innerWidth.value,
|
|
93
|
+
context.innerHeight.value,
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
const presentationAnnotations = computed(() => {
|
|
97
|
+
void revision.value
|
|
98
|
+
const opacities = new Map(
|
|
99
|
+
[...states.values()].flatMap((state) =>
|
|
100
|
+
[...state.series].map(([id, series]) => [id, series.opacity] as const),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
return annotations.project(projectedAnnotations.value, opacities).map((annotation) => ({
|
|
104
|
+
...annotation,
|
|
105
|
+
seriesOpacity: (annotation.seriesOpacity ?? 1) * pagination.opacity.value,
|
|
106
|
+
}))
|
|
107
|
+
})
|
|
108
|
+
function commit(preservePagination = false) {
|
|
109
|
+
if (!preservePagination) pagination.reset()
|
|
110
|
+
const previous = new Map(states)
|
|
111
|
+
states.clear()
|
|
112
|
+
for (const track of tracks.value)
|
|
113
|
+
states.set(
|
|
114
|
+
track.id,
|
|
115
|
+
staticTrack(track, visibleAnnotations(), previous.get(track.id), !!pendingFrom),
|
|
116
|
+
)
|
|
117
|
+
for (const track of tracks.value)
|
|
118
|
+
if (track.width > 0 && track.height > 0) {
|
|
119
|
+
for (const series of track.seriesList)
|
|
120
|
+
if ((series.source?.length ?? series.points.length) > 0) seen.add(series.id)
|
|
121
|
+
}
|
|
122
|
+
entering.clear()
|
|
123
|
+
pendingFrom = undefined
|
|
124
|
+
skipSignature = sampling.requestSignature.value
|
|
125
|
+
invalidate()
|
|
126
|
+
annotations.finish(projectedAnnotations.value)
|
|
127
|
+
}
|
|
128
|
+
const layoutSignature = computed(() => animationLayoutSignature(context))
|
|
129
|
+
function reconcile() {
|
|
130
|
+
if (disposed) return
|
|
131
|
+
const nextConfig = config.value
|
|
132
|
+
const dataChanged = props.data !== previousData
|
|
133
|
+
if (dataChanged) {
|
|
134
|
+
pendingFrom = new Map(states)
|
|
135
|
+
previousData = props.data
|
|
136
|
+
for (const id of states.keys())
|
|
137
|
+
controller.cancel({ kind: 'custom', id: `track:${id}`, scope: 'update' })
|
|
138
|
+
const interrupted = [...entering]
|
|
139
|
+
for (const id of interrupted) controller.cancel({ kind: 'series', id, scope: 'enter' })
|
|
140
|
+
// Anchor updates and annotation styles are independent channels (CTL-06). An interrupted
|
|
141
|
+
// enter no longer owns the wait; release after reconciling the new layout/configuration.
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
if (!disposed) interrupted.forEach((id) => annotations.release(id))
|
|
144
|
+
})
|
|
145
|
+
entering.clear()
|
|
146
|
+
}
|
|
147
|
+
const optionSignature = JSON.stringify(
|
|
148
|
+
Object.fromEntries(
|
|
149
|
+
Object.entries(chartExportOptions(props)).filter(
|
|
150
|
+
([key]) => !['annotations', 'annotationsVisible'].includes(key),
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
const layoutChanged =
|
|
155
|
+
(previousOptions !== '' && previousOptions !== optionSignature) ||
|
|
156
|
+
(seen.size > 0 &&
|
|
157
|
+
!dataChanged &&
|
|
158
|
+
((previousLayout !== '' && previousLayout !== layoutSignature.value) ||
|
|
159
|
+
(!pendingFrom && previousSamplingState === 'success' && tracks.value !== previousTracks)))
|
|
160
|
+
previousOptions = optionSignature
|
|
161
|
+
previousLayout = layoutSignature.value
|
|
162
|
+
previousTracks = tracks.value
|
|
163
|
+
previousSamplingState = sampling.state.value
|
|
164
|
+
controller.setEnabled(effective.value)
|
|
165
|
+
if (pagination.reconcile(dataChanged)) return
|
|
166
|
+
if (!effective.value || layoutChanged) {
|
|
167
|
+
controller.finishAll()
|
|
168
|
+
commit()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const stable = sampling.state.value !== 'pending' && context.layoutReady.value
|
|
172
|
+
if (pendingFrom && !stable) return
|
|
173
|
+
const canUpdate =
|
|
174
|
+
pendingFrom &&
|
|
175
|
+
sampling.state.value === 'success' &&
|
|
176
|
+
skipSignature !== sampling.requestSignature.value &&
|
|
177
|
+
nextConfig.animationUpdate.enabled
|
|
178
|
+
const knownIds = new Set(
|
|
179
|
+
preparedSeries.value.filter((series) => series.source.length > 0).map((series) => series.id),
|
|
180
|
+
)
|
|
181
|
+
for (const id of seen) if (!knownIds.has(id)) seen.delete(id)
|
|
182
|
+
const visibleIds = tracks.value.flatMap((track) => track.seriesList.map((series) => series.id))
|
|
183
|
+
const oldStates = pendingFrom ?? new Map(states)
|
|
184
|
+
const live = new Set(tracks.value.map((track) => track.id))
|
|
185
|
+
for (const id of states.keys()) if (!live.has(id)) states.delete(id)
|
|
186
|
+
for (const track of tracks.value) {
|
|
187
|
+
if (track.width <= 0 || track.height <= 0) {
|
|
188
|
+
states.set(track.id, staticTrack(track, visibleAnnotations()))
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
const from = oldStates.get(track.id)
|
|
192
|
+
const to = staticTrack(track, visibleAnnotations(), from, !!pendingFrom)
|
|
193
|
+
if (pendingFrom) {
|
|
194
|
+
states.set(track.id, to)
|
|
195
|
+
if (canUpdate && from && !hasThirdGeometry(from)) {
|
|
196
|
+
states.set(track.id, transitionTrack(from, to, 0, false))
|
|
197
|
+
controller.animate(
|
|
198
|
+
{ kind: 'custom', id: `track:${track.id}`, scope: 'update' },
|
|
199
|
+
{
|
|
200
|
+
duration: nextConfig.animationUpdate.duration,
|
|
201
|
+
easing: nextConfig.animationUpdate.easing,
|
|
202
|
+
force: true,
|
|
203
|
+
during: (p, frame) => {
|
|
204
|
+
states.set(track.id, transitionTrack(from, to, p, frame.terminal))
|
|
205
|
+
invalidate()
|
|
206
|
+
},
|
|
207
|
+
done: () =>
|
|
208
|
+
track.seriesPaths.forEach(() => nextConfig.animationUpdate.onComplete?.()),
|
|
209
|
+
aborted: () =>
|
|
210
|
+
track.seriesPaths.forEach(() => nextConfig.animationUpdate.onAbort?.()),
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
} else if (!states.has(track.id)) states.set(track.id, to)
|
|
215
|
+
else if (!controller.getActiveAnimationCount()) states.set(track.id, to)
|
|
216
|
+
for (const path of track.seriesPaths) {
|
|
217
|
+
const id = path.series.id
|
|
218
|
+
const hasPoints = (path.series.source?.length ?? path.series.points.length) > 0
|
|
219
|
+
if (!hasPoints) {
|
|
220
|
+
seen.delete(id)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (!stable) {
|
|
224
|
+
if (
|
|
225
|
+
!seen.has(id) &&
|
|
226
|
+
nextConfig.animation.enabled &&
|
|
227
|
+
skipSignature !== sampling.requestSignature.value
|
|
228
|
+
) {
|
|
229
|
+
const state = states.get(track.id)!
|
|
230
|
+
if (!state.clips.has(id))
|
|
231
|
+
state.clips.set(id, {
|
|
232
|
+
id: `${prefix}-${++clipSequence}`,
|
|
233
|
+
x: 0,
|
|
234
|
+
width: 0,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
if (seen.has(id)) continue
|
|
240
|
+
seen.add(id)
|
|
241
|
+
if (
|
|
242
|
+
!nextConfig.animation.enabled ||
|
|
243
|
+
sampling.state.value !== 'success' ||
|
|
244
|
+
skipSignature === sampling.requestSignature.value
|
|
245
|
+
)
|
|
246
|
+
continue
|
|
247
|
+
const state = states.get(track.id)!
|
|
248
|
+
const clip = { id: `${prefix}-${++clipSequence}`, x: 0, width: track.width }
|
|
249
|
+
state.clips.set(id, clip)
|
|
250
|
+
entering.add(id)
|
|
251
|
+
const binding = {
|
|
252
|
+
read: () => clip,
|
|
253
|
+
write: (value: { x: number; width: number }) => {
|
|
254
|
+
Object.assign(clip, value)
|
|
255
|
+
invalidate()
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
controller.animateSeriesEnter(id, binding, track.width, {
|
|
259
|
+
...nextConfig.animation,
|
|
260
|
+
delay:
|
|
261
|
+
nextConfig.animation.delay + visibleIds.indexOf(id) * nextConfig.animation.staggerDelay,
|
|
262
|
+
onComplete: () => {
|
|
263
|
+
state.clips.delete(id)
|
|
264
|
+
entering.delete(id)
|
|
265
|
+
invalidate()
|
|
266
|
+
queueMicrotask(() => {
|
|
267
|
+
if (!disposed) annotations.release(id)
|
|
268
|
+
})
|
|
269
|
+
nextConfig.animation.onComplete?.()
|
|
270
|
+
},
|
|
271
|
+
onAbort: () => {
|
|
272
|
+
nextConfig.animation.onAbort?.()
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (props.annotations !== previousAnnotations) {
|
|
278
|
+
for (const track of tracks.value) {
|
|
279
|
+
const state = states.get(track.id)
|
|
280
|
+
if (state) state.anchors = staticTrack(track, visibleAnnotations(), state).anchors
|
|
281
|
+
}
|
|
282
|
+
previousAnnotations = props.annotations
|
|
283
|
+
}
|
|
284
|
+
pendingFrom = undefined
|
|
285
|
+
invalidate()
|
|
286
|
+
if (!stable) return
|
|
287
|
+
annotations.reconcile(
|
|
288
|
+
projectedAnnotations.value,
|
|
289
|
+
props.annotations,
|
|
290
|
+
nextConfig.animationAnnotation,
|
|
291
|
+
nextConfig.animationAnnotation.enabled &&
|
|
292
|
+
effective.value &&
|
|
293
|
+
props.annotationsVisible &&
|
|
294
|
+
!props.cleanView,
|
|
295
|
+
entering,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
watch(
|
|
299
|
+
[
|
|
300
|
+
tracks,
|
|
301
|
+
() => props.data,
|
|
302
|
+
() => props.annotations,
|
|
303
|
+
() => props.annotationsVisible,
|
|
304
|
+
config,
|
|
305
|
+
layoutSignature,
|
|
306
|
+
sampling.state,
|
|
307
|
+
context.layoutReady,
|
|
308
|
+
context.currentPage,
|
|
309
|
+
effective,
|
|
310
|
+
],
|
|
311
|
+
reconcile,
|
|
312
|
+
{ immediate: true },
|
|
313
|
+
)
|
|
314
|
+
watch(
|
|
315
|
+
() => props.annotationsVisible,
|
|
316
|
+
(visible) => {
|
|
317
|
+
if (!visible)
|
|
318
|
+
controller.finishWhere(
|
|
319
|
+
(key) => key.kind === 'annotation' && ['enter', 'leave'].includes(key.scope),
|
|
320
|
+
() => annotations.finish([]),
|
|
321
|
+
)
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
watch(
|
|
325
|
+
() => config.value.animation.enabled,
|
|
326
|
+
(enabled) => {
|
|
327
|
+
if (!enabled)
|
|
328
|
+
controller.finishWhere(
|
|
329
|
+
(key) => key.kind === 'series' && key.scope === 'enter',
|
|
330
|
+
() => {
|
|
331
|
+
for (const state of states.values()) state.clips.clear()
|
|
332
|
+
entering.clear()
|
|
333
|
+
invalidate()
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
if (!enabled) annotations.release()
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
watch(
|
|
340
|
+
() => config.value.animationUpdate.enabled,
|
|
341
|
+
(enabled) => {
|
|
342
|
+
if (!enabled)
|
|
343
|
+
controller.finishWhere(
|
|
344
|
+
(key) => key.scope === 'update' || key.scope === 'domain',
|
|
345
|
+
() => {
|
|
346
|
+
pagination.reset()
|
|
347
|
+
for (const track of tracks.value) {
|
|
348
|
+
const old = states.get(track.id),
|
|
349
|
+
next = staticTrack(track, visibleAnnotations(), old, true)
|
|
350
|
+
if (old) next.clips = old.clips
|
|
351
|
+
states.set(track.id, next)
|
|
352
|
+
}
|
|
353
|
+
invalidate()
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
watch(
|
|
359
|
+
() => config.value.animationAnnotation.enabled,
|
|
360
|
+
(enabled) => {
|
|
361
|
+
if (!enabled)
|
|
362
|
+
controller.finishWhere(
|
|
363
|
+
(key) => key.kind === 'annotation' && ['enter', 'leave'].includes(key.scope),
|
|
364
|
+
() => annotations.finish(projectedAnnotations.value),
|
|
365
|
+
)
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
const reduceChanged = (event: MediaQueryListEvent) => {
|
|
369
|
+
reduced.value = event.matches
|
|
370
|
+
if (event.matches) controller.finishAll()
|
|
371
|
+
}
|
|
372
|
+
const visibilityChanged = () => controller.setPauseReason('document-hidden', document.hidden)
|
|
373
|
+
media?.addEventListener?.('change', reduceChanged)
|
|
374
|
+
if (typeof document !== 'undefined') {
|
|
375
|
+
document.addEventListener('visibilitychange', visibilityChanged)
|
|
376
|
+
visibilityChanged()
|
|
377
|
+
}
|
|
378
|
+
onScopeDispose(() => {
|
|
379
|
+
disposed = true
|
|
380
|
+
pagination.reset()
|
|
381
|
+
states.clear()
|
|
382
|
+
entering.clear()
|
|
383
|
+
annotations.clear()
|
|
384
|
+
media?.removeEventListener?.('change', reduceChanged)
|
|
385
|
+
if (typeof document !== 'undefined')
|
|
386
|
+
document.removeEventListener('visibilitychange', visibilityChanged)
|
|
387
|
+
controller.destroy()
|
|
388
|
+
})
|
|
389
|
+
function stabilizeAnimations(event?: Event) {
|
|
390
|
+
if (
|
|
391
|
+
pagination.active() &&
|
|
392
|
+
event?.target instanceof Element &&
|
|
393
|
+
event.target.closest('.waveform-chart__pagination')
|
|
394
|
+
)
|
|
395
|
+
return
|
|
396
|
+
if (sampling.state.value === 'pending') skipSignature = sampling.requestSignature.value
|
|
397
|
+
if (
|
|
398
|
+
controller.getActiveAnimationCount() ||
|
|
399
|
+
pendingFrom ||
|
|
400
|
+
pagination.active() ||
|
|
401
|
+
sampling.state.value === 'pending'
|
|
402
|
+
)
|
|
403
|
+
controller.finishAll()
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
presentationTracks,
|
|
407
|
+
presentationAnnotations,
|
|
408
|
+
paginationOpacity: pagination.opacity,
|
|
409
|
+
stabilizeAnimations,
|
|
410
|
+
wrap: <T extends object>(commands: T) => wrapAnimationCommands(commands, stabilizeAnimations),
|
|
411
|
+
wrapPagination: pagination.wrapPagination,
|
|
412
|
+
prepareExport: () => {
|
|
413
|
+
exportSuppressed.value = true
|
|
414
|
+
stabilizeAnimations()
|
|
415
|
+
},
|
|
416
|
+
releaseExport: () => {
|
|
417
|
+
exportSuppressed.value = false
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -84,7 +84,7 @@ function scheduleDragFrame() {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) {
|
|
87
|
-
if (!props.interactive || event.button !== 0 || dragState) return
|
|
87
|
+
if (rendered.leaving || !props.interactive || event.button !== 0 || dragState) return
|
|
88
88
|
event.preventDefault()
|
|
89
89
|
event.stopPropagation()
|
|
90
90
|
const target = event.currentTarget as SVGGElement | null
|
|
@@ -226,6 +226,14 @@ function handleContextMenu(annotationId: string, event: MouseEvent) {
|
|
|
226
226
|
emit('contextmenu', annotationId, event)
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
function labelTransform(rendered: RenderedAnnotation) {
|
|
230
|
+
const style = rendered.animationStyle
|
|
231
|
+
if (!style) return undefined
|
|
232
|
+
const box = draggedBox(rendered),
|
|
233
|
+
x = box.x + box.width / 2,
|
|
234
|
+
y = box.y + box.height / 2
|
|
235
|
+
return `translate(${x},${y + style.offsetY}) scale(${style.scale}) translate(${-x},${-y})`
|
|
236
|
+
}
|
|
229
237
|
function markerId(annotationId: string) {
|
|
230
238
|
return `waveform-annotation-arrow-${annotationId.replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
|
231
239
|
}
|
|
@@ -243,6 +251,9 @@ function markerId(annotationId: string) {
|
|
|
243
251
|
class="waveform-annotation"
|
|
244
252
|
:data-annotation-id="rendered.annotation.id"
|
|
245
253
|
:data-placement="rendered.placement"
|
|
254
|
+
:data-leaving="rendered.leaving || undefined"
|
|
255
|
+
:style="rendered.leaving ? { pointerEvents: 'none' } : undefined"
|
|
256
|
+
:opacity="(rendered.animationStyle?.opacity ?? 1) * (rendered.seriesOpacity ?? 1)"
|
|
246
257
|
@pointerdown="handlePointerDown(rendered, $event)"
|
|
247
258
|
@pointermove="handlePointerMove"
|
|
248
259
|
@pointerup="finishPointerDrag"
|
|
@@ -271,37 +282,39 @@ function markerId(annotationId: string) {
|
|
|
271
282
|
:stroke="rendered.style.borderColor"
|
|
272
283
|
:marker-end="`url(#${markerId(rendered.annotation.id)})`"
|
|
273
284
|
/>
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
:y="
|
|
287
|
-
draggedBox(rendered).y +
|
|
288
|
-
draggedBox(rendered).height / 2 -
|
|
289
|
-
((rendered.lines.length - 1) * ANNOTATION_TEXT_LINE_HEIGHT) / 2
|
|
290
|
-
"
|
|
291
|
-
:fill="rendered.style.textColor"
|
|
292
|
-
:style="{ font: ANNOTATION_TEXT_FONT }"
|
|
293
|
-
text-anchor="middle"
|
|
294
|
-
dominant-baseline="central"
|
|
295
|
-
>
|
|
296
|
-
<tspan
|
|
297
|
-
v-for="(line, index) in rendered.lines"
|
|
298
|
-
:key="`${rendered.annotation.id}-${index}`"
|
|
285
|
+
<g :transform="labelTransform(rendered)">
|
|
286
|
+
<rect
|
|
287
|
+
class="waveform-annotation__box"
|
|
288
|
+
:x="draggedBox(rendered).x"
|
|
289
|
+
:y="draggedBox(rendered).y"
|
|
290
|
+
:width="draggedBox(rendered).width"
|
|
291
|
+
:height="draggedBox(rendered).height"
|
|
292
|
+
:fill="rendered.style.backgroundColor"
|
|
293
|
+
:stroke="rendered.style.borderColor"
|
|
294
|
+
/>
|
|
295
|
+
<text
|
|
296
|
+
class="waveform-annotation__text"
|
|
299
297
|
:x="draggedBox(rendered).x + draggedBox(rendered).width / 2"
|
|
300
|
-
:
|
|
298
|
+
:y="
|
|
299
|
+
draggedBox(rendered).y +
|
|
300
|
+
draggedBox(rendered).height / 2 -
|
|
301
|
+
((rendered.lines.length - 1) * ANNOTATION_TEXT_LINE_HEIGHT) / 2
|
|
302
|
+
"
|
|
303
|
+
:fill="rendered.style.textColor"
|
|
304
|
+
:style="{ font: ANNOTATION_TEXT_FONT }"
|
|
305
|
+
text-anchor="middle"
|
|
306
|
+
dominant-baseline="central"
|
|
301
307
|
>
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
<tspan
|
|
309
|
+
v-for="(line, index) in rendered.lines"
|
|
310
|
+
:key="`${rendered.annotation.id}-${index}`"
|
|
311
|
+
:x="draggedBox(rendered).x + draggedBox(rendered).width / 2"
|
|
312
|
+
:dy="index === 0 ? 0 : ANNOTATION_TEXT_LINE_HEIGHT"
|
|
313
|
+
>
|
|
314
|
+
{{ line }}
|
|
315
|
+
</tspan>
|
|
316
|
+
</text>
|
|
317
|
+
</g>
|
|
305
318
|
</g>
|
|
306
319
|
</g>
|
|
307
320
|
</template>
|
|
@@ -57,6 +57,9 @@ export type AnnotationPlacement =
|
|
|
57
57
|
'top' | 'bottom' | 'right' | 'left' | 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
|
58
58
|
|
|
59
59
|
export interface RenderedAnnotation {
|
|
60
|
+
animationStyle?: { opacity: number; scale: number; offsetY: number }
|
|
61
|
+
leaving?: boolean
|
|
62
|
+
seriesOpacity?: number
|
|
60
63
|
annotation: WaveformAnnotation
|
|
61
64
|
trackIndex: number
|
|
62
65
|
anchorX: number
|
|
@@ -24,6 +24,8 @@ interface Context {
|
|
|
24
24
|
size: () => { width: number; height: number; titleHeight: number }
|
|
25
25
|
version: () => unknown
|
|
26
26
|
ready: () => boolean
|
|
27
|
+
prepare?: () => void
|
|
28
|
+
release?: () => void
|
|
27
29
|
}
|
|
28
30
|
export function useChartImageExport(c: Context) {
|
|
29
31
|
let busy = false
|
|
@@ -129,6 +131,7 @@ export function useChartImageExport(c: Context) {
|
|
|
129
131
|
return png
|
|
130
132
|
}
|
|
131
133
|
try {
|
|
134
|
+
c.prepare?.()
|
|
132
135
|
return await Promise.race([cancelled, generate()])
|
|
133
136
|
} catch (error) {
|
|
134
137
|
if (error instanceof Error && 'code' in error) throw error
|
|
@@ -149,6 +152,7 @@ export function useChartImageExport(c: Context) {
|
|
|
149
152
|
}
|
|
150
153
|
abort = undefined
|
|
151
154
|
busy = false
|
|
155
|
+
c.release?.()
|
|
152
156
|
}
|
|
153
157
|
}
|
|
154
158
|
return { exportImage }
|
|
@@ -83,7 +83,7 @@ export function formatYAxisTickLabel(
|
|
|
83
83
|
return label
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/**
|
|
86
|
+
/** Name spacing excludes scientific multipliers; outer clearance measures the full label. */
|
|
87
87
|
export function formatYAxisTickLayoutLabel(
|
|
88
88
|
value: number,
|
|
89
89
|
domain: [number, number],
|
|
@@ -215,7 +215,7 @@ export function axisTextMetrics(
|
|
|
215
215
|
measureTextWidth?: (text: string) => number,
|
|
216
216
|
maximumTick?: number,
|
|
217
217
|
minimumTick?: number,
|
|
218
|
-
): { tickTextWidth: number } {
|
|
218
|
+
): { tickTextWidth: number; titleTextWidth: number } {
|
|
219
219
|
const resolvedTicks = tickValues
|
|
220
220
|
? { domain, values: tickValues }
|
|
221
221
|
: resolveYAxisTicks(domain, tickCount, nice)
|
|
@@ -229,17 +229,26 @@ export function axisTextMetrics(
|
|
|
229
229
|
if (includeWithoutLastTick && resolvedTicks.values.length > 1) {
|
|
230
230
|
tickValueSets.push(resolvedTicks.values.slice(0, -1))
|
|
231
231
|
}
|
|
232
|
-
const
|
|
233
|
-
Y_AXIS_CHARACTER_WIDTH
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
),
|
|
232
|
+
const width = (text: string) =>
|
|
233
|
+
measureTextWidth ? measureTextWidth(text) : text.length * Y_AXIS_CHARACTER_WIDTH
|
|
234
|
+
const labels = tickValueSets.flatMap((values) =>
|
|
235
|
+
values.map((value) => ({
|
|
236
|
+
full: width(formatYAxisTickLabel(value, resolvedTicks.domain, values, unit)),
|
|
237
|
+
title: width(formatYAxisTickLayoutLabel(value, resolvedTicks.domain, values, unit)),
|
|
238
|
+
})),
|
|
240
239
|
)
|
|
240
|
+
// Collision handling can move metadata to any surviving tick in compact layouts.
|
|
241
|
+
if (includeWithoutLastTick) {
|
|
242
|
+
tickValueSets[0]!.forEach((value) => {
|
|
243
|
+
labels.push({
|
|
244
|
+
full: width(formatYAxisTickLabel(value, resolvedTicks.domain, [value], unit)),
|
|
245
|
+
title: width(formatYAxisTickLayoutLabel(value, resolvedTicks.domain, [value], unit)),
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
}
|
|
241
249
|
return {
|
|
242
|
-
tickTextWidth:
|
|
250
|
+
tickTextWidth: Math.max(Y_AXIS_CHARACTER_WIDTH, ...labels.map((label) => label.full)),
|
|
251
|
+
titleTextWidth: Math.max(Y_AXIS_CHARACTER_WIDTH, ...labels.map((label) => label.title)),
|
|
243
252
|
}
|
|
244
253
|
}
|
|
245
254
|
|
|
@@ -248,12 +257,16 @@ export function measureYAxisGroupClearance(
|
|
|
248
257
|
tickCount?: number,
|
|
249
258
|
nice = true,
|
|
250
259
|
): number {
|
|
260
|
+
const { tickTextWidth, titleTextWidth } = axisTextMetrics(
|
|
261
|
+
group.domain,
|
|
262
|
+
nice,
|
|
263
|
+
undefined,
|
|
264
|
+
group.seriesList[0]?.unit,
|
|
265
|
+
tickCount,
|
|
266
|
+
)
|
|
251
267
|
return (
|
|
252
|
-
|
|
253
|
-
.tickTextWidth +
|
|
268
|
+
Math.max(tickTextWidth, titleTextWidth + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH) +
|
|
254
269
|
Y_AXIS_TICK_PADDING +
|
|
255
|
-
Y_AXIS_LABEL_GAP +
|
|
256
|
-
Y_AXIS_LABEL_BAND_WIDTH +
|
|
257
270
|
Y_AXIS_OUTER_PADDING
|
|
258
271
|
)
|
|
259
272
|
}
|
|
@@ -312,6 +325,7 @@ export function buildYAxisSlots(
|
|
|
312
325
|
typeof padding === 'object' ? padding.labelTextStyle : undefined,
|
|
313
326
|
)
|
|
314
327
|
const widths = new Map<string, number>()
|
|
328
|
+
const titleWidths = new Map<string, number>()
|
|
315
329
|
tracks.forEach((track) => {
|
|
316
330
|
const sideIndexes = { left: 0, right: 0 }
|
|
317
331
|
resolveRenderedYAxisSeriesGroups(
|
|
@@ -324,7 +338,7 @@ export function buildYAxisSlots(
|
|
|
324
338
|
).forEach((group) => {
|
|
325
339
|
const sideIndex = sideIndexes[group.side]++
|
|
326
340
|
const key = `${group.side}:${sideIndex}`
|
|
327
|
-
const
|
|
341
|
+
const { tickTextWidth, titleTextWidth } = axisTextMetrics(
|
|
328
342
|
group.domain,
|
|
329
343
|
nice,
|
|
330
344
|
undefined,
|
|
@@ -334,8 +348,9 @@ export function buildYAxisSlots(
|
|
|
334
348
|
measureTextWidth,
|
|
335
349
|
group.maximumTick,
|
|
336
350
|
group.minimumTick,
|
|
337
|
-
)
|
|
338
|
-
widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH,
|
|
351
|
+
)
|
|
352
|
+
widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, tickTextWidth))
|
|
353
|
+
titleWidths.set(key, Math.max(titleWidths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, titleTextWidth))
|
|
339
354
|
})
|
|
340
355
|
})
|
|
341
356
|
|
|
@@ -348,15 +363,14 @@ export function buildYAxisSlots(
|
|
|
348
363
|
.sort((first, second) => first - second)
|
|
349
364
|
sideIndexes.forEach((sideIndex) => {
|
|
350
365
|
const tickTextWidth = widths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
|
|
366
|
+
const titleTextWidth = titleWidths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
|
|
351
367
|
const axisOffset = side === 'left' && clearance.left > 0 ? -clearance.left : clearance[side]
|
|
352
368
|
const labelDistance =
|
|
353
|
-
|
|
369
|
+
titleTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth / 2
|
|
354
370
|
const labelOffset = axisOffset + labelDistance * (side === 'left' ? -1 : 1)
|
|
355
371
|
const fullClearance =
|
|
356
|
-
tickTextWidth +
|
|
372
|
+
Math.max(tickTextWidth, titleTextWidth + Y_AXIS_LABEL_GAP + labelBandWidth) +
|
|
357
373
|
Y_AXIS_TICK_PADDING +
|
|
358
|
-
Y_AXIS_LABEL_GAP +
|
|
359
|
-
labelBandWidth +
|
|
360
374
|
Y_AXIS_OUTER_PADDING
|
|
361
375
|
slots.push({ side, sideIndex, axisOffset, labelOffset, clearance: fullClearance })
|
|
362
376
|
clearance[side] += fullClearance
|