waveform-analysis 0.1.35 → 0.1.37
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 +9 -5
- package/dist/index.cjs +1 -1
- package/dist/index.js +909 -816
- package/dist/style.css +1 -1
- package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +4 -0
- package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +3 -1
- package/dist/types/components/core/constants.d.ts +3 -1
- package/dist/types/components/core/types.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +2 -0
- package/dist/types/components/interaction/WaveformTooltip.vue.d.ts +1 -0
- package/dist/types/types/data.d.ts +3 -0
- package/package.json +1 -1
- package/src/App.test.ts +3 -2
- package/src/App.vue +1 -1
- package/src/components/WaveformChartView.vue +10 -1
- package/src/components/annotation/WaveformAnnotationEditor.css +35 -104
- package/src/components/annotation/WaveformAnnotationEditor.vue +87 -37
- package/src/components/annotation/components.test.ts +134 -20
- package/src/components/annotation/markup.test.ts +18 -2
- package/src/components/annotation/markup.ts +33 -0
- package/src/components/annotation/useWaveformChartAnnotations.ts +53 -12
- package/src/components/core/constants.test.ts +29 -0
- package/src/components/core/constants.ts +31 -9
- package/src/components/core/types.ts +2 -0
- package/src/components/interaction/WaveformTooltip.test.ts +17 -9
- package/src/components/interaction/WaveformTooltip.vue +41 -45
- package/src/components/interaction/useWaveformHover.ts +1 -0
- package/src/components/waveformChartCases/annotationEditingAndReprojection.test.ts +13 -3
- package/src/components/waveformChartCases/compactAndSharedHover.test.ts +4 -4
- package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +42 -2
- package/src/components/waveformChartCases/interactionAndAnnotationCreation.test.ts +24 -14
- package/src/components/waveformChartCases/legendVisibility.test.ts +10 -2
- package/src/components/waveformChartCases/multiAxisAndPagination.test.ts +1 -1
- package/src/components/waveformChartCases/presentationMode.test.ts +10 -2
- package/src/components/waveformChartCases/renderingStyles.test.ts +1 -1
- package/src/components/waveformChartCases/xDomainStrategy.test.ts +60 -0
- package/src/core/data.test.ts +22 -0
- package/src/core/data.ts +1 -0
- package/src/data/simulatedWaveforms.test.ts +1 -0
- package/src/data/simulatedWaveforms.ts +7 -1
- package/src/types/data.ts +3 -0
- package/src/views/FixedYDomainDemo.vue +2 -0
|
@@ -1,28 +1,93 @@
|
|
|
1
1
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
2
|
-
import {
|
|
2
|
+
import { InputNumber } from 'ant-design-vue'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
4
|
import { ColorPicker } from 'vue3-colorpicker'
|
|
5
|
+
import { defineComponent, h } from 'vue'
|
|
4
6
|
|
|
5
7
|
import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue'
|
|
6
8
|
import WaveformAnnotationEditor from './WaveformAnnotationEditor.vue'
|
|
7
9
|
import WaveformAnnotationLayer from './WaveformAnnotationLayer.vue'
|
|
8
10
|
|
|
11
|
+
const modalStub = defineComponent({
|
|
12
|
+
props: [
|
|
13
|
+
'visible',
|
|
14
|
+
'width',
|
|
15
|
+
'maskClosable',
|
|
16
|
+
'keyboard',
|
|
17
|
+
'wrapClassName',
|
|
18
|
+
'cancelText',
|
|
19
|
+
'okText',
|
|
20
|
+
'okButtonProps',
|
|
21
|
+
],
|
|
22
|
+
emits: ['cancel', 'ok'],
|
|
23
|
+
setup(props, { emit, slots }) {
|
|
24
|
+
return () => {
|
|
25
|
+
const title = slots.title?.() ?? []
|
|
26
|
+
const titleId = (title[0]?.props as { id?: string } | undefined)?.id
|
|
27
|
+
return h('div', { class: 'ant-modal-root' }, [
|
|
28
|
+
h(
|
|
29
|
+
'div',
|
|
30
|
+
{
|
|
31
|
+
class: ['ant-modal-wrap', props.wrapClassName],
|
|
32
|
+
role: 'dialog',
|
|
33
|
+
'aria-modal': 'true',
|
|
34
|
+
'aria-labelledby': titleId,
|
|
35
|
+
onClick: (event: MouseEvent) => {
|
|
36
|
+
if (event.target === event.currentTarget && props.maskClosable) emit('cancel')
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
[
|
|
40
|
+
h('div', { class: 'ant-modal' }, [
|
|
41
|
+
h('div', { class: 'ant-modal-header' }, [
|
|
42
|
+
h('div', { class: 'ant-modal-title' }, title),
|
|
43
|
+
]),
|
|
44
|
+
h('button', { class: 'ant-modal-close', onClick: () => emit('cancel') }, '×'),
|
|
45
|
+
slots.default?.(),
|
|
46
|
+
h('div', { class: 'ant-modal-footer' }, [
|
|
47
|
+
h('button', { class: 'ant-btn', onClick: () => emit('cancel') }, props.cancelText),
|
|
48
|
+
h(
|
|
49
|
+
'button',
|
|
50
|
+
{
|
|
51
|
+
class: 'ant-btn ant-btn-primary',
|
|
52
|
+
disabled: props.okButtonProps?.disabled,
|
|
53
|
+
onClick: () => emit('ok'),
|
|
54
|
+
},
|
|
55
|
+
props.okText,
|
|
56
|
+
),
|
|
57
|
+
]),
|
|
58
|
+
]),
|
|
59
|
+
],
|
|
60
|
+
),
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
const mountEditor = (options: { props: Record<string, unknown> }) =>
|
|
66
|
+
mount(WaveformAnnotationEditor, {
|
|
67
|
+
props: options.props as never,
|
|
68
|
+
global: { stubs: { Modal: modalStub, AModal: modalStub } },
|
|
69
|
+
})
|
|
70
|
+
|
|
9
71
|
describe('waveform annotation controls', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
document.body.innerHTML = ''
|
|
74
|
+
})
|
|
10
75
|
it('keeps dialog title ids unique across editor instances', () => {
|
|
11
|
-
const first =
|
|
76
|
+
const first = mountEditor({
|
|
12
77
|
props: {
|
|
13
78
|
annotation: { id: 'first', seriesId: 'a', x: 1, y: 2, text: '说明' },
|
|
14
79
|
mode: 'edit',
|
|
15
80
|
},
|
|
16
81
|
})
|
|
17
|
-
const second =
|
|
82
|
+
const second = mountEditor({
|
|
18
83
|
props: {
|
|
19
84
|
annotation: { id: 'second', seriesId: 'a', x: 1, y: 2, text: '说明' },
|
|
20
85
|
mode: 'edit',
|
|
21
86
|
},
|
|
22
87
|
})
|
|
23
88
|
|
|
24
|
-
const firstTitleId = first.get('
|
|
25
|
-
const secondTitleId = second.get('
|
|
89
|
+
const firstTitleId = first.get('.ant-modal-title [id]').attributes('id')
|
|
90
|
+
const secondTitleId = second.get('.ant-modal-title [id]').attributes('id')
|
|
26
91
|
|
|
27
92
|
expect(firstTitleId).toBeTruthy()
|
|
28
93
|
expect(secondTitleId).toBeTruthy()
|
|
@@ -31,8 +96,19 @@ describe('waveform annotation controls', () => {
|
|
|
31
96
|
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId)
|
|
32
97
|
})
|
|
33
98
|
|
|
99
|
+
it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
|
|
100
|
+
const wrapper = mountEditor({
|
|
101
|
+
props: {
|
|
102
|
+
annotation: { id: `hint-${mode}`, seriesId: 'a', x: 1, y: 2, text: '说明' },
|
|
103
|
+
mode,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(wrapper.get('[role="note"]').text()).toBe('修改 X 轴后失焦时会自动吸附最近的采样点')
|
|
108
|
+
})
|
|
109
|
+
|
|
34
110
|
it('allows changing the annotation series inside the editor', async () => {
|
|
35
|
-
const wrapper =
|
|
111
|
+
const wrapper = mountEditor({
|
|
36
112
|
props: {
|
|
37
113
|
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '说明' },
|
|
38
114
|
mode: 'edit',
|
|
@@ -77,17 +153,15 @@ describe('waveform annotation controls', () => {
|
|
|
77
153
|
|
|
78
154
|
it('validates text and emits an immutable edited annotation with style defaults', async () => {
|
|
79
155
|
const annotation = { id: 'note', seriesId: 'a', x: 1, y: 2, text: '' }
|
|
80
|
-
const wrapper =
|
|
156
|
+
const wrapper = mountEditor({
|
|
81
157
|
props: { annotation, mode: 'add' },
|
|
82
158
|
})
|
|
83
159
|
await flushPromises()
|
|
84
160
|
|
|
85
161
|
expect(wrapper.get('textarea').attributes('maxlength')).toBe('40')
|
|
86
|
-
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain(
|
|
87
|
-
'X (ms)1000.000',
|
|
88
|
-
)
|
|
162
|
+
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('X1000.000')
|
|
89
163
|
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('Y2')
|
|
90
|
-
expect(wrapper.get('button.
|
|
164
|
+
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
|
|
91
165
|
await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), {
|
|
92
166
|
timeout: 5000,
|
|
93
167
|
})
|
|
@@ -113,7 +187,7 @@ describe('waveform annotation controls', () => {
|
|
|
113
187
|
colorPickers[1].vm.$emit('update:pureColor', 'rgba(51, 51, 51, 0.8)')
|
|
114
188
|
colorPickers[2].vm.$emit('update:pureColor', 'rgba(255, 255, 255, 0.5)')
|
|
115
189
|
await wrapper.vm.$nextTick()
|
|
116
|
-
await wrapper.get('button.
|
|
190
|
+
await wrapper.get('button.ant-btn-primary').trigger('click')
|
|
117
191
|
|
|
118
192
|
const emitted = wrapper.emitted('confirm')?.[0]?.[0] as
|
|
119
193
|
| {
|
|
@@ -133,7 +207,7 @@ describe('waveform annotation controls', () => {
|
|
|
133
207
|
})
|
|
134
208
|
|
|
135
209
|
it('formats annotation coordinates using the selected display context', () => {
|
|
136
|
-
const wrapper =
|
|
210
|
+
const wrapper = mountEditor({
|
|
137
211
|
props: {
|
|
138
212
|
annotation: { id: 'note', seriesId: 'series', x: 1, y: 0.0000001, text: '说明' },
|
|
139
213
|
mode: 'edit',
|
|
@@ -142,13 +216,53 @@ describe('waveform annotation controls', () => {
|
|
|
142
216
|
})
|
|
143
217
|
|
|
144
218
|
const coordinates = wrapper.get('.waveform-annotation-editor__coordinates').text()
|
|
145
|
-
expect(coordinates).toContain('
|
|
219
|
+
expect(coordinates).toContain('X1.000')
|
|
146
220
|
expect(coordinates).toContain('Y0.0000001')
|
|
147
221
|
expect(coordinates).not.toContain('e-')
|
|
148
222
|
})
|
|
149
223
|
|
|
224
|
+
it('emits valid manual time input and disables save for invalid input', async () => {
|
|
225
|
+
const wrapper = mountEditor({
|
|
226
|
+
props: {
|
|
227
|
+
annotation: { id: 'note', seriesId: 'series', x: 1, y: 2, text: '说明' },
|
|
228
|
+
mode: 'edit',
|
|
229
|
+
timeUnit: 'ms',
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
const input = wrapper.getComponent(InputNumber)
|
|
233
|
+
expect(input.props('controls')).toBe(true)
|
|
234
|
+
await input.vm.$emit('update:value', 1500)
|
|
235
|
+
expect(wrapper.emitted('time-change')).toBeUndefined()
|
|
236
|
+
await input.vm.$emit('blur')
|
|
237
|
+
expect(wrapper.emitted('time-change')).toEqual([['1500']])
|
|
238
|
+
await input.vm.$emit('update:value', null)
|
|
239
|
+
await input.vm.$emit('blur')
|
|
240
|
+
expect(wrapper.get('[role="alert"]').text()).toBe('请输入有效的时间')
|
|
241
|
+
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('shows time validation errors and blocks confirmation', async () => {
|
|
245
|
+
const wrapper = mountEditor({
|
|
246
|
+
props: {
|
|
247
|
+
annotation: { id: 'note', seriesId: 'series', x: 1, y: 2, text: '说明' },
|
|
248
|
+
mode: 'edit',
|
|
249
|
+
timeError: '时间超出当前波形范围',
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const input = wrapper.getComponent(InputNumber)
|
|
254
|
+
await input.vm.$emit('blur')
|
|
255
|
+
expect(
|
|
256
|
+
wrapper.get('.waveform-annotation-editor__coordinate-input').attributes('aria-invalid'),
|
|
257
|
+
).toBe('true')
|
|
258
|
+
expect(wrapper.get('[role="alert"]').text()).toBe('时间超出当前波形范围')
|
|
259
|
+
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
|
|
260
|
+
await wrapper.setProps({ timeError: '' })
|
|
261
|
+
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
|
262
|
+
})
|
|
263
|
+
|
|
150
264
|
it('hydrates hexadecimal and rgba annotation colors', async () => {
|
|
151
|
-
const wrapper =
|
|
265
|
+
const wrapper = mountEditor({
|
|
152
266
|
props: {
|
|
153
267
|
annotation: {
|
|
154
268
|
id: 'colored-note',
|
|
@@ -176,7 +290,7 @@ describe('waveform annotation controls', () => {
|
|
|
176
290
|
})
|
|
177
291
|
|
|
178
292
|
it('supports modal dismissal and live character counting', async () => {
|
|
179
|
-
const editor =
|
|
293
|
+
const editor = mountEditor({
|
|
180
294
|
props: {
|
|
181
295
|
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
|
|
182
296
|
mode: 'edit',
|
|
@@ -184,23 +298,23 @@ describe('waveform annotation controls', () => {
|
|
|
184
298
|
})
|
|
185
299
|
|
|
186
300
|
expect(editor.get('[role="dialog"]').attributes('aria-modal')).toBe('true')
|
|
187
|
-
expect(editor.get('
|
|
301
|
+
expect(editor.get('.ant-modal-title').text()).toBe('编辑标注')
|
|
188
302
|
await editor.get('textarea').setValue('三字说明')
|
|
189
303
|
expect(editor.get('.waveform-annotation-editor__label-row').text()).toContain('4/40')
|
|
190
304
|
|
|
191
305
|
await editor.get('textarea').trigger('keydown', { key: 'Escape' })
|
|
192
|
-
await editor.get('.
|
|
306
|
+
await editor.get('.ant-modal-wrap').trigger('click')
|
|
193
307
|
expect(editor.emitted('cancel')).toHaveLength(2)
|
|
194
308
|
})
|
|
195
309
|
|
|
196
310
|
it('supports cancellation and context menu actions', async () => {
|
|
197
|
-
const editor =
|
|
311
|
+
const editor = mountEditor({
|
|
198
312
|
props: {
|
|
199
313
|
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
|
|
200
314
|
mode: 'edit',
|
|
201
315
|
},
|
|
202
316
|
})
|
|
203
|
-
await editor.
|
|
317
|
+
await editor.get('.ant-modal-close').trigger('click')
|
|
204
318
|
expect(editor.emitted('cancel')).toHaveLength(1)
|
|
205
319
|
|
|
206
320
|
const menu = mount(WaveformAnnotationContextMenu, {
|
|
@@ -56,6 +56,20 @@ describe('waveform annotation markup', () => {
|
|
|
56
56
|
).toBeNull()
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
+
it('snaps outside and between samples to the nearest complete-data point', () => {
|
|
60
|
+
const points = [
|
|
61
|
+
{ x: 1, y: 10 },
|
|
62
|
+
{ x: 2, y: 20 },
|
|
63
|
+
{ x: 4, y: 40 },
|
|
64
|
+
]
|
|
65
|
+
expect(findNearestPointByX(points, -10)).toEqual(points[0])
|
|
66
|
+
expect(findNearestPointByX(points, 3.1)).toEqual(points[2])
|
|
67
|
+
expect(findNearestPointByX(points, 10)).toEqual(points[2])
|
|
68
|
+
expect(findNearestPointByX(points, 2)).toEqual(points[1])
|
|
69
|
+
expect(findNearestPointByX([{ x: 2, y: 20 }], 2.1)).toEqual({ x: 2, y: 20 })
|
|
70
|
+
expect(findNearestPointByX(points, Number.NaN)).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
59
73
|
it('snaps annotations to nearest sample points while using interpolation for distance calculation', () => {
|
|
60
74
|
const track = createTrack(0, 'series', 0, [
|
|
61
75
|
{ x: 0, y: 0 },
|
|
@@ -89,14 +103,16 @@ describe('waveform annotation markup', () => {
|
|
|
89
103
|
expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 })
|
|
90
104
|
})
|
|
91
105
|
|
|
92
|
-
it('
|
|
106
|
+
it('uses the nearest screen-space sample for point-only series', () => {
|
|
93
107
|
const pointOnly = createTrack(0, 'points', 0, [
|
|
94
108
|
{ x: 0, y: 2 },
|
|
95
109
|
{ x: 2, y: 10 },
|
|
96
110
|
])
|
|
97
111
|
pointOnly.series.lineType = 'none'
|
|
98
112
|
|
|
99
|
-
expect(findAnnotationSeriesCandidates([pointOnly], 1,
|
|
113
|
+
expect(findAnnotationSeriesCandidates([pointOnly], 1, 5, 80)).toMatchObject([
|
|
114
|
+
{ point: { x: 0, y: 2 }, screenX: 0, screenY: 80, distance: 5 },
|
|
115
|
+
])
|
|
100
116
|
})
|
|
101
117
|
|
|
102
118
|
it('sorts line candidates by screen distance and keeps series metadata', () => {
|
|
@@ -25,6 +25,25 @@ export const ANNOTATION_CONNECTOR_LENGTH = 32
|
|
|
25
25
|
|
|
26
26
|
const pointBisector = bisector((point: { x: number }) => point.x)
|
|
27
27
|
|
|
28
|
+
function findNearestPointOnScreen(
|
|
29
|
+
track: AnnotationTrackLayout,
|
|
30
|
+
pointerX: number,
|
|
31
|
+
pointerY: number,
|
|
32
|
+
): { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null {
|
|
33
|
+
let nearest: { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null =
|
|
34
|
+
null
|
|
35
|
+
for (const point of track.series.points) {
|
|
36
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
|
|
37
|
+
const screenX = track.xScale(point.x)
|
|
38
|
+
const screenY = track.top + track.yScale(point.y)
|
|
39
|
+
const distance = Math.hypot(screenX - pointerX, screenY - pointerY)
|
|
40
|
+
if (!nearest || distance < nearest.distance) {
|
|
41
|
+
nearest = { point, screenX, screenY, distance }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return nearest
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
export function findNearestPointByX(
|
|
29
48
|
points: Array<{ x: number; y: number }>,
|
|
30
49
|
xValue: number,
|
|
@@ -74,6 +93,20 @@ export function findAnnotationSeriesCandidates(
|
|
|
74
93
|
): AnnotationSeriesCandidate[] {
|
|
75
94
|
return tracks
|
|
76
95
|
.flatMap((track): AnnotationSeriesCandidate[] => {
|
|
96
|
+
if (track.series.lineType === 'none') {
|
|
97
|
+
const nearest = findNearestPointOnScreen(track, pointerX, pointerY)
|
|
98
|
+
if (!nearest) return []
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
trackIndex: track.index,
|
|
102
|
+
seriesId: track.series.id,
|
|
103
|
+
name: track.series.name?.trim() || track.series.id,
|
|
104
|
+
color: track.series.color || DEFAULT_ANNOTATION_STYLE.borderColor,
|
|
105
|
+
unit: track.series.unit,
|
|
106
|
+
...nearest,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
}
|
|
77
110
|
const interpolatedPoint = interpolateAnnotationPoint(
|
|
78
111
|
track.series.points,
|
|
79
112
|
xValue,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pointer, type ScaleLinear } from 'd3'
|
|
2
|
-
import type
|
|
2
|
+
import { ref, type ComputedRef, type Ref } from 'vue'
|
|
3
3
|
|
|
4
4
|
import type { WaveformAnnotation, WaveformInteractionMode } from '../data/types'
|
|
5
5
|
import { findClosestTrackAtPointer } from '../core/layout'
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
ANNOTATION_AMBIGUITY_DISTANCE,
|
|
10
10
|
ANNOTATION_HIT_RADIUS,
|
|
11
11
|
findAnnotationSeriesCandidates,
|
|
12
|
-
|
|
12
|
+
findNearestPointByX,
|
|
13
13
|
} from './markup'
|
|
14
14
|
import type {
|
|
15
15
|
AnnotationEditorAnchor,
|
|
@@ -78,6 +78,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
78
78
|
activeInteractionMode,
|
|
79
79
|
isPresentationMode,
|
|
80
80
|
} = context
|
|
81
|
+
const timeError = ref('')
|
|
81
82
|
|
|
82
83
|
function toggleSeriesVisibility(seriesId: string) {
|
|
83
84
|
if (!chartSeries.value.some((series) => series.id === seriesId)) return
|
|
@@ -127,6 +128,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
127
128
|
anchor: AnnotationEditorAnchor,
|
|
128
129
|
candidates: AnnotationSeriesCandidate[],
|
|
129
130
|
) {
|
|
131
|
+
timeError.value = ''
|
|
130
132
|
annotationInteraction.openCreate(hit, makeAnnotationId, anchor)
|
|
131
133
|
editorSeriesOptions.value = candidates
|
|
132
134
|
const draft = annotationInteraction.editorDraft.value
|
|
@@ -140,7 +142,6 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
|
-
|
|
144
145
|
function changeDraftSeries(seriesId: string) {
|
|
145
146
|
if (isPresentationMode.value) return
|
|
146
147
|
const draft = annotationInteraction.editorDraft.value
|
|
@@ -149,24 +150,61 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
149
150
|
item.seriesList.some((series) => series.id === seriesId),
|
|
150
151
|
)
|
|
151
152
|
const series = track?.seriesList.find((item) => item.id === seriesId)
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!draft || !candidate || !track || !
|
|
153
|
+
const validPoints = series?.points.filter(
|
|
154
|
+
(item) => Number.isFinite(item.x) && Number.isFinite(item.y),
|
|
155
|
+
)
|
|
156
|
+
const point = validPoints && draft ? findNearestPointByX(validPoints, draft.annotation.x) : null
|
|
157
|
+
if (!draft || !candidate || !track || !validPoints?.length) {
|
|
158
|
+
timeError.value = '当前波形没有有效数据'
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (draft.annotation.x < validPoints[0].x || draft.annotation.x > validPoints.at(-1)!.x) {
|
|
162
|
+
timeError.value = '时间超出当前波形范围'
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
timeError.value = ''
|
|
157
166
|
draft.annotation = {
|
|
158
167
|
...draft.annotation,
|
|
159
168
|
seriesId,
|
|
160
|
-
y: point
|
|
169
|
+
y: point!.y,
|
|
161
170
|
style: { ...draft.annotation.style, borderColor: candidate.color },
|
|
162
171
|
}
|
|
163
172
|
}
|
|
164
|
-
|
|
173
|
+
function changeDraftTime(displayValue: string) {
|
|
174
|
+
if (isPresentationMode.value) return
|
|
175
|
+
const draft = annotationInteraction.editorDraft.value
|
|
176
|
+
const displayTime = Number(displayValue)
|
|
177
|
+
if (!draft || !Number.isFinite(displayTime)) {
|
|
178
|
+
timeError.value = '请输入有效的时间'
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const rawTime = props.timeUnit === 'ms' ? displayTime / 1000 : displayTime
|
|
182
|
+
const track = trackLayouts.value.find((item) =>
|
|
183
|
+
item.seriesList.some((series) => series.id === draft.annotation.seriesId),
|
|
184
|
+
)
|
|
185
|
+
const series = track?.seriesList.find((item) => item.id === draft.annotation.seriesId)
|
|
186
|
+
const validPoints = series?.points.filter(
|
|
187
|
+
(point) => Number.isFinite(point.x) && Number.isFinite(point.y),
|
|
188
|
+
)
|
|
189
|
+
if (!validPoints?.length) {
|
|
190
|
+
timeError.value = '当前波形没有有效数据'
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
const firstX = validPoints[0].x
|
|
194
|
+
const lastX = validPoints[validPoints.length - 1].x
|
|
195
|
+
if (rawTime < firstX || rawTime > lastX) {
|
|
196
|
+
timeError.value = '时间超出当前波形范围'
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
const point = findNearestPointByX(validPoints, rawTime)!
|
|
200
|
+
timeError.value = ''
|
|
201
|
+
draft.annotation = { ...draft.annotation, x: point.x, y: point.y }
|
|
202
|
+
}
|
|
165
203
|
function cancelAnnotation() {
|
|
204
|
+
timeError.value = ''
|
|
166
205
|
annotationInteraction.closeEditor()
|
|
167
206
|
editorSeriesOptions.value = []
|
|
168
207
|
}
|
|
169
|
-
|
|
170
208
|
function resolveTrackAtPointer(
|
|
171
209
|
pointerX: number,
|
|
172
210
|
pointerY: number,
|
|
@@ -292,6 +330,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
292
330
|
|
|
293
331
|
function editContextAnnotation() {
|
|
294
332
|
if (isPresentationMode.value) return
|
|
333
|
+
timeError.value = ''
|
|
295
334
|
const menu = annotationInteraction.contextMenu.value
|
|
296
335
|
const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
|
|
297
336
|
if (!annotation) return
|
|
@@ -328,7 +367,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
328
367
|
function confirmAnnotation(annotation: WaveformAnnotation) {
|
|
329
368
|
if (isPresentationMode.value) return
|
|
330
369
|
const draft = annotationInteraction.editorDraft.value
|
|
331
|
-
if (!draft) return
|
|
370
|
+
if (!draft || timeError.value) return
|
|
332
371
|
if (draft.mode === 'add') {
|
|
333
372
|
emit('update:annotations', [...props.annotations, annotation])
|
|
334
373
|
emit('annotation-create', annotation)
|
|
@@ -345,6 +384,8 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
|
|
345
384
|
return {
|
|
346
385
|
toggleSeriesVisibility,
|
|
347
386
|
changeDraftSeries,
|
|
387
|
+
changeDraftTime,
|
|
388
|
+
timeError,
|
|
348
389
|
cancelAnnotation,
|
|
349
390
|
resolveTrackAtPointer,
|
|
350
391
|
handleAnnotationClick,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { channelColors } from './constants'
|
|
4
|
+
|
|
5
|
+
describe('channelColors', () => {
|
|
6
|
+
it('contains the referenced dark palette followed by twenty supplemental colors', () => {
|
|
7
|
+
expect(channelColors).toHaveLength(30)
|
|
8
|
+
expect(channelColors.slice(0, 10)).toEqual([
|
|
9
|
+
'#0960bd',
|
|
10
|
+
'#ff7f0e',
|
|
11
|
+
'#2ca02c',
|
|
12
|
+
'#d62728',
|
|
13
|
+
'#9467bd',
|
|
14
|
+
'#8c564b',
|
|
15
|
+
'#e377c2',
|
|
16
|
+
'#7f7f7f',
|
|
17
|
+
'#bcbd22',
|
|
18
|
+
'#17becf',
|
|
19
|
+
])
|
|
20
|
+
expect(new Set(channelColors).size).toBe(channelColors.length)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('wraps around after the thirtieth color for automatic series assignment', () => {
|
|
24
|
+
expect(channelColors[0]).toBe('#0960bd')
|
|
25
|
+
expect(channelColors[29]).toBe('#31572c')
|
|
26
|
+
expect(channelColors[30 % channelColors.length]).toBe(channelColors[0])
|
|
27
|
+
expect(channelColors[31 % channelColors.length]).toBe(channelColors[1])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -125,19 +125,41 @@ export const TITLE_LINE_HEIGHT = 1.2
|
|
|
125
125
|
// ==================== 样式常量 ====================
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
|
-
*
|
|
128
|
+
* 多系列波形的默认颜色列表。
|
|
129
|
+
*
|
|
130
|
+
* 颜色保持较深并具备足够辨识度,避免大量系列同时绘制时使用浅色导致线条难以观察。
|
|
129
131
|
*/
|
|
130
132
|
export const channelColors = [
|
|
131
133
|
'#0960bd',
|
|
132
134
|
'#ff7f0e',
|
|
133
|
-
'#
|
|
134
|
-
'#
|
|
135
|
-
'#
|
|
136
|
-
'#
|
|
137
|
-
'#
|
|
138
|
-
'#
|
|
139
|
-
'#
|
|
140
|
-
'#
|
|
135
|
+
'#2ca02c',
|
|
136
|
+
'#d62728',
|
|
137
|
+
'#9467bd',
|
|
138
|
+
'#8c564b',
|
|
139
|
+
'#e377c2',
|
|
140
|
+
'#7f7f7f',
|
|
141
|
+
'#bcbd22',
|
|
142
|
+
'#17becf',
|
|
143
|
+
'#005f73',
|
|
144
|
+
'#0a9396',
|
|
145
|
+
'#ae2012',
|
|
146
|
+
'#bb3e03',
|
|
147
|
+
'#ca6702',
|
|
148
|
+
'#ee9b00',
|
|
149
|
+
'#9b2226',
|
|
150
|
+
'#3a0ca3',
|
|
151
|
+
'#4361ee',
|
|
152
|
+
'#4895ef',
|
|
153
|
+
'#560bad',
|
|
154
|
+
'#7209b7',
|
|
155
|
+
'#b5179e',
|
|
156
|
+
'#f72585',
|
|
157
|
+
'#006d77',
|
|
158
|
+
'#2f3e46',
|
|
159
|
+
'#354f52',
|
|
160
|
+
'#52796f',
|
|
161
|
+
'#800f2f',
|
|
162
|
+
'#31572c',
|
|
141
163
|
]
|
|
142
164
|
|
|
143
165
|
/**
|
|
@@ -13,6 +13,7 @@ import type { NormalizedWaveformGridLineOptions } from './grid'
|
|
|
13
13
|
*/
|
|
14
14
|
export interface DisplaySeries {
|
|
15
15
|
id: string
|
|
16
|
+
shotNo?: string
|
|
16
17
|
trackId?: string
|
|
17
18
|
name: string
|
|
18
19
|
unit?: string
|
|
@@ -63,6 +64,7 @@ export interface WaveformYAxisLayout {
|
|
|
63
64
|
*/
|
|
64
65
|
export interface HoveredSeriesPoint {
|
|
65
66
|
id: string
|
|
67
|
+
shotNo?: string
|
|
66
68
|
name: string
|
|
67
69
|
unit?: string
|
|
68
70
|
color: string
|
|
@@ -80,7 +80,7 @@ describe('WaveformTooltip', () => {
|
|
|
80
80
|
expect(tooltip.attributes('style')).toContain('max-width: 280px')
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
it('
|
|
83
|
+
it('omits units and errors from the strict x/y value format', () => {
|
|
84
84
|
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
|
85
85
|
const wrapper = mount(WaveformTooltip, {
|
|
86
86
|
props: {
|
|
@@ -102,10 +102,12 @@ describe('WaveformTooltip', () => {
|
|
|
102
102
|
},
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
-
expect(wrapper.get('.waveform-
|
|
105
|
+
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1 y:12)')
|
|
106
|
+
expect(wrapper.get('.waveform-tooltip__value').text()).not.toContain('C')
|
|
107
|
+
expect(wrapper.get('.waveform-tooltip__value').text()).not.toContain('+2')
|
|
106
108
|
})
|
|
107
109
|
|
|
108
|
-
it('
|
|
110
|
+
it('renders the configured shot number, channel, and formatted coordinates', () => {
|
|
109
111
|
const hoveredPoint = { x: 1, y: -1405.4932 }
|
|
110
112
|
const wrapper = mount(WaveformTooltip, {
|
|
111
113
|
props: {
|
|
@@ -117,6 +119,7 @@ describe('WaveformTooltip', () => {
|
|
|
117
119
|
{
|
|
118
120
|
trackIndex: 0,
|
|
119
121
|
name: 'ENG8KJXAc(10001)',
|
|
122
|
+
shotNo: '炮 7',
|
|
120
123
|
color: '#ffb43b',
|
|
121
124
|
unit: 'A',
|
|
122
125
|
point: hoveredPoint,
|
|
@@ -127,26 +130,31 @@ describe('WaveformTooltip', () => {
|
|
|
127
130
|
},
|
|
128
131
|
})
|
|
129
132
|
|
|
130
|
-
expect(wrapper.get('.waveform-
|
|
133
|
+
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('炮 7: ENG8KJXAc(10001)(A)')
|
|
134
|
+
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1,000 y:-1,405.4932)')
|
|
131
135
|
})
|
|
132
136
|
|
|
133
|
-
it('
|
|
137
|
+
it('renders each series row with the formatted x coordinate and series label', () => {
|
|
134
138
|
const wrapper = mount(WaveformTooltip, {
|
|
135
139
|
props: {
|
|
136
140
|
visible: true,
|
|
137
141
|
position: { x: 10, y: 10 },
|
|
138
142
|
timeUnit: 's',
|
|
139
143
|
hoveredPoint: { x: 1.234567, y: 12 },
|
|
140
|
-
seriesPoints: [
|
|
144
|
+
seriesPoints: [
|
|
145
|
+
{ trackIndex: 0, name: '温度', color: '#f00', point: { x: 1.234567, y: 12 } },
|
|
146
|
+
],
|
|
141
147
|
containerWidth: 400,
|
|
142
148
|
containerHeight: 300,
|
|
143
149
|
},
|
|
144
150
|
})
|
|
145
151
|
|
|
146
|
-
expect(wrapper.
|
|
152
|
+
expect(wrapper.find('.waveform-tooltip__time').exists()).toBe(false)
|
|
153
|
+
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('未配置炮号: 温度')
|
|
154
|
+
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1.2346 y:12)')
|
|
147
155
|
})
|
|
148
156
|
|
|
149
|
-
it('
|
|
157
|
+
it('uses the fallback shot number when shotNo is blank', () => {
|
|
150
158
|
const point = { x: 1, y: 12 }
|
|
151
159
|
const wrapper = mount(WaveformTooltip, {
|
|
152
160
|
props: {
|
|
@@ -160,6 +168,6 @@ describe('WaveformTooltip', () => {
|
|
|
160
168
|
},
|
|
161
169
|
})
|
|
162
170
|
|
|
163
|
-
expect(wrapper.
|
|
171
|
+
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('未配置炮号: 温度')
|
|
164
172
|
})
|
|
165
173
|
})
|