waveform-analysis 0.1.75 → 0.1.77

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 (78) hide show
  1. package/README.md +183 -42
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/animation/context.d.ts +1 -0
  6. package/dist/types/components/animation/paginationPresentation.d.ts +7 -7
  7. package/dist/types/components/animation/useWaveformAnimations.d.ts +1 -18
  8. package/dist/types/components/core/amplitudeLimits.d.ts +6 -0
  9. package/dist/types/components/core/layout.d.ts +6 -9
  10. package/dist/types/components/core/trackLayoutBuilder.d.ts +3 -1
  11. package/dist/types/components/core/useWaveformChartController.d.ts +2 -19
  12. package/dist/types/components/core/useWaveformData.d.ts +2 -2
  13. package/dist/types/components/core/useWaveformMeasurements.d.ts +4 -1
  14. package/dist/types/components/core/waveformChartTypes.d.ts +4 -2
  15. package/dist/types/components/core/xAxisTicks.d.ts +2 -0
  16. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  17. package/dist/types/components/core/yAxisTicks.d.ts +6 -0
  18. package/dist/types/components/data/types.d.ts +1 -1
  19. package/dist/types/components/interaction/integerZoom.d.ts +3 -3
  20. package/dist/types/components/interaction/stepZoom.d.ts +5 -0
  21. package/dist/types/components/interaction/useWaveformViewport.d.ts +0 -3
  22. package/dist/types/components/interaction/useWaveformZoom.d.ts +0 -2
  23. package/dist/types/components/rendering/WaveformSeriesLayer.vue.d.ts +0 -1
  24. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +0 -1
  25. package/dist/types/core/data.d.ts +1 -1
  26. package/dist/types/demo/DemoChartHost.vue.d.ts +1 -1
  27. package/dist/types/demo/remoteWaveformLoader.d.ts +23 -0
  28. package/dist/types/demo/types.d.ts +11 -2
  29. package/dist/types/demo/useRemoteWaveformWindow.d.ts +22 -0
  30. package/dist/types/index.d.ts +1 -1
  31. package/dist/types/types/chart.d.ts +8 -1
  32. package/dist/types/types/index.d.ts +1 -1
  33. package/dist/types/utils/formatters.d.ts +4 -4
  34. package/dist/types/utils/xAxisNumber.d.ts +2 -0
  35. package/package.json +1 -1
  36. package/src/App.vue +34 -100
  37. package/src/components/WaveformChartView.vue +0 -4
  38. package/src/components/animation/config.ts +1 -1
  39. package/src/components/animation/context.ts +9 -1
  40. package/src/components/animation/paginationPresentation.ts +55 -64
  41. package/src/components/animation/useWaveformAnimations.ts +36 -13
  42. package/src/components/controls/useChartCommands.ts +12 -13
  43. package/src/components/core/amplitudeLimits.ts +44 -0
  44. package/src/components/core/layout.ts +22 -34
  45. package/src/components/core/trackLayoutBuilder.ts +12 -5
  46. package/src/components/core/useWaveformChartController.ts +3 -4
  47. package/src/components/core/useWaveformChartLifecycle.ts +11 -2
  48. package/src/components/core/useWaveformData.ts +11 -5
  49. package/src/components/core/useWaveformLayout.ts +9 -4
  50. package/src/components/core/useWaveformMeasurements.ts +3 -0
  51. package/src/components/core/useWaveformYAxisLayout.ts +2 -0
  52. package/src/components/core/waveformChartTypes.ts +4 -1
  53. package/src/components/core/xAxisTicks.ts +69 -0
  54. package/src/components/core/yAxisLayoutMetrics.ts +4 -0
  55. package/src/components/core/yAxisTicks.ts +39 -0
  56. package/src/components/data/types.ts +2 -0
  57. package/src/components/interaction/integerZoom.ts +31 -12
  58. package/src/components/interaction/remoteViewport.ts +26 -4
  59. package/src/components/interaction/stepZoom.ts +33 -0
  60. package/src/components/interaction/useWaveformViewport.ts +3 -26
  61. package/src/components/interaction/useWaveformZoom.ts +50 -157
  62. package/src/components/interaction/zoomConstraints.ts +5 -5
  63. package/src/components/rendering/WaveformSeriesLayer.vue +1 -2
  64. package/src/components/rendering/WaveformTrack.vue +1 -6
  65. package/src/core/data.ts +5 -2
  66. package/src/data/simulatedWaveforms.ts +0 -10
  67. package/src/demo/DemoChartHost.vue +49 -4
  68. package/src/demo/remoteWaveformLoader.ts +114 -0
  69. package/src/demo/types.ts +11 -1
  70. package/src/demo/useRemoteWaveformWindow.ts +229 -0
  71. package/src/index.ts +2 -0
  72. package/src/types/chart.ts +10 -1
  73. package/src/types/index.ts +2 -0
  74. package/src/utils/formatters.ts +10 -5
  75. package/src/utils/xAxisNumber.ts +36 -0
  76. package/src/views/AnimationDemo.vue +14 -5
  77. package/dist/types/data/sx2Channel.d.ts +0 -10
  78. package/src/data/sx2Channel.ts +0 -315
@@ -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,
@@ -1,4 +1,5 @@
1
- import { scaleLinear } from 'd3'
1
+ import { resolveXAxisTicks } from '../core/xAxisTicks'
2
+ import { formatPlainNumber } from '../../utils/formatters'
2
3
 
3
4
  export interface IntegerZoomOptions {
4
5
  integerZoom?: boolean
@@ -10,17 +11,20 @@ export function integerZoomTicks(
10
11
  count: number,
11
12
  options: IntegerZoomOptions,
12
13
  ) {
13
- if (!options.integerZoom) return scaleLinear().domain(domain).ticks(count)
14
+ if (!options.integerZoom) return resolveXAxisTicks(domain, count)
14
15
  const factor = options.timeUnit === 's' ? 1 : 1000
15
- return scaleLinear()
16
- .domain(domain.map((value) => value * factor))
17
- .ticks(count)
18
- .filter(Number.isInteger)
16
+ const fractional = hasFractionalZoomDomain(domain, options)
17
+ return resolveXAxisTicks(
18
+ domain.map((value) => value * factor) as [number, number],
19
+ count,
20
+ fractional ? 0 : 1,
21
+ )
22
+ .filter((value) => fractional || Number.isInteger(value))
19
23
  .map((value) => value / factor)
20
24
  }
21
25
 
22
26
  export function formatIntegerZoomLabel(value: number): string {
23
- return String(Math.round(value))
27
+ return formatPlainNumber(Number(stableInteger(value).toPrecision(15)))
24
28
  }
25
29
 
26
30
  function stableInteger(value: number): number {
@@ -30,7 +34,17 @@ function stableInteger(value: number): number {
30
34
  : value
31
35
  }
32
36
 
33
- /** Align outwards in display units; all returned coordinates remain in seconds. */
37
+ function displayDomain(domain: [number, number], options: IntegerZoomOptions): [number, number] {
38
+ const factor = options.timeUnit === 's' ? 1 : 1000
39
+ return [stableInteger(domain[0] * factor), stableInteger(domain[1] * factor)]
40
+ }
41
+
42
+ /** Fractional viewports, including those panned across an integer, need decimal ticks. */
43
+ function hasFractionalZoomDomain(domain: [number, number], options: IntegerZoomOptions): boolean {
44
+ return displayDomain(domain, options).some((value) => !Number.isInteger(value))
45
+ }
46
+
47
+ /** Align outwards only when the displayed span reaches one unit. */
34
48
  export function alignIntegerZoomDomain(
35
49
  domain: [number, number],
36
50
  options: IntegerZoomOptions,
@@ -41,24 +55,29 @@ export function alignIntegerZoomDomain(
41
55
  const end = stableInteger(Math.max(domain[0], domain[1]) * factor)
42
56
  if (!Number.isSafeInteger(Math.floor(start)) || !Number.isSafeInteger(Math.ceil(end)))
43
57
  return domain
58
+ if (end > start && end - start < 1) return [start / factor, end / factor]
44
59
  const left = Math.floor(start)
45
60
  return [left / factor, Math.max(left + 1, Math.ceil(end)) / factor]
46
61
  }
47
62
 
48
- /** Recover aligned endpoints after a D3 transform, tolerating floating-point round-off. */
63
+ /** Recover endpoints after a D3 transform without applying a new zoom or changing pan span. */
49
64
  export function normalizeIntegerZoomDomain(
50
65
  domain: [number, number],
51
66
  options: IntegerZoomOptions,
52
67
  ): [number, number] {
53
- return alignIntegerZoomDomain(domain, options)
68
+ if (!options.integerZoom) return domain
69
+ const factor = options.timeUnit === 's' ? 1 : 1000
70
+ return displayDomain(domain, options).map((value) => value / factor) as [number, number]
54
71
  }
55
72
 
56
- /** Translation snaps to the nearest unit so panning preserves the visible span. */
73
+ /** Integer viewports snap translation; sub-unit viewports pan freely across integers. */
57
74
  export function alignIntegerPanDomain(
58
75
  domain: [number, number],
59
76
  options: IntegerZoomOptions,
60
77
  ): [number, number] {
61
78
  if (!options.integerZoom) return domain
62
79
  const factor = options.timeUnit === 's' ? 1 : 1000
63
- return [Math.round(domain[0] * factor) / factor, Math.round(domain[1] * factor) / factor]
80
+ if (Math.abs(domain[1] - domain[0]) * factor < 1) return domain
81
+ const start = Math.round(domain[0] * factor) / factor
82
+ return [start, start + (domain[1] - domain[0])]
64
83
  }
@@ -1,4 +1,6 @@
1
1
  import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+ import { hasMinimumVisibleXValues } from '../../core/rendering'
3
+ import { normalizeIntegerZoomDomain } from './integerZoom'
2
4
  import {
3
5
  constrainZoomDomain,
4
6
  resolveMinimumZoomSpan,
@@ -32,7 +34,13 @@ export function restoreViewportDomain(
32
34
  const boundary = remotePanBoundary(props)
33
35
  return boundary
34
36
  ? clampViewportDomain(domain, boundary)
35
- : constrainZoomDomain(domain, baseDomain, groups, props)
37
+ : normalizeIntegerZoomDomain(
38
+ constrainZoomDomain(domain, baseDomain, groups, {
39
+ ...props,
40
+ integerZoom: false,
41
+ }),
42
+ props,
43
+ )
36
44
  }
37
45
 
38
46
  /** Wheel/box zoom keeps the remote position and uses loaded data for scale limits. */
@@ -44,10 +52,24 @@ export function constrainViewportZoom(
44
52
  ) {
45
53
  const boundary = remotePanBoundary(props)
46
54
  if (!boundary) return constrainZoomDomain(domain, baseDomain, groups, props)
47
- return constrainZoomDomain(domain, boundary, [], {
55
+ const availableGroups = groups.filter((group) =>
56
+ hasMinimumVisibleXValues(group, boundary, props.minVisiblePoints),
57
+ )
58
+ // Only constrain against loaded groups that overlap the requested window. An
59
+ // unloaded remote window must remain free to move beyond the cached samples.
60
+ const loadedGroups = availableGroups.filter((group) =>
61
+ group.some((series) => {
62
+ const source = series.source
63
+ const length = source?.length ?? series.points.length
64
+ if (!length) return false
65
+ const first = source ? source.pointAt(0)! : series.points[0]
66
+ const last = source ? source.pointAt(length - 1)! : series.points[length - 1]
67
+ return first.x <= Math.max(...domain) && last.x >= Math.min(...domain)
68
+ }),
69
+ )
70
+ return constrainZoomDomain(domain, boundary, loadedGroups, {
48
71
  ...props,
49
- minVisiblePoints: 0,
50
72
  maxZoomScale: null,
51
- minZoomSpan: resolveMinimumZoomSpan(baseDomain, groups, props),
73
+ minZoomSpan: resolveMinimumZoomSpan(baseDomain, availableGroups, props),
52
74
  })
53
75
  }
@@ -0,0 +1,33 @@
1
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+ import { normalizeIntegerZoomDomain } from './integerZoom'
3
+ import { constrainViewportZoom } from './remoteViewport'
4
+ import type { ZoomSeriesGroup } from './zoomConstraints'
5
+
6
+ export const sameZoomDomain = (a: [number, number], b: [number, number]) =>
7
+ a.every((value, index) => Math.abs(value - b[index]!) < 1e-12)
8
+
9
+ /** Toolbar and wheel steps share their scale factor and constraints. */
10
+ export function resolveStepZoomDomain(
11
+ current: [number, number],
12
+ action: 'zoom-in' | 'zoom-out',
13
+ boundary: [number, number],
14
+ groups: readonly ZoomSeriesGroup[],
15
+ props: ResolvedWaveformChartProps,
16
+ anchorRatio = 0.5,
17
+ ): [number, number] {
18
+ const [start, end] = current
19
+ const ratio = Math.max(0, Math.min(1, anchorRatio))
20
+ const anchor = start + (end - start) * ratio
21
+ const span = (end - start) * (action === 'zoom-in' ? 0.5 : 2)
22
+ const constrained = constrainViewportZoom(
23
+ normalizeIntegerZoomDomain([anchor - span * ratio, anchor + span * (1 - ratio)], props),
24
+ boundary,
25
+ groups,
26
+ props,
27
+ )
28
+ // An unsatisfiable point constraint may expand to the entire boundary.
29
+ // A zoom-in step must not turn into a zoom-out operation.
30
+ return action === 'zoom-in' && constrained[1] - constrained[0] > end - start
31
+ ? [...current]
32
+ : constrained
33
+ }
@@ -12,6 +12,7 @@ import { constrainViewportZoom } from './remoteViewport'
12
12
  import { createViewportPan } from './viewportPan'
13
13
  import { transformForDomain } from './zoomConstraints'
14
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
15
+ import { sameZoomDomain } from './stepZoom'
15
16
  export interface ViewportContext {
16
17
  clearBoundaries?: (trackIndex?: number) => void
17
18
  props: ResolvedWaveformChartProps
@@ -32,8 +33,6 @@ export interface ViewportContext {
32
33
  isPresentationMode: ComputedRef<boolean>
33
34
  editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
34
35
  resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
35
- canZoomTrack: (track: TrackLayout) => boolean
36
- canZoomSharedTracks: () => boolean
37
36
  configureZoom: () => void
38
37
  cancelPendingZoom: () => void
39
38
  clearHover: () => void
@@ -59,8 +58,6 @@ export function useWaveformViewport(context: ViewportContext) {
59
58
  isPresentationMode,
60
59
  editorSeriesOptions,
61
60
  resolveInitialTrackDomain,
62
- canZoomTrack,
63
- canZoomSharedTracks,
64
61
  configureZoom,
65
62
  cancelPendingZoom,
66
63
  clearHover,
@@ -184,7 +181,7 @@ export function useWaveformViewport(context: ViewportContext) {
184
181
  }
185
182
  const applyBoxZoom = (active: ViewportSelectionState) => {
186
183
  const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
187
- if (!track || (active.independent ? !canZoomTrack(track) : !canZoomSharedTracks())) return
184
+ if (!track) return
188
185
  const offsetX = active.independent ? 0 : track.left
189
186
  const left = Math.max(
190
187
  0,
@@ -205,6 +202,7 @@ export function useWaveformViewport(context: ViewportContext) {
205
202
  groups,
206
203
  props,
207
204
  )
205
+ if (sameZoomDomain(track.xScale.domain() as [number, number], xDomain)) return
208
206
  if (active.independent) {
209
207
  const next = [...independentTransforms.value]
210
208
  next[track.index] = transformForDomain(xDomain, baseXDomain, track.width)
@@ -333,26 +331,6 @@ export function useWaveformViewport(context: ViewportContext) {
333
331
  editorSeriesOptions,
334
332
  configureZoom,
335
333
  })
336
- const requestViewportReset = (event: MouseEvent) => {
337
- if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
338
- if (props.displayMode === 'independent') {
339
- const target = event.target instanceof Element ? event.target : null
340
- const overlay = target?.closest('[data-independent-overlay-index]')
341
- const trackIndex = Number(overlay?.getAttribute('data-independent-overlay-index'))
342
- const track = trackLayouts.value.find((item) => item.index === trackIndex)
343
- if (!track) return
344
- event.preventDefault()
345
- resetViewport(trackIndex)
346
- emit('zoom-reset', {
347
- trackIndex,
348
- seriesIds: track.legendSeries.map((series) => series.id),
349
- })
350
- return
351
- }
352
- event.preventDefault()
353
- resetViewport()
354
- emit('zoom-reset', {})
355
- }
356
334
  return {
357
335
  selectionBox,
358
336
  beginViewportDrag,
@@ -362,6 +340,5 @@ export function useWaveformViewport(context: ViewportContext) {
362
340
  cancelViewportDrag,
363
341
  resetViewport,
364
342
  setViewportDomain,
365
- requestViewportReset,
366
343
  }
367
344
  }