waveform-analysis 0.1.65 → 0.1.67
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 +146 -5
- package/dist/index.cjs +1 -1
- package/dist/index.js +1326 -1118
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +7 -2
- package/dist/types/components/core/constants.d.ts +2 -0
- package/dist/types/components/core/grid.d.ts +1 -1
- package/dist/types/components/core/layout.d.ts +2 -2
- package/dist/types/components/core/pagination.d.ts +2 -0
- package/dist/types/components/core/title.d.ts +2 -1
- package/dist/types/components/core/trackLayoutBuilder.d.ts +5 -0
- package/dist/types/components/core/types.d.ts +2 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +9 -0
- package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
- package/dist/types/components/core/useWaveformPresentation.d.ts +3 -0
- package/dist/types/components/core/useWaveformYAxisLayout.d.ts +31 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +11 -2
- package/dist/types/components/core/yAxisLayoutMetrics.d.ts +1 -1
- package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/interaction/integerZoom.d.ts +12 -0
- package/dist/types/components/interaction/interactionTarget.d.ts +1 -0
- package/dist/types/components/interaction/remoteViewport.d.ts +8 -0
- package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -2
- package/dist/types/components/interaction/viewportDomain.d.ts +1 -1
- package/dist/types/components/interaction/viewportPan.d.ts +4 -0
- package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
- package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -0
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +6 -6
- package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +5 -1
- 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/data/recordedDampedWaveform.d.ts +1 -0
- package/dist/types/demo/types.d.ts +3 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types/chart.d.ts +17 -0
- package/dist/types/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/App.vue +6 -0
- package/src/components/WaveformChart.css +5 -0
- package/src/components/WaveformChart.vue +3 -0
- package/src/components/WaveformChartView.vue +14 -3
- package/src/components/core/constants.ts +2 -0
- package/src/components/core/grid.ts +4 -1
- package/src/components/core/layout.ts +16 -2
- package/src/components/core/pagination.ts +17 -0
- package/src/components/core/title.ts +7 -3
- package/src/components/core/trackLayoutBuilder.ts +39 -59
- package/src/components/core/types.ts +2 -0
- package/src/components/core/useWaveformChartController.ts +17 -1
- package/src/components/core/useWaveformChartLifecycle.ts +11 -17
- package/src/components/core/useWaveformLayout.ts +67 -107
- package/src/components/core/useWaveformPresentation.ts +27 -3
- package/src/components/core/useWaveformYAxisLayout.ts +140 -0
- package/src/components/core/waveformChartTypes.ts +15 -0
- package/src/components/core/yAxisLayoutMetrics.ts +11 -2
- package/src/components/core/yAxisTitleLayout.ts +78 -0
- package/src/components/data/types.ts +3 -0
- package/src/components/interaction/integerZoom.ts +64 -0
- package/src/components/interaction/interactionTarget.ts +10 -0
- package/src/components/interaction/remoteViewport.ts +53 -0
- package/src/components/interaction/useWaveformViewport.ts +16 -58
- package/src/components/interaction/useWaveformZoom.ts +13 -11
- package/src/components/interaction/viewportDomain.ts +11 -3
- package/src/components/interaction/viewportPan.ts +82 -0
- package/src/components/interaction/zoomConstraints.ts +7 -5
- package/src/components/rendering/WaveformLegend.vue +10 -1
- package/src/components/rendering/WaveformTrack.vue +24 -6
- package/src/components/rendering/WaveformTrackAxes.vue +16 -5
- package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
- package/src/components/rendering/WaveformZeroLines.vue +53 -0
- package/src/data/recordedDampedWaveform.ts +3 -0
- package/src/data/simulatedWaveforms.ts +12 -10
- 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 +4 -0
- package/src/styles.css +3 -3
- package/src/types/chart.ts +20 -0
- package/src/types/index.ts +3 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
import { margin, MINIMUM_PLOT_WIDTH } from './constants'
|
|
4
|
+
import { getGridGap, type NormalizedWaveformGridOptions } from './grid'
|
|
5
|
+
import { buildYAxisSlots } from './layout'
|
|
6
|
+
import type { DisplayTrack } from './types'
|
|
7
|
+
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
|
|
8
|
+
import { resolveYAxisLayoutMetrics } from './yAxisLayoutMetrics'
|
|
9
|
+
import { Y_AXIS_OUTER_PADDING } from './yAxisConstants'
|
|
10
|
+
|
|
11
|
+
interface YAxisLayoutContext {
|
|
12
|
+
props: ResolvedWaveformChartProps
|
|
13
|
+
chartTracks: ComputedRef<DisplayTrack[]>
|
|
14
|
+
layoutTracks: ComputedRef<DisplayTrack[]>
|
|
15
|
+
chartWidth: ComputedRef<number>
|
|
16
|
+
gridOptions: ComputedRef<NormalizedWaveformGridOptions>
|
|
17
|
+
viewportYDomains: ComputedRef<Record<string, [number, number]>>
|
|
18
|
+
yAxisTickCount: ComputedRef<number>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
22
|
+
const {
|
|
23
|
+
props,
|
|
24
|
+
chartTracks,
|
|
25
|
+
layoutTracks,
|
|
26
|
+
chartWidth,
|
|
27
|
+
gridOptions,
|
|
28
|
+
viewportYDomains,
|
|
29
|
+
yAxisTickCount,
|
|
30
|
+
} = context
|
|
31
|
+
const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
|
|
32
|
+
const showUnits = computed(() => !isEdgeCompact.value || props.unitDisplayMode === 'axis')
|
|
33
|
+
const yAxisMetrics = computed(() =>
|
|
34
|
+
resolveYAxisLayoutMetrics(
|
|
35
|
+
chartTracks.value,
|
|
36
|
+
props.overlayMode,
|
|
37
|
+
props.yDomain,
|
|
38
|
+
props.yDomains,
|
|
39
|
+
viewportYDomains.value,
|
|
40
|
+
props.axes?.y?.nice !== false,
|
|
41
|
+
yAxisTickCount.value,
|
|
42
|
+
props.displayMode === 'compact',
|
|
43
|
+
props.axes?.y?.upperPaddingRatio,
|
|
44
|
+
showUnits.value,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
const hasYAxisLabels = computed(() =>
|
|
48
|
+
chartTracks.value.some(
|
|
49
|
+
(track) =>
|
|
50
|
+
track.visibleSeries.length === 1 &&
|
|
51
|
+
Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
const hasVisibleWaveformData = computed(() =>
|
|
55
|
+
chartTracks.value.some((track) => track.visibleSeries.length > 0),
|
|
56
|
+
)
|
|
57
|
+
const chartLeftMargin = computed(() =>
|
|
58
|
+
Math.max(
|
|
59
|
+
margin.left,
|
|
60
|
+
hasYAxisLabels.value
|
|
61
|
+
? yAxisMetrics.value.fullClearance
|
|
62
|
+
: hasVisibleWaveformData.value
|
|
63
|
+
? yAxisMetrics.value.tickClearance
|
|
64
|
+
: 0,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
const yAxisSlots = computed(() =>
|
|
68
|
+
buildYAxisSlots(
|
|
69
|
+
layoutTracks.value.filter((track) => track.visibleSeries.length > 0),
|
|
70
|
+
props.overlayMode,
|
|
71
|
+
props.yDomain,
|
|
72
|
+
props.yDomains,
|
|
73
|
+
yAxisTickCount.value,
|
|
74
|
+
props.axes?.y?.nice !== false,
|
|
75
|
+
props.displayMode === 'compact',
|
|
76
|
+
viewportYDomains.value,
|
|
77
|
+
props.axes?.y?.upperPaddingRatio,
|
|
78
|
+
showUnits.value,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
const resolvedChartLeftMargin = computed(() =>
|
|
82
|
+
isEdgeCompact.value
|
|
83
|
+
? margin.right + Math.max(0, compactLeftClearance.value - Y_AXIS_OUTER_PADDING)
|
|
84
|
+
: props.overlayMode === 'multi-axis'
|
|
85
|
+
? Math.max(chartLeftMargin.value, yAxisSlots.value.clearance.left)
|
|
86
|
+
: chartLeftMargin.value,
|
|
87
|
+
)
|
|
88
|
+
// The same slot places the title and reserves its entire 20px band. The outer
|
|
89
|
+
// padding belongs to the chart edge, rather than the old fixed 48px minimum.
|
|
90
|
+
const compactLeftClearance = computed(() =>
|
|
91
|
+
!hasVisibleWaveformData.value
|
|
92
|
+
? 0
|
|
93
|
+
: props.overlayMode === 'multi-axis' || hasYAxisLabels.value
|
|
94
|
+
? yAxisSlots.value.clearance.left
|
|
95
|
+
: yAxisMetrics.value.tickClearance,
|
|
96
|
+
)
|
|
97
|
+
const chartRightMargin = computed(() =>
|
|
98
|
+
props.overlayMode === 'multi-axis'
|
|
99
|
+
? Math.max(margin.right, yAxisSlots.value.clearance.right)
|
|
100
|
+
: margin.right,
|
|
101
|
+
)
|
|
102
|
+
const innerWidth = computed(() =>
|
|
103
|
+
Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
|
|
104
|
+
)
|
|
105
|
+
const yAxisLayout = computed(() => {
|
|
106
|
+
const baseGap = getGridGap(props.displayMode)
|
|
107
|
+
const columnCount = gridOptions.value.columnCount
|
|
108
|
+
const hasMultipleColumns = columnCount > 1
|
|
109
|
+
const fullGap = Math.max(
|
|
110
|
+
baseGap,
|
|
111
|
+
isEdgeCompact.value ? yAxisSlots.value.clearance.left : yAxisMetrics.value.fullClearance,
|
|
112
|
+
)
|
|
113
|
+
const tickGap = Math.max(baseGap, yAxisMetrics.value.tickClearance)
|
|
114
|
+
const plotWidth = (innerWidth.value - fullGap * Math.max(0, columnCount - 1)) / columnCount
|
|
115
|
+
const canReserveLabelClearance = plotWidth >= MINIMUM_PLOT_WIDTH
|
|
116
|
+
return {
|
|
117
|
+
horizontalGap:
|
|
118
|
+
props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
|
|
119
|
+
? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
|
|
120
|
+
: hasMultipleColumns && hasVisibleWaveformData.value
|
|
121
|
+
? hasYAxisLabels.value && canReserveLabelClearance
|
|
122
|
+
? fullGap
|
|
123
|
+
: tickGap
|
|
124
|
+
: baseGap,
|
|
125
|
+
hideSecondaryLabels:
|
|
126
|
+
props.overlayMode !== 'multi-axis' &&
|
|
127
|
+
hasMultipleColumns &&
|
|
128
|
+
hasYAxisLabels.value &&
|
|
129
|
+
!canReserveLabelClearance,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
return {
|
|
133
|
+
yAxisMetrics,
|
|
134
|
+
yAxisSlots,
|
|
135
|
+
resolvedChartLeftMargin,
|
|
136
|
+
innerWidth,
|
|
137
|
+
yAxisLayout,
|
|
138
|
+
hasVisibleWaveformData,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
WaveformAxesOptions,
|
|
4
4
|
WaveformData,
|
|
5
5
|
WaveformDisplayMode,
|
|
6
|
+
WaveformUnitDisplayMode,
|
|
7
|
+
WaveformLayoutPreset,
|
|
6
8
|
WaveformFrameStyle,
|
|
7
9
|
WaveformInteractionMode,
|
|
8
10
|
WaveformLegendOptions,
|
|
@@ -18,6 +20,7 @@ import type {
|
|
|
18
20
|
WaveformZeroLineOptions,
|
|
19
21
|
WaveformZoomIntentPayload,
|
|
20
22
|
WaveformZoomEndPayload,
|
|
23
|
+
WaveformPanEndPayload,
|
|
21
24
|
WaveformZoomResetPayload,
|
|
22
25
|
} from '../data/types'
|
|
23
26
|
import type { WaveformGridOptions } from './grid'
|
|
@@ -26,6 +29,10 @@ export interface WaveformChartProps {
|
|
|
26
29
|
data: WaveformData
|
|
27
30
|
displayMode?: WaveformDisplayMode
|
|
28
31
|
overlayMode?: WaveformOverlayMode
|
|
32
|
+
/** Default axis units; optional chart-wide single-series legend units. */
|
|
33
|
+
unitDisplayMode?: WaveformUnitDisplayMode
|
|
34
|
+
/** Default spacing or compact title/time labels with a separate pagination band. */
|
|
35
|
+
layoutPreset?: WaveformLayoutPreset
|
|
29
36
|
width?: number
|
|
30
37
|
height?: number
|
|
31
38
|
xLabel?: string
|
|
@@ -33,7 +40,11 @@ export interface WaveformChartProps {
|
|
|
33
40
|
lineColor?: string
|
|
34
41
|
showTooltip?: boolean
|
|
35
42
|
zoomable?: boolean
|
|
43
|
+
/** Snap viewport endpoints outwards to integer X-axis display units. */
|
|
44
|
+
integerZoom?: boolean
|
|
36
45
|
pannable?: boolean
|
|
46
|
+
/** Global record bounds in seconds; enables horizontal panning beyond loaded data. */
|
|
47
|
+
panXDomain?: [number, number]
|
|
37
48
|
minZoomSpan?: number
|
|
38
49
|
minVisiblePoints?: number
|
|
39
50
|
maxZoomScale?: number | null
|
|
@@ -66,10 +77,13 @@ export interface WaveformChartProps {
|
|
|
66
77
|
type DefaultedProp =
|
|
67
78
|
| 'displayMode'
|
|
68
79
|
| 'overlayMode'
|
|
80
|
+
| 'unitDisplayMode'
|
|
81
|
+
| 'layoutPreset'
|
|
69
82
|
| 'yLabel'
|
|
70
83
|
| 'lineColor'
|
|
71
84
|
| 'showTooltip'
|
|
72
85
|
| 'zoomable'
|
|
86
|
+
| 'integerZoom'
|
|
73
87
|
| 'pannable'
|
|
74
88
|
| 'minVisiblePoints'
|
|
75
89
|
| 'xDomainStrategy'
|
|
@@ -94,6 +108,7 @@ export interface WaveformChartEmit {
|
|
|
94
108
|
(event: 'zoom-intent', payload: WaveformZoomIntentPayload): void
|
|
95
109
|
(event: 'zoom-change', domain: [number, number]): void
|
|
96
110
|
(event: 'zoom-end', payload: WaveformZoomEndPayload): void
|
|
111
|
+
(event: 'pan-end', payload: WaveformPanEndPayload): void
|
|
97
112
|
(event: 'zoom-reset', payload: WaveformZoomResetPayload): void
|
|
98
113
|
(event: 'update:annotations', annotations: WaveformAnnotation[]): void
|
|
99
114
|
(event: 'update:hidden-series-ids', ids: string[]): void
|
|
@@ -40,11 +40,20 @@ export function resolveYAxisLayoutMetrics(
|
|
|
40
40
|
nice: boolean,
|
|
41
41
|
tickCount: number,
|
|
42
42
|
compact: boolean,
|
|
43
|
+
upperPaddingRatio = 0,
|
|
44
|
+
showUnits = true,
|
|
43
45
|
): YAxisLayoutMetrics {
|
|
44
46
|
const axisText = chartTracks
|
|
45
47
|
.filter((track) => track.visibleSeries.length > 0)
|
|
46
48
|
.flatMap((track) =>
|
|
47
|
-
resolveRenderedYAxisSeriesGroups(
|
|
49
|
+
resolveRenderedYAxisSeriesGroups(
|
|
50
|
+
track,
|
|
51
|
+
overlayMode,
|
|
52
|
+
yDomain,
|
|
53
|
+
yDomains,
|
|
54
|
+
viewportYDomains,
|
|
55
|
+
upperPaddingRatio,
|
|
56
|
+
),
|
|
48
57
|
)
|
|
49
58
|
.map(
|
|
50
59
|
(group) =>
|
|
@@ -52,7 +61,7 @@ export function resolveYAxisLayoutMetrics(
|
|
|
52
61
|
group.domain,
|
|
53
62
|
nice,
|
|
54
63
|
undefined,
|
|
55
|
-
group.seriesList[0]?.unit,
|
|
64
|
+
showUnits ? group.seriesList[0]?.unit : undefined,
|
|
56
65
|
tickCount,
|
|
57
66
|
compact,
|
|
58
67
|
).tickTextWidth,
|
|
@@ -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
|
+
}
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
export type {
|
|
8
8
|
WaveformPoint,
|
|
9
9
|
WaveformDisplayMode,
|
|
10
|
+
WaveformUnitDisplayMode,
|
|
11
|
+
WaveformLayoutPreset,
|
|
10
12
|
WaveformOverlayMode,
|
|
11
13
|
WaveformInteractionMode,
|
|
12
14
|
WaveformXDomainStrategy,
|
|
13
15
|
WaveformZoomIntentPayload,
|
|
14
16
|
WaveformZoomEndPayload,
|
|
17
|
+
WaveformPanEndPayload,
|
|
15
18
|
WaveformZoomResetPayload,
|
|
16
19
|
WaveformAnnotationStyle,
|
|
17
20
|
WaveformAnnotation,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
|
|
2
|
+
import {
|
|
3
|
+
constrainZoomDomain,
|
|
4
|
+
resolveMinimumZoomSpan,
|
|
5
|
+
type ZoomSeriesGroup,
|
|
6
|
+
} from './zoomConstraints'
|
|
7
|
+
|
|
8
|
+
export function clampViewportDomain(
|
|
9
|
+
domain: [number, number],
|
|
10
|
+
boundary: [number, number],
|
|
11
|
+
): [number, number] {
|
|
12
|
+
const span = domain[1] - domain[0]
|
|
13
|
+
const boundarySpan = boundary[1] - boundary[0]
|
|
14
|
+
if (span >= boundarySpan) return [...boundary]
|
|
15
|
+
if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
|
|
16
|
+
if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
|
|
17
|
+
return domain
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function remotePanBoundary(props: Pick<ResolvedWaveformChartProps, 'panXDomain'>) {
|
|
21
|
+
const domain = props.panXDomain
|
|
22
|
+
return domain && domain.every(Number.isFinite) && domain[0] < domain[1] ? domain : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Restore remote windows without requiring points inside a not-yet-loaded interval. */
|
|
26
|
+
export function restoreViewportDomain(
|
|
27
|
+
domain: [number, number],
|
|
28
|
+
baseDomain: [number, number],
|
|
29
|
+
groups: readonly ZoomSeriesGroup[],
|
|
30
|
+
props: ResolvedWaveformChartProps,
|
|
31
|
+
): [number, number] {
|
|
32
|
+
const boundary = remotePanBoundary(props)
|
|
33
|
+
return boundary
|
|
34
|
+
? clampViewportDomain(domain, boundary)
|
|
35
|
+
: constrainZoomDomain(domain, baseDomain, groups, props)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Wheel/box zoom keeps the remote position and uses loaded data for scale limits. */
|
|
39
|
+
export function constrainViewportZoom(
|
|
40
|
+
domain: [number, number],
|
|
41
|
+
baseDomain: [number, number],
|
|
42
|
+
groups: readonly ZoomSeriesGroup[],
|
|
43
|
+
props: ResolvedWaveformChartProps,
|
|
44
|
+
) {
|
|
45
|
+
const boundary = remotePanBoundary(props)
|
|
46
|
+
if (!boundary) return constrainZoomDomain(domain, baseDomain, groups, props)
|
|
47
|
+
return constrainZoomDomain(domain, boundary, [], {
|
|
48
|
+
...props,
|
|
49
|
+
minVisiblePoints: 0,
|
|
50
|
+
maxZoomScale: null,
|
|
51
|
+
minZoomSpan: resolveMinimumZoomSpan(baseDomain, groups, props),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
@@ -2,16 +2,17 @@ import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
|
|
|
2
2
|
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
|
3
3
|
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
|
4
4
|
import type { DisplayTrack, TrackLayout } from '../core/types'
|
|
5
|
-
import { hasFixedYDomainForTrack } from '../core/yDomain'
|
|
6
5
|
import type { ResolvedWaveformChartProps, ViewportSelectionState } from '../core/waveformChartTypes'
|
|
7
6
|
import type { WaveformChartEmit } from '../core/waveformChartTypes'
|
|
8
7
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
|
9
8
|
import { tryReleasePointerCapture } from './pointerCapture'
|
|
10
9
|
import { transitionViewportInteraction } from './viewportInteractionState'
|
|
11
10
|
import { createViewportDomainSetter } from './viewportDomain'
|
|
12
|
-
import {
|
|
11
|
+
import { constrainViewportZoom } from './remoteViewport'
|
|
12
|
+
import { createViewportPan } from './viewportPan'
|
|
13
|
+
import { transformForDomain } from './zoomConstraints'
|
|
13
14
|
import { emitBoxZoomIntent } from './zoomEventPayload'
|
|
14
|
-
interface ViewportContext {
|
|
15
|
+
export interface ViewportContext {
|
|
15
16
|
props: ResolvedWaveformChartProps
|
|
16
17
|
emit: WaveformChartEmit
|
|
17
18
|
selection: Ref<ViewportSelectionState | null>
|
|
@@ -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
|
|
@@ -148,51 +141,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
|
|
148
141
|
trackLayouts.value.find((item) => item.hasVisibleSeries)
|
|
149
142
|
if (track) beginViewportDrag(event, track.index, false)
|
|
150
143
|
}
|
|
151
|
-
const applyPan = (
|
|
152
|
-
const width = track.width || 1
|
|
153
|
-
const height = track.height || 1
|
|
154
|
-
const dx = active.currentX - active.startX
|
|
155
|
-
const dy = active.currentY - active.startY
|
|
156
|
-
const xSpan = active.xDomain[1] - active.xDomain[0]
|
|
157
|
-
const sourceXDomain = active.independent
|
|
158
|
-
? resolveInitialTrackDomain(track)
|
|
159
|
-
: initialXDomain.value
|
|
160
|
-
const nextX = clampDomain(
|
|
161
|
-
[active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
|
|
162
|
-
sourceXDomain,
|
|
163
|
-
)
|
|
164
|
-
if (active.independent) {
|
|
165
|
-
const nextTransforms = [...independentTransforms.value]
|
|
166
|
-
nextTransforms[track.index] = transformForDomain(nextX, sourceXDomain, width)
|
|
167
|
-
independentTransforms.value = nextTransforms
|
|
168
|
-
} else {
|
|
169
|
-
sharedTransform.value = transformForDomain(nextX, sourceXDomain, innerWidth.value)
|
|
170
|
-
}
|
|
171
|
-
const targets = active.independent
|
|
172
|
-
? [track]
|
|
173
|
-
: trackLayouts.value.filter((target) => target.hasVisibleSeries)
|
|
174
|
-
const nextIndependentDomains = { ...independentYDomains.value }
|
|
175
|
-
const nextSharedDomains = { ...sharedYDomains.value }
|
|
176
|
-
targets.forEach((target) => {
|
|
177
|
-
const chartTrack = chartTracks.value.find((item) => item.id === target.id)
|
|
178
|
-
if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
|
|
179
|
-
return
|
|
180
|
-
}
|
|
181
|
-
const key = target.series?.trackId ?? target.series?.id ?? target.id
|
|
182
|
-
const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
|
|
183
|
-
const boundary = chartTrack?.yDomain ?? source
|
|
184
|
-
const ySpan = source[1] - source[0]
|
|
185
|
-
const nextY = clampDomain(
|
|
186
|
-
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
|
|
187
|
-
boundary,
|
|
188
|
-
)
|
|
189
|
-
if (active.independent) nextIndependentDomains[target.index] = nextY
|
|
190
|
-
else nextSharedDomains[key] = nextY
|
|
191
|
-
})
|
|
192
|
-
if (active.independent) independentYDomains.value = nextIndependentDomains
|
|
193
|
-
else sharedYDomains.value = nextSharedDomains
|
|
194
|
-
emit('zoom-change', nextX)
|
|
195
|
-
}
|
|
144
|
+
const applyPan = createViewportPan(context)
|
|
196
145
|
const updateViewportDrag = (event: PointerEvent) => {
|
|
197
146
|
if (isPresentationMode.value) return
|
|
198
147
|
const active = selection.value
|
|
@@ -242,7 +191,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
|
|
242
191
|
const groups = active.independent
|
|
243
192
|
? [track.seriesList]
|
|
244
193
|
: trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
|
|
245
|
-
const xDomain =
|
|
194
|
+
const xDomain = constrainViewportZoom(
|
|
246
195
|
[track.xScale.invert(left), track.xScale.invert(right)],
|
|
247
196
|
baseXDomain,
|
|
248
197
|
groups,
|
|
@@ -324,7 +273,16 @@ export function useWaveformViewport(context: ViewportContext) {
|
|
|
324
273
|
activeOverlay.value = undefined
|
|
325
274
|
event.preventDefault()
|
|
326
275
|
if (completed.kind === 'pan') {
|
|
327
|
-
applyPan(completed, track)
|
|
276
|
+
const domain = applyPan(completed, track)
|
|
277
|
+
if (domain.some((value, index) => Math.abs(value - completed.xDomain[index]) > 1e-12)) {
|
|
278
|
+
emit('pan-end', {
|
|
279
|
+
start: domain[0],
|
|
280
|
+
end: domain[1],
|
|
281
|
+
...(completed.independent
|
|
282
|
+
? { trackIndex: track.index, seriesIds: track.seriesList.map((series) => series.id) }
|
|
283
|
+
: {}),
|
|
284
|
+
})
|
|
285
|
+
}
|
|
328
286
|
void nextTick(configureZoom)
|
|
329
287
|
return
|
|
330
288
|
}
|
|
@@ -15,12 +15,9 @@ 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 {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
transformForDomain,
|
|
22
|
-
type ZoomSeriesGroup,
|
|
23
|
-
} from './zoomConstraints'
|
|
18
|
+
import { constrainViewportZoom } from './remoteViewport'
|
|
19
|
+
import { normalizeIntegerZoomDomain } from './integerZoom'
|
|
20
|
+
import { resolveMinimumZoomSpan, transformForDomain, type ZoomSeriesGroup } from './zoomConstraints'
|
|
24
21
|
import {
|
|
25
22
|
emitIndependentWheelZoomEnds,
|
|
26
23
|
emitSharedWheelZoomEnd,
|
|
@@ -90,7 +87,8 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
90
87
|
const domain = event.transform
|
|
91
88
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
|
92
89
|
.domain()
|
|
93
|
-
|
|
90
|
+
const [start, end] = normalizeIntegerZoomDomain(domain as [number, number], props)
|
|
91
|
+
emit('zoom-intent', { start, end, gesture: 'wheel' })
|
|
94
92
|
scheduleZoomCommit()
|
|
95
93
|
}
|
|
96
94
|
const handleIndependentZoom = (
|
|
@@ -108,8 +106,8 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
108
106
|
.rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
|
|
109
107
|
.domain()
|
|
110
108
|
emit('zoom-intent', {
|
|
111
|
-
start: domain[0],
|
|
112
|
-
end: domain[1],
|
|
109
|
+
start: normalizeIntegerZoomDomain(domain as [number, number], props)[0],
|
|
110
|
+
end: normalizeIntegerZoomDomain(domain as [number, number], props)[1],
|
|
113
111
|
trackIndex,
|
|
114
112
|
seriesIds: track.seriesList.map((series) => series.id),
|
|
115
113
|
gesture: 'wheel',
|
|
@@ -131,7 +129,7 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
131
129
|
const domain = transform
|
|
132
130
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
|
133
131
|
.domain()
|
|
134
|
-
emit('zoom-change', [domain[0], domain[1]])
|
|
132
|
+
emit('zoom-change', normalizeIntegerZoomDomain([domain[0], domain[1]], props))
|
|
135
133
|
}
|
|
136
134
|
if (!pendingIndependentZoomTransforms.size) return
|
|
137
135
|
const nextTransforms = [...independentTransforms.value]
|
|
@@ -238,7 +236,11 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
238
236
|
number,
|
|
239
237
|
number,
|
|
240
238
|
]
|
|
241
|
-
return transformForDomain(
|
|
239
|
+
return transformForDomain(
|
|
240
|
+
constrainViewportZoom(requested, domain, groups, props),
|
|
241
|
+
domain,
|
|
242
|
+
width,
|
|
243
|
+
)
|
|
242
244
|
}
|
|
243
245
|
const canZoomTrack = (track: TrackLayout): boolean => {
|
|
244
246
|
const minimum = Number(props.minVisiblePoints)
|
|
@@ -4,7 +4,8 @@ import type { ZoomTransform } from 'd3'
|
|
|
4
4
|
import type { TrackLayout } from '../core/types'
|
|
5
5
|
import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
|
|
6
6
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
|
7
|
-
import {
|
|
7
|
+
import { transformForDomain } from './zoomConstraints'
|
|
8
|
+
import { constrainViewportZoom } from './remoteViewport'
|
|
8
9
|
|
|
9
10
|
interface ViewportDomainContext {
|
|
10
11
|
props: ResolvedWaveformChartProps
|
|
@@ -21,6 +22,8 @@ interface ViewportDomainContext {
|
|
|
21
22
|
configureZoom: () => void
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export { clampViewportDomain } from './remoteViewport'
|
|
26
|
+
|
|
24
27
|
export function createViewportDomainSetter(context: ViewportDomainContext) {
|
|
25
28
|
return (domain: [number, number], trackIndex?: number) => {
|
|
26
29
|
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
|
|
@@ -37,7 +40,12 @@ export function createViewportDomainSetter(context: ViewportDomainContext) {
|
|
|
37
40
|
const track = context.trackLayouts.value.find((item) => item.index === index)
|
|
38
41
|
if (!track) return
|
|
39
42
|
const boundary = context.resolveInitialTrackDomain(track)
|
|
40
|
-
const constrained =
|
|
43
|
+
const constrained = constrainViewportZoom(
|
|
44
|
+
domain,
|
|
45
|
+
boundary,
|
|
46
|
+
[track.seriesList],
|
|
47
|
+
context.props,
|
|
48
|
+
)
|
|
41
49
|
nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
|
|
42
50
|
})
|
|
43
51
|
context.independentTransforms.value = nextTransforms
|
|
@@ -46,7 +54,7 @@ export function createViewportDomainSetter(context: ViewportDomainContext) {
|
|
|
46
54
|
const groups = context.trackLayouts.value
|
|
47
55
|
.filter((track) => track.hasVisibleSeries)
|
|
48
56
|
.map((track) => track.seriesList)
|
|
49
|
-
const constrained =
|
|
57
|
+
const constrained = constrainViewportZoom(domain, boundary, groups, context.props)
|
|
50
58
|
context.sharedTransform.value = transformForDomain(
|
|
51
59
|
constrained,
|
|
52
60
|
boundary,
|