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
|
@@ -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="
|
|
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
|
-
|
|
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
package/src/types/chart.ts
CHANGED
|
@@ -81,7 +81,7 @@ export interface WaveformZoomIntentPayload extends WaveformCommandMetadata {
|
|
|
81
81
|
seriesIds?: string[]
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/** Identifies the viewport reset by a
|
|
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>
|
package/src/types/index.ts
CHANGED
package/src/utils/formatters.ts
CHANGED
|
@@ -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)
|
|
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
|
+
}
|