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.
Files changed (112) hide show
  1. package/README.md +256 -46
  2. package/dist/assets/workerRuntime-BJiRmIZD.js +2446 -0
  3. package/dist/index.cjs +1 -2
  4. package/dist/index.js +1 -7115
  5. package/dist/style.css +1 -1
  6. package/dist/types/animation/WaveformAnimationController.d.ts +30 -0
  7. package/dist/types/animation/core/Animation.d.ts +13 -0
  8. package/dist/types/animation/core/Animator.d.ts +23 -0
  9. package/dist/types/animation/core/easing.d.ts +3 -0
  10. package/dist/types/animation/core/errors.d.ts +4 -0
  11. package/dist/types/animation/core/handle.d.ts +7 -0
  12. package/dist/types/animation/core/owner.d.ts +3 -0
  13. package/dist/types/animation/core/propertyGroups.d.ts +17 -0
  14. package/dist/types/animation/core/task.d.ts +29 -0
  15. package/dist/types/animation/core/types.d.ts +74 -0
  16. package/dist/types/animation/core/validation.d.ts +30 -0
  17. package/dist/types/animation/core/values.d.ts +8 -0
  18. package/dist/types/animation/presets/annotation.d.ts +4 -0
  19. package/dist/types/animation/presets/lineDrawing.d.ts +7 -0
  20. package/dist/types/animation/presets/shared.d.ts +14 -0
  21. package/dist/types/animation/presets/transitions.d.ts +4 -0
  22. package/dist/types/animation/types.d.ts +84 -0
  23. package/dist/types/components/WaveformChart.vue.d.ts +3 -2
  24. package/dist/types/components/animation/annotationPresentation.d.ts +17 -0
  25. package/dist/types/components/animation/chartExportVersion.d.ts +2 -0
  26. package/dist/types/components/animation/config.d.ts +31 -0
  27. package/dist/types/components/animation/context.d.ts +20 -0
  28. package/dist/types/components/animation/geometry.d.ts +10 -0
  29. package/dist/types/components/animation/paginationPresentation.d.ts +13 -0
  30. package/dist/types/components/animation/pathBuffer.d.ts +4 -0
  31. package/dist/types/components/animation/trackPresentation.d.ts +35 -0
  32. package/dist/types/components/animation/useWaveformAnimations.d.ts +27 -0
  33. package/dist/types/components/annotation/WaveformAnnotationContextMenu.vue.d.ts +2 -2
  34. package/dist/types/components/annotation/types.d.ts +7 -0
  35. package/dist/types/components/controls/useChartImageExport.d.ts +2 -0
  36. package/dist/types/components/core/layout.d.ts +2 -1
  37. package/dist/types/components/core/types.d.ts +8 -0
  38. package/dist/types/components/core/useWaveformChartController.d.ts +25 -0
  39. package/dist/types/components/core/useWaveformRenderSampling.d.ts +2 -0
  40. package/dist/types/components/core/waveformChartTypes.d.ts +2 -1
  41. package/dist/types/components/core/yAxisTitleLayout.d.ts +1 -1
  42. package/dist/types/components/interaction/useExtremaTickViewport.d.ts +1 -1
  43. package/dist/types/components/rendering/WaveformSeriesLayer.vue.d.ts +1 -0
  44. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +4 -3
  45. package/dist/types/components/rendering/updateAxis.d.ts +5 -0
  46. package/dist/types/components/rendering/updateGrid.d.ts +3 -0
  47. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +2 -0
  48. package/dist/types/components/utils/assignElement.d.ts +2 -0
  49. package/dist/types/demo/types.d.ts +2 -1
  50. package/dist/types/index.d.ts +9 -0
  51. package/dist/types/types/chart.d.ts +4 -2
  52. package/dist/types/views/AnimationDemo.vue.d.ts +3 -0
  53. package/package.json +5 -2
  54. package/src/App.vue +6 -3
  55. package/src/DemoRouterApp.vue +1 -0
  56. package/src/animation/WaveformAnimationController.ts +328 -0
  57. package/src/animation/core/Animation.ts +37 -0
  58. package/src/animation/core/Animator.ts +200 -0
  59. package/src/animation/core/easing.ts +74 -0
  60. package/src/animation/core/errors.ts +24 -0
  61. package/src/animation/core/handle.ts +34 -0
  62. package/src/animation/core/owner.ts +9 -0
  63. package/src/animation/core/propertyGroups.ts +143 -0
  64. package/src/animation/core/task.ts +153 -0
  65. package/src/animation/core/types.ts +81 -0
  66. package/src/animation/core/validation.ts +173 -0
  67. package/src/animation/core/values.ts +38 -0
  68. package/src/animation/presets/annotation.ts +66 -0
  69. package/src/animation/presets/lineDrawing.ts +51 -0
  70. package/src/animation/presets/shared.ts +58 -0
  71. package/src/animation/presets/transitions.ts +77 -0
  72. package/src/animation/types.ts +81 -0
  73. package/src/components/WaveformChart.vue +1 -0
  74. package/src/components/WaveformChartView.vue +13 -4
  75. package/src/components/animation/annotationPresentation.ts +145 -0
  76. package/src/components/animation/chartExportVersion.ts +5 -0
  77. package/src/components/animation/config.ts +87 -0
  78. package/src/components/animation/context.ts +56 -0
  79. package/src/components/animation/geometry.ts +148 -0
  80. package/src/components/animation/paginationPresentation.ts +114 -0
  81. package/src/components/animation/pathBuffer.ts +61 -0
  82. package/src/components/animation/trackPresentation.ts +172 -0
  83. package/src/components/animation/useWaveformAnimations.ts +420 -0
  84. package/src/components/annotation/WaveformAnnotationLayer.vue +43 -30
  85. package/src/components/annotation/types.ts +3 -0
  86. package/src/components/controls/useChartImageExport.ts +4 -0
  87. package/src/components/core/layout.ts +36 -22
  88. package/src/components/core/trackLayoutBuilder.ts +17 -23
  89. package/src/components/core/types.ts +4 -0
  90. package/src/components/core/useWaveformChartController.ts +24 -20
  91. package/src/components/core/useWaveformLayout.ts +3 -1
  92. package/src/components/core/useWaveformRenderSampling.ts +23 -1
  93. package/src/components/core/waveformChartTypes.ts +2 -1
  94. package/src/components/core/yAxisLayoutMetrics.ts +24 -16
  95. package/src/components/core/yAxisPadding.ts +10 -5
  96. package/src/components/core/yAxisTitleLayout.ts +9 -48
  97. package/src/components/interaction/useExtremaTickViewport.ts +1 -1
  98. package/src/components/rendering/WaveformSeriesLayer.vue +13 -1
  99. package/src/components/rendering/WaveformTrack.vue +6 -1
  100. package/src/components/rendering/WaveformTrackAxes.vue +64 -51
  101. package/src/components/rendering/WaveformTrackBackdrop.vue +35 -49
  102. package/src/components/rendering/updateAxis.ts +57 -0
  103. package/src/components/rendering/updateGrid.ts +42 -0
  104. package/src/components/rendering/yAxisTickVisibility.ts +45 -18
  105. package/src/components/utils/assignElement.ts +7 -0
  106. package/src/demo/DemoYAxisPaddingControls.vue +11 -3
  107. package/src/demo/types.ts +2 -1
  108. package/src/index.ts +53 -1
  109. package/src/router.ts +1 -0
  110. package/src/types/chart.ts +4 -2
  111. package/src/views/AnimationDemo.vue +129 -0
  112. 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
- <rect
275
- class="waveform-annotation__box"
276
- :x="draggedBox(rendered).x"
277
- :y="draggedBox(rendered).y"
278
- :width="draggedBox(rendered).width"
279
- :height="draggedBox(rendered).height"
280
- :fill="rendered.style.backgroundColor"
281
- :stroke="rendered.style.borderColor"
282
- />
283
- <text
284
- class="waveform-annotation__text"
285
- :x="draggedBox(rendered).x + draggedBox(rendered).width / 2"
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
- :dy="index === 0 ? 0 : ANNOTATION_TEXT_LINE_HEIGHT"
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
- {{ line }}
303
- </tspan>
304
- </text>
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
- /** Scientific multipliers retain their rendered position but do not reserve horizontal space. */
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 maximumTickWidth = Math.max(
233
- Y_AXIS_CHARACTER_WIDTH,
234
- ...tickValueSets.flatMap((values) =>
235
- values.map((value) => {
236
- const text = formatYAxisTickLayoutLabel(value, resolvedTicks.domain, values, unit)
237
- return measureTextWidth ? measureTextWidth(text) : text.length * Y_AXIS_CHARACTER_WIDTH
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: maximumTickWidth,
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
- axisTextMetrics(group.domain, nice, undefined, group.seriesList[0]?.unit, tickCount)
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 width = axisTextMetrics(
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
- ).tickTextWidth
338
- widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, 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
- tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth / 2
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