waveform-analysis 0.1.28 → 0.1.30
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 +57 -33
- package/dist/index.cjs +1 -1
- package/dist/index.js +590 -577
- package/dist/types/components/WaveformChart.vue.d.ts +1 -0
- package/dist/types/components/core/constants.d.ts +2 -0
- package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +6 -1
- package/dist/types/components/core/useWaveformLayout.d.ts +0 -1
- package/dist/types/components/core/useWaveformPresentation.d.ts +5 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +3 -2
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/index.d.ts +1 -1
- package/dist/types/components/waveform.d.ts +1 -1
- package/dist/types/demo/types.d.ts +13 -1
- package/dist/types/demo/useDemoXAxisLabelControls.d.ts +32 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +22 -0
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/formatters.d.ts +5 -2
- package/dist/types/utils/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/App.vue +17 -1
- package/src/components/WaveformChart.vue +1 -0
- package/src/components/WaveformChartView.vue +13 -10
- package/src/components/core/constants.ts +3 -0
- package/src/components/core/trackLayoutBuilder.ts +22 -4
- package/src/components/core/useWaveformLayout.ts +1 -3
- package/src/components/core/useWaveformPresentation.ts +17 -2
- package/src/components/core/waveformChartTypes.ts +3 -0
- package/src/components/data/types.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/components/rendering/WaveformTrackAxes.vue +6 -3
- package/src/components/waveform.ts +1 -0
- package/src/components/waveformChartCases/frameGridAndFormatting.test.ts +70 -3
- package/src/components/waveformChartCases/sizingAndTitles.test.ts +59 -0
- package/src/demo/DemoChartHost.vue +1 -0
- package/src/demo/DemoGridFrameControls.vue +99 -0
- package/src/demo/plotMarginControls.test.ts +29 -0
- package/src/demo/types.ts +13 -0
- package/src/demo/useDemoXAxisLabelControls.ts +77 -0
- package/src/demo/xAxisLabelControls.test.ts +86 -0
- package/src/index.ts +4 -0
- package/src/styles.css +9 -0
- package/src/types/chart.ts +29 -0
- package/src/types/index.ts +4 -0
- package/src/utils/formatters.test.ts +37 -5
- package/src/utils/formatters.ts +26 -16
- package/src/utils/index.ts +1 -0
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
375
|
+
expect(
|
|
376
|
+
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
|
|
377
|
+
).toBe(true)
|
|
311
378
|
})
|
|
312
379
|
})
|
|
@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
|
|
|
105
105
|
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
+
it('configures plot top and bottom margins and reacts to updates', async () => {
|
|
109
|
+
const wrapper = await mountSizedChart(
|
|
110
|
+
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
|
111
|
+
{
|
|
112
|
+
displayMode: 'compact',
|
|
113
|
+
grid: { rowCount: 1, columnCount: 1 },
|
|
114
|
+
plotMargin: { top: 30, bottom: 70 },
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
|
|
119
|
+
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
|
|
120
|
+
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
|
|
121
|
+
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
|
|
122
|
+
|
|
123
|
+
await wrapper.setProps({ plotMargin: { top: 12 } })
|
|
124
|
+
|
|
125
|
+
expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
|
|
126
|
+
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
|
|
127
|
+
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('falls back to default plot margins for invalid values', async () => {
|
|
131
|
+
const wrapper = await mountSizedChart(
|
|
132
|
+
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
|
133
|
+
{
|
|
134
|
+
displayMode: 'compact',
|
|
135
|
+
grid: { rowCount: 1, columnCount: 1 },
|
|
136
|
+
plotMargin: { top: -1, bottom: Number.NaN },
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
|
|
141
|
+
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
|
|
142
|
+
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
|
|
146
|
+
const wrapper = await mountSizedChart(
|
|
147
|
+
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
|
148
|
+
{
|
|
149
|
+
displayMode: 'compact',
|
|
150
|
+
grid: { rowCount: 1, columnCount: 1 },
|
|
151
|
+
title: { text: '固定标题' },
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
|
|
155
|
+
const xAxisTitle = wrapper.get('.waveform-chart__x-label')
|
|
156
|
+
const initialX = xAxisTitle.attributes('x')
|
|
157
|
+
const initialY = xAxisTitle.attributes('y')
|
|
158
|
+
|
|
159
|
+
await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
|
|
160
|
+
|
|
161
|
+
expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
|
|
162
|
+
expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
|
|
163
|
+
expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
|
|
164
|
+
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
|
|
165
|
+
})
|
|
166
|
+
|
|
108
167
|
it('does not render or reserve space for missing, hidden, or blank titles', async () => {
|
|
109
168
|
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
|
|
110
169
|
const wrapper = await mountSizedChart(
|
|
@@ -45,6 +45,7 @@ defineExpose({ resetViewport })
|
|
|
45
45
|
:clean-view="model.cleanView"
|
|
46
46
|
:presentation-mode="model.presentationMode"
|
|
47
47
|
:show-tooltip="model.showTooltip"
|
|
48
|
+
:plot-margin="model.plotMargin"
|
|
48
49
|
:zero-line="model.zeroLine"
|
|
49
50
|
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
|
|
50
51
|
:annotations-visible="model.annotationsVisible"
|
|
@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
|
|
8
8
|
</script>
|
|
9
9
|
|
|
10
10
|
<template>
|
|
11
|
+
<section class="control-section">
|
|
12
|
+
<h2>绘图区边距</h2>
|
|
13
|
+
<div class="plot-margin-controls">
|
|
14
|
+
<label class="frame-style-control">
|
|
15
|
+
<span>上边距</span>
|
|
16
|
+
<InputNumber
|
|
17
|
+
v-model:value="model.plotMarginTop"
|
|
18
|
+
:min="0"
|
|
19
|
+
:max="300"
|
|
20
|
+
:step="1"
|
|
21
|
+
addon-after="px"
|
|
22
|
+
size="small"
|
|
23
|
+
aria-label="绘图区上边距"
|
|
24
|
+
/>
|
|
25
|
+
</label>
|
|
26
|
+
<label class="frame-style-control">
|
|
27
|
+
<span>下边距</span>
|
|
28
|
+
<InputNumber
|
|
29
|
+
v-model:value="model.plotMarginBottom"
|
|
30
|
+
:min="0"
|
|
31
|
+
:max="300"
|
|
32
|
+
:step="1"
|
|
33
|
+
addon-after="px"
|
|
34
|
+
size="small"
|
|
35
|
+
aria-label="绘图区下边距"
|
|
36
|
+
/>
|
|
37
|
+
</label>
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
11
40
|
<section class="control-section">
|
|
12
41
|
<div class="control-section__header">
|
|
13
42
|
<h2>零值参考线</h2>
|
|
@@ -96,6 +125,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
|
|
96
125
|
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
|
|
97
126
|
</div>
|
|
98
127
|
</div>
|
|
128
|
+
<div class="x-axis-label-controls">
|
|
129
|
+
<label class="frame-style-control frame-style-control--switch">
|
|
130
|
+
<span>自定义 Label</span>
|
|
131
|
+
<Switch
|
|
132
|
+
v-model:checked="model.xAxisLabelFormatterEnabled"
|
|
133
|
+
size="small"
|
|
134
|
+
aria-label="自定义 X 轴 Label"
|
|
135
|
+
/>
|
|
136
|
+
</label>
|
|
137
|
+
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
|
|
138
|
+
<span>格式类型</span>
|
|
139
|
+
<Select
|
|
140
|
+
v-model:value="model.xAxisLabelFormat"
|
|
141
|
+
:options="model.xAxisLabelFormatOptions"
|
|
142
|
+
size="small"
|
|
143
|
+
aria-label="X 轴 Label 格式类型"
|
|
144
|
+
/>
|
|
145
|
+
</label>
|
|
146
|
+
<label
|
|
147
|
+
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
|
|
148
|
+
class="frame-style-control"
|
|
149
|
+
>
|
|
150
|
+
<span>运算倍率</span>
|
|
151
|
+
<InputNumber
|
|
152
|
+
v-model:value="model.xAxisLabelMultiplier"
|
|
153
|
+
:min="-1000000"
|
|
154
|
+
:max="1000000"
|
|
155
|
+
:step="0.1"
|
|
156
|
+
size="small"
|
|
157
|
+
aria-label="X 轴 Label 运算倍率"
|
|
158
|
+
/>
|
|
159
|
+
</label>
|
|
160
|
+
<label
|
|
161
|
+
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
|
|
162
|
+
class="frame-style-control"
|
|
163
|
+
>
|
|
164
|
+
<span>小数位数</span>
|
|
165
|
+
<InputNumber
|
|
166
|
+
v-model:value="model.xAxisLabelFractionDigits"
|
|
167
|
+
:min="0"
|
|
168
|
+
:max="12"
|
|
169
|
+
:step="1"
|
|
170
|
+
size="small"
|
|
171
|
+
aria-label="X 轴 Label 小数位数"
|
|
172
|
+
/>
|
|
173
|
+
</label>
|
|
174
|
+
<label
|
|
175
|
+
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
|
|
176
|
+
class="frame-style-control"
|
|
177
|
+
>
|
|
178
|
+
<span>时区</span>
|
|
179
|
+
<Select
|
|
180
|
+
v-model:value="model.xAxisLabelTimeZone"
|
|
181
|
+
:options="model.xAxisLabelTimeZoneOptions"
|
|
182
|
+
size="small"
|
|
183
|
+
aria-label="X 轴 Label 时区"
|
|
184
|
+
/>
|
|
185
|
+
</label>
|
|
186
|
+
<label
|
|
187
|
+
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
|
|
188
|
+
class="frame-style-control frame-style-control--switch"
|
|
189
|
+
>
|
|
190
|
+
<span>显示毫秒</span>
|
|
191
|
+
<Switch
|
|
192
|
+
v-model:checked="model.xAxisLabelShowMilliseconds"
|
|
193
|
+
size="small"
|
|
194
|
+
aria-label="X 轴 Label 显示毫秒"
|
|
195
|
+
/>
|
|
196
|
+
</label>
|
|
197
|
+
</div>
|
|
99
198
|
</section>
|
|
100
199
|
<section class="control-section">
|
|
101
200
|
<h2>图框样式</h2>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
2
|
+
import { InputNumber } from 'ant-design-vue'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import App from '../App.vue'
|
|
6
|
+
import { WaveformChart } from '../components'
|
|
7
|
+
|
|
8
|
+
describe('plot margin demo controls', () => {
|
|
9
|
+
it('updates the chart top and bottom margins from the sidebar', async () => {
|
|
10
|
+
const wrapper = mount(App)
|
|
11
|
+
await flushPromises()
|
|
12
|
+
const chart = wrapper.getComponent(WaveformChart)
|
|
13
|
+
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
|
|
14
|
+
|
|
15
|
+
expect(controls).toHaveLength(2)
|
|
16
|
+
expect(controls[0]?.props('value')).toBe(18)
|
|
17
|
+
expect(controls[1]?.props('value')).toBe(52)
|
|
18
|
+
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
|
|
19
|
+
|
|
20
|
+
controls[0]?.vm.$emit('update:value', 30)
|
|
21
|
+
controls[1]?.vm.$emit('update:value', 70)
|
|
22
|
+
await flushPromises()
|
|
23
|
+
|
|
24
|
+
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
|
|
25
|
+
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
|
|
26
|
+
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
|
|
27
|
+
wrapper.unmount()
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/demo/types.ts
CHANGED
|
@@ -10,10 +10,12 @@ import type {
|
|
|
10
10
|
WaveformLegendPosition,
|
|
11
11
|
WaveformLineStyle,
|
|
12
12
|
WaveformOverlayMode,
|
|
13
|
+
WaveformPlotMargin,
|
|
13
14
|
WaveformTitleOptions,
|
|
14
15
|
WaveformZeroLineOptions,
|
|
15
16
|
WaveformZoomEndPayload,
|
|
16
17
|
} from '../components'
|
|
18
|
+
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
|
|
17
19
|
|
|
18
20
|
interface SelectOption<T> {
|
|
19
21
|
label: string
|
|
@@ -25,6 +27,8 @@ export interface DemoControlPanelModel {
|
|
|
25
27
|
displayMode: WaveformDisplayMode
|
|
26
28
|
overlayMode: WaveformOverlayMode
|
|
27
29
|
showTooltip: boolean
|
|
30
|
+
plotMarginTop: number
|
|
31
|
+
plotMarginBottom: number
|
|
28
32
|
cleanView: boolean
|
|
29
33
|
presentationMode: boolean
|
|
30
34
|
selectedSeriesId: string
|
|
@@ -44,6 +48,14 @@ export interface DemoControlPanelModel {
|
|
|
44
48
|
verticalGridColor: string
|
|
45
49
|
xAxisLineVisible: boolean
|
|
46
50
|
yAxisLineVisible: boolean
|
|
51
|
+
xAxisLabelFormatterEnabled: boolean
|
|
52
|
+
xAxisLabelFormat: DemoXAxisLabelFormat
|
|
53
|
+
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
|
|
54
|
+
xAxisLabelMultiplier: number
|
|
55
|
+
xAxisLabelFractionDigits: number
|
|
56
|
+
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
|
|
57
|
+
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
|
|
58
|
+
xAxisLabelShowMilliseconds: boolean
|
|
47
59
|
frameBorderColor: string
|
|
48
60
|
frameBackgroundColor: string
|
|
49
61
|
frameBorderWidth: number
|
|
@@ -90,6 +102,7 @@ export interface DemoChartModel {
|
|
|
90
102
|
cleanView: boolean
|
|
91
103
|
presentationMode: boolean
|
|
92
104
|
showTooltip: boolean
|
|
105
|
+
plotMargin: WaveformPlotMargin
|
|
93
106
|
zeroLine: WaveformZeroLineOptions
|
|
94
107
|
frameWatermarkVisible: boolean
|
|
95
108
|
annotations: WaveformAnnotation[]
|
|
@@ -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
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -17,12 +17,16 @@ export type {
|
|
|
17
17
|
WaveformAnnotationStyle,
|
|
18
18
|
WaveformAnnotation,
|
|
19
19
|
WaveformRenderingOptions,
|
|
20
|
+
WaveformPlotMargin,
|
|
20
21
|
WaveformTitleTextStyle,
|
|
21
22
|
WaveformTitleOptions,
|
|
22
23
|
WaveformLegendPosition,
|
|
23
24
|
WaveformLegendOrientation,
|
|
24
25
|
WaveformLegendOptions,
|
|
25
26
|
WaveformFrameStyle,
|
|
27
|
+
WaveformXAxisLabelKind,
|
|
28
|
+
WaveformXAxisLabelFormatterContext,
|
|
29
|
+
WaveformXAxisLabelFormatter,
|
|
26
30
|
WaveformAxesOptions,
|
|
27
31
|
WaveformZeroLineOptions,
|
|
28
32
|
// 数据类型
|
package/src/styles.css
CHANGED
|
@@ -133,6 +133,14 @@ body {
|
|
|
133
133
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
.x-axis-label-controls {
|
|
137
|
+
display: grid;
|
|
138
|
+
gap: 10px;
|
|
139
|
+
margin-top: 12px;
|
|
140
|
+
padding-top: 12px;
|
|
141
|
+
border-top: 1px solid #eaecf0;
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
.grid-line-color-picker,
|
|
137
145
|
.grid-line-color-picker .vc-color-wrap {
|
|
138
146
|
width: 48px;
|
|
@@ -187,6 +195,7 @@ body {
|
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
.frame-style-controls,
|
|
198
|
+
.plot-margin-controls,
|
|
190
199
|
.auxiliary-style-controls {
|
|
191
200
|
display: grid;
|
|
192
201
|
gap: 10px;
|
package/src/types/chart.ts
CHANGED
|
@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
|
|
|
74
74
|
errorBarMinSpacing?: number
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Pixel margins reserved above and below the waveform plotting area. */
|
|
78
|
+
export interface WaveformPlotMargin {
|
|
79
|
+
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
|
|
80
|
+
top?: number
|
|
81
|
+
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
|
|
82
|
+
bottom?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
/** Text styling for the chart-level title. */
|
|
78
86
|
export interface WaveformTitleTextStyle {
|
|
79
87
|
color?: string
|
|
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
|
|
|
121
129
|
backgroundColor?: string
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
|
|
133
|
+
|
|
134
|
+
/** Context passed to custom X-axis label formatters. */
|
|
135
|
+
export interface WaveformXAxisLabelFormatterContext {
|
|
136
|
+
kind: WaveformXAxisLabelKind
|
|
137
|
+
/** X coordinate in the source data, before time-unit conversion. */
|
|
138
|
+
rawValue: number
|
|
139
|
+
timeUnit: 's' | 'ms'
|
|
140
|
+
/** Current visible X domain in source coordinates. */
|
|
141
|
+
domain: [number, number]
|
|
142
|
+
/** Current visible X domain converted to the selected display unit. */
|
|
143
|
+
displayDomain: [number, number]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type WaveformXAxisLabelFormatter = (
|
|
147
|
+
value: number,
|
|
148
|
+
context: WaveformXAxisLabelFormatterContext,
|
|
149
|
+
) => string
|
|
150
|
+
|
|
124
151
|
/** Controls axis baseline visibility while preserving tick marks and axis text. */
|
|
125
152
|
export interface WaveformAxesOptions {
|
|
126
153
|
x?: {
|
|
127
154
|
lineVisible?: boolean
|
|
155
|
+
/** Formats display-unit X values for ticks and visible-range endpoints. */
|
|
156
|
+
labelFormatter?: WaveformXAxisLabelFormatter
|
|
128
157
|
}
|
|
129
158
|
y?: {
|
|
130
159
|
lineVisible?: boolean
|
package/src/types/index.ts
CHANGED
|
@@ -12,12 +12,16 @@ export type {
|
|
|
12
12
|
WaveformAnnotationStyle,
|
|
13
13
|
WaveformAnnotation,
|
|
14
14
|
WaveformRenderingOptions,
|
|
15
|
+
WaveformPlotMargin,
|
|
15
16
|
WaveformTitleTextStyle,
|
|
16
17
|
WaveformTitleOptions,
|
|
17
18
|
WaveformLegendPosition,
|
|
18
19
|
WaveformLegendOrientation,
|
|
19
20
|
WaveformLegendOptions,
|
|
20
21
|
WaveformFrameStyle,
|
|
22
|
+
WaveformXAxisLabelKind,
|
|
23
|
+
WaveformXAxisLabelFormatterContext,
|
|
24
|
+
WaveformXAxisLabelFormatter,
|
|
21
25
|
WaveformAxesOptions,
|
|
22
26
|
WaveformZeroLineOptions,
|
|
23
27
|
} from './chart'
|