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
@@ -1,7 +1,6 @@
1
1
  import type { WaveformOverlayMode } from '../data/types'
2
2
  import {
3
3
  Y_AXIS_CHARACTER_WIDTH,
4
- Y_AXIS_LABEL_BAND_WIDTH,
5
4
  Y_AXIS_LABEL_GAP,
6
5
  Y_AXIS_OUTER_PADDING,
7
6
  Y_AXIS_TICK_PADDING,
@@ -9,6 +8,8 @@ import {
9
8
  import { axisTextMetrics, resolveRenderedYAxisSeriesGroups } from './layout'
10
9
  import type { DisplayTrack } from './types'
11
10
  import type { WaveformYDomain } from './yDomain'
11
+ import type { YAxisPaddingOptions } from './yAxisPadding'
12
+ import { resolveYAxisLabelBandWidth } from './yAxisLabelStyle'
12
13
 
13
14
  export interface YAxisLayoutMetrics {
14
15
  tickClearance: number
@@ -40,10 +41,13 @@ export function resolveYAxisLayoutMetrics(
40
41
  nice: boolean,
41
42
  tickCount: number,
42
43
  compact: boolean,
43
- upperPaddingRatio = 0,
44
+ padding?: YAxisPaddingOptions | number,
44
45
  showUnits = true,
45
46
  measureTextWidth?: (text: string) => number,
46
47
  ): YAxisLayoutMetrics {
48
+ const labelBandWidth = resolveYAxisLabelBandWidth(
49
+ typeof padding === 'object' ? padding.labelTextStyle : undefined,
50
+ )
47
51
  const axisText = chartTracks
48
52
  .filter((track) => track.series.length > 0)
49
53
  .flatMap((track) =>
@@ -53,7 +57,7 @@ export function resolveYAxisLayoutMetrics(
53
57
  yDomain,
54
58
  yDomains,
55
59
  viewportYDomains,
56
- upperPaddingRatio,
60
+ padding,
57
61
  ),
58
62
  )
59
63
  .map(
@@ -66,6 +70,7 @@ export function resolveYAxisLayoutMetrics(
66
70
  tickCount,
67
71
  compact,
68
72
  measureTextWidth,
73
+ group.maximumTick,
69
74
  ).tickTextWidth,
70
75
  )
71
76
  const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
@@ -74,13 +79,9 @@ export function resolveYAxisLayoutMetrics(
74
79
  Y_AXIS_TICK_PADDING +
75
80
  tickTextWidth +
76
81
  Y_AXIS_LABEL_GAP +
77
- Y_AXIS_LABEL_BAND_WIDTH / 2
82
+ labelBandWidth / 2
78
83
  )
79
84
  const fullClearance =
80
- tickTextWidth +
81
- Y_AXIS_TICK_PADDING +
82
- Y_AXIS_LABEL_GAP +
83
- Y_AXIS_LABEL_BAND_WIDTH +
84
- Y_AXIS_OUTER_PADDING
85
+ tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth + Y_AXIS_OUTER_PADDING
85
86
  return { tickClearance, fullClearance, labelCenterX }
86
87
  }
@@ -0,0 +1,45 @@
1
+ import type { WaveformAxesOptions } from '../../types'
2
+ import type { YAxisSeriesGroup } from './layout'
3
+
4
+ export type YAxisPaddingOptions = WaveformAxesOptions['y']
5
+
6
+ export function paddingRatio(enabled?: boolean, ratio?: number): number {
7
+ if (enabled === false) return 0
8
+ const value = ratio ?? (enabled === true ? 0.1 : 0)
9
+ return Number.isFinite(value) && value > 0 ? value : 0
10
+ }
11
+
12
+ export function applyYAxisPadding(
13
+ group: YAxisSeriesGroup,
14
+ viewport: [number, number] | undefined,
15
+ options: YAxisPaddingOptions | number,
16
+ ): YAxisSeriesGroup {
17
+ if (group.fixed) return group
18
+ const config = typeof options === 'number' ? { upperPaddingRatio: options } : options
19
+ const upper = paddingRatio(config?.upperPaddingEnabled, config?.upperPaddingRatio)
20
+ const lower = paddingRatio(config?.lowerPaddingEnabled, config?.lowerPaddingRatio)
21
+ const maxima = group.seriesList.flatMap((series) =>
22
+ Number.isFinite(series.rawYMaximum) ? [series.rawYMaximum!] : [],
23
+ )
24
+ const maximumTick = upper > 0 && maxima.length ? Math.max(...maxima) : undefined
25
+ const [a, b] = group.domain
26
+ const span = b - a
27
+ const min = lower > 0 ? a - span * lower : a
28
+ const max = upper > 0 ? b + span * upper : b
29
+ return {
30
+ ...group,
31
+ maximumTick,
32
+ domain: viewport ?? [Number.isFinite(min) ? min : a, Number.isFinite(max) ? max : b],
33
+ }
34
+ }
35
+
36
+ export function withMaximumTick(values: number[], maximum?: number): number[] {
37
+ if (maximum === undefined || !Number.isFinite(maximum)) return values
38
+ return [...values.filter((value) => !sameTick(value, maximum)), maximum].sort((a, b) => a - b)
39
+ }
40
+
41
+ export function sameTick(a: number, b: number): boolean {
42
+ return (
43
+ Math.abs(a - b) <= Number.EPSILON * 8 * Math.max(Math.abs(a), Math.abs(b), Number.MIN_VALUE)
44
+ )
45
+ }
@@ -1,23 +1,21 @@
1
1
  import { formatYAxisTickLayoutLabel } from './layout'
2
2
  import type { TrackLayout, WaveformYAxisLayout } from './types'
3
- import {
4
- Y_AXIS_CHARACTER_WIDTH,
5
- Y_AXIS_LABEL_BAND_WIDTH,
6
- Y_AXIS_TICK_PADDING,
7
- } from './yAxisConstants'
3
+ import type { WaveformYAxisLabelTextStyle } from '../../types'
4
+ import { resolveYAxisLabelBandWidth, resolveYAxisLabelFontSize } from './yAxisLabelStyle'
5
+ import { Y_AXIS_CHARACTER_WIDTH, Y_AXIS_TICK_PADDING } from './yAxisConstants'
8
6
 
9
- // Match the 12px channel names and 11px ticks in WaveformTrack.css.
10
- const TITLE_FONT_SIZE = 12
7
+ // Match the 11px ticks in WaveformTrack.css.
11
8
  const TICK_FONT_SIZE = 11
12
9
  const TEXT_GAP = 2
13
10
 
14
11
  interface TitleLayoutOptions {
12
+ labelTextStyle?: WaveformYAxisLabelTextStyle
15
13
  showUnits?: boolean
16
14
  compact?: boolean
17
15
  measureTextWidth?: (text: string) => number
18
16
  }
19
17
 
20
- function estimateTitleWidth(title: string): number {
18
+ function estimateTitleWidth(title: string, fontSize: number): number {
21
19
  return Array.from(title).reduce((width, character) => {
22
20
  // Latin labels average well below one em; full-width characters occupy one em.
23
21
  const em = /[\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60]/u.test(character)
@@ -27,7 +25,7 @@ function estimateTitleWidth(title: string): number {
27
25
  : /[ilI1., :;|'!]/u.test(character)
28
26
  ? 0.4
29
27
  : 0.65
30
- return width + em * TITLE_FONT_SIZE
28
+ return width + em * fontSize
31
29
  }, 0)
32
30
  }
33
31
 
@@ -41,7 +39,8 @@ function titleDistance(
41
39
  ): number {
42
40
  const series = axis.seriesList[0]
43
41
  const title = series?.name.trim() || yLabel || ''
44
- const halfTitleHeight = estimateTitleWidth(title) / 2
42
+ const halfTitleHeight =
43
+ estimateTitleWidth(title, resolveYAxisLabelFontSize(options.labelTextStyle)) / 2
45
44
  const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
46
45
  const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
47
46
  const lowestTick = Math.min(...axis.tickValues)
@@ -63,11 +62,11 @@ function titleDistance(
63
62
  )
64
63
  return [options.measureTextWidth?.(text) ?? text.length * Y_AXIS_CHARACTER_WIDTH]
65
64
  })
66
- // The 20px title band leaves 4px beside a 12px glyph.
65
+ // The title band leaves 4px beside each glyph.
67
66
  return (
68
67
  Math.max(Y_AXIS_CHARACTER_WIDTH, ...tickWidths) +
69
68
  Y_AXIS_TICK_PADDING +
70
- Y_AXIS_LABEL_BAND_WIDTH / 2
69
+ resolveYAxisLabelBandWidth(options.labelTextStyle) / 2
71
70
  )
72
71
  }
73
72
 
@@ -13,6 +13,7 @@ import { createViewportPan } from './viewportPan'
13
13
  import { transformForDomain } from './zoomConstraints'
14
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
15
15
  export interface ViewportContext {
16
+ clearBoundaries?: (trackIndex?: number) => void
16
17
  props: ResolvedWaveformChartProps
17
18
  emit: WaveformChartEmit
18
19
  selection: Ref<ViewportSelectionState | null>
@@ -104,8 +105,15 @@ export function useWaveformViewport(context: ViewportContext) {
104
105
  )
105
106
  const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => {
106
107
  if (isPresentationMode.value) return
107
- const panRequested = props.pannable && spacePressed.value
108
- if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return
108
+ const panRequested =
109
+ props.pannable &&
110
+ (props.interactionMode === 'pan' || (isZoomMode.value && spacePressed.value))
111
+ if (
112
+ (!props.zoomable && !panRequested) ||
113
+ (!isZoomMode.value && !panRequested) ||
114
+ event.button !== 0
115
+ )
116
+ return
109
117
  const overlay = event.currentTarget as SVGRectElement
110
118
  const track = trackLayouts.value.find((item) => item.index === trackIndex)
111
119
  if (!track) return
@@ -291,6 +299,7 @@ export function useWaveformViewport(context: ViewportContext) {
291
299
  }
292
300
  }
293
301
  const resetViewport = (trackIndex?: number) => {
302
+ context.clearBoundaries?.(trackIndex)
294
303
  cancelPendingZoom()
295
304
  cancelViewportDrag()
296
305
  if (props.displayMode === 'independent' && trackIndex !== undefined) {
@@ -4,8 +4,7 @@
4
4
  }
5
5
 
6
6
  .waveform-track__y-axis-label-bg {
7
- fill: white;
8
- opacity: 0.9;
7
+ fill: transparent;
9
8
  pointer-events: none;
10
9
  }
11
10
 
@@ -20,15 +20,14 @@ interface Props {
20
20
  interactionMode?: WaveformInteractionMode
21
21
  frameNumber?: string | number
22
22
  frameStyle?: WaveformFrameStyle
23
+ framePath?: string
23
24
  axes?: WaveformAxesOptions
24
25
  timeUnit: 's' | 'ms'
25
26
  yLabel?: string
26
27
  showAxisUnits?: boolean
27
28
  showLabelUnits?: boolean
28
29
  hideYAxisTitles?: boolean
29
- containYAxisEndpoints?: boolean
30
30
  containFrameStroke?: boolean
31
- omitFrameTop?: boolean
32
31
  cleanView?: boolean
33
32
  zeroLine?: Required<WaveformZeroLineOptions>
34
33
  }
@@ -77,12 +76,6 @@ const resolvedFrameStyle = computed(() => {
77
76
  const frameInset = computed(() =>
78
77
  props.containFrameStroke ? resolvedFrameStyle.value.borderWidth / 2 : 0,
79
78
  )
80
- const openFramePath = computed(() => {
81
- const inset = frameInset.value
82
- const right = Math.max(inset, (props.track.width ?? props.innerWidth) - inset)
83
- const bottom = Math.max(inset, props.track.height - inset)
84
- return `M ${inset} ${inset} V ${bottom} H ${right} V ${inset}`
85
- })
86
79
  </script>
87
80
 
88
81
  <template>
@@ -106,6 +99,7 @@ const openFramePath = computed(() => {
106
99
  :clean-view="cleanView"
107
100
  :frame-number="frameNumber"
108
101
  :frame-style="resolvedFrameStyle"
102
+ show-empty-frame
109
103
  />
110
104
  <WaveformTrackAxes
111
105
  :track="track"
@@ -117,14 +111,12 @@ const openFramePath = computed(() => {
117
111
  :show-units="showAxisUnits"
118
112
  :show-label-units="showLabelUnits"
119
113
  :hide-y-axis-titles="hideYAxisTitles"
120
- :contain-y-axis-endpoints="containYAxisEndpoints || displayMode === 'compact'"
121
114
  />
122
115
 
123
116
  <component
124
- :is="omitFrameTop ? 'path' : 'rect'"
125
- v-if="!track.isEmpty"
117
+ :is="framePath ? 'path' : 'rect'"
126
118
  class="waveform-track__plot-frame waveform-chart__plot-frame"
127
- :d="omitFrameTop ? openFramePath : undefined"
119
+ :d="framePath"
128
120
  :x="containFrameStroke ? frameInset : undefined"
129
121
  :y="containFrameStroke ? frameInset : undefined"
130
122
  :width="Math.max(0, (track.width ?? innerWidth) - frameInset * 2)"
@@ -1,12 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { axisBottom, axisLeft, axisRight, select } from 'd3'
3
- import { nextTick, onMounted, ref, watch } from 'vue'
3
+ import { computed, nextTick, onMounted, onBeforeUnmount, ref, watch } from 'vue'
4
4
 
5
5
  import type { WaveformAxesOptions } from '../../types'
6
6
  import { formatXAxisLabel } from '../../utils'
7
7
  import { formatYAxisTickLabel } from '../core/layout'
8
8
  import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
9
- import { Y_AXIS_LABEL_BAND_WIDTH } from '../core/yAxisConstants'
9
+ import { updateMaximumTickVisibility } from './yAxisTickVisibility'
10
+ import { sameTick } from '../core/yAxisPadding'
11
+ import { resolveYAxisLabelBandWidth, resolveYAxisLabelFontSize } from '../core/yAxisLabelStyle'
10
12
  import { formatChannelLabel } from './channelLabel'
11
13
 
12
14
  interface Props {
@@ -19,10 +21,19 @@ interface Props {
19
21
  showUnits?: boolean
20
22
  showLabelUnits?: boolean
21
23
  hideYAxisTitles?: boolean
22
- containYAxisEndpoints?: boolean
23
24
  }
24
25
 
25
26
  const props = withDefaults(defineProps<Props>(), { showUnits: true })
27
+ const labelBandWidth = computed(() => resolveYAxisLabelBandWidth(props.axes?.y?.labelTextStyle))
28
+ const labelTextStyle = computed(() => {
29
+ const style = props.axes?.y?.labelTextStyle
30
+ return {
31
+ fontSize: `${resolveYAxisLabelFontSize(style)}px`,
32
+ fontWeight: style?.fontWeight ?? 600,
33
+ fontFamily: style?.fontFamily,
34
+ fontStyle: style?.fontStyle ?? 'normal',
35
+ }
36
+ })
26
37
  const xAxisElement = ref<SVGGElement>()
27
38
  const yAxisElements = ref<SVGGElement[]>([])
28
39
 
@@ -62,24 +73,24 @@ function renderAxes() {
62
73
 
63
74
  const selection = select(element)
64
75
  selection.call(yAxis)
65
- const lowestTick = Math.min(...axis.tickValues)
66
- const highestTick = props.containYAxisEndpoints ? Math.max(...axis.tickValues) : undefined
67
76
  selection
68
77
  .selectAll<SVGTextElement, number>('.tick text')
69
78
  .attr('y', null)
70
- .attr('dy', (value) => (value === lowestTick || value === highestTick ? '0' : '0.32em'))
71
- .attr('dominant-baseline', (value) =>
72
- value === lowestTick
73
- ? 'text-after-edge'
74
- : value === highestTick
75
- ? 'text-before-edge'
76
- : null,
79
+ .attr('dy', '0')
80
+ .attr('dominant-baseline', 'central')
81
+ selection
82
+ .selectAll<SVGGElement, number>('.tick')
83
+ .attr('data-maximum-tick', (value) =>
84
+ axis.maximumTick !== undefined && sameTick(value, axis.maximumTick) ? 'true' : null,
77
85
  )
86
+ .attr('data-tick-value', (value) => value)
87
+ .attr('data-tick-y', (value) => axis.scale(value))
78
88
  selection
79
89
  .selectAll('path.domain')
80
90
  .attr('display', props.axes?.y?.lineVisible === false ? 'none' : null)
81
91
  })
82
92
 
93
+ scheduleVisibility()
83
94
  if (!xAxisElement.value) return
84
95
  const selection = select(xAxisElement.value)
85
96
  selection.call(
@@ -103,7 +114,24 @@ function renderAxes() {
103
114
  .attr('display', props.axes?.x?.lineVisible === false ? 'none' : null)
104
115
  }
105
116
 
117
+ let disposed = false
118
+ function scheduleVisibility() {
119
+ if (disposed) return
120
+ const apply = () => {
121
+ const svg = yAxisElements.value[0]?.ownerSVGElement
122
+ if (svg) updateMaximumTickVisibility(svg)
123
+ }
124
+ apply()
125
+ }
126
+ onBeforeUnmount(() => {
127
+ disposed = true
128
+ document.fonts?.removeEventListener?.('loadingdone', scheduleVisibility)
129
+ })
106
130
  onMounted(async () => {
131
+ document.fonts?.addEventListener?.('loadingdone', scheduleVisibility)
132
+ void document.fonts?.ready.then(() => {
133
+ if (!disposed) scheduleVisibility()
134
+ })
107
135
  await nextTick()
108
136
  renderAxes()
109
137
  })
@@ -116,7 +144,6 @@ watch(
116
144
  () => props.track.yAxisTickValues,
117
145
  () => props.timeUnit,
118
146
  () => props.showUnits,
119
- () => props.containYAxisEndpoints,
120
147
  () => props.axes?.x?.labelFormatter,
121
148
  () => props.axes?.x?.lineVisible,
122
149
  () => props.axes?.y?.lineVisible,
@@ -191,15 +218,16 @@ watch(
191
218
  >
192
219
  <rect
193
220
  class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
194
- :x="track.yAxes[0].labelX - Y_AXIS_LABEL_BAND_WIDTH / 2"
221
+ :x="track.yAxes[0].labelX - labelBandWidth / 2"
195
222
  :y="track.height / 2 - 40"
196
- :width="Y_AXIS_LABEL_BAND_WIDTH"
223
+ :width="labelBandWidth"
197
224
  height="80"
198
225
  rx="2"
199
226
  />
200
227
  <text
201
228
  class="waveform-track__y-axis-label waveform-chart__y-axis-label"
202
- :fill="track.series?.color"
229
+ :fill="axes?.y?.labelTextStyle?.color || track.series?.color"
230
+ :style="labelTextStyle"
203
231
  :transform="`translate(${track.yAxes[0].labelX}, ${track.height / 2}) rotate(-90)`"
204
232
  text-anchor="middle"
205
233
  dominant-baseline="central"
@@ -218,15 +246,16 @@ watch(
218
246
  >
219
247
  <rect
220
248
  class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
221
- :x="axis.labelX - Y_AXIS_LABEL_BAND_WIDTH / 2"
249
+ :x="axis.labelX - labelBandWidth / 2"
222
250
  :y="track.height / 2 - 40"
223
- :width="Y_AXIS_LABEL_BAND_WIDTH"
251
+ :width="labelBandWidth"
224
252
  height="80"
225
253
  rx="2"
226
254
  />
227
255
  <text
228
256
  class="waveform-track__y-axis-label waveform-chart__y-axis-label"
229
- :fill="axis.seriesList[0].color"
257
+ :fill="axes?.y?.labelTextStyle?.color || axis.seriesList[0].color"
258
+ :style="labelTextStyle"
230
259
  :transform="`translate(${axis.labelX}, ${track.height / 2}) rotate(-90)`"
231
260
  text-anchor="middle"
232
261
  dominant-baseline="central"
@@ -6,6 +6,7 @@ interface Props {
6
6
  clipPathId: string
7
7
  innerWidth: number
8
8
  cleanView: boolean
9
+ showEmptyFrame?: boolean
9
10
  frameNumber?: string | number
10
11
  frameStyle: {
11
12
  backgroundColor: string
@@ -17,7 +18,7 @@ defineProps<Props>()
17
18
 
18
19
  <template>
19
20
  <rect
20
- v-if="!track.isEmpty && !cleanView"
21
+ v-if="(!track.isEmpty || showEmptyFrame) && !cleanView"
21
22
  class="waveform-track__plot-background waveform-chart__plot-background"
22
23
  :width="track.width ?? innerWidth"
23
24
  :height="track.height"
@@ -26,7 +27,7 @@ defineProps<Props>()
26
27
  />
27
28
 
28
29
  <g
29
- v-if="!track.isEmpty && !cleanView"
30
+ v-if="(!track.isEmpty || showEmptyFrame) && !cleanView"
30
31
  :clip-path="`url(#${clipPathId}-${track.index})`"
31
32
  aria-hidden="true"
32
33
  >
@@ -89,7 +90,11 @@ defineProps<Props>()
89
90
  </g>
90
91
 
91
92
  <text
92
- v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined && !cleanView"
93
+ v-if="
94
+ (track.hasVisibleSeries || (track.isEmpty && showEmptyFrame)) &&
95
+ frameNumber !== undefined &&
96
+ !cleanView
97
+ "
93
98
  class="waveform-track__watermark waveform-chart__watermark"
94
99
  :x="(track.width ?? innerWidth) / 2"
95
100
  :y="track.height / 2"
@@ -0,0 +1,31 @@
1
+ import type { TrackLayout } from '../core/types'
2
+
3
+ type Frame = Pick<TrackLayout, 'left' | 'top' | 'width' | 'height' | 'isEmpty'>
4
+
5
+ /** Keep the lower frame's top edge as the single owner of a shared boundary. */
6
+ export function compactFramePath(track: Frame, tracks: Frame[], inset = 0): string | undefined {
7
+ let uncovered: [number, number][] = [[0, track.width]]
8
+ for (const below of tracks) {
9
+ if (below === track) continue
10
+ if (Math.abs(track.top + track.height - below.top) > 1e-6) continue
11
+ const start = Math.max(0, below.left - track.left)
12
+ const end = Math.min(track.width, below.left + below.width - track.left)
13
+ if (end <= start) continue
14
+ uncovered = uncovered.flatMap(([left, right]): [number, number][] => {
15
+ if (end <= left || start >= right) return [[left, right]]
16
+ const parts: [number, number][] = []
17
+ if (left < start) parts.push([left, start])
18
+ if (right > end) parts.push([end, right])
19
+ return parts
20
+ })
21
+ }
22
+ if (uncovered.length === 1 && uncovered[0][0] === 0 && uncovered[0][1] === track.width)
23
+ return undefined
24
+ inset = Number.isFinite(inset) ? Math.max(0, inset) : 0
25
+ const rightEdge = Math.max(inset, track.width - inset)
26
+ const bottomEdge = Math.max(inset, track.height - inset)
27
+ const bottom = uncovered
28
+ .map(([left, right]) => `M${Math.max(inset, left)},${bottomEdge}H${Math.min(rightEdge, right)}`)
29
+ .join('')
30
+ return `M${inset},${bottomEdge}V${inset}H${rightEdge}V${bottomEdge}${bottom}`
31
+ }
@@ -0,0 +1,95 @@
1
+ import { sameTick } from '../core/yAxisPadding'
2
+
3
+ interface Box {
4
+ left: number
5
+ right: number
6
+ top: number
7
+ bottom: number
8
+ }
9
+
10
+ function textBox(text: SVGTextElement): Box {
11
+ const rect = text.getBoundingClientRect()
12
+ if (rect.width && rect.height) return rect
13
+ const height = Number.parseFloat(getComputedStyle(text).fontSize) || 11
14
+ const width = (text.textContent?.length ?? 0) * height * 0.65
15
+ let x = Number(text.getAttribute('x')) || 0
16
+ let y = Number(text.getAttribute('y')) || 0
17
+ for (let parent: Element | null = text.parentElement; parent; parent = parent.parentElement) {
18
+ const match = parent
19
+ .getAttribute('transform')
20
+ ?.match(/translate\(([-\d.e+]+)[ ,]+([-\d.e+]+)\)/)
21
+ if (match) {
22
+ x += Number(match[1])
23
+ y += Number(match[2])
24
+ }
25
+ }
26
+ return { left: x - width, right: x, top: y - height / 2, bottom: y + height / 2 }
27
+ }
28
+
29
+ export function labelsOverlap(a: Box, b: Box): boolean {
30
+ return a.left < b.right && b.left < a.right && a.top < b.bottom + 4 && b.top < a.bottom + 4
31
+ }
32
+
33
+ export function linesOverlap(a: number, b: number, widthA = 1, widthB = 1): boolean {
34
+ return Math.abs(a - b) < (widthA + widthB) / 2 + 1
35
+ }
36
+
37
+ function hide(element: Element) {
38
+ element.setAttribute('display', 'none')
39
+ element.setAttribute('data-maximum-hidden', 'true')
40
+ }
41
+
42
+ function strokeWidth(element: Element): number {
43
+ return Number.parseFloat(getComputedStyle(element).strokeWidth) || 1
44
+ }
45
+
46
+ /** Apply after axes and grid are in the DOM; never feed the filtered set back into layout. */
47
+ export function updateMaximumTickVisibility(svg: SVGSVGElement) {
48
+ svg.querySelectorAll('[data-maximum-hidden]').forEach((el) => {
49
+ el.removeAttribute('display')
50
+ el.removeAttribute('data-maximum-hidden')
51
+ })
52
+ svg.querySelectorAll<SVGGElement>('.waveform-track__axis--y .tick').forEach((tick) => {
53
+ const position = Number(tick.getAttribute('data-tick-y'))
54
+ const height = Number(tick.closest('.waveform-track')?.getAttribute('data-track-height'))
55
+ const line = tick.querySelector('line')
56
+ if (sameTick(position, 0) || sameTick(position, height)) line?.setAttribute('display', 'none')
57
+ else line?.removeAttribute('display')
58
+ })
59
+ const maximumTicks = [...svg.querySelectorAll<SVGGElement>('.tick[data-maximum-tick]')]
60
+ const labels = [...svg.querySelectorAll<SVGTextElement>('.waveform-track__axis--y .tick text')]
61
+ maximumTicks.forEach((tick) => {
62
+ const text = tick.querySelector('text')
63
+ const line = tick.querySelector('line')
64
+ if (!text || !line) return
65
+ const box = textBox(text)
66
+ labels.forEach((other) => {
67
+ if (other.parentElement?.hasAttribute('data-maximum-tick')) return
68
+ if (labelsOverlap(box, textBox(other))) hide(other)
69
+ })
70
+ const axis = tick.parentElement!
71
+ const value = Number(tick.getAttribute('data-tick-value'))
72
+ const position = Number(tick.getAttribute('data-tick-y'))
73
+ axis.querySelectorAll<SVGGElement>('.tick:not([data-maximum-tick])').forEach((other) => {
74
+ const otherLine = other.querySelector('line')
75
+ if (
76
+ otherLine &&
77
+ linesOverlap(
78
+ position,
79
+ Number(other.getAttribute('data-tick-y')),
80
+ strokeWidth(line),
81
+ strokeWidth(otherLine),
82
+ )
83
+ )
84
+ hide(otherLine)
85
+ })
86
+ // Only the primary Y axis owns the existing horizontal grid.
87
+ if (axis.getAttribute('data-y-axis-index') !== '0') return
88
+ const track = axis.closest('.waveform-track')!
89
+ track.querySelectorAll('[data-grid-direction="horizontal"]').forEach((gridLine) => {
90
+ const gridY = Number(gridLine.getAttribute('y1'))
91
+ if (linesOverlap(position, gridY, strokeWidth(line), strokeWidth(gridLine))) hide(gridLine)
92
+ })
93
+ tick.setAttribute('data-maximum-value', String(value))
94
+ })
95
+ }
@@ -0,0 +1,24 @@
1
+ import type { WaveformPointRange } from './waveformPointSource'
2
+
3
+ /** Outside neighbors join the curve without changing visible-point statistics. */
4
+ export function lineBoundaryIndexes(range: WaveformPointRange, length: number): number[] {
5
+ if (!length || (range.start === range.end && (range.start === 0 || range.end === length)))
6
+ return []
7
+ return [range.start > 0 ? range.start - 1 : -1, range.end < length ? range.end : -1].filter(
8
+ (index) => index >= 0,
9
+ )
10
+ }
11
+
12
+ export function withLineBoundaries<T>(
13
+ points: T[],
14
+ range: WaveformPointRange,
15
+ length: number,
16
+ pointAt: (index: number) => T,
17
+ ): T[] {
18
+ const boundaries = lineBoundaryIndexes(range, length)
19
+ return [
20
+ ...boundaries.filter((index) => index < range.start).map(pointAt),
21
+ ...points,
22
+ ...boundaries.filter((index) => index >= range.end).map(pointAt),
23
+ ]
24
+ }