waveform-analysis 0.1.71 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -40
- package/dist/assets/{workerRuntime-o5An0M-W.js → workerRuntime-5p-EI-GL.js} +1 -1
- package/dist/index.cjs +2 -1
- package/dist/index.js +3874 -2778
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +24 -6
- package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
- package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +0 -1
- package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
- package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
- package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
- package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
- package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
- package/dist/types/components/controls/useChartCommands.d.ts +42 -0
- package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
- package/dist/types/components/controls/useControlMode.d.ts +8 -0
- package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
- package/dist/types/components/core/layout.d.ts +6 -3
- package/dist/types/components/core/niceScale.d.ts +34 -0
- package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
- package/dist/types/components/core/trackPagination.d.ts +1 -1
- package/dist/types/components/core/types.d.ts +5 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +24 -6
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
- package/dist/types/components/core/useWaveformData.d.ts +2 -0
- package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
- package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
- package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
- package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
- package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
- package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
- package/dist/types/components/core/yAxisPadding.d.ts +7 -0
- package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
- package/dist/types/components/interaction/useExtremaTickViewport.d.ts +5 -0
- package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
- package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
- package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -1
- package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
- package/dist/types/components/rendering/compactFrame.d.ts +5 -0
- package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
- package/dist/types/core/lineBoundary.d.ts +4 -0
- package/dist/types/core/rendering.d.ts +4 -0
- package/dist/types/core/renderingStrategies.d.ts +2 -0
- package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
- package/dist/types/data/simulatedWaveforms.d.ts +1 -1
- package/dist/types/data/sx2Channel.d.ts +10 -0
- package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
- package/dist/types/demo/types.d.ts +7 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types/chart.d.ts +32 -8
- package/dist/types/types/controls.d.ts +69 -0
- package/dist/types/types/index.d.ts +2 -1
- package/dist/types/utils/formatters.d.ts +1 -0
- package/package.json +7 -6
- package/src/App.vue +61 -14
- package/src/components/WaveformChart.css +10 -1
- package/src/components/WaveformChart.vue +6 -0
- package/src/components/WaveformChartView.vue +27 -26
- package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
- package/src/components/annotation/useWaveformChartAnnotations.ts +7 -18
- package/src/components/controls/WaveformToolbar.vue +247 -0
- package/src/components/controls/chartExportOptions.ts +32 -0
- package/src/components/controls/chartSnapshot.ts +132 -0
- package/src/components/controls/snapshotTitle.ts +73 -0
- package/src/components/controls/toolbarRegistry.ts +92 -0
- package/src/components/controls/useChartCommands.ts +294 -0
- package/src/components/controls/useChartImageExport.ts +155 -0
- package/src/components/controls/useControlMode.ts +30 -0
- package/src/components/core/latestTaskScheduler.ts +14 -2
- package/src/components/core/layout.ts +49 -36
- package/src/components/core/niceScale.ts +151 -0
- package/src/components/core/trackLayoutBuilder.ts +41 -17
- package/src/components/core/trackPagination.ts +3 -3
- package/src/components/core/types.ts +5 -0
- package/src/components/core/useWaveformChartController.ts +94 -3
- package/src/components/core/useWaveformChartLifecycle.ts +11 -111
- package/src/components/core/useWaveformData.ts +4 -0
- package/src/components/core/useWaveformLayout.ts +25 -5
- package/src/components/core/useWaveformMeasurements.ts +63 -0
- package/src/components/core/useWaveformRenderSampling.ts +80 -59
- package/src/components/core/useWaveformViewportRestore.ts +105 -0
- package/src/components/core/useWaveformYAxisLayout.ts +3 -2
- package/src/components/core/waveformChartTypes.ts +5 -0
- package/src/components/core/waveformSamplingSession.ts +49 -0
- package/src/components/core/workerSamplingClient.ts +38 -6
- package/src/components/core/yAxisLabelStyle.ts +10 -0
- package/src/components/core/yAxisLayoutMetrics.ts +11 -9
- package/src/components/core/yAxisPadding.ts +60 -0
- package/src/components/core/yAxisTitleLayout.ts +11 -12
- package/src/components/interaction/useExtremaTickViewport.ts +43 -0
- package/src/components/interaction/useWaveformViewport.ts +11 -2
- package/src/components/rendering/WaveformTrack.css +1 -2
- package/src/components/rendering/WaveformTrack.vue +5 -12
- package/src/components/rendering/WaveformTrackAxes.vue +66 -19
- package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
- package/src/components/rendering/compactFrame.ts +31 -0
- package/src/components/rendering/yAxisTickVisibility.ts +156 -0
- package/src/core/lineBoundary.ts +24 -0
- package/src/core/rendering.ts +48 -25
- package/src/core/renderingStrategies.ts +19 -13
- package/src/core/waveformPointSource.ts +23 -1
- package/src/core/workerSampling/outputBoundaries.ts +32 -0
- package/src/core/workerSampling/repository.ts +26 -18
- package/src/core/workerSampling/repositoryDataset.ts +33 -1
- package/src/core/workerSampling/workerRuntime.ts +13 -3
- package/src/data/simulatedWaveforms.ts +67 -2
- package/src/data/sx2Channel.ts +315 -0
- package/src/demo/DemoChartHost.vue +5 -1
- package/src/demo/DemoGridFrameControls.vue +2 -0
- package/src/demo/DemoViewControls.vue +5 -2
- package/src/demo/DemoYAxisPaddingControls.vue +53 -0
- package/src/demo/embedBridge.ts +85 -0
- package/src/demo/types.ts +7 -0
- package/src/index.ts +19 -0
- package/src/types/chart.ts +34 -8
- package/src/types/controls.ts +80 -0
- package/src/types/index.ts +18 -0
- package/src/utils/formatters.ts +2 -1
- package/src/views/WasmSamplingDemo.vue +8 -1
|
@@ -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 {
|
|
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,20 @@ interface Props {
|
|
|
19
21
|
showUnits?: boolean
|
|
20
22
|
showLabelUnits?: boolean
|
|
21
23
|
hideYAxisTitles?: boolean
|
|
22
|
-
|
|
24
|
+
hideOverlappingMaximumLabel?: boolean
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const props = withDefaults(defineProps<Props>(), { showUnits: true })
|
|
28
|
+
const labelBandWidth = computed(() => resolveYAxisLabelBandWidth(props.axes?.y?.labelTextStyle))
|
|
29
|
+
const labelTextStyle = computed(() => {
|
|
30
|
+
const style = props.axes?.y?.labelTextStyle
|
|
31
|
+
return {
|
|
32
|
+
fontSize: `${resolveYAxisLabelFontSize(style)}px`,
|
|
33
|
+
fontWeight: style?.fontWeight ?? 600,
|
|
34
|
+
fontFamily: style?.fontFamily,
|
|
35
|
+
fontStyle: style?.fontStyle ?? 'normal',
|
|
36
|
+
}
|
|
37
|
+
})
|
|
26
38
|
const xAxisElement = ref<SVGGElement>()
|
|
27
39
|
const yAxisElements = ref<SVGGElement[]>([])
|
|
28
40
|
|
|
@@ -45,6 +57,7 @@ function renderAxes() {
|
|
|
45
57
|
const element = yAxisElements.value[index]
|
|
46
58
|
if (!element) return
|
|
47
59
|
const unit = props.showUnits ? axis.seriesList[0]?.unit : undefined
|
|
60
|
+
const highestTick = Math.max(...axis.tickValues)
|
|
48
61
|
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
|
49
62
|
.tickFormat((value) =>
|
|
50
63
|
formatYAxisTickLabel(
|
|
@@ -62,24 +75,39 @@ function renderAxes() {
|
|
|
62
75
|
|
|
63
76
|
const selection = select(element)
|
|
64
77
|
selection.call(yAxis)
|
|
65
|
-
const lowestTick = Math.min(...axis.tickValues)
|
|
66
|
-
const highestTick = props.containYAxisEndpoints ? Math.max(...axis.tickValues) : undefined
|
|
67
78
|
selection
|
|
68
79
|
.selectAll<SVGTextElement, number>('.tick text')
|
|
69
80
|
.attr('y', null)
|
|
70
|
-
.attr('dy',
|
|
71
|
-
.attr('dominant-baseline',
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
.attr('dy', '0')
|
|
82
|
+
.attr('dominant-baseline', 'central')
|
|
83
|
+
.attr('data-axis-maximum-label', (value) =>
|
|
84
|
+
props.hideOverlappingMaximumLabel && sameTick(value, highestTick) ? 'true' : null,
|
|
85
|
+
)
|
|
86
|
+
.attr('data-axis-minimum-label', (value) =>
|
|
87
|
+
props.hideOverlappingMaximumLabel && sameTick(value, Math.min(...axis.tickValues))
|
|
88
|
+
? 'true'
|
|
89
|
+
: null,
|
|
77
90
|
)
|
|
91
|
+
selection
|
|
92
|
+
.attr('data-label-domain-min', axis.scale.domain()[0])
|
|
93
|
+
.attr('data-label-domain-max', axis.scale.domain()[1])
|
|
94
|
+
.attr('data-label-unit', unit ?? null)
|
|
95
|
+
selection
|
|
96
|
+
.selectAll<SVGGElement, number>('.tick')
|
|
97
|
+
.attr('data-maximum-tick', (value) =>
|
|
98
|
+
axis.maximumTick !== undefined && sameTick(value, axis.maximumTick) ? 'true' : null,
|
|
99
|
+
)
|
|
100
|
+
.attr('data-minimum-tick', (value) =>
|
|
101
|
+
axis.minimumTick !== undefined && sameTick(value, axis.minimumTick) ? 'true' : null,
|
|
102
|
+
)
|
|
103
|
+
.attr('data-tick-value', (value) => value)
|
|
104
|
+
.attr('data-tick-y', (value) => axis.scale(value))
|
|
78
105
|
selection
|
|
79
106
|
.selectAll('path.domain')
|
|
80
107
|
.attr('display', props.axes?.y?.lineVisible === false ? 'none' : null)
|
|
81
108
|
})
|
|
82
109
|
|
|
110
|
+
scheduleVisibility()
|
|
83
111
|
if (!xAxisElement.value) return
|
|
84
112
|
const selection = select(xAxisElement.value)
|
|
85
113
|
selection.call(
|
|
@@ -103,7 +131,24 @@ function renderAxes() {
|
|
|
103
131
|
.attr('display', props.axes?.x?.lineVisible === false ? 'none' : null)
|
|
104
132
|
}
|
|
105
133
|
|
|
134
|
+
let disposed = false
|
|
135
|
+
function scheduleVisibility() {
|
|
136
|
+
if (disposed) return
|
|
137
|
+
const apply = () => {
|
|
138
|
+
const svg = yAxisElements.value[0]?.ownerSVGElement
|
|
139
|
+
if (svg) updateMaximumTickVisibility(svg)
|
|
140
|
+
}
|
|
141
|
+
apply()
|
|
142
|
+
}
|
|
143
|
+
onBeforeUnmount(() => {
|
|
144
|
+
disposed = true
|
|
145
|
+
document.fonts?.removeEventListener?.('loadingdone', scheduleVisibility)
|
|
146
|
+
})
|
|
106
147
|
onMounted(async () => {
|
|
148
|
+
document.fonts?.addEventListener?.('loadingdone', scheduleVisibility)
|
|
149
|
+
void document.fonts?.ready.then(() => {
|
|
150
|
+
if (!disposed) scheduleVisibility()
|
|
151
|
+
})
|
|
107
152
|
await nextTick()
|
|
108
153
|
renderAxes()
|
|
109
154
|
})
|
|
@@ -116,10 +161,10 @@ watch(
|
|
|
116
161
|
() => props.track.yAxisTickValues,
|
|
117
162
|
() => props.timeUnit,
|
|
118
163
|
() => props.showUnits,
|
|
119
|
-
() => props.containYAxisEndpoints,
|
|
120
164
|
() => props.axes?.x?.labelFormatter,
|
|
121
165
|
() => props.axes?.x?.lineVisible,
|
|
122
166
|
() => props.axes?.y?.lineVisible,
|
|
167
|
+
() => props.hideOverlappingMaximumLabel,
|
|
123
168
|
],
|
|
124
169
|
async () => {
|
|
125
170
|
await nextTick()
|
|
@@ -191,15 +236,16 @@ watch(
|
|
|
191
236
|
>
|
|
192
237
|
<rect
|
|
193
238
|
class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
|
|
194
|
-
:x="track.yAxes[0].labelX -
|
|
239
|
+
:x="track.yAxes[0].labelX - labelBandWidth / 2"
|
|
195
240
|
:y="track.height / 2 - 40"
|
|
196
|
-
:width="
|
|
241
|
+
:width="labelBandWidth"
|
|
197
242
|
height="80"
|
|
198
243
|
rx="2"
|
|
199
244
|
/>
|
|
200
245
|
<text
|
|
201
246
|
class="waveform-track__y-axis-label waveform-chart__y-axis-label"
|
|
202
|
-
:fill="track.series?.color"
|
|
247
|
+
:fill="axes?.y?.labelTextStyle?.color || track.series?.color"
|
|
248
|
+
:style="labelTextStyle"
|
|
203
249
|
:transform="`translate(${track.yAxes[0].labelX}, ${track.height / 2}) rotate(-90)`"
|
|
204
250
|
text-anchor="middle"
|
|
205
251
|
dominant-baseline="central"
|
|
@@ -218,15 +264,16 @@ watch(
|
|
|
218
264
|
>
|
|
219
265
|
<rect
|
|
220
266
|
class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
|
|
221
|
-
:x="axis.labelX -
|
|
267
|
+
:x="axis.labelX - labelBandWidth / 2"
|
|
222
268
|
:y="track.height / 2 - 40"
|
|
223
|
-
:width="
|
|
269
|
+
:width="labelBandWidth"
|
|
224
270
|
height="80"
|
|
225
271
|
rx="2"
|
|
226
272
|
/>
|
|
227
273
|
<text
|
|
228
274
|
class="waveform-track__y-axis-label waveform-chart__y-axis-label"
|
|
229
|
-
:fill="axis.seriesList[0].color"
|
|
275
|
+
:fill="axes?.y?.labelTextStyle?.color || axis.seriesList[0].color"
|
|
276
|
+
:style="labelTextStyle"
|
|
230
277
|
:transform="`translate(${axis.labelX}, ${track.height / 2}) rotate(-90)`"
|
|
231
278
|
text-anchor="middle"
|
|
232
279
|
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="
|
|
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,156 @@
|
|
|
1
|
+
import { sameTick } from '../core/yAxisPadding'
|
|
2
|
+
import { formatYAxisTickLabel } from '../core/layout'
|
|
3
|
+
|
|
4
|
+
interface Box {
|
|
5
|
+
left: number
|
|
6
|
+
right: number
|
|
7
|
+
top: number
|
|
8
|
+
bottom: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function textBox(text: SVGTextElement): Box {
|
|
12
|
+
const rect = text.getBoundingClientRect()
|
|
13
|
+
if (rect.width && rect.height) return rect
|
|
14
|
+
const height = Number.parseFloat(getComputedStyle(text).fontSize) || 11
|
|
15
|
+
const width = (text.textContent?.length ?? 0) * height * 0.65
|
|
16
|
+
let x = Number(text.getAttribute('x')) || 0
|
|
17
|
+
let y = Number(text.getAttribute('y')) || 0
|
|
18
|
+
for (let parent: Element | null = text.parentElement; parent; parent = parent.parentElement) {
|
|
19
|
+
const match = parent
|
|
20
|
+
.getAttribute('transform')
|
|
21
|
+
?.match(/translate\(([-\d.e+]+)[ ,]+([-\d.e+]+)\)/)
|
|
22
|
+
if (match) {
|
|
23
|
+
x += Number(match[1])
|
|
24
|
+
y += Number(match[2])
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const startAligned = text.getAttribute('text-anchor') === 'start'
|
|
28
|
+
return {
|
|
29
|
+
left: startAligned ? x : x - width,
|
|
30
|
+
right: startAligned ? x + width : x,
|
|
31
|
+
top: y - height / 2,
|
|
32
|
+
bottom: y + height / 2,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function labelsOverlap(a: Box, b: Box): boolean {
|
|
37
|
+
return a.left < b.right && b.left < a.right && a.top < b.bottom + 4 && b.top < a.bottom + 4
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function linesOverlap(a: number, b: number, widthA = 1, widthB = 1): boolean {
|
|
41
|
+
return Math.abs(a - b) < (widthA + widthB) / 2 + 1
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hide(element: Element) {
|
|
45
|
+
element.setAttribute('display', 'none')
|
|
46
|
+
element.setAttribute('data-maximum-hidden', 'true')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function strokeWidth(element: Element): number {
|
|
50
|
+
return Number.parseFloat(getComputedStyle(element).strokeWidth) || 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatCompactLabels(svg: SVGSVGElement) {
|
|
54
|
+
svg.querySelectorAll<SVGTextElement>('text[data-axis-maximum-label]').forEach((maximum) => {
|
|
55
|
+
const axis = maximum.parentElement!.parentElement!
|
|
56
|
+
const labels = [...axis.querySelectorAll<SVGTextElement>('.tick text')]
|
|
57
|
+
const value = (text: SVGTextElement) =>
|
|
58
|
+
Number(text.parentElement!.getAttribute('data-tick-value'))
|
|
59
|
+
const visibleValues = labels
|
|
60
|
+
.filter((text) => text.getAttribute('display') !== 'none')
|
|
61
|
+
.map(value)
|
|
62
|
+
const domain: [number, number] = [
|
|
63
|
+
Number(axis.getAttribute('data-label-domain-min')),
|
|
64
|
+
Number(axis.getAttribute('data-label-domain-max')),
|
|
65
|
+
]
|
|
66
|
+
labels.forEach((text) => {
|
|
67
|
+
text.textContent = formatYAxisTickLabel(
|
|
68
|
+
value(text),
|
|
69
|
+
domain,
|
|
70
|
+
visibleValues,
|
|
71
|
+
axis.getAttribute('data-label-unit') ?? undefined,
|
|
72
|
+
axis.getAttribute('data-y-axis-side') === 'right' ? 'right' : 'left',
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply after axes and grid are in the DOM; never feed the filtered set back into layout. */
|
|
79
|
+
export function updateMaximumTickVisibility(svg: SVGSVGElement) {
|
|
80
|
+
svg.querySelectorAll('[data-maximum-hidden]').forEach((el) => {
|
|
81
|
+
el.removeAttribute('display')
|
|
82
|
+
el.removeAttribute('data-maximum-hidden')
|
|
83
|
+
})
|
|
84
|
+
svg.querySelectorAll('.waveform-track__axis--y .tick text').forEach((text) => {
|
|
85
|
+
text.removeAttribute('display')
|
|
86
|
+
})
|
|
87
|
+
formatCompactLabels(svg)
|
|
88
|
+
const minimumLabels = [...svg.querySelectorAll<SVGTextElement>('text[data-axis-minimum-label]')]
|
|
89
|
+
svg.querySelectorAll<SVGTextElement>('text[data-axis-maximum-label]').forEach((maximum) => {
|
|
90
|
+
const box = textBox(maximum)
|
|
91
|
+
if (minimumLabels.some((minimum) => labelsOverlap(box, textBox(minimum)))) hide(maximum)
|
|
92
|
+
})
|
|
93
|
+
svg.querySelectorAll<SVGGElement>('.waveform-track__axis--y .tick').forEach((tick) => {
|
|
94
|
+
const position = Number(tick.getAttribute('data-tick-y'))
|
|
95
|
+
const height = Number(tick.closest('.waveform-track')?.getAttribute('data-track-height'))
|
|
96
|
+
const line = tick.querySelector('line')
|
|
97
|
+
if (sameTick(position, 0) || sameTick(position, height)) line?.setAttribute('display', 'none')
|
|
98
|
+
else line?.removeAttribute('display')
|
|
99
|
+
})
|
|
100
|
+
const minimumTicks = [...svg.querySelectorAll<SVGGElement>('.tick[data-minimum-tick]')]
|
|
101
|
+
const maximumTicks = [...svg.querySelectorAll<SVGGElement>('.tick[data-maximum-tick]')]
|
|
102
|
+
const labels = [...svg.querySelectorAll<SVGTextElement>('.waveform-track__axis--y .tick text')]
|
|
103
|
+
minimumTicks.forEach((tick) => {
|
|
104
|
+
const text = tick.querySelector('text')
|
|
105
|
+
if (!text) return
|
|
106
|
+
maximumTicks.forEach((maximum) => {
|
|
107
|
+
const other = maximum.querySelector('text')
|
|
108
|
+
if (other && other !== text && labelsOverlap(textBox(text), textBox(other))) hide(other)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
const extremeTicks = [...new Set([...minimumTicks, ...maximumTicks])]
|
|
112
|
+
extremeTicks.forEach((tick) => {
|
|
113
|
+
const text = tick.querySelector('text')
|
|
114
|
+
const line = tick.querySelector('line')
|
|
115
|
+
if (!text || !line) return
|
|
116
|
+
const box = textBox(text)
|
|
117
|
+
labels.forEach((other) => {
|
|
118
|
+
if (text.getAttribute('display') === 'none') return
|
|
119
|
+
if (other.getAttribute('display') === 'none') return
|
|
120
|
+
if (other.parentElement?.matches('[data-minimum-tick], [data-maximum-tick]')) return
|
|
121
|
+
if (!tick.hasAttribute('data-minimum-tick') && other.hasAttribute('data-axis-minimum-label'))
|
|
122
|
+
return
|
|
123
|
+
if (labelsOverlap(box, textBox(other))) hide(other)
|
|
124
|
+
})
|
|
125
|
+
const axis = tick.parentElement!
|
|
126
|
+
const value = Number(tick.getAttribute('data-tick-value'))
|
|
127
|
+
const position = Number(tick.getAttribute('data-tick-y'))
|
|
128
|
+
axis
|
|
129
|
+
.querySelectorAll<SVGGElement>('.tick:not([data-maximum-tick]):not([data-minimum-tick])')
|
|
130
|
+
.forEach((other) => {
|
|
131
|
+
const otherLine = other.querySelector('line')
|
|
132
|
+
if (
|
|
133
|
+
otherLine &&
|
|
134
|
+
linesOverlap(
|
|
135
|
+
position,
|
|
136
|
+
Number(other.getAttribute('data-tick-y')),
|
|
137
|
+
strokeWidth(line),
|
|
138
|
+
strokeWidth(otherLine),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
hide(otherLine)
|
|
142
|
+
})
|
|
143
|
+
// Only the primary Y axis owns the existing horizontal grid.
|
|
144
|
+
if (axis.getAttribute('data-y-axis-index') !== '0') return
|
|
145
|
+
const track = axis.closest('.waveform-track')!
|
|
146
|
+
track.querySelectorAll('[data-grid-direction="horizontal"]').forEach((gridLine) => {
|
|
147
|
+
const gridY = Number(gridLine.getAttribute('y1'))
|
|
148
|
+
if (linesOverlap(position, gridY, strokeWidth(line), strokeWidth(gridLine))) hide(gridLine)
|
|
149
|
+
})
|
|
150
|
+
if (tick.hasAttribute('data-maximum-tick'))
|
|
151
|
+
tick.setAttribute('data-maximum-value', String(value))
|
|
152
|
+
if (tick.hasAttribute('data-minimum-tick'))
|
|
153
|
+
tick.setAttribute('data-minimum-value', String(value))
|
|
154
|
+
})
|
|
155
|
+
formatCompactLabels(svg)
|
|
156
|
+
}
|
|
@@ -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
|
+
}
|
package/src/core/rendering.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { pointSourceFromPoints, type WaveformPointSource } from './waveformPointSource'
|
|
2
|
+
import { withLineBoundaries } from './lineBoundary'
|
|
1
3
|
import { bisector } from 'd3'
|
|
2
4
|
|
|
3
5
|
import type { WaveformPoint } from '../types'
|
|
@@ -20,6 +22,7 @@ const acceptAllPoints = () => true
|
|
|
20
22
|
|
|
21
23
|
interface PointSeriesSource {
|
|
22
24
|
points: WaveformPoint[]
|
|
25
|
+
source?: WaveformPointSource
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
interface SeriesRenderSelectionOptions {
|
|
@@ -58,9 +61,10 @@ export function hasMinimumVisibleXValues(
|
|
|
58
61
|
const required = Math.ceil(minimum)
|
|
59
62
|
const xValues = new Set<number>()
|
|
60
63
|
for (const series of seriesList) {
|
|
61
|
-
const
|
|
64
|
+
const source = series.source ?? pointSourceFromPoints(series.points)
|
|
65
|
+
const range = source.visibleRange(domain)
|
|
62
66
|
for (let index = range.start; index < range.end; index += 1) {
|
|
63
|
-
xValues.add(
|
|
67
|
+
xValues.add(source.pointAt(index)!.x)
|
|
64
68
|
if (xValues.size >= required) return true
|
|
65
69
|
}
|
|
66
70
|
}
|
|
@@ -68,18 +72,20 @@ export function hasMinimumVisibleXValues(
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
function selectRenderablePointsInRange(
|
|
71
|
-
points:
|
|
75
|
+
points: WaveformPointSource,
|
|
72
76
|
range: VisiblePointRange,
|
|
73
77
|
domain: [number, number],
|
|
74
78
|
width: number,
|
|
75
79
|
options: ResolvedWaveformRenderingOptions,
|
|
76
80
|
): WaveformPoint[] {
|
|
81
|
+
if (range.start === range.end && (range.start === 0 || range.end === points.length)) return []
|
|
77
82
|
const start = Math.max(0, range.start - 1)
|
|
78
83
|
const end = Math.min(points.length, range.end + 1)
|
|
79
84
|
const visibleCount = end - start
|
|
80
85
|
if (visibleCount <= 0) return []
|
|
81
86
|
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
|
|
82
|
-
points,
|
|
87
|
+
points: [],
|
|
88
|
+
source: points,
|
|
83
89
|
range,
|
|
84
90
|
domain,
|
|
85
91
|
width,
|
|
@@ -96,25 +102,25 @@ function shouldDeferLineSampling(
|
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
function selectSamplingPlaceholderPoints(
|
|
99
|
-
points:
|
|
105
|
+
points: WaveformPointSource,
|
|
100
106
|
range: VisiblePointRange,
|
|
101
107
|
width: number,
|
|
102
108
|
options: ResolvedWaveformRenderingOptions,
|
|
103
109
|
): WaveformPoint[] {
|
|
104
|
-
const start =
|
|
105
|
-
const end = Math.min(points.length, range.end + 1)
|
|
110
|
+
const { start, end } = range
|
|
106
111
|
const count = end - start
|
|
107
|
-
if (count <= 0) return []
|
|
108
|
-
if (options.sampling.strategy === 'none') return points.slice(start, end)
|
|
109
112
|
const target =
|
|
110
113
|
options.sampling.maxPointCount ??
|
|
111
114
|
Math.max(1, Math.floor(width * options.sampling.maxPointsPerPixel))
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
const interior =
|
|
116
|
+
count <= target || options.sampling.strategy === 'none'
|
|
117
|
+
? points.pointsInRange(start, end)
|
|
118
|
+
: Array.from({ length: target }, (_, index) =>
|
|
119
|
+
points.pointAt(
|
|
120
|
+
start + (target === 1 ? 0 : Math.round((index * (count - 1)) / (target - 1))),
|
|
121
|
+
)!,
|
|
122
|
+
)
|
|
123
|
+
return withLineBoundaries(interior, range, points.length, (index) => points.pointAt(index)!)
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
/**
|
|
@@ -129,7 +135,7 @@ export function selectRenderablePoints(
|
|
|
129
135
|
): WaveformPoint[] {
|
|
130
136
|
if (!points.length || width <= 0) return []
|
|
131
137
|
return selectRenderablePointsInRange(
|
|
132
|
-
points,
|
|
138
|
+
pointSourceFromPoints(points),
|
|
133
139
|
resolveVisiblePointRange(points, domain),
|
|
134
140
|
domain,
|
|
135
141
|
width,
|
|
@@ -138,7 +144,7 @@ export function selectRenderablePoints(
|
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
function selectDecorationPointsInRange(
|
|
141
|
-
points:
|
|
147
|
+
points: WaveformPointSource,
|
|
142
148
|
range: VisiblePointRange,
|
|
143
149
|
domain: [number, number],
|
|
144
150
|
width: number,
|
|
@@ -148,10 +154,10 @@ function selectDecorationPointsInRange(
|
|
|
148
154
|
priorityPredicate?: (point: WaveformPoint) => boolean,
|
|
149
155
|
): WaveformPoint[] {
|
|
150
156
|
if (!downsample || minSpacing === 0) {
|
|
151
|
-
if (predicate === acceptAllPoints) return points.
|
|
157
|
+
if (predicate === acceptAllPoints) return points.pointsInRange(range.start, range.end)
|
|
152
158
|
const visiblePoints: WaveformPoint[] = []
|
|
153
159
|
for (let index = range.start; index < range.end; index += 1) {
|
|
154
|
-
if (predicate(points
|
|
160
|
+
if (predicate(points.pointAt(index)!)) visiblePoints.push(points.pointAt(index)!)
|
|
155
161
|
}
|
|
156
162
|
return visiblePoints
|
|
157
163
|
}
|
|
@@ -161,7 +167,7 @@ function selectDecorationPointsInRange(
|
|
|
161
167
|
const span = domainEnd - domainStart
|
|
162
168
|
if (span <= 0) {
|
|
163
169
|
for (let index = range.start; index < range.end; index += 1) {
|
|
164
|
-
if (predicate(points
|
|
170
|
+
if (predicate(points.pointAt(index)!)) return [points.pointAt(index)!]
|
|
165
171
|
}
|
|
166
172
|
return []
|
|
167
173
|
}
|
|
@@ -208,7 +214,7 @@ function selectDecorationPointsInRange(
|
|
|
208
214
|
}
|
|
209
215
|
|
|
210
216
|
for (let index = range.start; index < range.end; index += 1) {
|
|
211
|
-
const point = points
|
|
217
|
+
const point = points.pointAt(index)!
|
|
212
218
|
if (!predicate(point)) continue
|
|
213
219
|
first ??= point
|
|
214
220
|
last = point
|
|
@@ -252,7 +258,7 @@ export function selectDecorationPoints(
|
|
|
252
258
|
): WaveformPoint[] {
|
|
253
259
|
if (!points.length || width <= 0) return []
|
|
254
260
|
return selectDecorationPointsInRange(
|
|
255
|
-
points,
|
|
261
|
+
pointSourceFromPoints(points),
|
|
256
262
|
resolveVisiblePointRange(points, domain),
|
|
257
263
|
domain,
|
|
258
264
|
width,
|
|
@@ -268,8 +274,8 @@ function hasPointError(point: WaveformPoint): boolean {
|
|
|
268
274
|
return lower !== 0 || upper !== 0
|
|
269
275
|
}
|
|
270
276
|
|
|
271
|
-
export function
|
|
272
|
-
points:
|
|
277
|
+
export function selectSeriesRenderSourcePoints(
|
|
278
|
+
points: WaveformPointSource,
|
|
273
279
|
domain: [number, number],
|
|
274
280
|
width: number,
|
|
275
281
|
rendering: ResolvedWaveformRenderingOptions,
|
|
@@ -278,7 +284,7 @@ export function selectSeriesRenderPoints(
|
|
|
278
284
|
if (!points.length || width <= 0) {
|
|
279
285
|
return { linePoints: [], pointRenderPoints: [], errorBarRenderPoints: [] }
|
|
280
286
|
}
|
|
281
|
-
const range =
|
|
287
|
+
const range = points.visibleRange(domain)
|
|
282
288
|
const linePoints = selection.lineVisible
|
|
283
289
|
? (selection.linePointOverride ??
|
|
284
290
|
(shouldDeferLineSampling(range.end - range.start, rendering)
|
|
@@ -329,3 +335,20 @@ export function selectSeriesRenderPoints(
|
|
|
329
335
|
: [],
|
|
330
336
|
}
|
|
331
337
|
}
|
|
338
|
+
|
|
339
|
+
/** Public array adapter; internal layout passes its normalized source directly. */
|
|
340
|
+
export function selectSeriesRenderPoints(
|
|
341
|
+
points: WaveformPoint[],
|
|
342
|
+
domain: [number, number],
|
|
343
|
+
width: number,
|
|
344
|
+
rendering: ResolvedWaveformRenderingOptions,
|
|
345
|
+
selection: SeriesRenderSelectionOptions,
|
|
346
|
+
): SeriesRenderPointSelection {
|
|
347
|
+
return selectSeriesRenderSourcePoints(
|
|
348
|
+
pointSourceFromPoints(points),
|
|
349
|
+
domain,
|
|
350
|
+
width,
|
|
351
|
+
rendering,
|
|
352
|
+
selection,
|
|
353
|
+
)
|
|
354
|
+
}
|