waveform-analysis 0.1.71 → 0.1.72

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 (117) hide show
  1. package/README.md +241 -39
  2. package/dist/assets/{workerRuntime-o5An0M-W.js → workerRuntime-5p-EI-GL.js} +1 -1
  3. package/dist/index.cjs +2 -1
  4. package/dist/index.js +2922 -1910
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +24 -6
  7. package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
  8. package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
  9. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  10. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  11. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  12. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  13. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  14. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  15. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  16. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  17. package/dist/types/components/core/layout.d.ts +5 -3
  18. package/dist/types/components/core/niceScale.d.ts +34 -0
  19. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  20. package/dist/types/components/core/trackPagination.d.ts +1 -1
  21. package/dist/types/components/core/types.d.ts +3 -0
  22. package/dist/types/components/core/useWaveformChartController.d.ts +24 -5
  23. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  24. package/dist/types/components/core/useWaveformData.d.ts +1 -0
  25. package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
  26. package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
  27. package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
  28. package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
  29. package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
  30. package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
  31. package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
  32. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  33. package/dist/types/components/core/yAxisPadding.d.ts +7 -0
  34. package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
  35. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
  36. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  37. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  38. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +0 -1
  39. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  40. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  41. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  42. package/dist/types/core/lineBoundary.d.ts +4 -0
  43. package/dist/types/core/rendering.d.ts +4 -0
  44. package/dist/types/core/renderingStrategies.d.ts +2 -0
  45. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  46. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  47. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  48. package/dist/types/demo/types.d.ts +6 -0
  49. package/dist/types/index.d.ts +2 -1
  50. package/dist/types/types/chart.d.ts +30 -8
  51. package/dist/types/types/controls.d.ts +69 -0
  52. package/dist/types/types/index.d.ts +2 -1
  53. package/dist/types/utils/formatters.d.ts +1 -0
  54. package/package.json +7 -6
  55. package/src/App.vue +58 -14
  56. package/src/components/WaveformChart.css +10 -1
  57. package/src/components/WaveformChart.vue +6 -0
  58. package/src/components/WaveformChartView.vue +27 -23
  59. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  60. package/src/components/annotation/useWaveformChartAnnotations.ts +6 -1
  61. package/src/components/controls/WaveformToolbar.vue +248 -0
  62. package/src/components/controls/chartExportOptions.ts +32 -0
  63. package/src/components/controls/chartSnapshot.ts +132 -0
  64. package/src/components/controls/snapshotTitle.ts +73 -0
  65. package/src/components/controls/toolbarRegistry.ts +92 -0
  66. package/src/components/controls/useChartCommands.ts +294 -0
  67. package/src/components/controls/useChartImageExport.ts +155 -0
  68. package/src/components/controls/useControlMode.ts +30 -0
  69. package/src/components/core/latestTaskScheduler.ts +14 -2
  70. package/src/components/core/layout.ts +35 -34
  71. package/src/components/core/niceScale.ts +151 -0
  72. package/src/components/core/trackLayoutBuilder.ts +34 -17
  73. package/src/components/core/trackPagination.ts +3 -3
  74. package/src/components/core/types.ts +3 -0
  75. package/src/components/core/useWaveformChartController.ts +94 -3
  76. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  77. package/src/components/core/useWaveformData.ts +2 -0
  78. package/src/components/core/useWaveformLayout.ts +17 -5
  79. package/src/components/core/useWaveformMeasurements.ts +63 -0
  80. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  81. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  82. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  83. package/src/components/core/waveformChartTypes.ts +5 -0
  84. package/src/components/core/waveformSamplingSession.ts +49 -0
  85. package/src/components/core/workerSamplingClient.ts +38 -6
  86. package/src/components/core/yAxisLabelStyle.ts +10 -0
  87. package/src/components/core/yAxisLayoutMetrics.ts +10 -9
  88. package/src/components/core/yAxisPadding.ts +45 -0
  89. package/src/components/core/yAxisTitleLayout.ts +11 -12
  90. package/src/components/interaction/useWaveformViewport.ts +11 -2
  91. package/src/components/rendering/WaveformTrack.css +1 -2
  92. package/src/components/rendering/WaveformTrack.vue +4 -12
  93. package/src/components/rendering/WaveformTrackAxes.vue +48 -19
  94. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  95. package/src/components/rendering/compactFrame.ts +31 -0
  96. package/src/components/rendering/yAxisTickVisibility.ts +95 -0
  97. package/src/core/lineBoundary.ts +24 -0
  98. package/src/core/rendering.ts +48 -25
  99. package/src/core/renderingStrategies.ts +19 -13
  100. package/src/core/waveformPointSource.ts +23 -1
  101. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  102. package/src/core/workerSampling/repository.ts +26 -18
  103. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  104. package/src/core/workerSampling/workerRuntime.ts +13 -3
  105. package/src/data/simulatedWaveforms.ts +57 -2
  106. package/src/demo/DemoChartHost.vue +5 -1
  107. package/src/demo/DemoGridFrameControls.vue +2 -0
  108. package/src/demo/DemoViewControls.vue +5 -2
  109. package/src/demo/DemoYAxisPaddingControls.vue +46 -0
  110. package/src/demo/embedBridge.ts +85 -0
  111. package/src/demo/types.ts +6 -0
  112. package/src/index.ts +18 -0
  113. package/src/types/chart.ts +32 -8
  114. package/src/types/controls.ts +80 -0
  115. package/src/types/index.ts +18 -0
  116. package/src/utils/formatters.ts +2 -1
  117. package/src/views/WasmSamplingDemo.vue +8 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waveform-analysis",
3
- "version": "0.1.71",
3
+ "version": "0.1.72",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -55,20 +55,21 @@
55
55
  "scripts": {
56
56
  "dev": "vite",
57
57
  "build": "pnpm typecheck && pnpm build:lib && pnpm build:types && pnpm build:demo",
58
- "build:wasm": "wasm-pack build wasm --target web --release --out-dir pkg --locked",
58
+ "build:wasm": "node scripts/prepare-wasm.mjs --force",
59
59
  "build:lib": "vite build --config vite.lib.config.ts",
60
60
  "build:types": "node scripts/clean-types.mjs && vue-tsc -p tsconfig.lib.json && node scripts/prepare-package-types.mjs",
61
61
  "build:demo": "vite build",
62
62
  "preview": "vite preview --outDir dist-demo",
63
- "typecheck": "pnpm build:wasm && vue-tsc -b",
63
+ "typecheck": "pnpm prepare:wasm && vue-tsc -b",
64
64
  "check:file-length": "node scripts/check-file-length.mjs",
65
65
  "lint": "eslint . --max-warnings=0",
66
66
  "lint:oxlint": "oxlint . --deny-warnings",
67
67
  "lint:all": "pnpm lint:oxlint && pnpm lint",
68
68
  "format": "prettier --write .",
69
69
  "format:check": "prettier --check .",
70
- "test": "pnpm build:wasm && vitest run",
71
- "test:coverage": "pnpm build:wasm && vitest run --coverage",
72
- "test:package": "node scripts/test-package.mjs"
70
+ "test": "pnpm prepare:wasm && vitest run",
71
+ "test:coverage": "pnpm prepare:wasm && vitest run --coverage",
72
+ "test:package": "node scripts/test-package.mjs",
73
+ "prepare:wasm": "node scripts/prepare-wasm.mjs"
73
74
  }
74
75
  }
package/src/App.vue CHANGED
@@ -26,11 +26,23 @@ import DemoControlPanel from './demo/DemoControlPanel.vue'
26
26
  import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
27
27
  import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
28
28
 
29
- const fullChartData = createSimulatedWaveformData()
30
- const displayMode = ref<WaveformDisplayMode>('independent')
29
+ const demoPreset = new URLSearchParams(window.location.search).get('demo')
30
+ const compactNineDemo = demoPreset === 'compact-nine'
31
+ const compactFourteenDemo = demoPreset === 'compact-fourteen'
32
+ const fullChartData = createSimulatedWaveformData(compactFourteenDemo ? 14 : undefined)
33
+ if (compactNineDemo && fullChartData.kind === 'series') {
34
+ const trackIds = [...new Set(fullChartData.series.map((series) => series.trackId ?? series.id))]
35
+ const demoTrackIds = new Set(trackIds.slice(0, 9))
36
+ fullChartData.series = fullChartData.series.filter((series) =>
37
+ demoTrackIds.has(series.trackId ?? series.id),
38
+ )
39
+ }
40
+ const displayMode = ref<WaveformDisplayMode>(
41
+ compactNineDemo || compactFourteenDemo ? 'compact' : 'independent',
42
+ )
31
43
  const overlayMode = ref<WaveformOverlayMode>('single-axis')
32
- const rowCount = ref(4)
33
- const columnCount = ref(1)
44
+ const rowCount = ref(compactNineDemo ? 5 : 4)
45
+ const columnCount = ref(compactNineDemo || compactFourteenDemo ? 2 : 1)
34
46
  const frameBorderColor = ref('#1f2937')
35
47
  const frameBorderWidth = ref(2)
36
48
  const frameBorderStyle = ref<NonNullable<WaveformFrameStyle['borderStyle']>>('solid')
@@ -42,6 +54,10 @@ const verticalGridVisible = ref(true)
42
54
  const verticalGridColor = ref('#dfe5ef')
43
55
  const xAxisLineVisible = ref(false)
44
56
  const yAxisLineVisible = ref(false)
57
+ const upperPaddingEnabled = ref(true)
58
+ const lowerPaddingEnabled = ref(true)
59
+ const upperPaddingPercent = ref(10)
60
+ const lowerPaddingPercent = ref(10)
45
61
  const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
46
62
  const annotations = ref<WaveformAnnotation[]>([])
47
63
  const annotationsVisible = ref(true)
@@ -49,13 +65,14 @@ const cleanView = ref(false)
49
65
  const presentationMode = ref(false)
50
66
  const showTooltip = ref(true)
51
67
  const integerZoom = ref(true)
52
- const plotMarginTop = ref(18)
53
- const plotMarginBottom = ref(52)
68
+ const plotMarginTop = ref(10)
69
+ const plotMarginBottom = ref(44)
54
70
  const zeroLineVisible = ref(true)
55
71
  const zeroLineColor = ref('#ff0000')
56
72
  const zeroLineWidth = ref(1)
57
73
  const zeroLineOpacity = ref(0.5)
58
74
  const zeroLineDash = ref('6 4')
75
+ const toolbarVisible = ref(true)
59
76
  const interactionMode = ref<WaveformInteractionMode>('zoom')
60
77
  const legendPosition = ref<WaveformLegendPosition>('top-right')
61
78
  const legendOrientation = ref<WaveformLegendOrientation>('auto')
@@ -65,10 +82,10 @@ const titleVisible = ref(true)
65
82
  const titleText = ref('模拟波形分析')
66
83
  const titleAlign = ref<NonNullable<WaveformTitleOptions['align']>>('center')
67
84
  const titleFontFamily = ref('"Microsoft YaHei", "微软雅黑", sans-serif')
68
- const titleFontSize = ref(14)
85
+ const titleFontSize = ref(18)
69
86
  const titleRotation = ref(0)
70
87
  const titleColor = ref('#1f2937')
71
- const titleBold = ref(false)
88
+ const titleBold = ref(true)
72
89
  const titleItalic = ref(false)
73
90
  const titleUnderline = ref(false)
74
91
  const controlsOpen = ref(false)
@@ -123,7 +140,13 @@ const axes = computed<WaveformAxesOptions>(() => ({
123
140
  lineVisible: xAxisLineVisible.value,
124
141
  ...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
125
142
  },
126
- y: { lineVisible: yAxisLineVisible.value },
143
+ y: {
144
+ lineVisible: yAxisLineVisible.value,
145
+ upperPaddingEnabled: upperPaddingEnabled.value,
146
+ lowerPaddingEnabled: lowerPaddingEnabled.value,
147
+ upperPaddingRatio: upperPaddingPercent.value / 100,
148
+ lowerPaddingRatio: lowerPaddingPercent.value / 100,
149
+ },
127
150
  }))
128
151
  const zeroLine = computed<WaveformZeroLineOptions>(() => ({
129
152
  visible: zeroLineVisible.value,
@@ -243,7 +266,17 @@ function mergeIndependentWindow(
243
266
  }
244
267
  }
245
268
 
269
+ let pendingCommandId: string | undefined
270
+ let pendingPayloads: WaveformZoomEndPayload[] = []
246
271
  async function handleZoomEnd(payload: WaveformZoomEndPayload) {
272
+ if (payload.action === 'fit') return
273
+ if (payload.commandId && payload.commandId === pendingCommandId) {
274
+ pendingPayloads.push(payload)
275
+ return
276
+ }
277
+ pendingCommandId = payload.commandId
278
+ const batch = [payload]
279
+ pendingPayloads = batch
247
280
  // Demo-only sequence number cancellation. Production code should use AbortController
248
281
  // to cancel in-flight requests when a newer zoom gesture arrives.
249
282
  const requestSequence = ++zoomRequestSequence
@@ -252,11 +285,16 @@ async function handleZoomEnd(payload: WaveformZoomEndPayload) {
252
285
 
253
286
  // Demo-only stand-in for the backend response. Production code should replace this
254
287
  // with a request using payload.start/payload.end and the optional channel metadata.
255
- const responseData = filterWaveformData(fullChartData, payload.start, payload.end)
256
- chartData.value =
257
- payload.trackIndex !== undefined && payload.seriesIds?.length
258
- ? mergeIndependentWindow(chartData.value, responseData, payload.seriesIds)
259
- : responseData
288
+ let nextData = chartData.value
289
+ batch.forEach((item) => {
290
+ const responseData = filterWaveformData(fullChartData, item.start, item.end)
291
+ nextData =
292
+ item.trackIndex !== undefined && item.seriesIds?.length
293
+ ? mergeIndependentWindow(nextData, responseData, item.seriesIds)
294
+ : responseData
295
+ })
296
+ chartData.value = nextData
297
+ pendingCommandId = undefined
260
298
  }
261
299
 
262
300
  function resetWaveformViewport() {
@@ -297,6 +335,7 @@ onMounted(() => window.addEventListener('keydown', handleWindowKeydown))
297
335
  onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown))
298
336
 
299
337
  const controlPanelModel = reactive({
338
+ toolbarVisible,
300
339
  controlsOpen,
301
340
  displayMode,
302
341
  overlayMode,
@@ -324,6 +363,10 @@ const controlPanelModel = reactive({
324
363
  verticalGridColor,
325
364
  xAxisLineVisible,
326
365
  yAxisLineVisible,
366
+ upperPaddingEnabled,
367
+ lowerPaddingEnabled,
368
+ upperPaddingPercent,
369
+ lowerPaddingPercent,
327
370
  ...xAxisLabelControlModel,
328
371
  frameBorderColor,
329
372
  frameBackgroundColor,
@@ -354,6 +397,7 @@ const controlPanelModel = reactive({
354
397
  }) satisfies DemoControlPanelModel
355
398
 
356
399
  const chartModel = reactive({
400
+ toolbarVisible,
357
401
  data: displayChartData,
358
402
  initialXDomain,
359
403
  displayMode,
@@ -117,8 +117,17 @@
117
117
  font-size: 12px;
118
118
  }
119
119
 
120
+ .waveform-chart--interaction-pan:not(.waveform-chart--presentation) .waveform-chart__overlay {
121
+ cursor: grab;
122
+ }
123
+ .waveform-chart--interaction-pan.waveform-chart--panning .waveform-chart__overlay {
124
+ cursor: grabbing;
125
+ }
126
+ .waveform-chart--interaction-none .waveform-chart__overlay {
127
+ cursor: default;
128
+ }
129
+
120
130
  .waveform-chart--edge-compact .waveform-chart__x-label {
121
- font-size: 18px;
122
131
  font-weight: 600;
123
132
  }
124
133
 
@@ -38,6 +38,12 @@ const emit = defineEmits<WaveformChartEmit>()
38
38
  const controller = useWaveformChartController(props as ResolvedWaveformChartProps, emit)
39
39
 
40
40
  defineExpose({
41
+ exportImage: controller.exportImage,
42
+ zoomIn: controller.zoomIn,
43
+ zoomOut: controller.zoomOut,
44
+ fitToData: controller.fitToData,
45
+ setInteractionMode: controller.setInteractionMode,
46
+ getControlState: controller.getControlState,
41
47
  resetViewport: controller.resetViewport,
42
48
  setViewportDomain: controller.setViewportDomain,
43
49
  })
@@ -1,15 +1,22 @@
1
1
  <script setup lang="ts">
2
+ import WaveformToolbar from './controls/WaveformToolbar.vue'
2
3
  import { Pagination } from 'ant-design-vue'
3
- import { toRefs } from 'vue'
4
+ import { computed, toRefs } from 'vue'
4
5
 
5
6
  import { WaveformAnnotationContextMenu, WaveformAnnotationLayer } from './annotation'
6
7
  import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
7
8
  import type { WaveformChartController } from './core/useWaveformChartController'
8
- import type { TrackLayout } from './core/types'
9
9
  import { WaveformHoverHost } from './interaction'
10
10
  import { WaveformHoverLayer, WaveformLegend, WaveformTrack } from './rendering'
11
+ import { compactFramePath } from './rendering/compactFrame'
11
12
 
12
13
  const props = defineProps<{ controller: WaveformChartController }>()
14
+ const compactFrameInset = computed(() => {
15
+ const width = props.controller.frameStyle?.borderWidth
16
+ return props.controller.isEdgeCompact
17
+ ? (typeof width === 'number' && Number.isFinite(width) && width >= 0 ? width : 1) / 2
18
+ : 0
19
+ })
13
20
  const {
14
21
  displayMode,
15
22
  activeInteractionMode,
@@ -111,21 +118,6 @@ function handleChartPointerLeave() {
111
118
  pointerInsideChart.value = false
112
119
  handlePointerLeave.value()
113
120
  }
114
-
115
- function sharesFrameTop(track: TrackLayout): boolean {
116
- return (
117
- displayMode.value === 'compact' &&
118
- trackLayouts.value.some(
119
- (above) =>
120
- !above.isEmpty &&
121
- above !== track &&
122
- above.column === track.column &&
123
- Math.abs(above.left - track.left) < 1e-6 &&
124
- Math.abs(above.width - track.width) < 1e-6 &&
125
- Math.abs(above.top + above.height - track.top) < 1e-6,
126
- )
127
- )
128
- }
129
121
  </script>
130
122
 
131
123
  <template>
@@ -205,7 +197,7 @@ function sharesFrameTop(track: TrackLayout): boolean {
205
197
 
206
198
  <g :transform="`translate(${resolvedChartLeftMargin}, ${chartTopMargin})`">
207
199
  <g
208
- v-if="displayMode !== 'compact' && !isCleanView"
200
+ v-if="displayMode !== 'compact' && !hasWaveformData && !isCleanView"
209
201
  class="waveform-chart__grid-slots"
210
202
  aria-hidden="true"
211
203
  >
@@ -246,21 +238,28 @@ function sharesFrameTop(track: TrackLayout): boolean {
246
238
  v-for="track in trackLayouts"
247
239
  :key="`${track.index}-${track.id}`"
248
240
  :track="track"
241
+ :frame-path="
242
+ displayMode === 'compact'
243
+ ? compactFramePath(track, trackLayouts, compactFrameInset)
244
+ : undefined
245
+ "
249
246
  :clip-path-id="clipPathId"
250
247
  :inner-width="innerWidth"
251
248
  :zoomable="zoomable"
252
249
  :interactive="!isPresentationMode"
253
250
  :display-mode="displayMode"
254
251
  :interaction-mode="activeInteractionMode"
255
- :frame-number="resolveFrameNumber(track.id)"
252
+ :frame-number="
253
+ resolveFrameNumber(track.id, (currentPage - 1) * getPageSize(gridOptions) + track.index)
254
+ "
256
255
  :frame-style="frameStyle"
257
256
  :axes="axes"
258
257
  :show-axis-units="unitDisplayMode === 'axis'"
259
- :show-label-units="unitDisplayMode === 'channel-label-or-legend'"
258
+ :show-label-units="
259
+ unitDisplayMode === 'channel-label-or-legend' || unitDisplayMode === 'channel-label'
260
+ "
260
261
  :hide-y-axis-titles="hideYAxisTitles"
261
- :contain-y-axis-endpoints="isEdgeCompact"
262
262
  :contain-frame-stroke="isEdgeCompact"
263
- :omit-frame-top="sharesFrameTop(track)"
264
263
  :clean-view="isCleanView"
265
264
  :zero-line="resolvedZeroLine"
266
265
  :time-unit="timeUnit"
@@ -292,10 +291,12 @@ function sharesFrameTop(track: TrackLayout): boolean {
292
291
  />
293
292
 
294
293
  <WaveformAnnotationLayer
294
+ :cancel-key="activeInteractionMode"
295
+ :data-version="controller.data"
295
296
  v-if="!isCleanView"
296
297
  :annotations="renderedAnnotations"
297
298
  :visible="annotationsVisible"
298
- :interactive="!isPresentationMode"
299
+ :interactive="!isPresentationMode && activeInteractionMode !== 'none'"
299
300
  @contextmenu="handleExistingAnnotationContextMenu"
300
301
  @drag-start="beginAnnotationDrag"
301
302
  @move="handleAnnotationMove"
@@ -331,6 +332,7 @@ function sharesFrameTop(track: TrackLayout): boolean {
331
332
  <text
332
333
  v-if="resolvedXLabel && !isCleanView"
333
334
  class="waveform-chart__label waveform-chart__x-label"
335
+ :style="isEdgeCompact ? { fontSize: titleTextStyle.fontSize } : undefined"
334
336
  :x="plotCenterX"
335
337
  :y="xAxisTitleY"
336
338
  text-anchor="middle"
@@ -349,6 +351,8 @@ function sharesFrameTop(track: TrackLayout): boolean {
349
351
  </text>
350
352
  </svg>
351
353
 
354
+ <WaveformToolbar :controller="controller" />
355
+
352
356
  <Pagination
353
357
  v-if="paginationVisible"
354
358
  class="waveform-chart__pagination"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref, watch, onBeforeUnmount } from 'vue'
3
3
 
4
4
  import type { RenderedAnnotation } from './types'
5
5
  import { ANNOTATION_TEXT_FONT, ANNOTATION_TEXT_LINE_HEIGHT } from './markup'
@@ -8,6 +8,8 @@ interface Props {
8
8
  annotations: RenderedAnnotation[]
9
9
  visible: boolean
10
10
  interactive?: boolean
11
+ cancelKey?: string
12
+ dataVersion?: unknown
11
13
  }
12
14
 
13
15
  const props = withDefaults(defineProps<Props>(), {
@@ -211,6 +213,9 @@ watch(
211
213
  },
212
214
  )
213
215
 
216
+ watch([() => props.cancelKey, () => props.dataVersion, () => props.visible], cancelActiveDrag)
217
+ onBeforeUnmount(cancelActiveDrag)
218
+
214
219
  function handleContextMenu(annotationId: string, event: MouseEvent) {
215
220
  if (!props.interactive) return
216
221
  event.preventDefault()
@@ -282,7 +282,12 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
282
282
  }
283
283
 
284
284
  function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) {
285
- if (isPresentationMode.value || !props.annotationsVisible) return
285
+ if (
286
+ isPresentationMode.value ||
287
+ !props.annotationsVisible ||
288
+ activeInteractionMode.value === 'none'
289
+ )
290
+ return
286
291
  event.preventDefault()
287
292
  event.stopPropagation()
288
293
  const candidateContext = resolveAnnotationCandidates(event, trackIndex)
@@ -0,0 +1,248 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
3
+ import type { WaveformChartController } from '../core/useWaveformChartController'
4
+ import { resolveToolbar } from './toolbarRegistry'
5
+ const props = defineProps<{ controller: WaveformChartController }>()
6
+ const config = computed(() => resolveToolbar(props.controller.toolbar))
7
+ const compact = computed(() => props.controller.chartWidth < 440)
8
+ const expanded = ref(false)
9
+ const selected = ref('')
10
+ const menu = ref(false)
11
+ const busy = ref(false)
12
+ const feedback = ref('')
13
+ let disposed = false
14
+ onBeforeUnmount(() => {
15
+ disposed = true
16
+ })
17
+ watch(
18
+ () => props.controller.controlState,
19
+ (state) => {
20
+ if (!state.independent || !state.targets.some((t) => t.trackId === selected.value))
21
+ selected.value = ''
22
+ },
23
+ )
24
+ watch(
25
+ () => config.value.visible,
26
+ () => {
27
+ menu.value = false
28
+ },
29
+ )
30
+ async function download(format: 'png' | 'svg') {
31
+ menu.value = false
32
+ busy.value = true
33
+ feedback.value = ''
34
+ try {
35
+ const blob = await props.controller.exportImage({ format })
36
+ if (disposed) return
37
+ const url = URL.createObjectURL(blob)
38
+ const link = document.createElement('a')
39
+ try {
40
+ link.href = url
41
+ link.download = `waveform.${format}`
42
+ document.body.appendChild(link)
43
+ link.click()
44
+ } finally {
45
+ link.remove()
46
+ URL.revokeObjectURL(url)
47
+ }
48
+ } catch (error) {
49
+ if (!disposed) feedback.value = error instanceof Error ? error.message : '导出失败,请重试'
50
+ } finally {
51
+ busy.value = false
52
+ }
53
+ }
54
+ function toggleExpanded() {
55
+ expanded.value = !expanded.value
56
+ menu.value = false
57
+ }
58
+ function escape() {
59
+ menu.value = false
60
+ expanded.value = false
61
+ props.controller.cancelViewportDrag()
62
+ }
63
+ </script>
64
+ <template>
65
+ <div
66
+ v-if="config.visible"
67
+ class="waveform-toolbar"
68
+ :class="[
69
+ `waveform-toolbar--${config.display}`,
70
+ `waveform-toolbar--${config.position}`,
71
+ { 'waveform-toolbar--collapsed': compact && !expanded },
72
+ ]"
73
+ :style="{ top: `${controller.titleAreaHeight + 4}px` }"
74
+ role="toolbar"
75
+ aria-label="波形工具栏"
76
+ @pointerdown.stop
77
+ @click.stop
78
+ @dblclick.stop
79
+ @wheel.stop
80
+ @contextmenu.stop
81
+ @keydown.esc.stop="escape"
82
+ >
83
+ <button
84
+ v-if="compact"
85
+ type="button"
86
+ class="waveform-toolbar__toggle"
87
+ aria-label="图表工具"
88
+ :aria-expanded="expanded"
89
+ @click="toggleExpanded"
90
+ >
91
+ 工具
92
+ </button>
93
+ <select v-if="controller.controlState.independent" v-model="selected" aria-label="操作图框">
94
+ <option value="">当前页全部</option>
95
+ <option
96
+ v-for="target in controller.controlState.targets"
97
+ :key="target.trackId"
98
+ :value="target.trackId"
99
+ >
100
+ {{ target.label }}
101
+ </option>
102
+ </select>
103
+ <span v-else class="waveform-toolbar__scope">共享范围</span>
104
+ <div
105
+ v-for="(group, index) in config.groups"
106
+ :key="index"
107
+ class="waveform-toolbar__group"
108
+ :data-group="group[0]?.group"
109
+ >
110
+ <button
111
+ v-for="item in group"
112
+ :key="item.id"
113
+ type="button"
114
+ :data-command="item.id"
115
+ :title="item.label"
116
+ :aria-label="item.label"
117
+ :aria-pressed="item.group === 'mode' ? item.active(controller.controlState) : undefined"
118
+ :aria-expanded="item.id === 'export' ? menu : undefined"
119
+ :disabled="
120
+ !item.enabled(controller.controlState, selected || undefined) ||
121
+ (item.id === 'export' && busy)
122
+ "
123
+ @click="item.id === 'export' ? (menu = !menu) : item.run(controller, selected || undefined)"
124
+ >
125
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
126
+ </button>
127
+ </div>
128
+ <div v-if="menu" class="waveform-toolbar__menu" role="group" aria-label="图片格式">
129
+ <button type="button" @click="download('png')">下载 PNG</button>
130
+ <button type="button" @click="download('svg')">下载 SVG</button>
131
+ </div>
132
+ <span v-if="busy || feedback" class="waveform-toolbar__feedback" role="status">{{
133
+ busy ? '正在导出…' : feedback
134
+ }}</span>
135
+ </div>
136
+ </template>
137
+ <style scoped>
138
+ .waveform-toolbar {
139
+ position: absolute;
140
+ z-index: 10;
141
+ top: 4px;
142
+ right: 6px;
143
+ display: flex;
144
+ flex-wrap: wrap;
145
+ align-items: center;
146
+ gap: 4px;
147
+ max-width: calc(100% - 12px);
148
+ padding: 4px;
149
+ background: rgb(255 255 255 / 96%);
150
+ border: 1px solid #d0d5dd;
151
+ border-radius: 6px;
152
+ box-shadow: 0 2px 8px #0001;
153
+ }
154
+ .waveform-toolbar--collapsed > :not(.waveform-toolbar__toggle):not(.waveform-toolbar__feedback) {
155
+ display: none;
156
+ }
157
+ .waveform-toolbar--top-left {
158
+ left: 6px;
159
+ right: auto;
160
+ }
161
+ .waveform-toolbar--hover {
162
+ opacity: 0;
163
+ pointer-events: none;
164
+ }
165
+ .waveform-chart:hover .waveform-toolbar--hover,
166
+ .waveform-toolbar:focus-within,
167
+ .waveform-toolbar:has(.waveform-toolbar__menu),
168
+ .waveform-toolbar:has(.waveform-toolbar__feedback) {
169
+ opacity: 1;
170
+ pointer-events: auto;
171
+ }
172
+ .waveform-toolbar__group {
173
+ display: flex;
174
+ gap: 2px;
175
+ }
176
+ .waveform-toolbar__group + .waveform-toolbar__group {
177
+ border-left: 1px solid #d0d5dd;
178
+ padding-left: 4px;
179
+ }
180
+ button {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ min-width: 30px;
185
+ min-height: 30px;
186
+ border: 0;
187
+ border-radius: 4px;
188
+ background: transparent;
189
+ color: #344054;
190
+ cursor: pointer;
191
+ }
192
+ button:hover,
193
+ button[aria-pressed='true'] {
194
+ background: #e6f4ff;
195
+ color: #0960bd;
196
+ }
197
+ button:disabled {
198
+ opacity: 0.35;
199
+ cursor: default;
200
+ }
201
+ button:focus-visible,
202
+ select:focus-visible {
203
+ outline: 2px solid #0960bd;
204
+ outline-offset: 1px;
205
+ }
206
+ svg {
207
+ width: 18px;
208
+ height: 18px;
209
+ fill: none;
210
+ stroke: currentColor;
211
+ stroke-width: 1.6;
212
+ stroke-linecap: round;
213
+ stroke-linejoin: round;
214
+ }
215
+ select {
216
+ min-width: 0;
217
+ max-width: 120px;
218
+ height: 30px;
219
+ border: 1px solid #d0d5dd;
220
+ border-radius: 4px;
221
+ background: white;
222
+ }
223
+ .waveform-toolbar__scope {
224
+ font-size: 12px;
225
+ color: #667085;
226
+ }
227
+ .waveform-toolbar__menu {
228
+ position: absolute;
229
+ right: 0;
230
+ top: 100%;
231
+ padding: 6px;
232
+ border: 1px solid #d0d5dd;
233
+ border-radius: 4px;
234
+ background: white;
235
+ display: flex;
236
+ }
237
+ .waveform-toolbar__feedback {
238
+ flex-basis: 100%;
239
+ font-size: 12px;
240
+ max-width: 300px;
241
+ }
242
+ @media (hover: none) {
243
+ .waveform-toolbar--hover {
244
+ opacity: 1;
245
+ pointer-events: auto;
246
+ }
247
+ }
248
+ </style>
@@ -0,0 +1,32 @@
1
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+
3
+ /** Presentation changes invalidate an in-flight image snapshot, without serializing input data. */
4
+ export function chartExportOptions(props: ResolvedWaveformChartProps) {
5
+ const keys = [
6
+ 'annotations',
7
+ 'annotationsVisible',
8
+ 'title',
9
+ 'axes',
10
+ 'frameStyle',
11
+ 'frameNumber',
12
+ 'frameNumbers',
13
+ 'legend',
14
+ 'cleanView',
15
+ 'rendering',
16
+ 'displayMode',
17
+ 'overlayMode',
18
+ 'initialXDomain',
19
+ 'initialXDomains',
20
+ 'yDomain',
21
+ 'yDomains',
22
+ 'grid',
23
+ 'plotMargin',
24
+ 'xLabel',
25
+ 'yLabel',
26
+ 'timeUnit',
27
+ 'zeroLine',
28
+ 'layoutPreset',
29
+ 'unitDisplayMode',
30
+ ] as const satisfies readonly (keyof ResolvedWaveformChartProps)[]
31
+ return Object.fromEntries(keys.map((key) => [key, props[key]]))
32
+ }