waveform-analysis 0.1.64 → 0.1.66
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 +33 -4
- package/dist/index.cjs +1 -1
- package/dist/index.js +724 -621
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +3 -2
- package/dist/types/components/core/constants.d.ts +3 -1
- package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -0
- package/dist/types/components/core/types.d.ts +2 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +3 -0
- package/dist/types/components/core/useWaveformPresentation.d.ts +2 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +3 -1
- package/dist/types/components/core/yAxisConstants.d.ts +1 -1
- package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
- package/dist/types/components/interaction/integerZoom.d.ts +12 -0
- package/dist/types/components/interaction/viewportDomain.d.ts +1 -0
- package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -6
- package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +0 -4
- package/dist/types/components/rendering/WaveformZeroLines.vue.d.ts +12 -0
- package/dist/types/demo/types.d.ts +3 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/types/chart.d.ts +8 -0
- package/package.json +1 -1
- package/src/App.vue +8 -2
- package/src/components/WaveformChart.css +4 -0
- package/src/components/WaveformChart.vue +2 -1
- package/src/components/core/constants.ts +5 -3
- package/src/components/core/layout.ts +1 -1
- package/src/components/core/trackLayoutBuilder.ts +30 -19
- package/src/components/core/types.ts +2 -0
- package/src/components/core/useWaveformChartLifecycle.ts +1 -1
- package/src/components/core/useWaveformLayout.ts +34 -17
- package/src/components/core/useWaveformPresentation.ts +11 -1
- package/src/components/core/waveformChartTypes.ts +3 -0
- package/src/components/core/yAxisConstants.ts +1 -1
- package/src/components/core/yAxisTitleLayout.ts +78 -0
- package/src/components/interaction/integerZoom.ts +64 -0
- package/src/components/interaction/useWaveformViewport.ts +8 -12
- package/src/components/interaction/useWaveformZoom.ts +6 -4
- package/src/components/interaction/viewportDomain.ts +12 -0
- package/src/components/interaction/zoomConstraints.ts +7 -5
- package/src/components/rendering/WaveformLegend.vue +1 -1
- package/src/components/rendering/WaveformSeriesLayer.vue +1 -1
- package/src/components/rendering/WaveformTrack.css +5 -2
- package/src/components/rendering/WaveformTrack.vue +18 -5
- package/src/components/rendering/WaveformTrackAxes.vue +9 -3
- package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
- package/src/components/rendering/WaveformZeroLines.vue +53 -0
- package/src/demo/DemoChartHost.vue +1 -0
- package/src/demo/DemoGridFrameControls.vue +12 -0
- package/src/demo/DemoViewControls.vue +4 -0
- package/src/demo/types.ts +3 -0
- package/src/index.ts +1 -0
- package/src/styles.css +3 -3
- package/src/types/chart.ts +8 -0
|
@@ -18,7 +18,7 @@ const Y_AXIS_CHARACTER_WIDTH = 7
|
|
|
18
18
|
const Y_AXIS_TICK_PADDING = 7
|
|
19
19
|
const Y_AXIS_OUTER_PADDING = 4
|
|
20
20
|
const Y_AXIS_LABEL_GAP = 0
|
|
21
|
-
const Y_AXIS_LABEL_BAND_WIDTH =
|
|
21
|
+
const Y_AXIS_LABEL_BAND_WIDTH = 20
|
|
22
22
|
|
|
23
23
|
export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
|
|
24
24
|
if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
|
|
@@ -34,6 +34,13 @@ import {
|
|
|
34
34
|
} from './layout'
|
|
35
35
|
import type { DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
|
36
36
|
import { applyXDomainStrategy } from './xDomain'
|
|
37
|
+
import { alignLeftYAxisTitles } from './yAxisTitleLayout'
|
|
38
|
+
import {
|
|
39
|
+
alignIntegerZoomDomain,
|
|
40
|
+
normalizeIntegerZoomDomain,
|
|
41
|
+
integerZoomTicks,
|
|
42
|
+
formatIntegerZoomLabel,
|
|
43
|
+
} from '../interaction/integerZoom'
|
|
37
44
|
import {
|
|
38
45
|
Y_AXIS_LABEL_BAND_WIDTH,
|
|
39
46
|
Y_AXIS_LABEL_GAP,
|
|
@@ -56,6 +63,7 @@ export interface BuildTrackLayoutsOptions {
|
|
|
56
63
|
initialXDomain?: [number, number]
|
|
57
64
|
initialXDomains?: Record<string, [number, number]>
|
|
58
65
|
xDomainStrategy?: WaveformXDomainStrategy
|
|
66
|
+
integerZoom?: boolean
|
|
59
67
|
fixedYDomain?: [number, number]
|
|
60
68
|
fixedYDomains?: Record<string, [number, number]>
|
|
61
69
|
yDomains?: Record<string, [number, number]>
|
|
@@ -68,6 +76,7 @@ export interface BuildTrackLayoutsOptions {
|
|
|
68
76
|
linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>
|
|
69
77
|
hideSecondaryLabels: boolean
|
|
70
78
|
yAxisLabelX: number
|
|
79
|
+
yLabel?: string
|
|
71
80
|
yAxisSlots?: readonly YAxisSlot[]
|
|
72
81
|
showCompactEmptyTracks: boolean
|
|
73
82
|
}
|
|
@@ -82,16 +91,19 @@ function resolveIndependentXDomain(
|
|
|
82
91
|
options.initialXDomains?.[track.id] ??
|
|
83
92
|
options.initialXDomains?.[seriesId] ??
|
|
84
93
|
options.initialXDomain
|
|
85
|
-
return
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
return alignIntegerZoomDomain(
|
|
95
|
+
explicitDomain
|
|
96
|
+
? applyXDomainStrategy(explicitDomain, strategy, true)
|
|
97
|
+
: applyXDomainStrategy(track.xDomain, strategy),
|
|
98
|
+
options,
|
|
99
|
+
)
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
|
|
91
103
|
const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
|
|
92
104
|
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
|
|
93
105
|
|
|
94
|
-
|
|
106
|
+
const layouts: TrackLayout[] = visibleCells.flatMap((cell, index) => {
|
|
95
107
|
const isEmpty = !cell.series || cell.series.series.length === 0
|
|
96
108
|
if (!cell.series && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks))
|
|
97
109
|
return []
|
|
@@ -116,6 +128,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
116
128
|
? (options.independentTransforms[index] ?? zoomIdentity)
|
|
117
129
|
: zoomIdentity
|
|
118
130
|
const xScale = transform.rescaleX(baseXScale)
|
|
131
|
+
xScale.domain(normalizeIntegerZoomDomain(xScale.domain() as [number, number], options))
|
|
119
132
|
const yAxisGroups = resolveRenderedYAxisSeriesGroups(
|
|
120
133
|
displayTrack,
|
|
121
134
|
options.overlayMode,
|
|
@@ -178,25 +191,19 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
178
191
|
const fallbackYScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0])
|
|
179
192
|
if (options.yAxisNice !== false) fallbackYScale.nice()
|
|
180
193
|
const yScale = yAxes[0]?.scale ?? fallbackYScale
|
|
181
|
-
const xMajorTicks =
|
|
194
|
+
const xMajorTicks = integerZoomTicks(
|
|
195
|
+
xScale.domain() as [number, number],
|
|
196
|
+
Math.max(2, Math.floor(cell.width / 100)),
|
|
197
|
+
options,
|
|
198
|
+
)
|
|
199
|
+
const xAxisLabelFormatter =
|
|
200
|
+
options.xAxisLabelFormatter ?? (options.integerZoom ? formatIntegerZoomLabel : undefined)
|
|
182
201
|
const yMajorTicks = yAxes[0]?.majorTicks ?? []
|
|
183
202
|
const yAxisTickValues = yAxes[0]?.tickValues ?? []
|
|
184
203
|
const domain = xScale.domain() as [number, number]
|
|
185
204
|
const endpointLabels = {
|
|
186
|
-
start: formatXAxisLabel(
|
|
187
|
-
|
|
188
|
-
domain,
|
|
189
|
-
options.timeUnit,
|
|
190
|
-
'start',
|
|
191
|
-
options.xAxisLabelFormatter,
|
|
192
|
-
),
|
|
193
|
-
end: formatXAxisLabel(
|
|
194
|
-
domain[1],
|
|
195
|
-
domain,
|
|
196
|
-
options.timeUnit,
|
|
197
|
-
'end',
|
|
198
|
-
options.xAxisLabelFormatter,
|
|
199
|
-
),
|
|
205
|
+
start: formatXAxisLabel(domain[0], domain, options.timeUnit, 'start', xAxisLabelFormatter),
|
|
206
|
+
end: formatXAxisLabel(domain[1], domain, options.timeUnit, 'end', xAxisLabelFormatter),
|
|
200
207
|
}
|
|
201
208
|
const leftClearance = endpointLabels.start.length * 7 + 10
|
|
202
209
|
const rightClearance = endpointLabels.end.length * 7 + 10
|
|
@@ -259,6 +266,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
259
266
|
yScale,
|
|
260
267
|
yAxes,
|
|
261
268
|
xMajorTicks,
|
|
269
|
+
xAxisLabelFormatter,
|
|
262
270
|
xMinorTicks: buildMinorTicks(xMajorTicks, 3, domain),
|
|
263
271
|
yMajorTicks,
|
|
264
272
|
yMinorTicks: yAxes[0]?.minorTicks ?? [],
|
|
@@ -279,4 +287,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
279
287
|
: bottomCells.has(cell.slotIndex))),
|
|
280
288
|
}
|
|
281
289
|
})
|
|
290
|
+
|
|
291
|
+
alignLeftYAxisTitles(layouts, options.yLabel)
|
|
292
|
+
return layouts
|
|
282
293
|
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
WaveformLineStyle,
|
|
6
6
|
WaveformPoint,
|
|
7
7
|
WaveformPointType,
|
|
8
|
+
WaveformXAxisLabelFormatter,
|
|
8
9
|
} from '../../types'
|
|
9
10
|
import type { WaveformPointSource } from '../../core/waveformPointSource'
|
|
10
11
|
import type { NormalizedWaveformGridLineOptions } from './grid'
|
|
@@ -114,6 +115,7 @@ export interface TrackLayout {
|
|
|
114
115
|
yMinorTicks: number[]
|
|
115
116
|
yAxisTickValues: number[]
|
|
116
117
|
xAxisTickValues: number[]
|
|
118
|
+
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
|
|
117
119
|
endpointLabels: { start: string; end: string }
|
|
118
120
|
path: string | null
|
|
119
121
|
seriesPaths: TrackSeriesPath[]
|
|
@@ -239,7 +239,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|
|
239
239
|
)
|
|
240
240
|
|
|
241
241
|
watch(
|
|
242
|
-
() => props.displayMode,
|
|
242
|
+
() => [props.displayMode, props.integerZoom, props.integerZoom ? props.timeUnit : undefined],
|
|
243
243
|
() => {
|
|
244
244
|
const previousPage = currentPage.value
|
|
245
245
|
currentPage.value = 1
|
|
@@ -21,6 +21,7 @@ import { createFrameNumberResolver, resolvePageableTracks } from './trackPaginat
|
|
|
21
21
|
import type { PreparedWaveformSeries } from './useWaveformData'
|
|
22
22
|
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
|
|
23
23
|
import { applyXDomainStrategy } from './xDomain'
|
|
24
|
+
import { alignIntegerZoomDomain, normalizeIntegerZoomDomain } from '../interaction/integerZoom'
|
|
24
25
|
import { resolveYAxisLayoutMetrics, resolveViewportYDomains } from './yAxisLayoutMetrics'
|
|
25
26
|
import type { useWaveformAnnotationInteraction } from '../annotation'
|
|
26
27
|
interface LayoutContext {
|
|
@@ -220,13 +221,19 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
220
221
|
Number.isFinite(domain[1]) &&
|
|
221
222
|
domain[0] !== domain[1]
|
|
222
223
|
) {
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
return alignIntegerZoomDomain(
|
|
225
|
+
applyXDomainStrategy(
|
|
226
|
+
domain[0] < domain[1] ? domain : [domain[1], domain[0]],
|
|
227
|
+
props.xDomainStrategy,
|
|
228
|
+
true,
|
|
229
|
+
),
|
|
230
|
+
props,
|
|
227
231
|
)
|
|
228
232
|
}
|
|
229
|
-
return
|
|
233
|
+
return alignIntegerZoomDomain(
|
|
234
|
+
applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy),
|
|
235
|
+
props,
|
|
236
|
+
)
|
|
230
237
|
})
|
|
231
238
|
const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
|
|
232
239
|
const configuredDomain =
|
|
@@ -239,24 +246,32 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
239
246
|
Number.isFinite(configuredDomain[1]) &&
|
|
240
247
|
configuredDomain[0] !== configuredDomain[1]
|
|
241
248
|
) {
|
|
242
|
-
return
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
249
|
+
return alignIntegerZoomDomain(
|
|
250
|
+
applyXDomainStrategy(
|
|
251
|
+
configuredDomain[0] < configuredDomain[1]
|
|
252
|
+
? configuredDomain
|
|
253
|
+
: [configuredDomain[1], configuredDomain[0]],
|
|
254
|
+
props.xDomainStrategy,
|
|
255
|
+
true,
|
|
256
|
+
),
|
|
257
|
+
props,
|
|
248
258
|
)
|
|
249
259
|
}
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
260
|
+
return alignIntegerZoomDomain(
|
|
261
|
+
applyXDomainStrategy(
|
|
262
|
+
paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
|
|
263
|
+
props.xDomainStrategy,
|
|
264
|
+
),
|
|
265
|
+
props,
|
|
253
266
|
)
|
|
254
267
|
}
|
|
255
|
-
const sharedZoomDomain = computed(
|
|
256
|
-
(
|
|
268
|
+
const sharedZoomDomain = computed(() =>
|
|
269
|
+
normalizeIntegerZoomDomain(
|
|
257
270
|
sharedTransform.value
|
|
258
271
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
|
259
272
|
.domain() as [number, number],
|
|
273
|
+
props,
|
|
274
|
+
),
|
|
260
275
|
)
|
|
261
276
|
const gridCells = computed(() => {
|
|
262
277
|
const cells = resolveGridCellGeometry(
|
|
@@ -280,6 +295,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
280
295
|
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
|
|
281
296
|
initialXDomains: props.initialXDomains,
|
|
282
297
|
xDomainStrategy: props.xDomainStrategy,
|
|
298
|
+
integerZoom: props.integerZoom,
|
|
283
299
|
fixedYDomain: props.yDomain,
|
|
284
300
|
fixedYDomains: props.yDomains,
|
|
285
301
|
yDomains: viewportYDomains.value,
|
|
@@ -290,10 +306,11 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
290
306
|
rendering: renderingOptions.value,
|
|
291
307
|
linePointOverrides: linePointOverrides?.value,
|
|
292
308
|
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
|
|
309
|
+
yLabel: props.yLabel,
|
|
293
310
|
yAxisLabelX:
|
|
294
311
|
yAxisSlots.value.slots.find((slot) => slot.side === 'left' && slot.sideIndex === 0)
|
|
295
312
|
?.labelOffset ?? yAxisMetrics.value.labelCenterX,
|
|
296
|
-
yAxisSlots:
|
|
313
|
+
yAxisSlots: yAxisSlots.value.slots,
|
|
297
314
|
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
|
|
298
315
|
}),
|
|
299
316
|
)
|
|
@@ -53,14 +53,24 @@ export function useWaveformPresentation(context: PresentationContext) {
|
|
|
53
53
|
const isPresentationMode = computed(() => props.presentationMode === true)
|
|
54
54
|
const resolvedZeroLine = computed(() => {
|
|
55
55
|
const width = props.zeroLine.width
|
|
56
|
+
const opacity = props.zeroLine.opacity
|
|
57
|
+
const threshold = props.zeroLine.boundaryThreshold
|
|
56
58
|
return {
|
|
57
|
-
visible: props.zeroLine.visible
|
|
59
|
+
visible: props.zeroLine.visible !== false,
|
|
58
60
|
color: props.zeroLine.color || ZERO_LINE_DEFAULTS.COLOR,
|
|
59
61
|
width:
|
|
60
62
|
typeof width === 'number' && Number.isFinite(width) && width > 0
|
|
61
63
|
? width
|
|
62
64
|
: ZERO_LINE_DEFAULTS.WIDTH,
|
|
63
65
|
dash: props.zeroLine.dash ?? ZERO_LINE_DEFAULTS.DASH,
|
|
66
|
+
opacity:
|
|
67
|
+
typeof opacity === 'number' && Number.isFinite(opacity) && opacity >= 0 && opacity <= 1
|
|
68
|
+
? opacity
|
|
69
|
+
: ZERO_LINE_DEFAULTS.OPACITY,
|
|
70
|
+
boundaryThreshold:
|
|
71
|
+
typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 0
|
|
72
|
+
? threshold
|
|
73
|
+
: ZERO_LINE_DEFAULTS.BOUNDARY_THRESHOLD,
|
|
64
74
|
}
|
|
65
75
|
})
|
|
66
76
|
const legendBackgroundColor = computed(
|
|
@@ -33,6 +33,8 @@ export interface WaveformChartProps {
|
|
|
33
33
|
lineColor?: string
|
|
34
34
|
showTooltip?: boolean
|
|
35
35
|
zoomable?: boolean
|
|
36
|
+
/** Snap viewport endpoints outwards to integer X-axis display units. */
|
|
37
|
+
integerZoom?: boolean
|
|
36
38
|
pannable?: boolean
|
|
37
39
|
minZoomSpan?: number
|
|
38
40
|
minVisiblePoints?: number
|
|
@@ -70,6 +72,7 @@ type DefaultedProp =
|
|
|
70
72
|
| 'lineColor'
|
|
71
73
|
| 'showTooltip'
|
|
72
74
|
| 'zoomable'
|
|
75
|
+
| 'integerZoom'
|
|
73
76
|
| 'pannable'
|
|
74
77
|
| 'minVisiblePoints'
|
|
75
78
|
| 'xDomainStrategy'
|
|
@@ -2,7 +2,7 @@ export const Y_AXIS_CHARACTER_WIDTH = 7
|
|
|
2
2
|
export const Y_AXIS_TICK_PADDING = 7
|
|
3
3
|
export const Y_AXIS_OUTER_PADDING = 4
|
|
4
4
|
export const Y_AXIS_LABEL_GAP = 0
|
|
5
|
-
export const Y_AXIS_LABEL_BAND_WIDTH =
|
|
5
|
+
export const Y_AXIS_LABEL_BAND_WIDTH = 20
|
|
6
6
|
export const Y_AXIS_RIGHT_LABEL_OFFSET = 10
|
|
7
7
|
|
|
8
8
|
export function resolveYAxisSides(axisCount: number): Array<'left' | 'right'> {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { formatYAxisTickLabel } from './layout'
|
|
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'
|
|
8
|
+
|
|
9
|
+
// Match the existing 14px channel names and 11px ticks in WaveformTrack.css.
|
|
10
|
+
const TITLE_FONT_SIZE = 14
|
|
11
|
+
const TICK_FONT_SIZE = 11
|
|
12
|
+
const TEXT_GAP = 2
|
|
13
|
+
|
|
14
|
+
function estimateTitleWidth(title: string): number {
|
|
15
|
+
return Array.from(title).reduce((width, character) => {
|
|
16
|
+
// Latin labels average well below one em; full-width characters occupy one em.
|
|
17
|
+
const em = /[\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60]/u.test(character)
|
|
18
|
+
? 1
|
|
19
|
+
: /[MW@%]/u.test(character)
|
|
20
|
+
? 0.95
|
|
21
|
+
: /[ilI1., :;|'!]/u.test(character)
|
|
22
|
+
? 0.4
|
|
23
|
+
: 0.65
|
|
24
|
+
return width + em * TITLE_FONT_SIZE
|
|
25
|
+
}, 0)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type TitleTrack = Pick<TrackLayout, 'height' | 'column' | 'yAxes'>
|
|
29
|
+
|
|
30
|
+
function titleDistance(track: TitleTrack, axis: WaveformYAxisLayout, yLabel?: string): number {
|
|
31
|
+
const series = axis.seriesList[0]
|
|
32
|
+
const title = series?.name.trim() || yLabel || ''
|
|
33
|
+
const halfTitleHeight = estimateTitleWidth(title) / 2
|
|
34
|
+
const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
|
|
35
|
+
const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
|
|
36
|
+
const lowestTick = Math.min(...axis.tickValues)
|
|
37
|
+
const tickWidths = axis.tickValues.flatMap((value) => {
|
|
38
|
+
// D3 places ticks at scale(value) + 0.5. The lowest label is bottom-aligned;
|
|
39
|
+
// all other labels are vertically centered using dy="0.32em".
|
|
40
|
+
const y = axis.scale(value) + 0.5
|
|
41
|
+
const top = y - (value === lowestTick ? TICK_FONT_SIZE : TICK_FONT_SIZE / 2)
|
|
42
|
+
const bottom = value === lowestTick ? y : y + TICK_FONT_SIZE / 2
|
|
43
|
+
if (bottom < titleTop || top > titleBottom) return []
|
|
44
|
+
return [
|
|
45
|
+
formatYAxisTickLabel(
|
|
46
|
+
value,
|
|
47
|
+
axis.scale.domain() as [number, number],
|
|
48
|
+
axis.tickValues,
|
|
49
|
+
series?.unit,
|
|
50
|
+
).length * Y_AXIS_CHARACTER_WIDTH,
|
|
51
|
+
]
|
|
52
|
+
})
|
|
53
|
+
// The 20px title band already leaves 3px beside a 14px glyph.
|
|
54
|
+
return (
|
|
55
|
+
Math.max(Y_AXIS_CHARACTER_WIDTH, ...tickWidths) +
|
|
56
|
+
Y_AXIS_TICK_PADDING +
|
|
57
|
+
Y_AXIS_LABEL_BAND_WIDTH / 2
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function alignLeftYAxisTitles(layouts: TitleTrack[], yLabel?: string): void {
|
|
62
|
+
const distances = new Map<string, number>()
|
|
63
|
+
for (const track of layouts) {
|
|
64
|
+
let leftIndex = 0
|
|
65
|
+
for (const axis of track.yAxes) {
|
|
66
|
+
if (axis.side !== 'left') continue
|
|
67
|
+
const key = `${track.column}:${leftIndex++}`
|
|
68
|
+
distances.set(key, Math.max(distances.get(key) ?? 0, titleDistance(track, axis, yLabel)))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const track of layouts) {
|
|
72
|
+
let leftIndex = 0
|
|
73
|
+
for (const axis of track.yAxes) {
|
|
74
|
+
if (axis.side !== 'left') continue
|
|
75
|
+
axis.labelX = axis.x - distances.get(`${track.column}:${leftIndex++}`)!
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { scaleLinear } from 'd3'
|
|
2
|
+
|
|
3
|
+
export interface IntegerZoomOptions {
|
|
4
|
+
integerZoom?: boolean
|
|
5
|
+
timeUnit?: 's' | 'ms'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function integerZoomTicks(
|
|
9
|
+
domain: [number, number],
|
|
10
|
+
count: number,
|
|
11
|
+
options: IntegerZoomOptions,
|
|
12
|
+
) {
|
|
13
|
+
if (!options.integerZoom) return scaleLinear().domain(domain).ticks(count)
|
|
14
|
+
const factor = options.timeUnit === 's' ? 1 : 1000
|
|
15
|
+
return scaleLinear()
|
|
16
|
+
.domain(domain.map((value) => value * factor))
|
|
17
|
+
.ticks(count)
|
|
18
|
+
.filter(Number.isInteger)
|
|
19
|
+
.map((value) => value / factor)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatIntegerZoomLabel(value: number): string {
|
|
23
|
+
return String(Math.round(value))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stableInteger(value: number): number {
|
|
27
|
+
const rounded = Math.round(value)
|
|
28
|
+
return Math.abs(value - rounded) <= Number.EPSILON * Math.max(1, Math.abs(value)) * 16
|
|
29
|
+
? rounded
|
|
30
|
+
: value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Align outwards in display units; all returned coordinates remain in seconds. */
|
|
34
|
+
export function alignIntegerZoomDomain(
|
|
35
|
+
domain: [number, number],
|
|
36
|
+
options: IntegerZoomOptions,
|
|
37
|
+
): [number, number] {
|
|
38
|
+
if (!options.integerZoom) return domain
|
|
39
|
+
const factor = options.timeUnit === 's' ? 1 : 1000
|
|
40
|
+
const start = stableInteger(Math.min(domain[0], domain[1]) * factor)
|
|
41
|
+
const end = stableInteger(Math.max(domain[0], domain[1]) * factor)
|
|
42
|
+
if (!Number.isSafeInteger(Math.floor(start)) || !Number.isSafeInteger(Math.ceil(end)))
|
|
43
|
+
return domain
|
|
44
|
+
const left = Math.floor(start)
|
|
45
|
+
return [left / factor, Math.max(left + 1, Math.ceil(end)) / factor]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Recover aligned endpoints after a D3 transform, tolerating floating-point round-off. */
|
|
49
|
+
export function normalizeIntegerZoomDomain(
|
|
50
|
+
domain: [number, number],
|
|
51
|
+
options: IntegerZoomOptions,
|
|
52
|
+
): [number, number] {
|
|
53
|
+
return alignIntegerZoomDomain(domain, options)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Translation snaps to the nearest unit so panning preserves the visible span. */
|
|
57
|
+
export function alignIntegerPanDomain(
|
|
58
|
+
domain: [number, number],
|
|
59
|
+
options: IntegerZoomOptions,
|
|
60
|
+
): [number, number] {
|
|
61
|
+
if (!options.integerZoom) return domain
|
|
62
|
+
const factor = options.timeUnit === 's' ? 1 : 1000
|
|
63
|
+
return [Math.round(domain[0] * factor) / factor, Math.round(domain[1] * factor) / factor]
|
|
64
|
+
}
|
|
@@ -8,7 +8,8 @@ import type { WaveformChartEmit } from '../core/waveformChartTypes'
|
|
|
8
8
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
|
9
9
|
import { tryReleasePointerCapture } from './pointerCapture'
|
|
10
10
|
import { transitionViewportInteraction } from './viewportInteractionState'
|
|
11
|
-
import { createViewportDomainSetter } from './viewportDomain'
|
|
11
|
+
import { clampViewportDomain as clampDomain, createViewportDomainSetter } from './viewportDomain'
|
|
12
|
+
import { alignIntegerPanDomain } from './integerZoom'
|
|
12
13
|
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
|
13
14
|
import { emitBoxZoomIntent } from './zoomEventPayload'
|
|
14
15
|
interface ViewportContext {
|
|
@@ -92,14 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
|
|
|
92
93
|
height: Math.abs(active.currentY - active.startY),
|
|
93
94
|
}
|
|
94
95
|
})
|
|
95
|
-
const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
|
|
96
|
-
const span = domain[1] - domain[0]
|
|
97
|
-
const boundarySpan = boundary[1] - boundary[0]
|
|
98
|
-
if (span >= boundarySpan) return [...boundary]
|
|
99
|
-
if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
|
|
100
|
-
if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
|
|
101
|
-
return domain
|
|
102
|
-
}
|
|
103
96
|
const currentYDomains = (): Record<string, [number, number]> =>
|
|
104
97
|
Object.fromEntries(
|
|
105
98
|
trackLayouts.value
|
|
@@ -157,9 +150,12 @@ export function useWaveformViewport(context: ViewportContext) {
|
|
|
157
150
|
const sourceXDomain = active.independent
|
|
158
151
|
? resolveInitialTrackDomain(track)
|
|
159
152
|
: initialXDomain.value
|
|
160
|
-
const nextX =
|
|
161
|
-
|
|
162
|
-
|
|
153
|
+
const nextX = alignIntegerPanDomain(
|
|
154
|
+
clampDomain(
|
|
155
|
+
[active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
|
|
156
|
+
sourceXDomain,
|
|
157
|
+
),
|
|
158
|
+
props,
|
|
163
159
|
)
|
|
164
160
|
if (active.independent) {
|
|
165
161
|
const nextTransforms = [...independentTransforms.value]
|
|
@@ -15,6 +15,7 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
|
|
|
15
15
|
import type { TrackLayout } from '../core/types'
|
|
16
16
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
|
17
17
|
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
|
18
|
+
import { normalizeIntegerZoomDomain } from './integerZoom'
|
|
18
19
|
import {
|
|
19
20
|
constrainZoomDomain,
|
|
20
21
|
resolveMinimumZoomSpan,
|
|
@@ -90,7 +91,8 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
90
91
|
const domain = event.transform
|
|
91
92
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
|
92
93
|
.domain()
|
|
93
|
-
|
|
94
|
+
const [start, end] = normalizeIntegerZoomDomain(domain as [number, number], props)
|
|
95
|
+
emit('zoom-intent', { start, end, gesture: 'wheel' })
|
|
94
96
|
scheduleZoomCommit()
|
|
95
97
|
}
|
|
96
98
|
const handleIndependentZoom = (
|
|
@@ -108,8 +110,8 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
108
110
|
.rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
|
|
109
111
|
.domain()
|
|
110
112
|
emit('zoom-intent', {
|
|
111
|
-
start: domain[0],
|
|
112
|
-
end: domain[1],
|
|
113
|
+
start: normalizeIntegerZoomDomain(domain as [number, number], props)[0],
|
|
114
|
+
end: normalizeIntegerZoomDomain(domain as [number, number], props)[1],
|
|
113
115
|
trackIndex,
|
|
114
116
|
seriesIds: track.seriesList.map((series) => series.id),
|
|
115
117
|
gesture: 'wheel',
|
|
@@ -131,7 +133,7 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
131
133
|
const domain = transform
|
|
132
134
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
|
133
135
|
.domain()
|
|
134
|
-
emit('zoom-change', [domain[0], domain[1]])
|
|
136
|
+
emit('zoom-change', normalizeIntegerZoomDomain([domain[0], domain[1]], props))
|
|
135
137
|
}
|
|
136
138
|
if (!pendingIndependentZoomTransforms.size) return
|
|
137
139
|
const nextTransforms = [...independentTransforms.value]
|
|
@@ -21,6 +21,18 @@ interface ViewportDomainContext {
|
|
|
21
21
|
configureZoom: () => void
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function clampViewportDomain(
|
|
25
|
+
domain: [number, number],
|
|
26
|
+
boundary: [number, number],
|
|
27
|
+
): [number, number] {
|
|
28
|
+
const span = domain[1] - domain[0]
|
|
29
|
+
const boundarySpan = boundary[1] - boundary[0]
|
|
30
|
+
if (span >= boundarySpan) return [...boundary]
|
|
31
|
+
if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
|
|
32
|
+
if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
|
|
33
|
+
return domain
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
export function createViewportDomainSetter(context: ViewportDomainContext) {
|
|
25
37
|
return (domain: [number, number], trackIndex?: number) => {
|
|
26
38
|
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
|
|
@@ -3,6 +3,7 @@ import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
|
|
3
3
|
import type { WaveformPoint } from '../../types'
|
|
4
4
|
import { pointSourceFromPoints, type WaveformPointSource } from '../../core/waveformPointSource'
|
|
5
5
|
import { ZOOM_CONSTRAINTS } from '../core/constants'
|
|
6
|
+
import { alignIntegerZoomDomain, type IntegerZoomOptions } from './integerZoom'
|
|
6
7
|
|
|
7
8
|
interface PointSeriesSource {
|
|
8
9
|
points: WaveformPoint[]
|
|
@@ -11,7 +12,7 @@ interface PointSeriesSource {
|
|
|
11
12
|
|
|
12
13
|
export type ZoomSeriesGroup = readonly PointSeriesSource[]
|
|
13
14
|
|
|
14
|
-
interface ZoomConstraintOptions {
|
|
15
|
+
interface ZoomConstraintOptions extends IntegerZoomOptions {
|
|
15
16
|
minZoomSpan?: number
|
|
16
17
|
minVisiblePoints?: number
|
|
17
18
|
maxZoomScale?: number | null
|
|
@@ -72,7 +73,7 @@ export function resolveMinimumZoomSpan(
|
|
|
72
73
|
groups: readonly ZoomSeriesGroup[],
|
|
73
74
|
options: ZoomConstraintOptions,
|
|
74
75
|
): number {
|
|
75
|
-
const normalized = normalizedBoundary(boundary)
|
|
76
|
+
const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
|
|
76
77
|
const boundarySpan = normalized[1] - normalized[0]
|
|
77
78
|
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
|
|
78
79
|
const configuredSpan =
|
|
@@ -88,7 +89,8 @@ export function resolveMinimumZoomSpan(
|
|
|
88
89
|
: options.maxZoomScale !== null && configuredSpan === 0 && required === 0
|
|
89
90
|
? boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
|
90
91
|
: 0
|
|
91
|
-
|
|
92
|
+
const integerSpan = options.integerZoom ? (options.timeUnit === 's' ? 1 : 0.001) : 0
|
|
93
|
+
return Math.max(configuredSpan, pointSpan, scaleSpan, integerSpan)
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
function expandToMinimumPoints(
|
|
@@ -139,7 +141,7 @@ export function constrainZoomDomain(
|
|
|
139
141
|
groups: readonly ZoomSeriesGroup[],
|
|
140
142
|
options: ZoomConstraintOptions,
|
|
141
143
|
): [number, number] {
|
|
142
|
-
const normalized = normalizedBoundary(boundary)
|
|
144
|
+
const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
|
|
143
145
|
const boundarySpan = normalized[1] - normalized[0]
|
|
144
146
|
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
|
|
145
147
|
if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
|
|
@@ -157,7 +159,7 @@ export function constrainZoomDomain(
|
|
|
157
159
|
const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
|
|
158
160
|
const center = (expanded[0] + expanded[1]) / 2
|
|
159
161
|
const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
|
|
160
|
-
return [start, start + span]
|
|
162
|
+
return alignIntegerZoomDomain([start, start + span], options)
|
|
161
163
|
}
|
|
162
164
|
|
|
163
165
|
export function transformForDomain(
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
.waveform-track__y-axis-label {
|
|
13
|
-
font-size:
|
|
14
|
-
font-weight:
|
|
13
|
+
font-size: 14px;
|
|
14
|
+
font-weight: 600;
|
|
15
15
|
pointer-events: none;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
.waveform-track__axis-endpoint {
|
|
84
84
|
fill: #667085;
|
|
85
85
|
font-size: 11px;
|
|
86
|
+
font-weight: 700;
|
|
86
87
|
pointer-events: none;
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -90,6 +91,7 @@
|
|
|
90
91
|
fill: #666;
|
|
91
92
|
font-family: sans-serif;
|
|
92
93
|
font-size: 10px;
|
|
94
|
+
font-weight: 700;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
.waveform-track__axis path,
|
|
@@ -100,4 +102,5 @@
|
|
|
100
102
|
.waveform-track__axis text {
|
|
101
103
|
fill: #667085;
|
|
102
104
|
font-size: 11px;
|
|
105
|
+
font-weight: 700;
|
|
103
106
|
}
|