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
@@ -1,15 +1,20 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
 
4
4
  import { WaveformChart } from '../components'
5
5
  import type { DemoChartModel } from './types'
6
+ import { applyXDomainStrategy } from '../components/core/xDomain'
6
7
 
7
8
  const model = defineModel<DemoChartModel>('model', { required: true })
9
+ const xDomainStrategy = { type: 'integer-ms' as const, includeExplicit: true }
10
+ const panXDomain = computed(() =>
11
+ applyXDomainStrategy(model.value.recordDomain, xDomainStrategy, true),
12
+ )
8
13
 
9
14
  const waveformChartRef = ref<{ resetViewport: (trackIndex?: number) => void }>()
10
15
 
11
- function resetViewport() {
12
- waveformChartRef.value?.resetViewport()
16
+ function resetViewport(trackIndex?: number) {
17
+ waveformChartRef.value?.resetViewport(trackIndex)
13
18
  }
14
19
 
15
20
  defineExpose({ resetViewport })
@@ -17,6 +22,17 @@ defineExpose({ resetViewport })
17
22
 
18
23
  <template>
19
24
  <section class="chart-panel">
25
+ <div
26
+ v-if="model.loading || model.errors.length"
27
+ class="remote-window-status"
28
+ aria-live="polite"
29
+ >
30
+ <span v-if="model.loading" role="status">正在加载当前时间范围…</span>
31
+ <template v-if="model.errors.length">
32
+ <span role="alert">{{ model.errors.join(';') }}</span>
33
+ <button type="button" :disabled="model.loading" @click="model.retry()">重试</button>
34
+ </template>
35
+ </div>
20
36
  <WaveformChart
21
37
  ref="waveformChartRef"
22
38
  layout-preset="edge-compact"
@@ -25,15 +41,19 @@ defineExpose({ resetViewport })
25
41
  v-model:hidden-series-ids="model.hiddenSeriesIds"
26
42
  :data="model.data"
27
43
  :min-visible-points="2"
44
+ :min-zoom-span="model.minZoomSpan"
45
+ :max-zoom-scale="null"
46
+ :pan-x-domain="panXDomain"
28
47
  :integer-zoom="model.integerZoom"
29
48
  :initial-x-domain="model.initialXDomain"
30
- :x-domain-strategy="{ type: 'integer-ms', includeExplicit: true }"
49
+ :x-domain-strategy="xDomainStrategy"
31
50
  :display-mode="model.displayMode"
32
51
  :overlay-mode="model.overlayMode"
33
52
  :grid="{
34
53
  rowCount: model.rowCount,
35
54
  columnCount: model.columnCount,
36
55
  showPagination: true,
56
+ fillIncompleteLastRow: true,
37
57
  trackLines: model.gridTrackLines,
38
58
  }"
39
59
  :title="model.title"
@@ -55,8 +75,33 @@ defineExpose({ resetViewport })
55
75
  v-model:interaction-mode="model.interactionMode"
56
76
  :toolbar="model.toolbarVisible"
57
77
  pannable
78
+ @zoom-intent="model.handleZoomIntent"
79
+ @zoom-change="model.handleZoomChange"
58
80
  @zoom-end="model.handleZoomEnd"
81
+ @pan-end="model.handlePanEnd"
59
82
  @zoom-reset="model.resetWaveformViewport"
60
83
  />
61
84
  </section>
62
85
  </template>
86
+
87
+ <style scoped>
88
+ .chart-panel {
89
+ position: relative;
90
+ }
91
+
92
+ .remote-window-status {
93
+ position: absolute;
94
+ top: 6px;
95
+ right: 12px;
96
+ z-index: 2;
97
+ display: flex;
98
+ align-items: center;
99
+ gap: 8px;
100
+ max-width: calc(100% - 24px);
101
+ padding: 6px 10px;
102
+ border: 1px solid #d0d5dd;
103
+ border-radius: 4px;
104
+ background: #fff;
105
+ color: #344054;
106
+ }
107
+ </style>
@@ -0,0 +1,114 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import { normalizeWaveformSeries } from '../core'
3
+ import { normalizeWaveformSeriesSources } from '../core/data'
4
+ import type { WaveformData } from '../types'
5
+
6
+ export interface RemoteWindowRequest {
7
+ start: number
8
+ end: number
9
+ seriesIds?: string[]
10
+ }
11
+
12
+ export type RemoteWindowLoader = (
13
+ request: RemoteWindowRequest,
14
+ signal: AbortSignal,
15
+ ) => Promise<WaveformData>
16
+
17
+ export const remoteWindowLoaderKey: InjectionKey<RemoteWindowLoader> = Symbol('remoteWindowLoader')
18
+
19
+ export interface MockWindowOptions {
20
+ delayMs?: number | ((request: RemoteWindowRequest) => number)
21
+ empty?: boolean | ((request: RemoteWindowRequest) => boolean)
22
+ fail?: boolean | ((request: RemoteWindowRequest) => boolean)
23
+ }
24
+
25
+ /** Preserve series identities even when a window has no samples. */
26
+ export function selectWaveformWindow(
27
+ data: WaveformData,
28
+ request: RemoteWindowRequest,
29
+ empty = false,
30
+ ): WaveformData {
31
+ const series = normalizeWaveformSeriesSources(data, true)
32
+ const selected = request.seriesIds ? new Set(request.seriesIds) : undefined
33
+ const windows = series
34
+ .filter((item) => !selected || selected.has(item.id))
35
+ .map(({ source, ...item }) => {
36
+ const range = source.visibleRange([request.start, request.end])
37
+ return {
38
+ ...item,
39
+ data: {
40
+ kind: 'points' as const,
41
+ points: empty ? [] : source.pointsInRange(range.start, range.end),
42
+ },
43
+ }
44
+ })
45
+ return data.kind === 'series'
46
+ ? { kind: 'series', series: windows }
47
+ : (windows[0]?.data ?? { kind: 'points', points: [] })
48
+ }
49
+
50
+ export function mergeWaveformWindow(
51
+ current: WaveformData,
52
+ response: WaveformData,
53
+ seriesIds?: string[],
54
+ ): WaveformData {
55
+ if (!seriesIds || current.kind !== 'series' || response.kind !== 'series') return response
56
+ const selected = new Set(seriesIds)
57
+ const byId = new Map(response.series.map((series) => [series.id, series]))
58
+ return {
59
+ kind: 'series',
60
+ series: current.series.map((series) =>
61
+ series.id && selected.has(series.id) ? (byId.get(series.id) ?? series) : series,
62
+ ),
63
+ }
64
+ }
65
+
66
+ /** Metadata is derived from the complete record, never from a loaded window. */
67
+ export function waveformRecordMetadata(data: WaveformData) {
68
+ let minimum = Infinity
69
+ let maximum = -Infinity
70
+ let minZoomSpan = Infinity
71
+ for (const series of normalizeWaveformSeries(data)) {
72
+ for (let index = 0; index < series.points.length; index++) {
73
+ const x = series.points[index].x
74
+ minimum = Math.min(minimum, x)
75
+ maximum = Math.max(maximum, x)
76
+ const previous = series.points[index - 1]?.x
77
+ if (previous !== undefined && x > previous) {
78
+ minZoomSpan = Math.min(minZoomSpan, x - previous)
79
+ }
80
+ }
81
+ }
82
+ return {
83
+ domain: (minimum < maximum ? [minimum, maximum] : [0, 1]) as [number, number],
84
+ minZoomSpan: Number.isFinite(minZoomSpan) ? minZoomSpan : undefined,
85
+ }
86
+ }
87
+
88
+ export function createMockWindowLoader(
89
+ source: () => WaveformData,
90
+ options: MockWindowOptions = {},
91
+ ): RemoteWindowLoader {
92
+ return (request, signal) =>
93
+ new Promise((resolve, reject) => {
94
+ const abort = () => {
95
+ clearTimeout(timer)
96
+ signal.removeEventListener('abort', abort)
97
+ reject(new DOMException('Request cancelled', 'AbortError'))
98
+ }
99
+ const delay =
100
+ typeof options.delayMs === 'function' ? options.delayMs(request) : options.delayMs
101
+ const timer = setTimeout(() => {
102
+ signal.removeEventListener('abort', abort)
103
+ const fail = typeof options.fail === 'function' ? options.fail(request) : options.fail
104
+ if (fail) {
105
+ reject(new Error('模拟数据加载失败'))
106
+ return
107
+ }
108
+ const empty = typeof options.empty === 'function' ? options.empty(request) : options.empty
109
+ resolve(selectWaveformWindow(source(), request, empty))
110
+ }, delay ?? 80)
111
+ signal.addEventListener('abort', abort, { once: true })
112
+ if (signal.aborted) abort()
113
+ })
114
+ }
package/src/demo/types.ts CHANGED
@@ -14,7 +14,9 @@ import type {
14
14
  WaveformTitleOptions,
15
15
  WaveformZeroLineOptions,
16
16
  WaveformZoomEndPayload,
17
+ WaveformZoomIntentPayload,
17
18
  } from '../components'
19
+ import type { WaveformZoomResetPayload, WaveformPanEndPayload } from '../types'
18
20
  import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
19
21
 
20
22
  interface SelectOption<T> {
@@ -97,6 +99,11 @@ export interface DemoChartModel {
97
99
  toolbarVisible: boolean
98
100
  data: WaveformData
99
101
  initialXDomain?: [number, number]
102
+ recordDomain: [number, number]
103
+ minZoomSpan?: number
104
+ loading: boolean
105
+ errors: string[]
106
+ retry: () => Promise<void[]>
100
107
  displayMode: WaveformDisplayMode
101
108
  overlayMode: WaveformOverlayMode
102
109
  rowCount: number
@@ -120,5 +127,8 @@ export interface DemoChartModel {
120
127
  interactionMode: WaveformInteractionMode
121
128
  hiddenSeriesIds: string[]
122
129
  handleZoomEnd: (payload: WaveformZoomEndPayload) => void | Promise<void>
123
- resetWaveformViewport: () => void
130
+ handleZoomIntent: (payload: WaveformZoomIntentPayload) => void
131
+ handleZoomChange: (domain: [number, number]) => void
132
+ handlePanEnd: (payload: WaveformPanEndPayload) => void | Promise<void>
133
+ resetWaveformViewport: (payload?: WaveformZoomResetPayload) => void | Promise<void>
124
134
  }
@@ -0,0 +1,229 @@
1
+ import { computed, onScopeDispose, shallowRef, watch, type Ref } from 'vue'
2
+ import type {
3
+ WaveformData,
4
+ WaveformPanEndPayload,
5
+ WaveformZoomEndPayload,
6
+ WaveformZoomIntentPayload,
7
+ WaveformZoomResetPayload,
8
+ } from '../types'
9
+ import {
10
+ createMockWindowLoader,
11
+ mergeWaveformWindow,
12
+ selectWaveformWindow,
13
+ waveformRecordMetadata,
14
+ type RemoteWindowLoader,
15
+ type RemoteWindowRequest,
16
+ } from './remoteWaveformLoader'
17
+
18
+ interface WindowState {
19
+ request: RemoteWindowRequest
20
+ version: number
21
+ controller?: AbortController
22
+ loading: boolean
23
+ error?: string
24
+ }
25
+
26
+ interface RemoteWindowOptions {
27
+ loadWindow?: RemoteWindowLoader
28
+ resetViewport?: (trackIndex?: number) => void
29
+ }
30
+
31
+ type WindowPayload = WaveformPanEndPayload | WaveformZoomEndPayload
32
+
33
+ export function useRemoteWaveformWindow(
34
+ source: Ref<WaveformData>,
35
+ options: RemoteWindowOptions = {},
36
+ ) {
37
+ const metadata = computed(() => waveformRecordMetadata(source.value))
38
+ const recordDomain = computed(() => metadata.value.domain)
39
+ const initialXDomain = computed(() => recordDomain.value)
40
+ const minZoomSpan = computed(() => metadata.value.minZoomSpan)
41
+ const chartData = shallowRef<WaveformData>(
42
+ selectWaveformWindow(
43
+ source.value,
44
+ { start: recordDomain.value[0], end: recordDomain.value[1] },
45
+ true,
46
+ ),
47
+ )
48
+ const states = shallowRef(new Map<string, WindowState>())
49
+ const loadWindow = options.loadWindow ?? createMockWindowLoader(() => source.value)
50
+ let disposed = false
51
+ let intentTarget: string | undefined
52
+ let intentSequence = 0
53
+ const pendingCommands = new Map<string, object>()
54
+
55
+ const keyFor = (seriesIds?: string[]) =>
56
+ seriesIds?.length ? JSON.stringify([...seriesIds].sort()) : 'shared'
57
+ const publish = () => (states.value = new Map(states.value))
58
+
59
+ function cancel(key: string) {
60
+ const state = states.value.get(key)
61
+ if (!state) return
62
+ state.version++
63
+ state.controller?.abort()
64
+ state.controller = undefined
65
+ state.loading = false
66
+ state.error = undefined
67
+ }
68
+
69
+ function cancelAll() {
70
+ for (const key of states.value.keys()) cancel(key)
71
+ publish()
72
+ }
73
+
74
+ function handleZoomIntent(payload: WaveformZoomIntentPayload) {
75
+ const key = keyFor(payload.seriesIds)
76
+ if (key === 'shared') pendingCommands.clear()
77
+ else pendingCommands.delete(key)
78
+ if (key === 'shared') cancelAll()
79
+ else {
80
+ cancel('shared')
81
+ cancel(key)
82
+ }
83
+ const previous = states.value.get(key)
84
+ states.value.set(key, {
85
+ request: { start: payload.start, end: payload.end, seriesIds: payload.seriesIds },
86
+ version: previous?.version ?? 0,
87
+ loading: false,
88
+ })
89
+ publish()
90
+ // Command intent/change/end arrive synchronously per target. Scope their
91
+ // changes so the next target cannot cancel a valid request in the same batch.
92
+ intentTarget = key
93
+ const sequence = ++intentSequence
94
+ queueMicrotask(() => {
95
+ if (sequence === intentSequence) intentTarget = undefined
96
+ })
97
+ }
98
+
99
+ function handleZoomChange(domain: [number, number]) {
100
+ if (intentTarget) {
101
+ cancel(intentTarget)
102
+ publish()
103
+ } else {
104
+ cancelAll()
105
+ // This event has no target metadata. It must never start an unscoped
106
+ // request in independent mode; pan-end supplies the final target.
107
+ for (const state of states.value.values()) {
108
+ state.request = { ...state.request, start: domain[0], end: domain[1] }
109
+ }
110
+ }
111
+ }
112
+
113
+ async function load(payload: WindowPayload) {
114
+ if (disposed) return
115
+ const key = keyFor(payload.seriesIds)
116
+ if (key === 'shared') cancelAll()
117
+ else cancel('shared')
118
+ cancel(key)
119
+ const controller = new AbortController()
120
+ const state: WindowState = {
121
+ request: {
122
+ start: payload.start,
123
+ end: payload.end,
124
+ ...(payload.seriesIds?.length ? { seriesIds: [...payload.seriesIds] } : {}),
125
+ },
126
+ version: (states.value.get(key)?.version ?? 0) + 1,
127
+ controller,
128
+ loading: true,
129
+ }
130
+ const version = state.version
131
+ const record = source.value
132
+ states.value.set(key, state)
133
+ publish()
134
+ const current = () =>
135
+ !disposed &&
136
+ source.value === record &&
137
+ !controller.signal.aborted &&
138
+ states.value.get(key) === state &&
139
+ state.version === version
140
+ try {
141
+ const response = await loadWindow(state.request, controller.signal)
142
+ if (!current()) return
143
+ chartData.value = mergeWaveformWindow(chartData.value, response, state.request.seriesIds)
144
+ } catch (error) {
145
+ if (!current()) return
146
+ state.error = error instanceof Error ? error.message : '数据加载失败'
147
+ } finally {
148
+ if (current()) {
149
+ state.loading = false
150
+ state.controller = undefined
151
+ publish()
152
+ }
153
+ }
154
+ }
155
+
156
+ function resetWaveformViewport(payload?: WaveformZoomResetPayload) {
157
+ pendingCommands.clear()
158
+ if (!payload?.seriesIds?.length) {
159
+ cancelAll()
160
+ states.value = new Map()
161
+ }
162
+ options.resetViewport?.(payload?.trackIndex)
163
+ return load({
164
+ start: initialXDomain.value[0],
165
+ end: initialXDomain.value[1],
166
+ seriesIds: payload?.seriesIds,
167
+ })
168
+ }
169
+
170
+ function retry() {
171
+ return Promise.all(
172
+ [...states.value.values()].filter((state) => state.error).map((state) => load(state.request)),
173
+ )
174
+ }
175
+
176
+ async function handleZoomEnd(payload: WaveformZoomEndPayload) {
177
+ if (!payload.commandId) return load(payload)
178
+ const key = keyFor(payload.seriesIds)
179
+ const pending = {}
180
+ const record = source.value
181
+ pendingCommands.set(key, pending)
182
+ // Collect the synchronous command batch before starting requests. Fit has
183
+ // no intent event, so its unscoped changes would otherwise cancel siblings.
184
+ await Promise.resolve()
185
+ if (disposed || source.value !== record || pendingCommands.get(key) !== pending) return
186
+ pendingCommands.delete(key)
187
+ return load(payload)
188
+ }
189
+
190
+ function handlePanEnd(payload: WaveformPanEndPayload) {
191
+ pendingCommands.clear()
192
+ return load(payload)
193
+ }
194
+
195
+ watch(source, () => {
196
+ pendingCommands.clear()
197
+ cancelAll()
198
+ states.value = new Map()
199
+ intentTarget = undefined
200
+ chartData.value = selectWaveformWindow(
201
+ source.value,
202
+ { start: recordDomain.value[0], end: recordDomain.value[1] },
203
+ true,
204
+ )
205
+ void resetWaveformViewport()
206
+ })
207
+ onScopeDispose(() => {
208
+ disposed = true
209
+ pendingCommands.clear()
210
+ cancelAll()
211
+ })
212
+
213
+ return {
214
+ chartData,
215
+ recordDomain,
216
+ initialXDomain,
217
+ minZoomSpan,
218
+ loading: computed(() => [...states.value.values()].some((state) => state.loading)),
219
+ errors: computed(() =>
220
+ [...states.value.values()].flatMap((state) => (state.error ? [state.error] : [])),
221
+ ),
222
+ handleZoomIntent,
223
+ handleZoomChange,
224
+ handleZoomEnd,
225
+ handlePanEnd,
226
+ resetWaveformViewport,
227
+ retry,
228
+ }
229
+ }
package/src/index.ts CHANGED
@@ -62,6 +62,8 @@ export type {
62
62
  // 类型定义
63
63
  export type {
64
64
  // 图表类型
65
+ WaveformAmplitudeLimit,
66
+ WaveformAmplitudeLimits,
65
67
  WaveformPoint,
66
68
  WaveformDisplayMode,
67
69
  WaveformUnitDisplayMode,
@@ -81,7 +81,7 @@ export interface WaveformZoomIntentPayload extends WaveformCommandMetadata {
81
81
  seriesIds?: string[]
82
82
  }
83
83
 
84
- /** Identifies the viewport reset by a double-click gesture. */
84
+ /** Identifies the viewport reset by a toolbar or API command. */
85
85
  export interface WaveformZoomResetPayload extends WaveformCommandMetadata {
86
86
  trackIndex?: number
87
87
  seriesIds?: string[]
@@ -319,3 +319,12 @@ export interface WaveformZeroLineOptions {
319
319
  /** Hide within this fraction of a Y-axis tick interval from either boundary. Defaults to 0.02. */
320
320
  boundaryThreshold?: number
321
321
  }
322
+
323
+ /** Strict amplitude display bounds, in the series' original Y units. */
324
+ export interface WaveformAmplitudeLimit {
325
+ lower?: number
326
+ upper?: number
327
+ }
328
+
329
+ /** Keyed by stable normalized series IDs (not track IDs). */
330
+ export type WaveformAmplitudeLimits = Record<string, WaveformAmplitudeLimit>
@@ -4,6 +4,8 @@
4
4
 
5
5
  // 图表类型
6
6
  export type {
7
+ WaveformAmplitudeLimit,
8
+ WaveformAmplitudeLimits,
7
9
  WaveformPoint,
8
10
  WaveformDisplayMode,
9
11
  WaveformUnitDisplayMode,
@@ -1,4 +1,5 @@
1
1
  import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
2
+ import { formatXAxisNumber } from './xAxisNumber'
2
3
 
3
4
  /**
4
5
  * 时间单位类型
@@ -147,7 +148,11 @@ export function formatXAxisLabel(
147
148
  formatter?: WaveformXAxisLabelFormatter,
148
149
  ): string {
149
150
  const value = displayTime(rawValue, timeUnit)
150
- if (!formatter) return formatPlainNumber(value)
151
+ if (!formatter)
152
+ return formatXAxisNumber(value, [
153
+ displayTime(domain[0], timeUnit),
154
+ displayTime(domain[1], timeUnit),
155
+ ])
151
156
 
152
157
  return formatter(value, {
153
158
  kind,
@@ -173,9 +178,9 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
173
178
  }
174
179
 
175
180
  /**
176
- * 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
181
+ * 将 X 轴端点时间格式化为当前显示单位下的自适应精度文本
177
182
  * @param value 时间值(秒)
178
- * @param _domain 数据域(为保持现有调用签名而保留)
183
+ * @param _domain 用于计算显示精度的可视域
179
184
  * @param timeUnit 时间单位
180
185
  * @returns 格式化的时间字符串
181
186
  */
@@ -188,10 +193,10 @@ export function formatEndpointTime(
188
193
  }
189
194
 
190
195
  /**
191
- * 将 X 轴时间刻度格式化为当前显示单位下的普通十进制文本
196
+ * 将 X 轴时间刻度格式化为当前显示单位下的自适应精度文本
192
197
  * @param value 时间值(秒)
193
198
  * @param timeUnit 时间单位
194
- * @param _domain 数据域(为保持现有调用签名而保留)
199
+ * @param _domain 用于计算显示精度的可视域
195
200
  * @returns 格式化的时间字符串
196
201
  */
197
202
  export function formatAxisTime(
@@ -0,0 +1,36 @@
1
+ /** Compact labels share precision so even extremely close endpoints stay distinct. */
2
+ export function formatXAxisNumber(value: number, domain: [number, number]): string {
3
+ if (!Number.isFinite(value)) return String(value)
4
+ const span = Math.abs(domain[1] - domain[0])
5
+ const digits =
6
+ Number.isFinite(span) && span > 0 ? Math.max(0, Math.ceil(-Math.log10(span)) + 1) : 0
7
+ const clean = (number: number) => Number(number.toPrecision(15))
8
+ const fractionalDigits = (number: number) => {
9
+ const text = String(clean(number))
10
+ return text.includes('e') ? 6 : (text.split('.')[1]?.length ?? 0)
11
+ }
12
+ // Wide windows can still have fractional boundaries; do not label 2.76 as 3.
13
+ const precision =
14
+ span >= 1 || span === 0 ? Math.max(digits, Math.min(6, fractionalDigits(value))) : digits
15
+ const fixed = (number: number) => {
16
+ const rounded = Number(clean(number).toFixed(Math.min(6, precision)))
17
+ return Math.abs(rounded) >= 1e21
18
+ ? rounded.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 6 })
19
+ : String(rounded)
20
+ }
21
+ if (
22
+ precision <= 6 &&
23
+ (value === 0 || Number(fixed(value)) !== 0) &&
24
+ (span === 0 || fixed(domain[0]) !== fixed(domain[1]))
25
+ ) {
26
+ return fixed(value)
27
+ }
28
+ for (let significant = 2; significant <= 17; significant++) {
29
+ const scientific = (number: number) => {
30
+ const [mantissa, exponent] = number.toExponential(significant - 1).split('e')
31
+ return `${Number(mantissa)}e${exponent}`
32
+ }
33
+ if (span === 0 || scientific(domain[0]) !== scientific(domain[1])) return scientific(value)
34
+ }
35
+ return value.toExponential(16)
36
+ }
@@ -1,10 +0,0 @@
1
- export declare const sx2Channel: {
2
- chnl: string;
3
- chnl_id: number;
4
- dat_unit: string;
5
- dev: number;
6
- shot: number;
7
- time_unit: string;
8
- data: number[];
9
- time: number[];
10
- };