waveform-analysis 0.1.75 → 0.1.76
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 +178 -23
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/types/components/core/amplitudeLimits.d.ts +6 -0
- package/dist/types/components/core/layout.d.ts +6 -9
- package/dist/types/components/core/trackLayoutBuilder.d.ts +3 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +1 -1
- package/dist/types/components/core/useWaveformData.d.ts +2 -2
- package/dist/types/components/core/useWaveformMeasurements.d.ts +4 -1
- package/dist/types/components/core/waveformChartTypes.d.ts +4 -2
- package/dist/types/components/core/xAxisTicks.d.ts +2 -0
- package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
- package/dist/types/components/core/yAxisTicks.d.ts +6 -0
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/interaction/integerZoom.d.ts +3 -3
- package/dist/types/components/interaction/stepZoom.d.ts +5 -0
- package/dist/types/components/interaction/useWaveformViewport.d.ts +0 -3
- package/dist/types/components/interaction/useWaveformZoom.d.ts +0 -2
- package/dist/types/core/data.d.ts +1 -1
- package/dist/types/demo/DemoChartHost.vue.d.ts +1 -1
- package/dist/types/demo/remoteWaveformLoader.d.ts +23 -0
- package/dist/types/demo/types.d.ts +11 -2
- package/dist/types/demo/useRemoteWaveformWindow.d.ts +22 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +8 -1
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/formatters.d.ts +4 -4
- package/dist/types/utils/xAxisNumber.d.ts +2 -0
- package/package.json +1 -1
- package/src/App.vue +34 -100
- package/src/components/WaveformChartView.vue +0 -2
- package/src/components/controls/useChartCommands.ts +12 -13
- package/src/components/core/amplitudeLimits.ts +44 -0
- package/src/components/core/layout.ts +22 -34
- package/src/components/core/trackLayoutBuilder.ts +12 -5
- package/src/components/core/useWaveformChartController.ts +3 -3
- package/src/components/core/useWaveformChartLifecycle.ts +11 -2
- package/src/components/core/useWaveformData.ts +11 -5
- package/src/components/core/useWaveformLayout.ts +9 -4
- package/src/components/core/useWaveformMeasurements.ts +3 -0
- package/src/components/core/useWaveformYAxisLayout.ts +2 -0
- package/src/components/core/waveformChartTypes.ts +4 -1
- package/src/components/core/xAxisTicks.ts +69 -0
- package/src/components/core/yAxisLayoutMetrics.ts +4 -0
- package/src/components/core/yAxisTicks.ts +39 -0
- package/src/components/data/types.ts +2 -0
- package/src/components/interaction/integerZoom.ts +31 -12
- package/src/components/interaction/remoteViewport.ts +26 -4
- package/src/components/interaction/stepZoom.ts +33 -0
- package/src/components/interaction/useWaveformViewport.ts +3 -26
- package/src/components/interaction/useWaveformZoom.ts +50 -157
- package/src/components/interaction/zoomConstraints.ts +5 -5
- package/src/core/data.ts +5 -2
- package/src/data/simulatedWaveforms.ts +0 -10
- package/src/demo/DemoChartHost.vue +49 -4
- package/src/demo/remoteWaveformLoader.ts +114 -0
- package/src/demo/types.ts +11 -1
- package/src/demo/useRemoteWaveformWindow.ts +229 -0
- package/src/index.ts +2 -0
- package/src/types/chart.ts +10 -1
- package/src/types/index.ts +2 -0
- package/src/utils/formatters.ts +10 -5
- package/src/utils/xAxisNumber.ts +36 -0
- package/dist/types/data/sx2Channel.d.ts +0 -10
- package/src/data/sx2Channel.ts +0 -315
|
@@ -16,6 +16,7 @@ import { paddedDomain } from '../../utils'
|
|
|
16
16
|
import { transformForDomain } from '../interaction/zoomConstraints'
|
|
17
17
|
import { constrainViewportZoom } from '../interaction/remoteViewport'
|
|
18
18
|
import { normalizeIntegerZoomDomain } from '../interaction/integerZoom'
|
|
19
|
+
import { resolveStepZoomDomain, sameZoomDomain } from '../interaction/stepZoom'
|
|
19
20
|
import type { useControlMode } from './useControlMode'
|
|
20
21
|
|
|
21
22
|
type Domain = [number, number]
|
|
@@ -34,7 +35,7 @@ interface Context {
|
|
|
34
35
|
legacyReset: (index?: number) => void
|
|
35
36
|
legacySet: (domain: Domain, index?: number) => void
|
|
36
37
|
}
|
|
37
|
-
const same =
|
|
38
|
+
const same = sameZoomDomain
|
|
38
39
|
export function useChartCommands(c: Context) {
|
|
39
40
|
let sequence = 0
|
|
40
41
|
const independent = computed(() => c.props.displayMode === 'independent')
|
|
@@ -104,18 +105,16 @@ export function useChartCommands(c: Context) {
|
|
|
104
105
|
paddedDomain(tracks.flatMap((t) => t.seriesList.flatMap((s) => s.xDomain))),
|
|
105
106
|
c.props.xDomainStrategy,
|
|
106
107
|
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
c.props,
|
|
118
|
-
)
|
|
108
|
+
const seriesGroups = tracks.filter((t) => t.hasVisibleSeries).map((t) => t.seriesList)
|
|
109
|
+
const xDomain =
|
|
110
|
+
before && (action === 'zoom-in' || action === 'zoom-out')
|
|
111
|
+
? resolveStepZoomDomain(before.xDomain, action, bounds, seriesGroups, c.props)
|
|
112
|
+
: constrainViewportZoom(
|
|
113
|
+
normalizeIntegerZoomDomain(domain ?? bounds, c.props),
|
|
114
|
+
bounds,
|
|
115
|
+
seriesGroups,
|
|
116
|
+
c.props,
|
|
117
|
+
)
|
|
119
118
|
const resetsY = action === 'reset' || action === 'fit'
|
|
120
119
|
const hasY =
|
|
121
120
|
resetsY &&
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { WaveformAmplitudeLimit, WaveformAmplitudeLimits } from '../../types'
|
|
2
|
+
|
|
3
|
+
export function normalizeAmplitudeLimit(
|
|
4
|
+
limit?: WaveformAmplitudeLimit,
|
|
5
|
+
): WaveformAmplitudeLimit | undefined {
|
|
6
|
+
if (!limit) return undefined
|
|
7
|
+
const lower = Number.isFinite(limit.lower) ? limit.lower : undefined
|
|
8
|
+
const upper = Number.isFinite(limit.upper) ? limit.upper : undefined
|
|
9
|
+
if (lower === undefined && upper === undefined) return undefined
|
|
10
|
+
if (lower !== undefined && upper !== undefined && lower >= upper) return undefined
|
|
11
|
+
return { lower, upper }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mergeAmplitudeLimits(
|
|
15
|
+
series: readonly { id: string }[],
|
|
16
|
+
limits?: WaveformAmplitudeLimits,
|
|
17
|
+
): WaveformAmplitudeLimit | undefined {
|
|
18
|
+
const valid = series.flatMap((item) => {
|
|
19
|
+
const limit = normalizeAmplitudeLimit(limits?.[item.id])
|
|
20
|
+
return limit ? [limit] : []
|
|
21
|
+
})
|
|
22
|
+
const lowers = valid.flatMap((limit) => (limit.lower === undefined ? [] : [limit.lower]))
|
|
23
|
+
const uppers = valid.flatMap((limit) => (limit.upper === undefined ? [] : [limit.upper]))
|
|
24
|
+
return normalizeAmplitudeLimit({
|
|
25
|
+
lower: lowers.length ? Math.min(...lowers) : undefined,
|
|
26
|
+
upper: uppers.length ? Math.max(...uppers) : undefined,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function applyAmplitudeLimits(
|
|
31
|
+
padded: [number, number],
|
|
32
|
+
limit: WaveformAmplitudeLimit,
|
|
33
|
+
automatic: [number, number] = padded,
|
|
34
|
+
): [number, number] {
|
|
35
|
+
let lower = limit.lower ?? padded[0]
|
|
36
|
+
let upper = limit.upper ?? padded[1]
|
|
37
|
+
if (lower >= upper) {
|
|
38
|
+
const fixed = limit.lower ?? limit.upper!
|
|
39
|
+
const span = Math.max(automatic[1] - automatic[0], Math.abs(fixed) * 0.1, 1)
|
|
40
|
+
if (limit.lower !== undefined) upper = lower + span
|
|
41
|
+
else lower = upper - span
|
|
42
|
+
}
|
|
43
|
+
return [lower, upper]
|
|
44
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
WaveformAmplitudeLimit,
|
|
3
|
+
WaveformAmplitudeLimits,
|
|
4
|
+
WaveformOverlayMode,
|
|
5
|
+
} from '../../types'
|
|
2
6
|
import { formatScientificAxisLabel, formatScientificAxisExponent, paddedDomain } from '../../utils'
|
|
3
7
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
|
4
8
|
import { MAX_MULTI_Y_AXIS_COUNT } from './constants'
|
|
@@ -8,7 +12,9 @@ import {
|
|
|
8
12
|
resolveTrackFixedYDomain,
|
|
9
13
|
type WaveformYDomain,
|
|
10
14
|
} from './yDomain'
|
|
11
|
-
import {
|
|
15
|
+
import { resolveYAxisTicks } from './yAxisTicks'
|
|
16
|
+
export { resolveYAxisTicks, type ResolvedYAxisTicks } from './yAxisTicks'
|
|
17
|
+
import { applyAmplitudeLimits, mergeAmplitudeLimits } from './amplitudeLimits'
|
|
12
18
|
import { applyYAxisPadding, withMaximumTick, type YAxisPaddingOptions } from './yAxisPadding'
|
|
13
19
|
import { resolveYAxisLabelBandWidth } from './yAxisLabelStyle'
|
|
14
20
|
|
|
@@ -28,36 +34,6 @@ export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number)
|
|
|
28
34
|
return 5
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
export interface ResolvedYAxisTicks {
|
|
32
|
-
domain: [number, number]
|
|
33
|
-
values: number[]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function resolveYAxisTicks(
|
|
37
|
-
domain: [number, number],
|
|
38
|
-
tickCount = 5,
|
|
39
|
-
nice = true,
|
|
40
|
-
): ResolvedYAxisTicks {
|
|
41
|
-
if (!nice) {
|
|
42
|
-
// 如果不需要 nice,使用简单的线性分割
|
|
43
|
-
const effectiveTickCount =
|
|
44
|
-
typeof tickCount === 'number' && Number.isFinite(tickCount)
|
|
45
|
-
? Math.max(2, Math.floor(tickCount))
|
|
46
|
-
: 5
|
|
47
|
-
const [axisStart, axisEnd] = domain
|
|
48
|
-
return {
|
|
49
|
-
domain,
|
|
50
|
-
values: Array.from(
|
|
51
|
-
{ length: effectiveTickCount },
|
|
52
|
-
(_, index) => axisStart + ((axisEnd - axisStart) * index) / (effectiveTickCount - 1),
|
|
53
|
-
),
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 使用 ECharts 兼容的 1/2/5 系列算法
|
|
58
|
-
return resolveYAxisTicksECharts(domain, tickCount)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
37
|
export function formatYAxisTickLabel(
|
|
62
38
|
value: number,
|
|
63
39
|
domain: [number, number],
|
|
@@ -101,6 +77,7 @@ export interface YAxisSeriesGroup {
|
|
|
101
77
|
seriesList: DisplaySeries[]
|
|
102
78
|
domain: [number, number]
|
|
103
79
|
fixed: boolean
|
|
80
|
+
amplitudeLimit?: WaveformAmplitudeLimit
|
|
104
81
|
maximumTick?: number
|
|
105
82
|
minimumTick?: number
|
|
106
83
|
}
|
|
@@ -191,6 +168,7 @@ export function resolveRenderedYAxisSeriesGroups(
|
|
|
191
168
|
yDomains?: Record<string, WaveformYDomain>,
|
|
192
169
|
viewportYDomains?: Record<string, [number, number]>,
|
|
193
170
|
padding?: YAxisPaddingOptions | number,
|
|
171
|
+
amplitudeLimits?: WaveformAmplitudeLimits,
|
|
194
172
|
): YAxisSeriesGroup[] {
|
|
195
173
|
const viewportYDomain = viewportYDomains?.[track.id]
|
|
196
174
|
const axisTrack =
|
|
@@ -198,7 +176,13 @@ export function resolveRenderedYAxisSeriesGroups(
|
|
|
198
176
|
? track
|
|
199
177
|
: { ...track, visibleSeries: track.series }
|
|
200
178
|
return resolveYAxisSeriesGroups(axisTrack, overlayMode, yDomain, yDomains).map((group) => {
|
|
201
|
-
const
|
|
179
|
+
const limit = mergeAmplitudeLimits(group.seriesList, amplitudeLimits)
|
|
180
|
+
const automatic = limit ? buildYAxisSeriesGroups(axisTrack, overlayMode)[group.index]! : group
|
|
181
|
+
const padded = applyYAxisPadding(automatic, limit ? undefined : viewportYDomain, padding)
|
|
182
|
+
if (limit) {
|
|
183
|
+
padded.domain = applyAmplitudeLimits(padded.domain, limit, automatic.domain)
|
|
184
|
+
padded.amplitudeLimit = limit
|
|
185
|
+
}
|
|
202
186
|
return track.visibleSeries.length
|
|
203
187
|
? padded
|
|
204
188
|
: { ...padded, maximumTick: undefined, minimumTick: undefined }
|
|
@@ -215,10 +199,11 @@ export function axisTextMetrics(
|
|
|
215
199
|
measureTextWidth?: (text: string) => number,
|
|
216
200
|
maximumTick?: number,
|
|
217
201
|
minimumTick?: number,
|
|
202
|
+
amplitudeLimit?: WaveformAmplitudeLimit,
|
|
218
203
|
): { tickTextWidth: number; titleTextWidth: number } {
|
|
219
204
|
const resolvedTicks = tickValues
|
|
220
205
|
? { domain, values: tickValues }
|
|
221
|
-
: resolveYAxisTicks(domain, tickCount, nice)
|
|
206
|
+
: resolveYAxisTicks(domain, tickCount, nice, amplitudeLimit)
|
|
222
207
|
const inRange = (value?: number) =>
|
|
223
208
|
value !== undefined && value >= resolvedTicks.domain[0] && value <= resolvedTicks.domain[1]
|
|
224
209
|
? value
|
|
@@ -320,6 +305,7 @@ export function buildYAxisSlots(
|
|
|
320
305
|
padding?: YAxisPaddingOptions | number,
|
|
321
306
|
showUnits = true,
|
|
322
307
|
measureTextWidth?: (text: string) => number,
|
|
308
|
+
amplitudeLimits?: WaveformAmplitudeLimits,
|
|
323
309
|
): { slots: YAxisSlot[]; clearance: { left: number; right: number } } {
|
|
324
310
|
const labelBandWidth = resolveYAxisLabelBandWidth(
|
|
325
311
|
typeof padding === 'object' ? padding.labelTextStyle : undefined,
|
|
@@ -335,6 +321,7 @@ export function buildYAxisSlots(
|
|
|
335
321
|
yDomains,
|
|
336
322
|
viewportYDomains,
|
|
337
323
|
padding,
|
|
324
|
+
amplitudeLimits,
|
|
338
325
|
).forEach((group) => {
|
|
339
326
|
const sideIndex = sideIndexes[group.side]++
|
|
340
327
|
const key = `${group.side}:${sideIndex}`
|
|
@@ -348,6 +335,7 @@ export function buildYAxisSlots(
|
|
|
348
335
|
measureTextWidth,
|
|
349
336
|
group.maximumTick,
|
|
350
337
|
group.minimumTick,
|
|
338
|
+
group.amplitudeLimit,
|
|
351
339
|
)
|
|
352
340
|
widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, tickTextWidth))
|
|
353
341
|
titleWidths.set(key, Math.max(titleWidths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, titleTextWidth))
|
|
@@ -6,6 +6,7 @@ import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
|
|
6
6
|
|
|
7
7
|
import { type ResolvedWaveformRenderingOptions } from '../../core/rendering'
|
|
8
8
|
import type {
|
|
9
|
+
WaveformAmplitudeLimits,
|
|
9
10
|
WaveformDisplayMode,
|
|
10
11
|
WaveformOverlayMode,
|
|
11
12
|
WaveformPoint,
|
|
@@ -34,7 +35,6 @@ import {
|
|
|
34
35
|
alignIntegerZoomDomain,
|
|
35
36
|
normalizeIntegerZoomDomain,
|
|
36
37
|
integerZoomTicks,
|
|
37
|
-
formatIntegerZoomLabel,
|
|
38
38
|
} from '../interaction/integerZoom'
|
|
39
39
|
import {
|
|
40
40
|
Y_AXIS_LABEL_GAP,
|
|
@@ -59,6 +59,7 @@ export interface BuildTrackLayoutsOptions {
|
|
|
59
59
|
initialXDomains?: Record<string, [number, number]>
|
|
60
60
|
xDomainStrategy?: WaveformXDomainStrategy
|
|
61
61
|
integerZoom?: boolean
|
|
62
|
+
amplitudeLimits?: WaveformAmplitudeLimits
|
|
62
63
|
fixedYDomain?: [number, number]
|
|
63
64
|
fixedYDomains?: Record<string, [number, number]>
|
|
64
65
|
yDomains?: Record<string, [number, number]>
|
|
@@ -79,6 +80,7 @@ export interface BuildTrackLayoutsOptions {
|
|
|
79
80
|
useNonEmptyBottomTracks?: boolean
|
|
80
81
|
compactYAxisLayout?: boolean
|
|
81
82
|
showAxisUnits?: boolean
|
|
83
|
+
domainTimeUnit?: 's' | 'ms'
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function resolveIndependentXDomain(
|
|
@@ -96,7 +98,7 @@ function resolveIndependentXDomain(
|
|
|
96
98
|
explicitDomain
|
|
97
99
|
? applyXDomainStrategy(explicitDomain, strategy, true)
|
|
98
100
|
: applyXDomainStrategy(track.xDomain, strategy),
|
|
99
|
-
options,
|
|
101
|
+
{ integerZoom: options.integerZoom, timeUnit: options.domainTimeUnit ?? options.timeUnit },
|
|
100
102
|
)
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -141,13 +143,19 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
141
143
|
options.fixedYDomains,
|
|
142
144
|
options.yDomains,
|
|
143
145
|
options.yAxisPadding ?? options.yAxisUpperPaddingRatio,
|
|
146
|
+
options.amplitudeLimits,
|
|
144
147
|
)
|
|
145
148
|
const sideIndexes = { left: 0, right: 0 }
|
|
146
149
|
const sideOffsets = { left: 0, right: 0 }
|
|
147
150
|
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
|
|
148
151
|
const sideIndex = sideIndexes[group.side]++
|
|
149
152
|
const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
|
|
150
|
-
const resolvedTicks = resolveYAxisTicks(
|
|
153
|
+
const resolvedTicks = resolveYAxisTicks(
|
|
154
|
+
group.domain,
|
|
155
|
+
tickCount,
|
|
156
|
+
options.yAxisNice !== false,
|
|
157
|
+
group.amplitudeLimit,
|
|
158
|
+
)
|
|
151
159
|
const scale = scaleLinear(resolvedTicks.domain, [cell.plotHeight, 0])
|
|
152
160
|
const majorTicks = resolvedTicks.values
|
|
153
161
|
const maximumTick =
|
|
@@ -220,8 +228,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|
|
220
228
|
Math.max(2, Math.floor(cell.width / 100)),
|
|
221
229
|
options,
|
|
222
230
|
)
|
|
223
|
-
const xAxisLabelFormatter =
|
|
224
|
-
options.xAxisLabelFormatter ?? (options.integerZoom ? formatIntegerZoomLabel : undefined)
|
|
231
|
+
const xAxisLabelFormatter = options.xAxisLabelFormatter
|
|
225
232
|
const yMajorTicks =
|
|
226
233
|
yAxes[0]?.majorTicks ??
|
|
227
234
|
(isEmpty
|
|
@@ -32,6 +32,7 @@ import { resolveCompactPaginationBand } from './pagination'
|
|
|
32
32
|
import type { WaveformHoverState } from './types'
|
|
33
33
|
import { useWaveformChartLifecycle } from './useWaveformChartLifecycle'
|
|
34
34
|
import { usePreparedWaveformSeries } from './useWaveformData'
|
|
35
|
+
import { remotePanBoundary } from '../interaction/remoteViewport'
|
|
35
36
|
import { useWaveformLayout } from './useWaveformLayout'
|
|
36
37
|
import { useWaveformPresentation } from './useWaveformPresentation'
|
|
37
38
|
import { usePlotLabelLayout } from './usePlotLabelLayout'
|
|
@@ -82,7 +83,7 @@ export function useWaveformChartController(
|
|
|
82
83
|
const hoverThrottle = useAnimationFrameThrottle()
|
|
83
84
|
|
|
84
85
|
watch(
|
|
85
|
-
[() => props.yDomain, () => props.yDomains],
|
|
86
|
+
[() => props.yDomain, () => props.yDomains, () => props.amplitudeLimits],
|
|
86
87
|
() => {
|
|
87
88
|
sharedYDomains.value = {}
|
|
88
89
|
independentYDomains.value = {}
|
|
@@ -102,6 +103,7 @@ export function useWaveformChartController(
|
|
|
102
103
|
handleBeforeDataReferenceChange()
|
|
103
104
|
},
|
|
104
105
|
() => handleDataReferenceChange(),
|
|
106
|
+
() => Boolean(remotePanBoundary(props)),
|
|
105
107
|
)
|
|
106
108
|
|
|
107
109
|
const presentation = useWaveformPresentation({
|
|
@@ -263,8 +265,6 @@ export function useWaveformChartController(
|
|
|
263
265
|
isPresentationMode,
|
|
264
266
|
editorSeriesOptions,
|
|
265
267
|
resolveInitialTrackDomain,
|
|
266
|
-
canZoomTrack: zoom.canZoomTrack,
|
|
267
|
-
canZoomSharedTracks: zoom.canZoomSharedTracks,
|
|
268
268
|
configureZoom: zoom.configureZoom,
|
|
269
269
|
cancelPendingZoom: zoom.cancelPendingZoom,
|
|
270
270
|
clearHover: () => hover.clearHover(),
|
|
@@ -16,6 +16,7 @@ import type { NormalizedWaveformGridOptions } from './grid'
|
|
|
16
16
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
|
17
17
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
|
18
18
|
import { isEditableTarget } from '../interaction/interactionTarget'
|
|
19
|
+
import { remotePanBoundary } from '../interaction/remoteViewport'
|
|
19
20
|
|
|
20
21
|
export interface LifecycleContext {
|
|
21
22
|
preserveFullViewport?: () => boolean
|
|
@@ -64,7 +65,6 @@ export interface LifecycleContext {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|
67
|
-
useWaveformMeasurements(context)
|
|
68
68
|
const {
|
|
69
69
|
props,
|
|
70
70
|
emit,
|
|
@@ -135,6 +135,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|
|
135
135
|
const { handleBeforeDataReferenceChange, handleDataReferenceChange } =
|
|
136
136
|
useWaveformViewportRestore(context)
|
|
137
137
|
|
|
138
|
+
useWaveformMeasurements(context, {
|
|
139
|
+
beforeResize: () => {
|
|
140
|
+
if (remotePanBoundary(props)) handleBeforeDataReferenceChange()
|
|
141
|
+
},
|
|
142
|
+
afterResize: () => {
|
|
143
|
+
if (remotePanBoundary(props)) handleDataReferenceChange()
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
138
147
|
watch(
|
|
139
148
|
[
|
|
140
149
|
innerWidth,
|
|
@@ -163,7 +172,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|
|
163
172
|
)
|
|
164
173
|
|
|
165
174
|
watch(
|
|
166
|
-
() => [props.displayMode, props.integerZoom
|
|
175
|
+
() => [props.displayMode, props.integerZoom],
|
|
167
176
|
() => {
|
|
168
177
|
const previousPage = currentPage.value
|
|
169
178
|
currentPage.value = 1
|
|
@@ -31,8 +31,11 @@ export interface PreparedWaveformSeries {
|
|
|
31
31
|
hasErrorPoints: boolean
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function prepareWaveformSeries(
|
|
35
|
-
|
|
34
|
+
export function prepareWaveformSeries(
|
|
35
|
+
data: WaveformData,
|
|
36
|
+
retainEmpty = false,
|
|
37
|
+
): PreparedWaveformSeries[] {
|
|
38
|
+
return normalizeWaveformSeriesSources(data, retainEmpty).map(({ source, ...series }) => {
|
|
36
39
|
const sourceMetrics = source.metrics(series.errorBar.visible)
|
|
37
40
|
return {
|
|
38
41
|
...series,
|
|
@@ -61,11 +64,14 @@ export function usePreparedWaveformSeries(
|
|
|
61
64
|
data: () => WaveformData,
|
|
62
65
|
onBeforeDataChange: () => void,
|
|
63
66
|
onDataChange: () => void,
|
|
67
|
+
retainEmpty: () => boolean = () => false,
|
|
64
68
|
) {
|
|
65
|
-
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(
|
|
66
|
-
|
|
69
|
+
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(
|
|
70
|
+
prepareWaveformSeries(data(), retainEmpty()),
|
|
71
|
+
)
|
|
72
|
+
watch([data, retainEmpty], ([nextData, preserveEmpty]) => {
|
|
67
73
|
onBeforeDataChange()
|
|
68
|
-
preparedSeries.value = prepareWaveformSeries(nextData)
|
|
74
|
+
preparedSeries.value = prepareWaveformSeries(nextData, preserveEmpty)
|
|
69
75
|
onDataChange()
|
|
70
76
|
})
|
|
71
77
|
return preparedSeries
|
|
@@ -52,6 +52,9 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
52
52
|
annotationInteraction,
|
|
53
53
|
linePointOverrides,
|
|
54
54
|
} = context
|
|
55
|
+
// Display-unit switches must not change the base used by existing D3 transforms.
|
|
56
|
+
const domainTimeUnit = props.timeUnit
|
|
57
|
+
const domainOptions = () => ({ integerZoom: props.integerZoom, timeUnit: domainTimeUnit })
|
|
55
58
|
const chartSeries = computed<DisplaySeries[]>(() =>
|
|
56
59
|
preparedSeries.value.map((series, index): DisplaySeries => ({
|
|
57
60
|
...series,
|
|
@@ -174,12 +177,12 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
174
177
|
props.xDomainStrategy,
|
|
175
178
|
true,
|
|
176
179
|
),
|
|
177
|
-
|
|
180
|
+
domainOptions(),
|
|
178
181
|
)
|
|
179
182
|
}
|
|
180
183
|
return alignIntegerZoomDomain(
|
|
181
184
|
applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy),
|
|
182
|
-
|
|
185
|
+
domainOptions(),
|
|
183
186
|
)
|
|
184
187
|
})
|
|
185
188
|
const resolveOriginalTrackDomain = (track: TrackLayout): [number, number] => {
|
|
@@ -201,7 +204,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
201
204
|
props.xDomainStrategy,
|
|
202
205
|
true,
|
|
203
206
|
),
|
|
204
|
-
|
|
207
|
+
domainOptions(),
|
|
205
208
|
)
|
|
206
209
|
}
|
|
207
210
|
return alignIntegerZoomDomain(
|
|
@@ -209,7 +212,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
209
212
|
paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
|
|
210
213
|
props.xDomainStrategy,
|
|
211
214
|
),
|
|
212
|
-
|
|
215
|
+
domainOptions(),
|
|
213
216
|
)
|
|
214
217
|
}
|
|
215
218
|
const initialXDomain = computed(
|
|
@@ -261,10 +264,12 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
261
264
|
effectiveXDomains: context.boundaries?.value,
|
|
262
265
|
xDomainStrategy: props.xDomainStrategy,
|
|
263
266
|
integerZoom: props.integerZoom,
|
|
267
|
+
amplitudeLimits: props.amplitudeLimits,
|
|
264
268
|
fixedYDomain: props.yDomain,
|
|
265
269
|
fixedYDomains: props.yDomains,
|
|
266
270
|
yDomains: viewportYDomains.value,
|
|
267
271
|
timeUnit: props.timeUnit,
|
|
272
|
+
domainTimeUnit,
|
|
268
273
|
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
|
|
269
274
|
yAxisSplitNumber: props.axes?.y?.splitNumber,
|
|
270
275
|
yAxisNice: props.axes?.y?.nice,
|
|
@@ -15,6 +15,7 @@ export function useWaveformMeasurements(
|
|
|
15
15
|
| 'titleAreaReserved'
|
|
16
16
|
| 'titleMeasureStyle'
|
|
17
17
|
>,
|
|
18
|
+
resizeHooks: { beforeResize?: () => void; afterResize?: () => void } = {},
|
|
18
19
|
) {
|
|
19
20
|
const {
|
|
20
21
|
container,
|
|
@@ -53,8 +54,10 @@ export function useWaveformMeasurements(
|
|
|
53
54
|
onMounted(() => {
|
|
54
55
|
if (!container.value) return
|
|
55
56
|
resizeObserver.value = new ResizeObserver(([entry]) => {
|
|
57
|
+
resizeHooks.beforeResize?.()
|
|
56
58
|
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
|
|
57
59
|
observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
|
|
60
|
+
resizeHooks.afterResize?.()
|
|
58
61
|
void nextTick(measureTitle)
|
|
59
62
|
})
|
|
60
63
|
resizeObserver.value.observe(container.value)
|
|
@@ -49,6 +49,7 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
|
49
49
|
props.axes?.y,
|
|
50
50
|
showUnits.value,
|
|
51
51
|
isEdgeCompact.value ? measureYAxisTextWidth : undefined,
|
|
52
|
+
props.amplitudeLimits,
|
|
52
53
|
),
|
|
53
54
|
)
|
|
54
55
|
const hasYAxisLabels = computed(() =>
|
|
@@ -84,6 +85,7 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
|
84
85
|
props.axes?.y,
|
|
85
86
|
showUnits.value,
|
|
86
87
|
isEdgeCompact.value ? measureYAxisTextWidth : undefined,
|
|
88
|
+
props.amplitudeLimits,
|
|
87
89
|
),
|
|
88
90
|
)
|
|
89
91
|
const resolvedChartLeftMargin = computed(() =>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WaveformAnimationProps } from '../../animation/types'
|
|
2
2
|
import type { WaveformToolbarOptions, WaveformControlState } from '../../types/controls'
|
|
3
3
|
import type {
|
|
4
|
+
WaveformAmplitudeLimits,
|
|
4
5
|
WaveformAnnotation,
|
|
5
6
|
WaveformAxesOptions,
|
|
6
7
|
WaveformData,
|
|
@@ -43,7 +44,7 @@ export interface WaveformChartProps extends WaveformAnimationProps {
|
|
|
43
44
|
lineColor?: string
|
|
44
45
|
showTooltip?: boolean
|
|
45
46
|
zoomable?: boolean
|
|
46
|
-
/**
|
|
47
|
+
/** Prefer integer display-unit endpoints; preserve any window narrower than one unit. */
|
|
47
48
|
integerZoom?: boolean
|
|
48
49
|
pannable?: boolean
|
|
49
50
|
/** Global record bounds in seconds; enables horizontal panning beyond loaded data. */
|
|
@@ -54,6 +55,8 @@ export interface WaveformChartProps extends WaveformAnimationProps {
|
|
|
54
55
|
initialXDomain?: [number, number]
|
|
55
56
|
initialXDomains?: Record<string, [number, number]>
|
|
56
57
|
xDomainStrategy?: WaveformXDomainStrategy
|
|
58
|
+
/** Strict bounds per normalized series ID; active bounds override Y viewports. */
|
|
59
|
+
amplitudeLimits?: WaveformAmplitudeLimits
|
|
57
60
|
yDomain?: [number, number]
|
|
58
61
|
yDomains?: Record<string, [number, number]>
|
|
59
62
|
timeUnit?: 's' | 'ms'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { scaleLinear } from 'd3'
|
|
2
|
+
|
|
3
|
+
const MAX_TICK_COUNT = 3000
|
|
4
|
+
|
|
5
|
+
/** ECharts-style numeric ticks inside the viewport; never expands its domain. */
|
|
6
|
+
export function resolveXAxisTicks(
|
|
7
|
+
domain: readonly [number, number],
|
|
8
|
+
splitNumber: number,
|
|
9
|
+
minInterval = 0,
|
|
10
|
+
): number[] {
|
|
11
|
+
const [start, end] = domain
|
|
12
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return []
|
|
13
|
+
if (start === end) return normalizeTicks([start], start, end)
|
|
14
|
+
if (start > end) return resolveXAxisTicks([end, start], splitNumber, minInterval).reverse()
|
|
15
|
+
|
|
16
|
+
const count = Number.isFinite(splitNumber) ? Math.max(1, Math.round(splitNumber)) : 5
|
|
17
|
+
const roughInterval = (end - start) / count
|
|
18
|
+
const exponent = Math.floor(Math.log10(roughInterval))
|
|
19
|
+
const power = 10 ** exponent
|
|
20
|
+
const fraction = roughInterval / power
|
|
21
|
+
const coefficient =
|
|
22
|
+
fraction < 1.5 ? 1 : fraction < 2.5 ? 2 : fraction < 4 ? 3 : fraction < 7 ? 5 : 10
|
|
23
|
+
const interval = Math.max(coefficient * power, minInterval)
|
|
24
|
+
const precision = Math.max(0, 2 - Math.floor(Math.log10(interval)))
|
|
25
|
+
const fallback = () => fallbackTicks(start, end, count)
|
|
26
|
+
|
|
27
|
+
// Extreme magnitudes may exceed decimal rounding or safe integer index precision.
|
|
28
|
+
if (!Number.isFinite(interval) || interval <= 0 || precision > 100) return fallback()
|
|
29
|
+
const first = Math.ceil(snapTickIndex(start / interval))
|
|
30
|
+
const last = Math.floor(snapTickIndex(end / interval))
|
|
31
|
+
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last)) return fallback()
|
|
32
|
+
if (last - first + 1 > MAX_TICK_COUNT) return fallback()
|
|
33
|
+
|
|
34
|
+
const ticks: number[] = []
|
|
35
|
+
for (let index = first; index <= last; index++) {
|
|
36
|
+
const value = Number((index * interval).toFixed(precision))
|
|
37
|
+
if (ticks.length && value <= ticks[ticks.length - 1]!) return fallback()
|
|
38
|
+
ticks.push(value)
|
|
39
|
+
}
|
|
40
|
+
return normalizeTicks(ticks, start, end)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fallbackTicks(start: number, end: number, count: number): number[] {
|
|
44
|
+
const scale = scaleLinear().domain([start, end])
|
|
45
|
+
let targetCount = Math.min(count, MAX_TICK_COUNT)
|
|
46
|
+
while (true) {
|
|
47
|
+
const ticks = normalizeTicks(scale.ticks(targetCount), start, end)
|
|
48
|
+
if (ticks.length <= MAX_TICK_COUNT) return ticks
|
|
49
|
+
// D3 treats count as a hint. Regenerate with a wider interval instead of truncating.
|
|
50
|
+
targetCount = Math.max(1, Math.floor(targetCount / 2))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeTicks(values: number[], start: number, end: number): number[] {
|
|
55
|
+
return [
|
|
56
|
+
...new Set(
|
|
57
|
+
values
|
|
58
|
+
.filter((value) => Number.isFinite(value) && value >= start && value <= end)
|
|
59
|
+
.map((value) => (value === 0 ? 0 : value)),
|
|
60
|
+
),
|
|
61
|
+
].sort((a, b) => a - b)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function snapTickIndex(value: number): number {
|
|
65
|
+
const rounded = Math.round(value)
|
|
66
|
+
return Math.abs(value - rounded) <= Number.EPSILON * Math.max(1, Math.abs(value)) * 4
|
|
67
|
+
? rounded
|
|
68
|
+
: value
|
|
69
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WaveformAmplitudeLimits } from '../../types'
|
|
1
2
|
import type { WaveformOverlayMode } from '../data/types'
|
|
2
3
|
import {
|
|
3
4
|
Y_AXIS_CHARACTER_WIDTH,
|
|
@@ -44,6 +45,7 @@ export function resolveYAxisLayoutMetrics(
|
|
|
44
45
|
padding?: YAxisPaddingOptions | number,
|
|
45
46
|
showUnits = true,
|
|
46
47
|
measureTextWidth?: (text: string) => number,
|
|
48
|
+
amplitudeLimits?: WaveformAmplitudeLimits,
|
|
47
49
|
): YAxisLayoutMetrics {
|
|
48
50
|
const labelBandWidth = resolveYAxisLabelBandWidth(
|
|
49
51
|
typeof padding === 'object' ? padding.labelTextStyle : undefined,
|
|
@@ -58,6 +60,7 @@ export function resolveYAxisLayoutMetrics(
|
|
|
58
60
|
yDomains,
|
|
59
61
|
viewportYDomains,
|
|
60
62
|
padding,
|
|
63
|
+
amplitudeLimits,
|
|
61
64
|
),
|
|
62
65
|
)
|
|
63
66
|
.map((group) =>
|
|
@@ -71,6 +74,7 @@ export function resolveYAxisLayoutMetrics(
|
|
|
71
74
|
measureTextWidth,
|
|
72
75
|
group.maximumTick,
|
|
73
76
|
group.minimumTick,
|
|
77
|
+
group.amplitudeLimit,
|
|
74
78
|
),
|
|
75
79
|
)
|
|
76
80
|
const tickTextWidth = Math.max(
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { WaveformAmplitudeLimit } from '../../types'
|
|
2
|
+
import { resolveYAxisTicksECharts } from './niceScale'
|
|
3
|
+
|
|
4
|
+
export interface ResolvedYAxisTicks {
|
|
5
|
+
domain: [number, number]
|
|
6
|
+
values: number[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveYAxisTicks(
|
|
10
|
+
domain: [number, number],
|
|
11
|
+
tickCount = 5,
|
|
12
|
+
nice = true,
|
|
13
|
+
limit?: WaveformAmplitudeLimit,
|
|
14
|
+
): ResolvedYAxisTicks {
|
|
15
|
+
if (!nice) {
|
|
16
|
+
// 如果不需要 nice,使用简单的线性分割
|
|
17
|
+
const effectiveTickCount =
|
|
18
|
+
typeof tickCount === 'number' && Number.isFinite(tickCount)
|
|
19
|
+
? Math.max(2, Math.floor(tickCount))
|
|
20
|
+
: 5
|
|
21
|
+
const [axisStart, axisEnd] = domain
|
|
22
|
+
return {
|
|
23
|
+
domain,
|
|
24
|
+
values: Array.from(
|
|
25
|
+
{ length: effectiveTickCount },
|
|
26
|
+
(_, index) => axisStart + ((axisEnd - axisStart) * index) / (effectiveTickCount - 1),
|
|
27
|
+
),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 使用 ECharts 兼容的 1/2/5 系列算法
|
|
32
|
+
const ticks = resolveYAxisTicksECharts(domain, tickCount)
|
|
33
|
+
if (!limit) return ticks
|
|
34
|
+
const bounded: [number, number] = [limit.lower ?? ticks.domain[0], limit.upper ?? ticks.domain[1]]
|
|
35
|
+
return {
|
|
36
|
+
domain: bounded,
|
|
37
|
+
values: ticks.values.filter((value) => value >= bounded[0] && value <= bounded[1]),
|
|
38
|
+
}
|
|
39
|
+
}
|