waveform-analysis 0.1.24 → 0.1.25
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 +40 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +534 -481
- package/dist/types/App.vue.d.ts +4 -0
- package/dist/types/DemoRouterApp.vue.d.ts +3 -0
- package/dist/types/components/core/layout.d.ts +6 -3
- package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +2 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +2 -0
- package/dist/types/components/core/yDomain.d.ts +7 -0
- package/dist/types/demo/DemoChartHost.vue.d.ts +14 -0
- package/dist/types/demo/DemoControlPanel.vue.d.ts +11 -0
- package/dist/types/demo/DemoGridFrameControls.vue.d.ts +11 -0
- package/dist/types/demo/DemoTitleLegendControls.vue.d.ts +11 -0
- package/dist/types/demo/DemoViewControls.vue.d.ts +11 -0
- package/dist/types/demo/types.d.ts +84 -0
- package/dist/types/router.d.ts +3 -0
- package/dist/types/views/FixedYDomainDemo.vue.d.ts +3 -0
- package/package.json +2 -1
- package/src/DemoRouterApp.vue +14 -0
- package/src/components/core/fixedYDomainLayout.test.ts +116 -0
- package/src/components/core/layout.ts +43 -10
- package/src/components/core/trackLayoutBuilder.ts +17 -7
- package/src/components/core/useWaveformChartController.ts +10 -0
- package/src/components/core/useWaveformLayout.ts +20 -4
- package/src/components/core/waveformChartTypes.ts +2 -0
- package/src/components/core/yDomain.test.ts +56 -0
- package/src/components/core/yDomain.ts +50 -0
- package/src/components/interaction/useWaveformViewport.ts +6 -1
- package/src/components/waveformChartCases/layoutAndDomains.test.ts +25 -0
- package/src/components/waveformChartCases/viewportBoxAndPan.test.ts +50 -0
- package/src/demoRoutes.css +197 -0
- package/src/main.ts +4 -2
- package/src/router.ts +21 -0
- package/src/styles.css +1 -2
- package/src/views/FixedYDomainDemo.test.ts +44 -0
- package/src/views/FixedYDomainDemo.vue +149 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { DisplayTrack } from './types'
|
|
4
|
+
import { hasFixedYDomainForTrack, mergeYDomains, normalizeYDomain } from './yDomain'
|
|
5
|
+
|
|
6
|
+
const track: DisplayTrack = {
|
|
7
|
+
id: 'shared-track',
|
|
8
|
+
series: [],
|
|
9
|
+
visibleSeries: [
|
|
10
|
+
{
|
|
11
|
+
id: 'channel-a',
|
|
12
|
+
name: 'A',
|
|
13
|
+
color: '#1677ff',
|
|
14
|
+
lineType: 'linear',
|
|
15
|
+
lineStyle: 'solid',
|
|
16
|
+
pointType: 'none',
|
|
17
|
+
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
|
18
|
+
points: [],
|
|
19
|
+
xDomain: [0, 1],
|
|
20
|
+
yDomain: [0, 1],
|
|
21
|
+
hasErrorPoints: false,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
xDomain: [0, 1],
|
|
25
|
+
yDomain: [0, 1],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('fixed Y domains', () => {
|
|
29
|
+
it('normalizes valid reversed domains', () => {
|
|
30
|
+
expect(normalizeYDomain([97, 3])).toEqual([3, 97])
|
|
31
|
+
expect(normalizeYDomain([-10, 10])).toEqual([-10, 10])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it.each([undefined, [1, 1], [Number.NaN, 1], [0, Number.POSITIVE_INFINITY]])(
|
|
35
|
+
'rejects an invalid domain: %j',
|
|
36
|
+
(domain) => {
|
|
37
|
+
expect(normalizeYDomain(domain as [number, number] | undefined)).toBeUndefined()
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
it('detects valid track, series, and global configuration only', () => {
|
|
42
|
+
expect(hasFixedYDomainForTrack(track, [-1, 1])).toBe(true)
|
|
43
|
+
expect(hasFixedYDomainForTrack(track, undefined, { 'shared-track': [-2, 2] })).toBe(true)
|
|
44
|
+
expect(hasFixedYDomainForTrack(track, undefined, { 'channel-a': [-3, 3] })).toBe(true)
|
|
45
|
+
expect(hasFixedYDomainForTrack(track, [0, 0], { 'channel-a': [1, 1] })).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('merges configured and automatic series domains without padding', () => {
|
|
49
|
+
expect(
|
|
50
|
+
mergeYDomains([
|
|
51
|
+
[3, 97],
|
|
52
|
+
[-20, 40],
|
|
53
|
+
]),
|
|
54
|
+
).toEqual([-20, 97])
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DisplaySeries, DisplayTrack } from './types'
|
|
2
|
+
|
|
3
|
+
export type WaveformYDomain = [number, number]
|
|
4
|
+
|
|
5
|
+
export function normalizeYDomain(
|
|
6
|
+
domain: readonly [number, number] | undefined,
|
|
7
|
+
): WaveformYDomain | undefined {
|
|
8
|
+
if (
|
|
9
|
+
!domain ||
|
|
10
|
+
!Number.isFinite(domain[0]) ||
|
|
11
|
+
!Number.isFinite(domain[1]) ||
|
|
12
|
+
domain[0] === domain[1]
|
|
13
|
+
) {
|
|
14
|
+
return undefined
|
|
15
|
+
}
|
|
16
|
+
return domain[0] < domain[1] ? [domain[0], domain[1]] : [domain[1], domain[0]]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveTrackFixedYDomain(
|
|
20
|
+
track: Pick<DisplayTrack, 'id'>,
|
|
21
|
+
yDomains?: Record<string, WaveformYDomain>,
|
|
22
|
+
): WaveformYDomain | undefined {
|
|
23
|
+
return normalizeYDomain(yDomains?.[track.id])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveSeriesFixedYDomain(
|
|
27
|
+
series: Pick<DisplaySeries, 'id'>,
|
|
28
|
+
yDomain?: WaveformYDomain,
|
|
29
|
+
yDomains?: Record<string, WaveformYDomain>,
|
|
30
|
+
): WaveformYDomain | undefined {
|
|
31
|
+
return normalizeYDomain(yDomains?.[series.id]) ?? normalizeYDomain(yDomain)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hasFixedYDomainForTrack(
|
|
35
|
+
track: Pick<DisplayTrack, 'id' | 'visibleSeries'>,
|
|
36
|
+
yDomain?: WaveformYDomain,
|
|
37
|
+
yDomains?: Record<string, WaveformYDomain>,
|
|
38
|
+
): boolean {
|
|
39
|
+
return Boolean(
|
|
40
|
+
resolveTrackFixedYDomain(track, yDomains) ||
|
|
41
|
+
track.visibleSeries.some((series) => resolveSeriesFixedYDomain(series, yDomain, yDomains)),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mergeYDomains(domains: readonly WaveformYDomain[]): WaveformYDomain {
|
|
46
|
+
return [
|
|
47
|
+
Math.min(...domains.map((domain) => domain[0])),
|
|
48
|
+
Math.max(...domains.map((domain) => domain[1])),
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -3,6 +3,7 @@ import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from
|
|
|
3
3
|
|
|
4
4
|
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
|
5
5
|
import type { DisplayTrack, TrackLayout } from '../core/types'
|
|
6
|
+
import { hasFixedYDomainForTrack } from '../core/yDomain'
|
|
6
7
|
import type {
|
|
7
8
|
ResolvedWaveformChartProps,
|
|
8
9
|
ViewportSelectionState,
|
|
@@ -190,9 +191,13 @@ export function useWaveformViewport(context: ViewportContext) {
|
|
|
190
191
|
const nextIndependentDomains = { ...independentYDomains.value }
|
|
191
192
|
const nextSharedDomains = { ...sharedYDomains.value }
|
|
192
193
|
targets.forEach((target) => {
|
|
194
|
+
const chartTrack = chartTracks.value.find((item) => item.id === target.id)
|
|
195
|
+
if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
|
|
196
|
+
return
|
|
197
|
+
}
|
|
193
198
|
const key = target.series.trackId ?? target.series.id
|
|
194
199
|
const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
|
|
195
|
-
const boundary =
|
|
200
|
+
const boundary = chartTrack?.yDomain ?? source
|
|
196
201
|
const ySpan = source[1] - source[0]
|
|
197
202
|
const nextY = clampDomain(
|
|
198
203
|
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
|
|
@@ -294,6 +294,31 @@ describe('WaveformChart', () => {
|
|
|
294
294
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
|
295
295
|
})
|
|
296
296
|
|
|
297
|
+
it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
|
|
298
|
+
const wrapper = await mountSizedChart({
|
|
299
|
+
kind: 'points',
|
|
300
|
+
points: [
|
|
301
|
+
{ x: 0, y: 0 },
|
|
302
|
+
{ x: 1, y: 100 },
|
|
303
|
+
],
|
|
304
|
+
})
|
|
305
|
+
const yTickLabels = () =>
|
|
306
|
+
wrapper
|
|
307
|
+
.get('.waveform-chart__axis--y')
|
|
308
|
+
.findAll('.tick text')
|
|
309
|
+
.map((tick) => tick.text())
|
|
310
|
+
|
|
311
|
+
await wrapper.setProps({ yDomain: [3, 97] })
|
|
312
|
+
await flushPromises()
|
|
313
|
+
expect(yTickLabels()).toContain('3.00')
|
|
314
|
+
expect(yTickLabels()).toContain('97.00')
|
|
315
|
+
|
|
316
|
+
await wrapper.setProps({ yDomain: undefined })
|
|
317
|
+
await flushPromises()
|
|
318
|
+
expect(yTickLabels()).not.toContain('3.00')
|
|
319
|
+
expect(yTickLabels()).not.toContain('97.00')
|
|
320
|
+
})
|
|
321
|
+
|
|
297
322
|
it('keeps annotations bound to their channel while paging', async () => {
|
|
298
323
|
const wrapper = await mountSizedChart(gridSeries(3), {
|
|
299
324
|
grid: { rowCount: 1, columnCount: 1 },
|
|
@@ -224,6 +224,56 @@ describe('WaveformChart', () => {
|
|
|
224
224
|
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
+
it('keeps a fixed Y domain through panning and viewport reset', async () => {
|
|
228
|
+
const wrapper = await mountSizedChart(
|
|
229
|
+
{
|
|
230
|
+
kind: 'points',
|
|
231
|
+
points: [
|
|
232
|
+
{ x: 0, y: 0 },
|
|
233
|
+
{ x: 1, y: 100 },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
{ pannable: true, yDomain: [3, 97] },
|
|
237
|
+
)
|
|
238
|
+
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
|
239
|
+
const width = Number(overlay.attributes('width'))
|
|
240
|
+
const height = Number(overlay.attributes('height'))
|
|
241
|
+
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
242
|
+
value: () => ({ left: 0, top: 0, width, height }),
|
|
243
|
+
})
|
|
244
|
+
const yTickLabels = () =>
|
|
245
|
+
wrapper
|
|
246
|
+
.get('.waveform-chart__axis--y')
|
|
247
|
+
.findAll('.tick text')
|
|
248
|
+
.map((tick) => tick.text())
|
|
249
|
+
const initialLabels = yTickLabels()
|
|
250
|
+
|
|
251
|
+
await wrapper.trigger('pointerenter')
|
|
252
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
|
|
253
|
+
const down = new MouseEvent('pointerdown', {
|
|
254
|
+
button: 0,
|
|
255
|
+
clientX: width / 2,
|
|
256
|
+
clientY: height / 2,
|
|
257
|
+
bubbles: true,
|
|
258
|
+
})
|
|
259
|
+
Object.defineProperty(down, 'pointerId', { value: 34 })
|
|
260
|
+
overlay.element.dispatchEvent(down)
|
|
261
|
+
const move = new MouseEvent('pointermove', {
|
|
262
|
+
clientX: width / 2 + 20,
|
|
263
|
+
clientY: height / 2 + 40,
|
|
264
|
+
bubbles: true,
|
|
265
|
+
})
|
|
266
|
+
Object.defineProperty(move, 'pointerId', { value: 34 })
|
|
267
|
+
overlay.element.dispatchEvent(move)
|
|
268
|
+
await flushPromises()
|
|
269
|
+
|
|
270
|
+
expect(yTickLabels()).toEqual(initialLabels)
|
|
271
|
+
;(wrapper.vm as unknown as { resetViewport: () => void }).resetViewport()
|
|
272
|
+
await flushPromises()
|
|
273
|
+
expect(yTickLabels()).toEqual(initialLabels)
|
|
274
|
+
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
|
|
275
|
+
})
|
|
276
|
+
|
|
227
277
|
it('does not activate pannable on a chart that the pointer is outside', async () => {
|
|
228
278
|
const data: WaveformData = {
|
|
229
279
|
kind: 'points',
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
.demo-shell {
|
|
2
|
+
display: grid;
|
|
3
|
+
grid-template-rows: 48px minmax(0, 1fr);
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100%;
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.demo-shell__header {
|
|
10
|
+
z-index: 40;
|
|
11
|
+
display: flex;
|
|
12
|
+
gap: 24px;
|
|
13
|
+
align-items: center;
|
|
14
|
+
min-width: 0;
|
|
15
|
+
padding: 0 16px;
|
|
16
|
+
background: #fff;
|
|
17
|
+
border-bottom: 1px solid #e4e7ec;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.demo-shell__brand {
|
|
21
|
+
flex: 0 0 auto;
|
|
22
|
+
color: #101828;
|
|
23
|
+
font-size: 14px;
|
|
24
|
+
font-weight: 700;
|
|
25
|
+
text-decoration: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.demo-shell__nav {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-self: stretch;
|
|
31
|
+
min-width: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.demo-shell__nav a {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
padding: 0 14px;
|
|
38
|
+
color: #475467;
|
|
39
|
+
font-size: 13px;
|
|
40
|
+
text-decoration: none;
|
|
41
|
+
border-bottom: 2px solid transparent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.demo-shell__nav a:hover,
|
|
45
|
+
.demo-shell__nav a:focus-visible,
|
|
46
|
+
.demo-shell__nav a.router-link-exact-active {
|
|
47
|
+
color: #0958d9;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.demo-shell__nav a.router-link-exact-active {
|
|
51
|
+
background: #f0f7ff;
|
|
52
|
+
border-bottom-color: #1677ff;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.demo-shell__content {
|
|
56
|
+
min-width: 0;
|
|
57
|
+
min-height: 0;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.fixed-domain-demo {
|
|
62
|
+
display: grid;
|
|
63
|
+
grid-template-rows: auto auto minmax(0, 1fr);
|
|
64
|
+
gap: 12px;
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 100%;
|
|
67
|
+
padding: 16px;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.fixed-domain-demo__toolbar {
|
|
72
|
+
display: flex;
|
|
73
|
+
gap: 24px;
|
|
74
|
+
align-items: center;
|
|
75
|
+
justify-content: space-between;
|
|
76
|
+
min-width: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.fixed-domain-demo__toolbar h1 {
|
|
80
|
+
margin: 0;
|
|
81
|
+
color: #101828;
|
|
82
|
+
font-size: 18px;
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
letter-spacing: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.fixed-domain-demo__toolbar p {
|
|
88
|
+
margin: 4px 0 0;
|
|
89
|
+
color: #667085;
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.fixed-domain-demo__range-controls {
|
|
94
|
+
display: flex;
|
|
95
|
+
gap: 16px;
|
|
96
|
+
align-items: center;
|
|
97
|
+
min-height: 48px;
|
|
98
|
+
padding: 8px 12px;
|
|
99
|
+
background: #fff;
|
|
100
|
+
border: 1px solid #e4e7ec;
|
|
101
|
+
border-radius: 6px;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.fixed-domain-demo__range-controls label {
|
|
105
|
+
display: flex;
|
|
106
|
+
gap: 8px;
|
|
107
|
+
align-items: center;
|
|
108
|
+
color: #475467;
|
|
109
|
+
font-size: 12px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.fixed-domain-demo__range-controls .ant-input-number {
|
|
113
|
+
width: 112px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.fixed-domain-demo__range-controls strong {
|
|
117
|
+
color: #344054;
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.fixed-domain-demo__range-controls strong:not(:first-child) {
|
|
122
|
+
padding-left: 16px;
|
|
123
|
+
border-left: 1px solid #e4e7ec;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.fixed-domain-demo__auto-state {
|
|
127
|
+
color: #667085;
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.fixed-domain-demo__chart {
|
|
132
|
+
min-width: 0;
|
|
133
|
+
min-height: 0;
|
|
134
|
+
overflow: hidden;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.fixed-domain-demo__chart > .waveform-chart {
|
|
138
|
+
min-height: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@media (max-width: 900px) {
|
|
142
|
+
.demo-shell {
|
|
143
|
+
grid-template-rows: 44px minmax(0, 1fr);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.demo-shell__header {
|
|
147
|
+
gap: 8px;
|
|
148
|
+
padding: 0 8px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.demo-shell__brand {
|
|
152
|
+
display: none;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.demo-shell__nav {
|
|
156
|
+
width: 100%;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.demo-shell__nav a {
|
|
160
|
+
flex: 1 1 0;
|
|
161
|
+
justify-content: center;
|
|
162
|
+
padding: 0 6px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.fixed-domain-demo {
|
|
166
|
+
gap: 8px;
|
|
167
|
+
padding: 10px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.fixed-domain-demo__toolbar {
|
|
171
|
+
display: grid;
|
|
172
|
+
gap: 8px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.fixed-domain-demo__toolbar .ant-radio-group {
|
|
176
|
+
display: flex;
|
|
177
|
+
width: 100%;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.fixed-domain-demo__toolbar .ant-radio-button-wrapper {
|
|
181
|
+
flex: 1 1 0;
|
|
182
|
+
padding-inline: 6px;
|
|
183
|
+
text-align: center;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.fixed-domain-demo__range-controls {
|
|
187
|
+
flex-wrap: wrap;
|
|
188
|
+
gap: 8px 12px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.fixed-domain-demo__range-controls strong:not(:first-child) {
|
|
192
|
+
width: 100%;
|
|
193
|
+
padding: 8px 0 0;
|
|
194
|
+
border-top: 1px solid #e4e7ec;
|
|
195
|
+
border-left: 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createApp } from 'vue'
|
|
2
2
|
import 'ant-design-vue/dist/antd.css'
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import DemoRouterApp from './DemoRouterApp.vue'
|
|
5
|
+
import { router } from './router'
|
|
6
|
+
import './demoRoutes.css'
|
|
5
7
|
import './styles.css'
|
|
6
8
|
|
|
7
|
-
createApp(
|
|
9
|
+
createApp(DemoRouterApp).use(router).mount('#app')
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
|
|
2
|
+
|
|
3
|
+
import App from './App.vue'
|
|
4
|
+
|
|
5
|
+
export const routes: RouteRecordRaw[] = [
|
|
6
|
+
{
|
|
7
|
+
path: '/',
|
|
8
|
+
name: 'workspace',
|
|
9
|
+
component: App,
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
path: '/fixed-y-domain',
|
|
13
|
+
name: 'fixed-y-domain',
|
|
14
|
+
component: () => import('./views/FixedYDomainDemo.vue'),
|
|
15
|
+
},
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export const router = createRouter({
|
|
19
|
+
history: createWebHashHistory(),
|
|
20
|
+
routes,
|
|
21
|
+
})
|
package/src/styles.css
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { WaveformChart } from '../components'
|
|
5
|
+
import { routes } from '../router'
|
|
6
|
+
import FixedYDomainDemo from './FixedYDomainDemo.vue'
|
|
7
|
+
|
|
8
|
+
describe('fixed Y-domain demo route', () => {
|
|
9
|
+
it('registers the example route', () => {
|
|
10
|
+
expect(routes).toEqual(
|
|
11
|
+
expect.arrayContaining([
|
|
12
|
+
expect.objectContaining({
|
|
13
|
+
path: '/fixed-y-domain',
|
|
14
|
+
name: 'fixed-y-domain',
|
|
15
|
+
}),
|
|
16
|
+
]),
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('switches between global, automatic, and per-channel ranges', async () => {
|
|
21
|
+
const wrapper = mount(FixedYDomainDemo)
|
|
22
|
+
await flushPromises()
|
|
23
|
+
const chart = () => wrapper.getComponent(WaveformChart)
|
|
24
|
+
const modeInputs = wrapper.get('[aria-label="Y 轴范围模式"]').findAll('input[type="radio"]')
|
|
25
|
+
|
|
26
|
+
expect(chart().props('yDomain')).toEqual([-80, 80])
|
|
27
|
+
expect(chart().props('yDomains')).toBeUndefined()
|
|
28
|
+
|
|
29
|
+
await modeInputs[0]?.setValue(true)
|
|
30
|
+
await flushPromises()
|
|
31
|
+
expect(chart().props('yDomain')).toBeUndefined()
|
|
32
|
+
expect(chart().props('yDomains')).toBeUndefined()
|
|
33
|
+
|
|
34
|
+
await modeInputs[2]?.setValue(true)
|
|
35
|
+
await flushPromises()
|
|
36
|
+
expect(chart().props('yDomain')).toBeUndefined()
|
|
37
|
+
expect(chart().props('yDomains')).toEqual({
|
|
38
|
+
voltage: [-65, 65],
|
|
39
|
+
current: [-260, 260],
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
wrapper.unmount()
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { InputNumber, RadioButton, RadioGroup } from 'ant-design-vue'
|
|
4
|
+
|
|
5
|
+
import { WaveformChart, type WaveformData } from '../components'
|
|
6
|
+
|
|
7
|
+
type RangeMode = 'auto' | 'global' | 'channel'
|
|
8
|
+
|
|
9
|
+
const rangeMode = ref<RangeMode>('global')
|
|
10
|
+
const globalMinimum = ref(-80)
|
|
11
|
+
const globalMaximum = ref(80)
|
|
12
|
+
const voltageMinimum = ref(-65)
|
|
13
|
+
const voltageMaximum = ref(65)
|
|
14
|
+
const currentMinimum = ref(-260)
|
|
15
|
+
const currentMaximum = ref(260)
|
|
16
|
+
|
|
17
|
+
const chartData: WaveformData = {
|
|
18
|
+
kind: 'series',
|
|
19
|
+
series: [
|
|
20
|
+
{
|
|
21
|
+
id: 'voltage',
|
|
22
|
+
name: '电压',
|
|
23
|
+
unit: 'V',
|
|
24
|
+
color: '#1677ff',
|
|
25
|
+
data: {
|
|
26
|
+
kind: 'points',
|
|
27
|
+
points: Array.from({ length: 800 }, (_, index) => {
|
|
28
|
+
const x = index / 80
|
|
29
|
+
const pulse = index % 240 >= 112 && index % 240 <= 122 ? 58 : 0
|
|
30
|
+
return { x, y: 48 * Math.sin(x * Math.PI * 1.5) + pulse }
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'current',
|
|
36
|
+
name: '电流',
|
|
37
|
+
unit: 'mA',
|
|
38
|
+
color: '#d4380d',
|
|
39
|
+
data: {
|
|
40
|
+
kind: 'points',
|
|
41
|
+
points: Array.from({ length: 800 }, (_, index) => {
|
|
42
|
+
const x = index / 80
|
|
43
|
+
const pulse = index % 300 >= 184 && index % 300 <= 192 ? -210 : 0
|
|
44
|
+
return { x, y: 185 * Math.cos(x * Math.PI) + pulse }
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function orderedDomain(minimum: number, maximum: number): [number, number] | undefined {
|
|
52
|
+
if (!Number.isFinite(minimum) || !Number.isFinite(maximum) || minimum === maximum) {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
return minimum < maximum ? [minimum, maximum] : [maximum, minimum]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const yDomain = computed<[number, number] | undefined>(() =>
|
|
59
|
+
rangeMode.value === 'global'
|
|
60
|
+
? orderedDomain(globalMinimum.value, globalMaximum.value)
|
|
61
|
+
: undefined,
|
|
62
|
+
)
|
|
63
|
+
const yDomains = computed<Record<string, [number, number]> | undefined>(() => {
|
|
64
|
+
if (rangeMode.value !== 'channel') return undefined
|
|
65
|
+
const voltage = orderedDomain(voltageMinimum.value, voltageMaximum.value)
|
|
66
|
+
const current = orderedDomain(currentMinimum.value, currentMaximum.value)
|
|
67
|
+
return {
|
|
68
|
+
...(voltage ? { voltage } : {}),
|
|
69
|
+
...(current ? { current } : {}),
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
const rangeSummary = computed(() => {
|
|
73
|
+
if (rangeMode.value === 'auto') return '自动计算'
|
|
74
|
+
if (rangeMode.value === 'global') {
|
|
75
|
+
return yDomain.value ? `${yDomain.value[0]} ~ ${yDomain.value[1]}` : '自动计算'
|
|
76
|
+
}
|
|
77
|
+
const voltage = yDomains.value?.voltage
|
|
78
|
+
const current = yDomains.value?.current
|
|
79
|
+
return `电压 ${voltage?.[0] ?? '-'} ~ ${voltage?.[1] ?? '-'} V · 电流 ${
|
|
80
|
+
current?.[0] ?? '-'
|
|
81
|
+
} ~ ${current?.[1] ?? '-'} mA`
|
|
82
|
+
})
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<main class="fixed-domain-demo">
|
|
87
|
+
<header class="fixed-domain-demo__toolbar">
|
|
88
|
+
<div>
|
|
89
|
+
<h1>固定振幅范围</h1>
|
|
90
|
+
<p>当前范围:{{ rangeSummary }}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<RadioGroup v-model:value="rangeMode" button-style="solid" aria-label="Y 轴范围模式">
|
|
93
|
+
<RadioButton value="auto">自动</RadioButton>
|
|
94
|
+
<RadioButton value="global">全局固定</RadioButton>
|
|
95
|
+
<RadioButton value="channel">按通道固定</RadioButton>
|
|
96
|
+
</RadioGroup>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
<section class="fixed-domain-demo__range-controls">
|
|
100
|
+
<template v-if="rangeMode === 'global'">
|
|
101
|
+
<label>
|
|
102
|
+
<span>下限</span>
|
|
103
|
+
<InputNumber v-model:value="globalMinimum" aria-label="全局振幅下限" />
|
|
104
|
+
</label>
|
|
105
|
+
<label>
|
|
106
|
+
<span>上限</span>
|
|
107
|
+
<InputNumber v-model:value="globalMaximum" aria-label="全局振幅上限" />
|
|
108
|
+
</label>
|
|
109
|
+
</template>
|
|
110
|
+
<template v-else-if="rangeMode === 'channel'">
|
|
111
|
+
<strong>电压</strong>
|
|
112
|
+
<label>
|
|
113
|
+
<span>下限</span>
|
|
114
|
+
<InputNumber v-model:value="voltageMinimum" aria-label="电压振幅下限" />
|
|
115
|
+
</label>
|
|
116
|
+
<label>
|
|
117
|
+
<span>上限</span>
|
|
118
|
+
<InputNumber v-model:value="voltageMaximum" aria-label="电压振幅上限" />
|
|
119
|
+
</label>
|
|
120
|
+
<strong>电流</strong>
|
|
121
|
+
<label>
|
|
122
|
+
<span>下限</span>
|
|
123
|
+
<InputNumber v-model:value="currentMinimum" aria-label="电流振幅下限" />
|
|
124
|
+
</label>
|
|
125
|
+
<label>
|
|
126
|
+
<span>上限</span>
|
|
127
|
+
<InputNumber v-model:value="currentMaximum" aria-label="电流振幅上限" />
|
|
128
|
+
</label>
|
|
129
|
+
</template>
|
|
130
|
+
<span v-else class="fixed-domain-demo__auto-state">根据可见数据自动计算</span>
|
|
131
|
+
</section>
|
|
132
|
+
|
|
133
|
+
<section class="fixed-domain-demo__chart">
|
|
134
|
+
<WaveformChart
|
|
135
|
+
:data="chartData"
|
|
136
|
+
:y-domain="yDomain"
|
|
137
|
+
:y-domains="yDomains"
|
|
138
|
+
display-mode="independent"
|
|
139
|
+
:grid="{ rowCount: 2, columnCount: 1, showPagination: false }"
|
|
140
|
+
:title="{ visible: true, text: '振幅上下限示例', align: 'left' }"
|
|
141
|
+
:zero-line="{ visible: true, color: '#98a2b3', width: 1, dash: '5 4' }"
|
|
142
|
+
:legend="{ position: 'top-right', backgroundColor: 'rgba(255,255,255,0.8)' }"
|
|
143
|
+
x-label="时间(s)"
|
|
144
|
+
time-unit="s"
|
|
145
|
+
:show-tooltip="true"
|
|
146
|
+
/>
|
|
147
|
+
</section>
|
|
148
|
+
</main>
|
|
149
|
+
</template>
|