waveform-analysis 0.1.70 → 0.1.72
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 +244 -45
- package/dist/assets/workerRuntime-5p-EI-GL.js +1 -0
- package/dist/index.cjs +2 -1
- package/dist/index.js +2924 -1915
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +24 -6
- package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
- package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
- package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
- package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
- package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
- package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
- package/dist/types/components/controls/useChartCommands.d.ts +42 -0
- package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
- package/dist/types/components/controls/useControlMode.d.ts +8 -0
- package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
- package/dist/types/components/core/layout.d.ts +5 -3
- package/dist/types/components/core/niceScale.d.ts +34 -0
- package/dist/types/components/core/pagination.d.ts +2 -2
- package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
- package/dist/types/components/core/trackPagination.d.ts +1 -1
- package/dist/types/components/core/types.d.ts +3 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +24 -5
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
- package/dist/types/components/core/useWaveformData.d.ts +1 -0
- package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
- package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
- package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
- package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
- package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
- package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
- package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
- package/dist/types/components/core/yAxisPadding.d.ts +7 -0
- package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
- package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
- package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
- package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +0 -1
- package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
- package/dist/types/components/rendering/compactFrame.d.ts +5 -0
- package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
- package/dist/types/core/lineBoundary.d.ts +4 -0
- package/dist/types/core/rendering.d.ts +4 -0
- package/dist/types/core/renderingStrategies.d.ts +2 -0
- package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
- package/dist/types/data/simulatedWaveforms.d.ts +1 -1
- package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
- package/dist/types/demo/types.d.ts +6 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/types/chart.d.ts +30 -8
- package/dist/types/types/controls.d.ts +69 -0
- package/dist/types/types/index.d.ts +2 -1
- package/dist/types/utils/formatters.d.ts +1 -0
- package/package.json +7 -6
- package/src/App.vue +58 -14
- package/src/components/WaveformChart.css +10 -1
- package/src/components/WaveformChart.vue +6 -0
- package/src/components/WaveformChartView.vue +27 -23
- package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
- package/src/components/annotation/useWaveformChartAnnotations.ts +6 -1
- package/src/components/controls/WaveformToolbar.vue +248 -0
- package/src/components/controls/chartExportOptions.ts +32 -0
- package/src/components/controls/chartSnapshot.ts +132 -0
- package/src/components/controls/snapshotTitle.ts +73 -0
- package/src/components/controls/toolbarRegistry.ts +92 -0
- package/src/components/controls/useChartCommands.ts +294 -0
- package/src/components/controls/useChartImageExport.ts +155 -0
- package/src/components/controls/useControlMode.ts +30 -0
- package/src/components/core/latestTaskScheduler.ts +14 -2
- package/src/components/core/layout.ts +35 -34
- package/src/components/core/niceScale.ts +151 -0
- package/src/components/core/pagination.ts +3 -16
- package/src/components/core/trackLayoutBuilder.ts +34 -17
- package/src/components/core/trackPagination.ts +3 -3
- package/src/components/core/types.ts +3 -0
- package/src/components/core/useWaveformChartController.ts +95 -10
- package/src/components/core/useWaveformChartLifecycle.ts +11 -111
- package/src/components/core/useWaveformData.ts +2 -0
- package/src/components/core/useWaveformLayout.ts +17 -5
- package/src/components/core/useWaveformMeasurements.ts +63 -0
- package/src/components/core/useWaveformRenderSampling.ts +80 -59
- package/src/components/core/useWaveformViewportRestore.ts +105 -0
- package/src/components/core/useWaveformYAxisLayout.ts +3 -2
- package/src/components/core/waveformChartTypes.ts +5 -0
- package/src/components/core/waveformSamplingSession.ts +49 -0
- package/src/components/core/workerSamplingClient.ts +38 -6
- package/src/components/core/yAxisLabelStyle.ts +10 -0
- package/src/components/core/yAxisLayoutMetrics.ts +10 -9
- package/src/components/core/yAxisPadding.ts +45 -0
- package/src/components/core/yAxisTitleLayout.ts +11 -12
- package/src/components/interaction/useWaveformViewport.ts +11 -2
- package/src/components/rendering/WaveformTrack.css +1 -2
- package/src/components/rendering/WaveformTrack.vue +4 -12
- package/src/components/rendering/WaveformTrackAxes.vue +48 -19
- package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
- package/src/components/rendering/compactFrame.ts +31 -0
- package/src/components/rendering/yAxisTickVisibility.ts +95 -0
- package/src/core/lineBoundary.ts +24 -0
- package/src/core/rendering.ts +48 -25
- package/src/core/renderingStrategies.ts +19 -13
- package/src/core/waveformPointSource.ts +23 -1
- package/src/core/workerSampling/outputBoundaries.ts +32 -0
- package/src/core/workerSampling/repository.ts +26 -18
- package/src/core/workerSampling/repositoryDataset.ts +33 -1
- package/src/core/workerSampling/workerRuntime.ts +13 -3
- package/src/data/simulatedWaveforms.ts +57 -2
- package/src/demo/DemoChartHost.vue +5 -1
- package/src/demo/DemoGridFrameControls.vue +2 -0
- package/src/demo/DemoViewControls.vue +5 -2
- package/src/demo/DemoYAxisPaddingControls.vue +46 -0
- package/src/demo/embedBridge.ts +85 -0
- package/src/demo/types.ts +6 -0
- package/src/index.ts +18 -0
- package/src/types/chart.ts +32 -8
- package/src/types/controls.ts +80 -0
- package/src/types/index.ts +18 -0
- package/src/utils/formatters.ts +2 -1
- package/src/views/WasmSamplingDemo.vue +8 -1
- package/dist/assets/workerRuntime-BcYng9TR.js +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { nextTick, onBeforeUnmount, watch } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
WaveformImageExportError,
|
|
4
|
+
WaveformImageExportErrorCode,
|
|
5
|
+
WaveformImageExportOptions,
|
|
6
|
+
} from '../../types/controls'
|
|
7
|
+
import { updateMaximumTickVisibility } from '../rendering/yAxisTickVisibility'
|
|
8
|
+
import { createChartSnapshot } from './chartSnapshot'
|
|
9
|
+
export function imageExportError(code: WaveformImageExportErrorCode): WaveformImageExportError {
|
|
10
|
+
const messages = {
|
|
11
|
+
'export-invalid-options': '图片导出参数无效',
|
|
12
|
+
'export-unavailable': '图表尚未具备有效尺寸',
|
|
13
|
+
'export-busy': '已有导出正在执行',
|
|
14
|
+
'export-cancelled': '图表已卸载,导出取消',
|
|
15
|
+
'export-render-failed': '图片编码失败',
|
|
16
|
+
'export-stale': '图表已变化,请重新导出',
|
|
17
|
+
'export-timeout': '等待图表稳定超时,请重试',
|
|
18
|
+
}
|
|
19
|
+
return Object.assign(new Error(messages[code]), { code })
|
|
20
|
+
}
|
|
21
|
+
interface Context {
|
|
22
|
+
container: () => HTMLElement | undefined
|
|
23
|
+
svg: () => SVGSVGElement | undefined
|
|
24
|
+
size: () => { width: number; height: number; titleHeight: number }
|
|
25
|
+
version: () => unknown
|
|
26
|
+
ready: () => boolean
|
|
27
|
+
}
|
|
28
|
+
export function useChartImageExport(c: Context) {
|
|
29
|
+
let busy = false
|
|
30
|
+
let disposed = false
|
|
31
|
+
let revision = 0
|
|
32
|
+
let abort: ((error: WaveformImageExportError) => void) | undefined
|
|
33
|
+
watch(
|
|
34
|
+
c.version,
|
|
35
|
+
() => {
|
|
36
|
+
revision++
|
|
37
|
+
abort?.(imageExportError('export-stale'))
|
|
38
|
+
},
|
|
39
|
+
{ flush: 'sync' },
|
|
40
|
+
)
|
|
41
|
+
onBeforeUnmount(() => {
|
|
42
|
+
disposed = true
|
|
43
|
+
abort?.(imageExportError('export-cancelled'))
|
|
44
|
+
})
|
|
45
|
+
async function exportImage(options: WaveformImageExportOptions = {}): Promise<Blob> {
|
|
46
|
+
const format = options.format ?? 'png'
|
|
47
|
+
const scale = options.scale ?? 1
|
|
48
|
+
if (
|
|
49
|
+
!['png', 'svg'].includes(format) ||
|
|
50
|
+
!Number.isFinite(scale) ||
|
|
51
|
+
scale <= 0 ||
|
|
52
|
+
scale > 4 ||
|
|
53
|
+
(format === 'svg' && scale !== 1) ||
|
|
54
|
+
(options.backgroundColor !== undefined &&
|
|
55
|
+
(typeof options.backgroundColor !== 'string' ||
|
|
56
|
+
!CSS.supports('color', options.backgroundColor)))
|
|
57
|
+
)
|
|
58
|
+
throw imageExportError('export-invalid-options')
|
|
59
|
+
if (disposed) throw imageExportError('export-cancelled')
|
|
60
|
+
if (busy) throw imageExportError('export-busy')
|
|
61
|
+
const container = c.container(),
|
|
62
|
+
svg = c.svg(),
|
|
63
|
+
size = c.size()
|
|
64
|
+
if (!container || !svg || size.width <= 0 || size.height <= 0)
|
|
65
|
+
throw imageExportError('export-unavailable')
|
|
66
|
+
busy = true
|
|
67
|
+
const captured = revision
|
|
68
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
69
|
+
let poll: ReturnType<typeof setTimeout> | undefined
|
|
70
|
+
let url: string | undefined
|
|
71
|
+
let img: HTMLImageElement | undefined
|
|
72
|
+
let canvas: HTMLCanvasElement | undefined
|
|
73
|
+
let stopped = false
|
|
74
|
+
const check = () => {
|
|
75
|
+
if (disposed) throw imageExportError('export-cancelled')
|
|
76
|
+
if (captured !== revision) throw imageExportError('export-stale')
|
|
77
|
+
if (stopped) throw imageExportError('export-timeout')
|
|
78
|
+
}
|
|
79
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
80
|
+
abort = reject
|
|
81
|
+
timer = setTimeout(() => reject(imageExportError('export-timeout')), 5000)
|
|
82
|
+
})
|
|
83
|
+
const generate = async () => {
|
|
84
|
+
await nextTick()
|
|
85
|
+
check()
|
|
86
|
+
await new Promise<void>((resolve) => {
|
|
87
|
+
const test = () => {
|
|
88
|
+
if (stopped) return
|
|
89
|
+
if (c.ready()) resolve()
|
|
90
|
+
else poll = setTimeout(test, 16)
|
|
91
|
+
}
|
|
92
|
+
test()
|
|
93
|
+
})
|
|
94
|
+
await document.fonts?.ready
|
|
95
|
+
await nextTick()
|
|
96
|
+
check()
|
|
97
|
+
updateMaximumTickVisibility(svg)
|
|
98
|
+
const snapshot = createChartSnapshot(
|
|
99
|
+
container,
|
|
100
|
+
svg,
|
|
101
|
+
size.width,
|
|
102
|
+
size.height,
|
|
103
|
+
size.titleHeight,
|
|
104
|
+
options.backgroundColor,
|
|
105
|
+
)
|
|
106
|
+
const blob = new Blob([snapshot], { type: 'image/svg+xml' })
|
|
107
|
+
if (format === 'svg') return blob
|
|
108
|
+
url = URL.createObjectURL(blob)
|
|
109
|
+
img = new Image()
|
|
110
|
+
await new Promise<void>((resolve, reject) => {
|
|
111
|
+
img!.onload = () => resolve()
|
|
112
|
+
img!.onerror = () => reject(imageExportError('export-render-failed'))
|
|
113
|
+
img!.src = url!
|
|
114
|
+
})
|
|
115
|
+
check()
|
|
116
|
+
canvas = document.createElement('canvas')
|
|
117
|
+
canvas.width = Math.round(size.width * scale)
|
|
118
|
+
canvas.height = Math.round(size.height * scale)
|
|
119
|
+
const ctx = canvas.getContext('2d')
|
|
120
|
+
if (!ctx) throw imageExportError('export-render-failed')
|
|
121
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
|
122
|
+
const png = await new Promise<Blob>((resolve, reject) =>
|
|
123
|
+
canvas!.toBlob(
|
|
124
|
+
(result) => (result ? resolve(result) : reject(imageExportError('export-render-failed'))),
|
|
125
|
+
'image/png',
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
check()
|
|
129
|
+
return png
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return await Promise.race([cancelled, generate()])
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof Error && 'code' in error) throw error
|
|
135
|
+
throw imageExportError('export-render-failed')
|
|
136
|
+
} finally {
|
|
137
|
+
stopped = true
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
clearTimeout(poll)
|
|
140
|
+
if (img) {
|
|
141
|
+
img.onload = null
|
|
142
|
+
img.onerror = null
|
|
143
|
+
img.src = ''
|
|
144
|
+
}
|
|
145
|
+
if (url) URL.revokeObjectURL(url)
|
|
146
|
+
if (canvas) {
|
|
147
|
+
canvas.width = 0
|
|
148
|
+
canvas.height = 0
|
|
149
|
+
}
|
|
150
|
+
abort = undefined
|
|
151
|
+
busy = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { exportImage }
|
|
155
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { computed, ref, watch } from 'vue'
|
|
2
|
+
import type { WaveformControlResult } from '../../types/controls'
|
|
3
|
+
import type { WaveformInteractionMode } from '../../types'
|
|
4
|
+
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
|
5
|
+
|
|
6
|
+
export function useControlMode(props: ResolvedWaveformChartProps, emit: WaveformChartEmit) {
|
|
7
|
+
const internalMode = ref<WaveformInteractionMode>('zoom')
|
|
8
|
+
const mode = computed(() => props.interactionMode ?? internalMode.value)
|
|
9
|
+
const canSetMode = (value: WaveformInteractionMode) =>
|
|
10
|
+
!props.presentationMode &&
|
|
11
|
+
(value === 'none' ||
|
|
12
|
+
(value === 'zoom' && props.zoomable) ||
|
|
13
|
+
(value === 'pan' && props.pannable) ||
|
|
14
|
+
(value === 'annotation' && props.annotationsVisible))
|
|
15
|
+
function setInteractionMode(value: WaveformInteractionMode): WaveformControlResult {
|
|
16
|
+
let status: Extract<WaveformControlResult, { kind: 'mode' }>['status']
|
|
17
|
+
if (!canSetMode(value)) status = 'disabled'
|
|
18
|
+
else if (mode.value === value) status = 'unchanged'
|
|
19
|
+
else if (props.interactionMode !== undefined) {
|
|
20
|
+
emit('update:interactionMode', value)
|
|
21
|
+
status = 'requested'
|
|
22
|
+
} else {
|
|
23
|
+
internalMode.value = value
|
|
24
|
+
status = 'applied'
|
|
25
|
+
}
|
|
26
|
+
return { kind: 'mode', status, targets: [] }
|
|
27
|
+
}
|
|
28
|
+
watch(mode, (value) => emit('interaction-mode-change', value))
|
|
29
|
+
return { mode, canSetMode, setInteractionMode }
|
|
30
|
+
}
|
|
@@ -26,7 +26,9 @@ interface ScheduledTask {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Runs at most one task at a time and retains only the newest pending task. */
|
|
29
|
-
export function createLatestTaskScheduler(
|
|
29
|
+
export function createLatestTaskScheduler(
|
|
30
|
+
onError: (error: unknown, generation: number) => void = () => undefined,
|
|
31
|
+
) {
|
|
30
32
|
let generation = 0
|
|
31
33
|
let running = false
|
|
32
34
|
let pending: ScheduledTask | undefined
|
|
@@ -40,7 +42,17 @@ export function createLatestTaskScheduler() {
|
|
|
40
42
|
while (pending && !disposed) {
|
|
41
43
|
const task = pending
|
|
42
44
|
pending = undefined
|
|
43
|
-
|
|
45
|
+
try {
|
|
46
|
+
await task.run(task.generation)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!disposed && task.generation === generation) {
|
|
49
|
+
try {
|
|
50
|
+
onError(error, task.generation)
|
|
51
|
+
} catch {
|
|
52
|
+
/* Reporting must not stall the queue. */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
57
|
} finally {
|
|
46
58
|
running = false
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { scaleLinear } from 'd3'
|
|
2
|
-
|
|
3
1
|
import type { WaveformOverlayMode } from '../../types'
|
|
4
2
|
import { formatScientificAxisLabel, formatScientificAxisExponent, paddedDomain } from '../../utils'
|
|
5
3
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
|
@@ -10,6 +8,9 @@ import {
|
|
|
10
8
|
resolveTrackFixedYDomain,
|
|
11
9
|
type WaveformYDomain,
|
|
12
10
|
} from './yDomain'
|
|
11
|
+
import { resolveYAxisTicksECharts } from './niceScale'
|
|
12
|
+
import { applyYAxisPadding, withMaximumTick, type YAxisPaddingOptions } from './yAxisPadding'
|
|
13
|
+
import { resolveYAxisLabelBandWidth } from './yAxisLabelStyle'
|
|
13
14
|
|
|
14
15
|
// 导出常量供外部使用
|
|
15
16
|
export { MAX_MULTI_Y_AXIS_COUNT } from './constants'
|
|
@@ -37,21 +38,24 @@ export function resolveYAxisTicks(
|
|
|
37
38
|
tickCount = 5,
|
|
38
39
|
nice = true,
|
|
39
40
|
): ResolvedYAxisTicks {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
// 使用 ECharts 兼容的 1/2/5 系列算法
|
|
58
|
+
return resolveYAxisTicksECharts(domain, tickCount)
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
export function formatYAxisTickLabel(
|
|
@@ -71,7 +75,8 @@ export function formatYAxisTickLabel(
|
|
|
71
75
|
})
|
|
72
76
|
if (suffixMetadata) {
|
|
73
77
|
const unitLabel = unit?.trim()
|
|
74
|
-
|
|
78
|
+
const exponent = formatScientificAxisExponent(...domain)
|
|
79
|
+
return [label, exponent, !exponent && unitLabel ? `(${unitLabel})` : null]
|
|
75
80
|
.filter(Boolean)
|
|
76
81
|
.join(' ')
|
|
77
82
|
}
|
|
@@ -96,6 +101,7 @@ export interface YAxisSeriesGroup {
|
|
|
96
101
|
seriesList: DisplaySeries[]
|
|
97
102
|
domain: [number, number]
|
|
98
103
|
fixed: boolean
|
|
104
|
+
maximumTick?: number
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
export interface YAxisSlot {
|
|
@@ -183,7 +189,7 @@ export function resolveRenderedYAxisSeriesGroups(
|
|
|
183
189
|
yDomain?: WaveformYDomain,
|
|
184
190
|
yDomains?: Record<string, WaveformYDomain>,
|
|
185
191
|
viewportYDomains?: Record<string, [number, number]>,
|
|
186
|
-
|
|
192
|
+
padding?: YAxisPaddingOptions | number,
|
|
187
193
|
): YAxisSeriesGroup[] {
|
|
188
194
|
const viewportYDomain = viewportYDomains?.[track.id]
|
|
189
195
|
const axisTrack =
|
|
@@ -191,17 +197,7 @@ export function resolveRenderedYAxisSeriesGroups(
|
|
|
191
197
|
? track
|
|
192
198
|
: { ...track, visibleSeries: track.series }
|
|
193
199
|
return resolveYAxisSeriesGroups(axisTrack, overlayMode, yDomain, yDomains).map((group) =>
|
|
194
|
-
|
|
195
|
-
? { ...group, domain: viewportYDomain }
|
|
196
|
-
: !group.fixed && Number.isFinite(upperPaddingRatio) && upperPaddingRatio > 0
|
|
197
|
-
? {
|
|
198
|
-
...group,
|
|
199
|
-
domain: [
|
|
200
|
-
group.domain[0],
|
|
201
|
-
group.domain[1] + (group.domain[1] - group.domain[0]) * upperPaddingRatio,
|
|
202
|
-
] as WaveformYDomain,
|
|
203
|
-
}
|
|
204
|
-
: group,
|
|
200
|
+
applyYAxisPadding(group, viewportYDomain, padding),
|
|
205
201
|
)
|
|
206
202
|
}
|
|
207
203
|
|
|
@@ -213,11 +209,12 @@ export function axisTextMetrics(
|
|
|
213
209
|
tickCount = 5,
|
|
214
210
|
includeWithoutLastTick = false,
|
|
215
211
|
measureTextWidth?: (text: string) => number,
|
|
212
|
+
maximumTick?: number,
|
|
216
213
|
): { tickTextWidth: number } {
|
|
217
214
|
const resolvedTicks = tickValues
|
|
218
215
|
? { domain, values: tickValues }
|
|
219
216
|
: resolveYAxisTicks(domain, tickCount, nice)
|
|
220
|
-
const tickValueSets = [resolvedTicks.values]
|
|
217
|
+
const tickValueSets = [withMaximumTick(resolvedTicks.values, maximumTick)]
|
|
221
218
|
if (includeWithoutLastTick && resolvedTicks.values.length > 1) {
|
|
222
219
|
tickValueSets.push(resolvedTicks.values.slice(0, -1))
|
|
223
220
|
}
|
|
@@ -296,10 +293,13 @@ export function buildYAxisSlots(
|
|
|
296
293
|
nice = true,
|
|
297
294
|
includeWithoutLastTick = false,
|
|
298
295
|
viewportYDomains?: Record<string, [number, number]>,
|
|
299
|
-
|
|
296
|
+
padding?: YAxisPaddingOptions | number,
|
|
300
297
|
showUnits = true,
|
|
301
298
|
measureTextWidth?: (text: string) => number,
|
|
302
299
|
): { slots: YAxisSlot[]; clearance: { left: number; right: number } } {
|
|
300
|
+
const labelBandWidth = resolveYAxisLabelBandWidth(
|
|
301
|
+
typeof padding === 'object' ? padding.labelTextStyle : undefined,
|
|
302
|
+
)
|
|
303
303
|
const widths = new Map<string, number>()
|
|
304
304
|
tracks.forEach((track) => {
|
|
305
305
|
const sideIndexes = { left: 0, right: 0 }
|
|
@@ -309,7 +309,7 @@ export function buildYAxisSlots(
|
|
|
309
309
|
yDomain,
|
|
310
310
|
yDomains,
|
|
311
311
|
viewportYDomains,
|
|
312
|
-
|
|
312
|
+
padding,
|
|
313
313
|
).forEach((group) => {
|
|
314
314
|
const sideIndex = sideIndexes[group.side]++
|
|
315
315
|
const key = `${group.side}:${sideIndex}`
|
|
@@ -321,6 +321,7 @@ export function buildYAxisSlots(
|
|
|
321
321
|
tickCount,
|
|
322
322
|
includeWithoutLastTick,
|
|
323
323
|
measureTextWidth,
|
|
324
|
+
group.maximumTick,
|
|
324
325
|
).tickTextWidth
|
|
325
326
|
widths.set(key, Math.max(widths.get(key) ?? Y_AXIS_CHARACTER_WIDTH, width))
|
|
326
327
|
})
|
|
@@ -337,13 +338,13 @@ export function buildYAxisSlots(
|
|
|
337
338
|
const tickTextWidth = widths.get(`${side}:${sideIndex}`) ?? Y_AXIS_CHARACTER_WIDTH
|
|
338
339
|
const axisOffset = side === 'left' && clearance.left > 0 ? -clearance.left : clearance[side]
|
|
339
340
|
const labelDistance =
|
|
340
|
-
tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP +
|
|
341
|
+
tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth / 2
|
|
341
342
|
const labelOffset = axisOffset + labelDistance * (side === 'left' ? -1 : 1)
|
|
342
343
|
const fullClearance =
|
|
343
344
|
tickTextWidth +
|
|
344
345
|
Y_AXIS_TICK_PADDING +
|
|
345
346
|
Y_AXIS_LABEL_GAP +
|
|
346
|
-
|
|
347
|
+
labelBandWidth +
|
|
347
348
|
Y_AXIS_OUTER_PADDING
|
|
348
349
|
slots.push({ side, sideIndex, axisOffset, labelOffset, clearance: fullClearance })
|
|
349
350
|
clearance[side] += fullClearance
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECharts-compatible Nice Number algorithm for axis tick calculation
|
|
3
|
+
* Based on "Nice Numbers for Graph Labels" from Graphics Gems
|
|
4
|
+
*
|
|
5
|
+
* References:
|
|
6
|
+
* - https://github.com/cenfun/nice-ticks
|
|
7
|
+
* - https://github.com/qianfan-Zhao/nice_number
|
|
8
|
+
* - https://stackoverflow.com/questions/8506881/nice-label-algorithm-for-charts-with-minimum-ticks
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 将数字转换为"优雅"的数字(1, 2, 5 系列)
|
|
13
|
+
* @param value - 原始值
|
|
14
|
+
* @param round - 是否向上取整
|
|
15
|
+
* @returns 优雅的数字
|
|
16
|
+
*/
|
|
17
|
+
function niceNumber(value: number, round: boolean): number {
|
|
18
|
+
if (value === 0) return 0
|
|
19
|
+
|
|
20
|
+
const exponent = Math.floor(Math.log10(Math.abs(value)))
|
|
21
|
+
const fraction = Math.abs(value) / Math.pow(10, exponent)
|
|
22
|
+
|
|
23
|
+
let niceFraction: number
|
|
24
|
+
if (round) {
|
|
25
|
+
// 向上取整到 1, 2, 5, 10
|
|
26
|
+
if (fraction < 1.5) niceFraction = 1
|
|
27
|
+
else if (fraction < 3) niceFraction = 2
|
|
28
|
+
else if (fraction < 7) niceFraction = 5
|
|
29
|
+
else niceFraction = 10
|
|
30
|
+
} else {
|
|
31
|
+
// 向下取整到 1, 2, 5, 10
|
|
32
|
+
if (fraction <= 1) niceFraction = 1
|
|
33
|
+
else if (fraction <= 2) niceFraction = 2
|
|
34
|
+
else if (fraction <= 5) niceFraction = 5
|
|
35
|
+
else niceFraction = 10
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return niceFraction * Math.pow(10, exponent) * Math.sign(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NiceScaleResult {
|
|
42
|
+
/** 扩展后的最小值 */
|
|
43
|
+
niceMin: number
|
|
44
|
+
/** 扩展后的最大值 */
|
|
45
|
+
niceMax: number
|
|
46
|
+
/** 刻度间隔 */
|
|
47
|
+
tickSpacing: number
|
|
48
|
+
/** 刻度值数组 */
|
|
49
|
+
ticks: number[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 计算 ECharts 风格的优雅刻度
|
|
54
|
+
* @param min - 数据最小值
|
|
55
|
+
* @param max - 数据最大值
|
|
56
|
+
* @param maxTicks - 期望的最大刻度数(类似 ECharts 的 splitNumber,默认 5)
|
|
57
|
+
* @returns 刻度计算结果
|
|
58
|
+
*/
|
|
59
|
+
export function calculateNiceScale(min: number, max: number, maxTicks = 5): NiceScaleResult {
|
|
60
|
+
// 处理边界情况
|
|
61
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
62
|
+
return { niceMin: 0, niceMax: 1, tickSpacing: 1, ticks: [0, 1] }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (min === max) {
|
|
66
|
+
if (min === 0) {
|
|
67
|
+
// 特殊处理零点
|
|
68
|
+
return {
|
|
69
|
+
niceMin: -1,
|
|
70
|
+
niceMax: 1,
|
|
71
|
+
tickSpacing: 0.5,
|
|
72
|
+
ticks: [-1, -0.5, 0, 0.5, 1],
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const value = min
|
|
76
|
+
const spacing = Math.abs(value) * 0.2
|
|
77
|
+
return {
|
|
78
|
+
niceMin: value - spacing,
|
|
79
|
+
niceMax: value + spacing,
|
|
80
|
+
tickSpacing: spacing,
|
|
81
|
+
ticks: [value - spacing, value, value + spacing],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 确保 min < max
|
|
86
|
+
if (min > max) {
|
|
87
|
+
;[min, max] = [max, min]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const range = max - min
|
|
91
|
+
|
|
92
|
+
// 计算优雅的刻度间隔
|
|
93
|
+
const roughTickSpacing = range / Math.max(1, maxTicks - 1)
|
|
94
|
+
const tickSpacing = niceNumber(roughTickSpacing, true)
|
|
95
|
+
|
|
96
|
+
// 计算优雅的边界
|
|
97
|
+
const niceMin = Math.floor(min / tickSpacing) * tickSpacing
|
|
98
|
+
const niceMax = Math.ceil(max / tickSpacing) * tickSpacing
|
|
99
|
+
|
|
100
|
+
// 生成刻度值
|
|
101
|
+
const ticks: number[] = []
|
|
102
|
+
const tickCount = Math.round((niceMax - niceMin) / tickSpacing) + 1
|
|
103
|
+
|
|
104
|
+
// 防止刻度过多(安全检查)
|
|
105
|
+
if (tickCount > 100) {
|
|
106
|
+
// 退回到简单的线性刻度
|
|
107
|
+
const safeTickCount = Math.min(maxTicks + 2, 10)
|
|
108
|
+
const safeSpacing = (max - min) / (safeTickCount - 1)
|
|
109
|
+
for (let i = 0; i < safeTickCount; i++) {
|
|
110
|
+
ticks.push(min + safeSpacing * i)
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
niceMin: min,
|
|
114
|
+
niceMax: max,
|
|
115
|
+
tickSpacing: safeSpacing,
|
|
116
|
+
ticks,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 生成刻度值(处理浮点数精度问题)
|
|
121
|
+
for (let i = 0; i < tickCount; i++) {
|
|
122
|
+
const tick = niceMin + tickSpacing * i
|
|
123
|
+
// 修正浮点数误差 - 使用更智能的四舍五入
|
|
124
|
+
// 确定合适的精度(基于刻度间隔的量级)
|
|
125
|
+
const precision = Math.max(0, -Math.floor(Math.log10(Math.abs(tickSpacing))) + 1)
|
|
126
|
+
const roundedTick = Number(tick.toFixed(precision))
|
|
127
|
+
ticks.push(roundedTick)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
// Keep floating-point multiplication from shrinking the enclosing data domain.
|
|
132
|
+
niceMin: Math.min(min, ticks[0] ?? niceMin),
|
|
133
|
+
niceMax: Math.max(max, ticks.at(-1) ?? niceMax),
|
|
134
|
+
tickSpacing,
|
|
135
|
+
ticks,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* ECharts 兼容的刻度计算(与 resolveYAxisTicks 接口兼容)
|
|
141
|
+
*/
|
|
142
|
+
export function resolveYAxisTicksECharts(
|
|
143
|
+
domain: [number, number],
|
|
144
|
+
tickCount = 5,
|
|
145
|
+
): { domain: [number, number]; values: number[] } {
|
|
146
|
+
const result = calculateNiceScale(domain[0], domain[1], tickCount)
|
|
147
|
+
return {
|
|
148
|
+
domain: [result.niceMin, result.niceMax],
|
|
149
|
+
values: result.ticks,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -1,17 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export function resolveCompactPaginationBand(
|
|
3
|
-
visible:
|
|
4
|
-
width: number,
|
|
5
|
-
pageCount: number,
|
|
6
|
-
timeLabelCenter: number,
|
|
7
|
-
timeLabel: string,
|
|
8
|
-
): 0 | 16 | 40 {
|
|
9
|
-
if (!visible) return 0
|
|
10
|
-
if (width <= 520) return 40
|
|
11
|
-
const controls = Math.min(pageCount, 9) + 2
|
|
12
|
-
const paginationWidth = controls * 32 + controls * 8
|
|
13
|
-
// A full 18px em per character also accommodates wide glyphs and Chinese labels.
|
|
14
|
-
const labelRight = timeLabelCenter + (Array.from(timeLabel).length * 18) / 2
|
|
15
|
-
const availableWidth = width - 10 - labelRight - 8
|
|
16
|
-
return availableWidth >= paginationWidth ? 16 : 40
|
|
1
|
+
/** Keep compact chart height stable regardless of pagination width or page count. */
|
|
2
|
+
export function resolveCompactPaginationBand(visible: boolean): 0 | 16 {
|
|
3
|
+
return visible ? 16 : 0
|
|
17
4
|
}
|