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.
Files changed (65) hide show
  1. package/README.md +178 -23
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/types/components/core/amplitudeLimits.d.ts +6 -0
  5. package/dist/types/components/core/layout.d.ts +6 -9
  6. package/dist/types/components/core/trackLayoutBuilder.d.ts +3 -1
  7. package/dist/types/components/core/useWaveformChartController.d.ts +1 -1
  8. package/dist/types/components/core/useWaveformData.d.ts +2 -2
  9. package/dist/types/components/core/useWaveformMeasurements.d.ts +4 -1
  10. package/dist/types/components/core/waveformChartTypes.d.ts +4 -2
  11. package/dist/types/components/core/xAxisTicks.d.ts +2 -0
  12. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  13. package/dist/types/components/core/yAxisTicks.d.ts +6 -0
  14. package/dist/types/components/data/types.d.ts +1 -1
  15. package/dist/types/components/interaction/integerZoom.d.ts +3 -3
  16. package/dist/types/components/interaction/stepZoom.d.ts +5 -0
  17. package/dist/types/components/interaction/useWaveformViewport.d.ts +0 -3
  18. package/dist/types/components/interaction/useWaveformZoom.d.ts +0 -2
  19. package/dist/types/core/data.d.ts +1 -1
  20. package/dist/types/demo/DemoChartHost.vue.d.ts +1 -1
  21. package/dist/types/demo/remoteWaveformLoader.d.ts +23 -0
  22. package/dist/types/demo/types.d.ts +11 -2
  23. package/dist/types/demo/useRemoteWaveformWindow.d.ts +22 -0
  24. package/dist/types/index.d.ts +1 -1
  25. package/dist/types/types/chart.d.ts +8 -1
  26. package/dist/types/types/index.d.ts +1 -1
  27. package/dist/types/utils/formatters.d.ts +4 -4
  28. package/dist/types/utils/xAxisNumber.d.ts +2 -0
  29. package/package.json +1 -1
  30. package/src/App.vue +34 -100
  31. package/src/components/WaveformChartView.vue +0 -2
  32. package/src/components/controls/useChartCommands.ts +12 -13
  33. package/src/components/core/amplitudeLimits.ts +44 -0
  34. package/src/components/core/layout.ts +22 -34
  35. package/src/components/core/trackLayoutBuilder.ts +12 -5
  36. package/src/components/core/useWaveformChartController.ts +3 -3
  37. package/src/components/core/useWaveformChartLifecycle.ts +11 -2
  38. package/src/components/core/useWaveformData.ts +11 -5
  39. package/src/components/core/useWaveformLayout.ts +9 -4
  40. package/src/components/core/useWaveformMeasurements.ts +3 -0
  41. package/src/components/core/useWaveformYAxisLayout.ts +2 -0
  42. package/src/components/core/waveformChartTypes.ts +4 -1
  43. package/src/components/core/xAxisTicks.ts +69 -0
  44. package/src/components/core/yAxisLayoutMetrics.ts +4 -0
  45. package/src/components/core/yAxisTicks.ts +39 -0
  46. package/src/components/data/types.ts +2 -0
  47. package/src/components/interaction/integerZoom.ts +31 -12
  48. package/src/components/interaction/remoteViewport.ts +26 -4
  49. package/src/components/interaction/stepZoom.ts +33 -0
  50. package/src/components/interaction/useWaveformViewport.ts +3 -26
  51. package/src/components/interaction/useWaveformZoom.ts +50 -157
  52. package/src/components/interaction/zoomConstraints.ts +5 -5
  53. package/src/core/data.ts +5 -2
  54. package/src/data/simulatedWaveforms.ts +0 -10
  55. package/src/demo/DemoChartHost.vue +49 -4
  56. package/src/demo/remoteWaveformLoader.ts +114 -0
  57. package/src/demo/types.ts +11 -1
  58. package/src/demo/useRemoteWaveformWindow.ts +229 -0
  59. package/src/index.ts +2 -0
  60. package/src/types/chart.ts +10 -1
  61. package/src/types/index.ts +2 -0
  62. package/src/utils/formatters.ts +10 -5
  63. package/src/utils/xAxisNumber.ts +36 -0
  64. package/dist/types/data/sx2Channel.d.ts +0 -10
  65. 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 = (a: Domain, b: Domain) => a.every((v, i) => Math.abs(v - b[i]!) < 1e-12)
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
- let requested: Domain = domain ?? bounds
108
- if (before && (action === 'zoom-in' || action === 'zoom-out')) {
109
- const [a, b] = before.xDomain
110
- const half = (b - a) * (action === 'zoom-in' ? 0.25 : 1)
111
- requested = [(a + b) / 2 - half, (a + b) / 2 + half]
112
- }
113
- const xDomain = constrainViewportZoom(
114
- normalizeIntegerZoomDomain(requested, c.props),
115
- bounds,
116
- tracks.filter((t) => t.hasVisibleSeries).map((t) => t.seriesList),
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 { WaveformOverlayMode } from '../../types'
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 { resolveYAxisTicksECharts } from './niceScale'
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 padded = applyYAxisPadding(group, viewportYDomain, padding)
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(group.domain, tickCount, options.yAxisNice !== false)
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, props.integerZoom ? props.timeUnit : undefined],
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(data: WaveformData): PreparedWaveformSeries[] {
35
- return normalizeWaveformSeriesSources(data).map(({ source, ...series }) => {
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[]>(prepareWaveformSeries(data()))
66
- watch(data, (nextData) => {
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
- props,
180
+ domainOptions(),
178
181
  )
179
182
  }
180
183
  return alignIntegerZoomDomain(
181
184
  applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy),
182
- props,
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
- props,
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
- props,
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
- /** Snap viewport endpoints outwards to integer X-axis display units. */
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
+ }
@@ -5,6 +5,8 @@
5
5
 
6
6
  // 重新导出所有类型
7
7
  export type {
8
+ WaveformAmplitudeLimit,
9
+ WaveformAmplitudeLimits,
8
10
  WaveformPoint,
9
11
  WaveformDisplayMode,
10
12
  WaveformUnitDisplayMode,