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.
Files changed (120) hide show
  1. package/README.md +244 -45
  2. package/dist/assets/workerRuntime-5p-EI-GL.js +1 -0
  3. package/dist/index.cjs +2 -1
  4. package/dist/index.js +2924 -1915
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +24 -6
  7. package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
  8. package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
  9. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  10. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  11. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  12. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  13. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  14. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  15. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  16. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  17. package/dist/types/components/core/layout.d.ts +5 -3
  18. package/dist/types/components/core/niceScale.d.ts +34 -0
  19. package/dist/types/components/core/pagination.d.ts +2 -2
  20. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  21. package/dist/types/components/core/trackPagination.d.ts +1 -1
  22. package/dist/types/components/core/types.d.ts +3 -0
  23. package/dist/types/components/core/useWaveformChartController.d.ts +24 -5
  24. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  25. package/dist/types/components/core/useWaveformData.d.ts +1 -0
  26. package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
  27. package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
  28. package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
  29. package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
  30. package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
  31. package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
  32. package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
  33. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  34. package/dist/types/components/core/yAxisPadding.d.ts +7 -0
  35. package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
  36. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
  37. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  38. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  39. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +0 -1
  40. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  41. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  42. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  43. package/dist/types/core/lineBoundary.d.ts +4 -0
  44. package/dist/types/core/rendering.d.ts +4 -0
  45. package/dist/types/core/renderingStrategies.d.ts +2 -0
  46. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  47. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  48. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  49. package/dist/types/demo/types.d.ts +6 -0
  50. package/dist/types/index.d.ts +2 -1
  51. package/dist/types/types/chart.d.ts +30 -8
  52. package/dist/types/types/controls.d.ts +69 -0
  53. package/dist/types/types/index.d.ts +2 -1
  54. package/dist/types/utils/formatters.d.ts +1 -0
  55. package/package.json +7 -6
  56. package/src/App.vue +58 -14
  57. package/src/components/WaveformChart.css +10 -1
  58. package/src/components/WaveformChart.vue +6 -0
  59. package/src/components/WaveformChartView.vue +27 -23
  60. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  61. package/src/components/annotation/useWaveformChartAnnotations.ts +6 -1
  62. package/src/components/controls/WaveformToolbar.vue +248 -0
  63. package/src/components/controls/chartExportOptions.ts +32 -0
  64. package/src/components/controls/chartSnapshot.ts +132 -0
  65. package/src/components/controls/snapshotTitle.ts +73 -0
  66. package/src/components/controls/toolbarRegistry.ts +92 -0
  67. package/src/components/controls/useChartCommands.ts +294 -0
  68. package/src/components/controls/useChartImageExport.ts +155 -0
  69. package/src/components/controls/useControlMode.ts +30 -0
  70. package/src/components/core/latestTaskScheduler.ts +14 -2
  71. package/src/components/core/layout.ts +35 -34
  72. package/src/components/core/niceScale.ts +151 -0
  73. package/src/components/core/pagination.ts +3 -16
  74. package/src/components/core/trackLayoutBuilder.ts +34 -17
  75. package/src/components/core/trackPagination.ts +3 -3
  76. package/src/components/core/types.ts +3 -0
  77. package/src/components/core/useWaveformChartController.ts +95 -10
  78. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  79. package/src/components/core/useWaveformData.ts +2 -0
  80. package/src/components/core/useWaveformLayout.ts +17 -5
  81. package/src/components/core/useWaveformMeasurements.ts +63 -0
  82. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  83. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  84. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  85. package/src/components/core/waveformChartTypes.ts +5 -0
  86. package/src/components/core/waveformSamplingSession.ts +49 -0
  87. package/src/components/core/workerSamplingClient.ts +38 -6
  88. package/src/components/core/yAxisLabelStyle.ts +10 -0
  89. package/src/components/core/yAxisLayoutMetrics.ts +10 -9
  90. package/src/components/core/yAxisPadding.ts +45 -0
  91. package/src/components/core/yAxisTitleLayout.ts +11 -12
  92. package/src/components/interaction/useWaveformViewport.ts +11 -2
  93. package/src/components/rendering/WaveformTrack.css +1 -2
  94. package/src/components/rendering/WaveformTrack.vue +4 -12
  95. package/src/components/rendering/WaveformTrackAxes.vue +48 -19
  96. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  97. package/src/components/rendering/compactFrame.ts +31 -0
  98. package/src/components/rendering/yAxisTickVisibility.ts +95 -0
  99. package/src/core/lineBoundary.ts +24 -0
  100. package/src/core/rendering.ts +48 -25
  101. package/src/core/renderingStrategies.ts +19 -13
  102. package/src/core/waveformPointSource.ts +23 -1
  103. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  104. package/src/core/workerSampling/repository.ts +26 -18
  105. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  106. package/src/core/workerSampling/workerRuntime.ts +13 -3
  107. package/src/data/simulatedWaveforms.ts +57 -2
  108. package/src/demo/DemoChartHost.vue +5 -1
  109. package/src/demo/DemoGridFrameControls.vue +2 -0
  110. package/src/demo/DemoViewControls.vue +5 -2
  111. package/src/demo/DemoYAxisPaddingControls.vue +46 -0
  112. package/src/demo/embedBridge.ts +85 -0
  113. package/src/demo/types.ts +6 -0
  114. package/src/index.ts +18 -0
  115. package/src/types/chart.ts +32 -8
  116. package/src/types/controls.ts +80 -0
  117. package/src/types/index.ts +18 -0
  118. package/src/utils/formatters.ts +2 -1
  119. package/src/views/WasmSamplingDemo.vue +8 -1
  120. 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
- await task.run(task.generation)
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
- const effectiveTickCount =
41
- typeof tickCount === 'number' && Number.isFinite(tickCount)
42
- ? Math.max(2, Math.floor(tickCount))
43
- : 5
44
- const scale = scaleLinear(domain, [1, 0])
45
- if (nice) scale.nice(Math.max(1, effectiveTickCount - 1))
46
- const resolvedDomain = scale.domain() as [number, number]
47
- const [axisStart, axisEnd] = resolvedDomain
48
- return {
49
- domain: resolvedDomain,
50
- values: Array.from(
51
- { length: effectiveTickCount },
52
- (_, index) => axisStart + ((axisEnd - axisStart) * index) / (effectiveTickCount - 1),
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
- return [label, formatScientificAxisExponent(...domain), unitLabel ? `(${unitLabel})` : null]
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
- upperPaddingRatio = 0,
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
- !group.fixed && viewportYDomain
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
- upperPaddingRatio = 0,
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
- upperPaddingRatio,
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 + Y_AXIS_LABEL_BAND_WIDTH / 2
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
- Y_AXIS_LABEL_BAND_WIDTH +
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
- /** Conservative Ant pagination sizing, including first/last pages and both ellipses. */
2
- export function resolveCompactPaginationBand(
3
- visible: boolean,
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
  }