waveform-analysis 0.1.28 → 0.1.29

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.
@@ -1,6 +1,6 @@
1
1
  import { type ZoomTransform } from 'd3';
2
2
  import { type ResolvedWaveformRenderingOptions } from '../../core/rendering';
3
- import type { WaveformDisplayMode, WaveformOverlayMode } from '../../types';
3
+ import type { WaveformDisplayMode, WaveformOverlayMode, WaveformXAxisLabelFormatter } from '../../types';
4
4
  import { type GridCellGeometry, type NormalizedWaveformGridOptions } from './grid';
5
5
  import type { DisplayTrack, TrackLayout } from './types';
6
6
  interface SeriesGridCell extends GridCellGeometry {
@@ -19,6 +19,7 @@ export interface BuildTrackLayoutsOptions {
19
19
  fixedYDomains?: Record<string, [number, number]>;
20
20
  yDomains?: Record<string, [number, number]>;
21
21
  timeUnit: 's' | 'ms';
22
+ xAxisLabelFormatter?: WaveformXAxisLabelFormatter;
22
23
  rendering: ResolvedWaveformRenderingOptions;
23
24
  hideSecondaryLabels: boolean;
24
25
  yAxisLabelX: number;
@@ -1,4 +1,5 @@
1
1
  import type { WaveformAnnotation, WaveformAxesOptions, WaveformData, WaveformDisplayMode, WaveformFrameStyle, WaveformGridTrackLines, WaveformInteractionMode, WaveformLegendOrientation, WaveformLegendPosition, WaveformLineStyle, WaveformOverlayMode, WaveformTitleOptions, WaveformZeroLineOptions, WaveformZoomEndPayload } from '../components';
2
+ import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls';
2
3
  interface SelectOption<T> {
3
4
  label: string;
4
5
  value: T;
@@ -27,6 +28,14 @@ export interface DemoControlPanelModel {
27
28
  verticalGridColor: string;
28
29
  xAxisLineVisible: boolean;
29
30
  yAxisLineVisible: boolean;
31
+ xAxisLabelFormatterEnabled: boolean;
32
+ xAxisLabelFormat: DemoXAxisLabelFormat;
33
+ xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>;
34
+ xAxisLabelMultiplier: number;
35
+ xAxisLabelFractionDigits: number;
36
+ xAxisLabelTimeZone: DemoXAxisLabelTimeZone;
37
+ xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>;
38
+ xAxisLabelShowMilliseconds: boolean;
30
39
  frameBorderColor: string;
31
40
  frameBackgroundColor: string;
32
41
  frameBorderWidth: number;
@@ -0,0 +1,32 @@
1
+ import type { WaveformXAxisLabelFormatter } from '../types';
2
+ export type DemoXAxisLabelFormat = 'number' | 'datetime';
3
+ export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC';
4
+ export declare function formatDemoTimestamp(value: number, timeZone: DemoXAxisLabelTimeZone, showMilliseconds: boolean): string;
5
+ export declare function useDemoXAxisLabelControls(): {
6
+ controlModel: {
7
+ xAxisLabelFormatterEnabled: import("vue").Ref<boolean, boolean>;
8
+ xAxisLabelFormat: import("vue").Ref<DemoXAxisLabelFormat, DemoXAxisLabelFormat>;
9
+ xAxisLabelFormatOptions: ({
10
+ label: string;
11
+ value: "number";
12
+ } | {
13
+ label: string;
14
+ value: "datetime";
15
+ })[];
16
+ xAxisLabelMultiplier: import("vue").Ref<number, number>;
17
+ xAxisLabelFractionDigits: import("vue").Ref<number, number>;
18
+ xAxisLabelTimeZone: import("vue").Ref<DemoXAxisLabelTimeZone, DemoXAxisLabelTimeZone>;
19
+ xAxisLabelTimeZoneOptions: ({
20
+ label: string;
21
+ value: "Asia/Shanghai";
22
+ } | {
23
+ label: string;
24
+ value: "local";
25
+ } | {
26
+ label: string;
27
+ value: "UTC";
28
+ })[];
29
+ xAxisLabelShowMilliseconds: import("vue").Ref<boolean, boolean>;
30
+ };
31
+ xAxisLabelFormatter: import("vue").ComputedRef<WaveformXAxisLabelFormatter | undefined>;
32
+ };
@@ -3,7 +3,7 @@
3
3
  * @packageDocumentation
4
4
  */
5
5
  export { default as WaveformChart } from './components/WaveformChart.vue';
6
- export type { WaveformPoint, WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, WaveformTitleTextStyle, WaveformTitleOptions, WaveformLegendPosition, WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, WaveformAxesOptions, WaveformZeroLineOptions, SingleWaveformData, WaveformLineType, WaveformLineStyle, WaveformPointType, WaveformErrorBarOptions, ResolvedWaveformErrorBarOptions, WaveformSeries, WaveformData, NormalizedWaveformSeries, } from './types';
6
+ export type { WaveformPoint, WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, WaveformTitleTextStyle, WaveformTitleOptions, WaveformLegendPosition, WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, WaveformXAxisLabelKind, WaveformXAxisLabelFormatterContext, WaveformXAxisLabelFormatter, WaveformAxesOptions, WaveformZeroLineOptions, SingleWaveformData, WaveformLineType, WaveformLineStyle, WaveformPointType, WaveformErrorBarOptions, ResolvedWaveformErrorBarOptions, WaveformSeries, WaveformData, NormalizedWaveformSeries, } from './types';
7
7
  export type { WaveformGridOptions, WaveformGridLineOptions, WaveformGridTrackLines, } from './components/core/grid';
8
8
  export { normalizeWaveformData, normalizeWaveformSeries } from './core';
9
9
  export { paddedDomain, buildMinorTicks, displayTime, formatEndpointTime, formatAxisTime, formatTooltipTime, resolveTrackGeometry, clamp, type TimeUnit, type TrackGeometry, } from './utils';
@@ -106,10 +106,25 @@ export interface WaveformFrameStyle {
106
106
  borderStyle?: 'solid' | 'dashed' | 'dotted';
107
107
  backgroundColor?: string;
108
108
  }
109
+ export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end';
110
+ /** Context passed to custom X-axis label formatters. */
111
+ export interface WaveformXAxisLabelFormatterContext {
112
+ kind: WaveformXAxisLabelKind;
113
+ /** X coordinate in the source data, before time-unit conversion. */
114
+ rawValue: number;
115
+ timeUnit: 's' | 'ms';
116
+ /** Current visible X domain in source coordinates. */
117
+ domain: [number, number];
118
+ /** Current visible X domain converted to the selected display unit. */
119
+ displayDomain: [number, number];
120
+ }
121
+ export type WaveformXAxisLabelFormatter = (value: number, context: WaveformXAxisLabelFormatterContext) => string;
109
122
  /** Controls axis baseline visibility while preserving tick marks and axis text. */
110
123
  export interface WaveformAxesOptions {
111
124
  x?: {
112
125
  lineVisible?: boolean;
126
+ /** Formats display-unit X values for ticks and visible-range endpoints. */
127
+ labelFormatter?: WaveformXAxisLabelFormatter;
113
128
  };
114
129
  y?: {
115
130
  lineVisible?: boolean;
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * 类型定义统一导出
3
3
  */
4
- export type { WaveformPoint, WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, WaveformTitleTextStyle, WaveformTitleOptions, WaveformLegendPosition, WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, WaveformAxesOptions, WaveformZeroLineOptions, } from './chart';
4
+ export type { WaveformPoint, WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, WaveformTitleTextStyle, WaveformTitleOptions, WaveformLegendPosition, WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, WaveformXAxisLabelKind, WaveformXAxisLabelFormatterContext, WaveformXAxisLabelFormatter, WaveformAxesOptions, WaveformZeroLineOptions, } from './chart';
5
5
  export type { SingleWaveformData, WaveformLineType, WaveformLineStyle, WaveformPointType, WaveformErrorBarOptions, ResolvedWaveformErrorBarOptions, WaveformSeries, WaveformData, NormalizedWaveformSeries, } from './data';
@@ -1,3 +1,4 @@
1
+ import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types';
1
2
  /**
2
3
  * 时间单位类型
3
4
  */
@@ -33,6 +34,8 @@ export declare function formatPlainNumber(value: number): string;
33
34
  * @returns 转换后的显示值
34
35
  */
35
36
  export declare function displayTime(value: number, timeUnit: TimeUnit): number;
37
+ /** Format an X-axis value without changing its source coordinate. */
38
+ export declare function formatXAxisLabel(rawValue: number, domain: [number, number], timeUnit: TimeUnit, kind: WaveformXAxisLabelKind, formatter?: WaveformXAxisLabelFormatter): string;
36
39
  /**
37
40
  * 计算端点标签的小数位数
38
41
  * @param domain 数据域 [最小值, 最大值]
@@ -41,7 +44,7 @@ export declare function displayTime(value: number, timeUnit: TimeUnit): number;
41
44
  */
42
45
  export declare function endpointFractionDigits(domain: [number, number], timeUnit: TimeUnit): number;
43
46
  /**
44
- * 将 X 轴端点时间格式化为当前显示单位下的普通整数
47
+ * 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
45
48
  * @param value 时间值(秒)
46
49
  * @param _domain 数据域(为保持现有调用签名而保留)
47
50
  * @param timeUnit 时间单位
@@ -49,7 +52,7 @@ export declare function endpointFractionDigits(domain: [number, number], timeUni
49
52
  */
50
53
  export declare function formatEndpointTime(value: number, _domain: [number, number], timeUnit: TimeUnit): string;
51
54
  /**
52
- * 将 X 轴时间刻度格式化为当前显示单位下的普通整数
55
+ * 将 X 轴时间刻度格式化为当前显示单位下的普通十进制文本
53
56
  * @param value 时间值(秒)
54
57
  * @param timeUnit 时间单位
55
58
  * @param _domain 数据域(为保持现有调用签名而保留)
@@ -2,6 +2,6 @@
2
2
  * 工具函数模块统一导出
3
3
  */
4
4
  export { paddedDomain, buildMinorTicks } from './domain';
5
- export { displayTime, endpointFractionDigits, formatEndpointTime, formatAxisTime, formatTooltipTime, formatAnnotationTime, formatPlainNumber, formatScientificAxisLabel, formatScientificAxisExponent, formatScientificYAxisLabel, formatTooltipNumber, resolveScientificAxisExponent, shouldUseScientificAxisLabel, shouldUseScientificYAxisLabel, type ScientificAxisLabelOptions, type ScientificYAxisLabelOptions, type TimeUnit, } from './formatters';
5
+ export { displayTime, endpointFractionDigits, formatEndpointTime, formatAxisTime, formatXAxisLabel, formatTooltipTime, formatAnnotationTime, formatPlainNumber, formatScientificAxisLabel, formatScientificAxisExponent, formatScientificYAxisLabel, formatTooltipNumber, resolveScientificAxisExponent, shouldUseScientificAxisLabel, shouldUseScientificYAxisLabel, type ScientificAxisLabelOptions, type ScientificYAxisLabelOptions, type TimeUnit, } from './formatters';
6
6
  export { resolveTrackGeometry, clamp, type TrackGeometry } from './geometry';
7
7
  export { downsampleLTTB, downsampleMinMax, adaptiveSampling, calculateSamplingThreshold, } from './sampling';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waveform-analysis",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
package/src/App.vue CHANGED
@@ -23,6 +23,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
23
23
  import DemoChartHost from './demo/DemoChartHost.vue'
24
24
  import DemoControlPanel from './demo/DemoControlPanel.vue'
25
25
  import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
26
+ import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
26
27
 
27
28
  const fullChartData = createSimulatedWaveformData()
28
29
  const displayMode = ref<WaveformDisplayMode>('independent')
@@ -40,6 +41,7 @@ const verticalGridVisible = ref(true)
40
41
  const verticalGridColor = ref('#dfe5ef')
41
42
  const xAxisLineVisible = ref(false)
42
43
  const yAxisLineVisible = ref(false)
44
+ const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
43
45
  const annotations = ref<WaveformAnnotation[]>([])
44
46
  const annotationsVisible = ref(true)
45
47
  const cleanView = ref(false)
@@ -112,7 +114,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
112
114
  backgroundColor: frameBackgroundColor.value,
113
115
  }))
114
116
  const axes = computed<WaveformAxesOptions>(() => ({
115
- x: { lineVisible: xAxisLineVisible.value },
117
+ x: {
118
+ lineVisible: xAxisLineVisible.value,
119
+ ...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
120
+ },
116
121
  y: { lineVisible: yAxisLineVisible.value },
117
122
  }))
118
123
  const zeroLine = computed<WaveformZeroLineOptions>(() => ({
@@ -308,6 +313,7 @@ const controlPanelModel = reactive({
308
313
  verticalGridColor,
309
314
  xAxisLineVisible,
310
315
  yAxisLineVisible,
316
+ ...xAxisLabelControlModel,
311
317
  frameBorderColor,
312
318
  frameBackgroundColor,
313
319
  frameBorderWidth,
@@ -12,8 +12,13 @@ import {
12
12
  selectSeriesRenderPoints,
13
13
  type ResolvedWaveformRenderingOptions,
14
14
  } from '../../core/rendering'
15
- import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
16
- import { buildMinorTicks, formatEndpointTime } from '../../utils'
15
+ import type {
16
+ WaveformDisplayMode,
17
+ WaveformOverlayMode,
18
+ WaveformPoint,
19
+ WaveformXAxisLabelFormatter,
20
+ } from '../../types'
21
+ import { buildMinorTicks, formatXAxisLabel } from '../../utils'
17
22
  import {
18
23
  getBottomRowCellIndexes,
19
24
  type GridCellGeometry,
@@ -45,6 +50,7 @@ export interface BuildTrackLayoutsOptions {
45
50
  fixedYDomains?: Record<string, [number, number]>
46
51
  yDomains?: Record<string, [number, number]>
47
52
  timeUnit: 's' | 'ms'
53
+ xAxisLabelFormatter?: WaveformXAxisLabelFormatter
48
54
  rendering: ResolvedWaveformRenderingOptions
49
55
  hideSecondaryLabels: boolean
50
56
  yAxisLabelX: number
@@ -147,8 +153,20 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
147
153
  const yAxisTickValues = yAxes[0]?.tickValues ?? []
148
154
  const domain = xScale.domain() as [number, number]
149
155
  const endpointLabels = {
150
- start: formatEndpointTime(domain[0], domain, options.timeUnit),
151
- end: formatEndpointTime(domain[1], domain, options.timeUnit),
156
+ start: formatXAxisLabel(
157
+ domain[0],
158
+ domain,
159
+ options.timeUnit,
160
+ 'start',
161
+ options.xAxisLabelFormatter,
162
+ ),
163
+ end: formatXAxisLabel(
164
+ domain[1],
165
+ domain,
166
+ options.timeUnit,
167
+ 'end',
168
+ options.xAxisLabelFormatter,
169
+ ),
152
170
  }
153
171
  const leftClearance = endpointLabels.start.length * 7 + 10
154
172
  const rightClearance = endpointLabels.end.length * 7 + 10
@@ -285,6 +285,7 @@ export function useWaveformLayout(context: LayoutContext) {
285
285
  )
286
286
  : sharedYDomains.value,
287
287
  timeUnit: props.timeUnit,
288
+ xAxisLabelFormatter: props.axes?.x?.labelFormatter,
288
289
  rendering: renderingOptions.value,
289
290
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
290
291
  yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
3
3
  import { nextTick, onMounted, ref, watch } from 'vue'
4
4
 
5
5
  import type { WaveformAxesOptions } from '../../types'
6
- import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
6
+ import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
7
7
  import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
8
8
 
9
9
  interface Props {
@@ -66,10 +66,12 @@ function renderAxes() {
66
66
  axisBottom(props.track.xScale)
67
67
  .tickValues(props.track.xAxisTickValues)
68
68
  .tickFormat((value) =>
69
- formatAxisTime(
69
+ formatXAxisLabel(
70
70
  Number(value),
71
- props.timeUnit,
72
71
  props.track.xScale.domain() as [number, number],
72
+ props.timeUnit,
73
+ 'tick',
74
+ props.axes?.x?.labelFormatter,
73
75
  ),
74
76
  )
75
77
  .tickSize(-4)
@@ -93,6 +95,7 @@ watch(
93
95
  () => props.track.xAxisTickValues,
94
96
  () => props.track.yAxisTickValues,
95
97
  () => props.timeUnit,
98
+ () => props.axes?.x?.labelFormatter,
96
99
  () => props.axes?.x?.lineVisible,
97
100
  () => props.axes?.y?.lineVisible,
98
101
  ],
@@ -1,7 +1,8 @@
1
1
  import { flushPromises } from '@vue/test-utils'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import { flushAnimationFrames } from '../../test/setup'
5
+ import type { WaveformXAxisLabelFormatter } from '../../types'
5
6
  import { type WaveformData } from '../waveform'
6
7
 
7
8
  import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,7 +163,7 @@ describe('WaveformChart', () => {
162
163
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
163
164
  String(trackWidth),
164
165
  )
165
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990')
166
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
166
167
  expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
167
168
  })
168
169
 
@@ -267,6 +268,70 @@ describe('WaveformChart', () => {
267
268
  expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
268
269
  })
269
270
 
271
+ it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
272
+ const data: WaveformData = {
273
+ kind: 'points',
274
+ points: [
275
+ { x: 0.125, y: 0 },
276
+ { x: 1.875, y: 1 },
277
+ ],
278
+ }
279
+ const originalPoints = data.points.map((point) => ({ ...point }))
280
+ const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
281
+ `${context.kind}:${value / 10}`
282
+ const formatter = vi.fn(labelFormatter)
283
+ const wrapper = await mountSizedChart(data, {
284
+ timeUnit: 'ms',
285
+ axes: { x: { labelFormatter: formatter } },
286
+ })
287
+
288
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
289
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
290
+ expect(
291
+ wrapper
292
+ .get('.waveform-chart__axis--x')
293
+ .findAll('.tick text')
294
+ .every((tick) => tick.text().startsWith('tick:')),
295
+ ).toBe(true)
296
+
297
+ const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
298
+ const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
299
+ const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
300
+ expect(startCall).toEqual([
301
+ 125,
302
+ {
303
+ kind: 'start',
304
+ rawValue: 0.125,
305
+ timeUnit: 'ms',
306
+ domain: [0.125, 1.875],
307
+ displayDomain: [125, 1875],
308
+ },
309
+ ])
310
+ expect(tickCall?.[0]).toBeTypeOf('number')
311
+ expect(tickCall?.[1]).toMatchObject({
312
+ kind: 'tick',
313
+ timeUnit: 'ms',
314
+ domain: [0.125, 1.875],
315
+ displayDomain: [125, 1875],
316
+ })
317
+ expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
318
+ expect(data.points).toEqual(originalPoints)
319
+
320
+ await wrapper.setProps({
321
+ axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
322
+ })
323
+ await flushPromises()
324
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
325
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
326
+ expect(
327
+ wrapper
328
+ .get('.waveform-chart__axis--x')
329
+ .findAll('.tick text')
330
+ .every((tick) => tick.text().startsWith('updated:')),
331
+ ).toBe(true)
332
+ expect(data.points).toEqual(originalPoints)
333
+ })
334
+
270
335
  it('keeps zoom-change domains in source seconds', async () => {
271
336
  const wrapper = await mountSizedChart({
272
337
  kind: 'points',
@@ -307,6 +372,8 @@ describe('WaveformChart', () => {
307
372
  )
308
373
  expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
309
374
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
310
- expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/^-?\d+$/)
375
+ expect(
376
+ Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
377
+ ).toBe(true)
311
378
  })
312
379
  })
@@ -96,6 +96,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
96
96
  <Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
97
97
  </div>
98
98
  </div>
99
+ <div class="x-axis-label-controls">
100
+ <label class="frame-style-control frame-style-control--switch">
101
+ <span>自定义 Label</span>
102
+ <Switch
103
+ v-model:checked="model.xAxisLabelFormatterEnabled"
104
+ size="small"
105
+ aria-label="自定义 X 轴 Label"
106
+ />
107
+ </label>
108
+ <label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
109
+ <span>格式类型</span>
110
+ <Select
111
+ v-model:value="model.xAxisLabelFormat"
112
+ :options="model.xAxisLabelFormatOptions"
113
+ size="small"
114
+ aria-label="X 轴 Label 格式类型"
115
+ />
116
+ </label>
117
+ <label
118
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
119
+ class="frame-style-control"
120
+ >
121
+ <span>运算倍率</span>
122
+ <InputNumber
123
+ v-model:value="model.xAxisLabelMultiplier"
124
+ :min="-1000000"
125
+ :max="1000000"
126
+ :step="0.1"
127
+ size="small"
128
+ aria-label="X 轴 Label 运算倍率"
129
+ />
130
+ </label>
131
+ <label
132
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
133
+ class="frame-style-control"
134
+ >
135
+ <span>小数位数</span>
136
+ <InputNumber
137
+ v-model:value="model.xAxisLabelFractionDigits"
138
+ :min="0"
139
+ :max="12"
140
+ :step="1"
141
+ size="small"
142
+ aria-label="X 轴 Label 小数位数"
143
+ />
144
+ </label>
145
+ <label
146
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
147
+ class="frame-style-control"
148
+ >
149
+ <span>时区</span>
150
+ <Select
151
+ v-model:value="model.xAxisLabelTimeZone"
152
+ :options="model.xAxisLabelTimeZoneOptions"
153
+ size="small"
154
+ aria-label="X 轴 Label 时区"
155
+ />
156
+ </label>
157
+ <label
158
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
159
+ class="frame-style-control frame-style-control--switch"
160
+ >
161
+ <span>显示毫秒</span>
162
+ <Switch
163
+ v-model:checked="model.xAxisLabelShowMilliseconds"
164
+ size="small"
165
+ aria-label="X 轴 Label 显示毫秒"
166
+ />
167
+ </label>
168
+ </div>
99
169
  </section>
100
170
  <section class="control-section">
101
171
  <h2>图框样式</h2>
package/src/demo/types.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  WaveformZeroLineOptions,
15
15
  WaveformZoomEndPayload,
16
16
  } from '../components'
17
+ import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
17
18
 
18
19
  interface SelectOption<T> {
19
20
  label: string
@@ -44,6 +45,14 @@ export interface DemoControlPanelModel {
44
45
  verticalGridColor: string
45
46
  xAxisLineVisible: boolean
46
47
  yAxisLineVisible: boolean
48
+ xAxisLabelFormatterEnabled: boolean
49
+ xAxisLabelFormat: DemoXAxisLabelFormat
50
+ xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
51
+ xAxisLabelMultiplier: number
52
+ xAxisLabelFractionDigits: number
53
+ xAxisLabelTimeZone: DemoXAxisLabelTimeZone
54
+ xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
55
+ xAxisLabelShowMilliseconds: boolean
47
56
  frameBorderColor: string
48
57
  frameBackgroundColor: string
49
58
  frameBorderWidth: number
@@ -0,0 +1,77 @@
1
+ import { computed, ref } from 'vue'
2
+
3
+ import type { WaveformXAxisLabelFormatter } from '../types'
4
+
5
+ export type DemoXAxisLabelFormat = 'number' | 'datetime'
6
+ export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
7
+
8
+ const formatOptions = [
9
+ { label: '数值', value: 'number' as const },
10
+ { label: '固定时间', value: 'datetime' as const },
11
+ ]
12
+ const timeZoneOptions = [
13
+ { label: '中国标准时间', value: 'Asia/Shanghai' as const },
14
+ { label: '本地时区', value: 'local' as const },
15
+ { label: 'UTC', value: 'UTC' as const },
16
+ ]
17
+
18
+ function pad(value: number, length = 2): string {
19
+ return String(value).padStart(length, '0')
20
+ }
21
+
22
+ export function formatDemoTimestamp(
23
+ value: number,
24
+ timeZone: DemoXAxisLabelTimeZone,
25
+ showMilliseconds: boolean,
26
+ ): string {
27
+ const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
28
+ const date = new Date(value + offset)
29
+ if (!Number.isFinite(date.getTime())) return String(value)
30
+
31
+ const useUtcFields = timeZone !== 'local'
32
+ const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
33
+ const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
34
+ const day = useUtcFields ? date.getUTCDate() : date.getDate()
35
+ const hours = useUtcFields ? date.getUTCHours() : date.getHours()
36
+ const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
37
+ const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
38
+ const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
39
+ const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
40
+ return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
41
+ }
42
+
43
+ export function useDemoXAxisLabelControls() {
44
+ const xAxisLabelFormatterEnabled = ref(false)
45
+ const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
46
+ const xAxisLabelMultiplier = ref(1)
47
+ const xAxisLabelFractionDigits = ref(3)
48
+ const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
49
+ const xAxisLabelShowMilliseconds = ref(false)
50
+ const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
51
+ if (!xAxisLabelFormatterEnabled.value) return undefined
52
+ if (xAxisLabelFormat.value === 'datetime') {
53
+ return (value) =>
54
+ formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
55
+ }
56
+
57
+ const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
58
+ const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
59
+ ? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
60
+ : 0
61
+ return (value) => (value * multiplier).toFixed(fractionDigits)
62
+ })
63
+
64
+ return {
65
+ controlModel: {
66
+ xAxisLabelFormatterEnabled,
67
+ xAxisLabelFormat,
68
+ xAxisLabelFormatOptions: formatOptions,
69
+ xAxisLabelMultiplier,
70
+ xAxisLabelFractionDigits,
71
+ xAxisLabelTimeZone,
72
+ xAxisLabelTimeZoneOptions: timeZoneOptions,
73
+ xAxisLabelShowMilliseconds,
74
+ },
75
+ xAxisLabelFormatter,
76
+ }
77
+ }
@@ -0,0 +1,86 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { InputNumber, Select } from 'ant-design-vue'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import App from '../App.vue'
6
+ import { WaveformChart } from '../components'
7
+ import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
8
+
9
+ describe('X-axis label demo controls', () => {
10
+ it('formats timestamps in fixed UTC and China-standard-time formats', () => {
11
+ expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
12
+ expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
13
+ expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
14
+ expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
15
+ })
16
+
17
+ it('configures numeric and timestamp labels from the sidebar', async () => {
18
+ const wrapper = mount(App)
19
+ await flushPromises()
20
+ const chart = wrapper.getComponent(WaveformChart)
21
+
22
+ expect(chart.props('axes')).toEqual({
23
+ x: { lineVisible: false },
24
+ y: { lineVisible: false },
25
+ })
26
+ expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
27
+ expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
28
+ expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
29
+ expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
30
+
31
+ await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
32
+ await flushPromises()
33
+ const inputNumbers = wrapper.findAllComponents(InputNumber)
34
+ const multiplierInput = inputNumbers.find((input) =>
35
+ input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
36
+ )
37
+ const fractionDigitsInput = inputNumbers.find((input) =>
38
+ input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
39
+ )
40
+ expect(multiplierInput).toBeDefined()
41
+ expect(fractionDigitsInput).toBeDefined()
42
+ expect(multiplierInput?.props('value')).toBe(1)
43
+ expect(fractionDigitsInput?.props('value')).toBe(3)
44
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
45
+
46
+ multiplierInput?.vm.$emit('update:value', 2)
47
+ fractionDigitsInput?.vm.$emit('update:value', 2)
48
+ await flushPromises()
49
+ const formatter = chart.props('axes')?.x?.labelFormatter
50
+ expect(formatter).toBeTypeOf('function')
51
+ expect(formatter?.(1.234, {} as never)).toBe('2.47')
52
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
53
+
54
+ const formatSelect = wrapper
55
+ .findAllComponents(Select)
56
+ .find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
57
+ expect(formatSelect?.props('value')).toBe('number')
58
+ formatSelect?.vm.$emit('update:value', 'datetime')
59
+ await flushPromises()
60
+ expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
61
+ expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
62
+ expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
63
+ expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
64
+ expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
65
+
66
+ await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
67
+ await flushPromises()
68
+ expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
69
+ '1970-01-01 08:00:00.123',
70
+ )
71
+ const timeZoneSelect = wrapper
72
+ .findAllComponents(Select)
73
+ .find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
74
+ timeZoneSelect?.vm.$emit('update:value', 'UTC')
75
+ await flushPromises()
76
+ expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
77
+ '1970-01-01 00:00:00.123',
78
+ )
79
+
80
+ await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
81
+ await flushPromises()
82
+ expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
83
+ expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
84
+ wrapper.unmount()
85
+ })
86
+ })