waveform-analysis 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +34 -33
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +902 -806
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/annotation/WaveformAnnotationEditor.vue.d.ts +4 -0
  6. package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +3 -1
  7. package/dist/types/components/core/constants.d.ts +3 -1
  8. package/dist/types/components/core/useWaveformChartController.d.ts +2 -0
  9. package/package.json +1 -1
  10. package/src/App.test.ts +4 -3
  11. package/src/App.vue +1 -1
  12. package/src/components/WaveformChartView.vue +10 -1
  13. package/src/components/annotation/WaveformAnnotationEditor.css +35 -104
  14. package/src/components/annotation/WaveformAnnotationEditor.vue +87 -37
  15. package/src/components/annotation/components.test.ts +134 -20
  16. package/src/components/annotation/markup.test.ts +18 -2
  17. package/src/components/annotation/markup.ts +33 -0
  18. package/src/components/annotation/useWaveformChartAnnotations.ts +53 -12
  19. package/src/components/core/constants.test.ts +29 -0
  20. package/src/components/core/constants.ts +31 -9
  21. package/src/components/waveformChartCases/annotationEditingAndReprojection.test.ts +13 -3
  22. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +40 -0
  23. package/src/components/waveformChartCases/interactionAndAnnotationCreation.test.ts +24 -14
  24. package/src/components/waveformChartCases/legendVisibility.test.ts +44 -19
  25. package/src/components/waveformChartCases/multiAxisAndPagination.test.ts +29 -3
  26. package/src/components/waveformChartCases/presentationMode.test.ts +26 -5
  27. package/src/components/waveformChartCases/renderingStyles.test.ts +1 -1
  28. package/src/components/waveformChartCases/xDomainStrategy.test.ts +60 -0
  29. package/src/data/simulatedWaveforms.test.ts +7 -3
  30. package/src/data/simulatedWaveforms.ts +14 -1
  31. package/src/test/waveformChart.ts +12 -0
@@ -1,28 +1,93 @@
1
1
  import { flushPromises, mount } from '@vue/test-utils'
2
- import { describe, expect, it, vi } from 'vitest'
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 = mount(WaveformAnnotationEditor, {
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 = mount(WaveformAnnotationEditor, {
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('h2').attributes('id')
25
- const secondTitleId = second.get('h2').attributes('id')
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 = mount(WaveformAnnotationEditor, {
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 = mount(WaveformAnnotationEditor, {
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.is-primary').attributes('disabled')).toBeDefined()
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.is-primary').trigger('click')
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 = mount(WaveformAnnotationEditor, {
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('X (s)1.000')
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 = mount(WaveformAnnotationEditor, {
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 = mount(WaveformAnnotationEditor, {
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('h2').text()).toBe('编辑标注')
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('.waveform-annotation-editor').trigger('click')
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 = mount(WaveformAnnotationEditor, {
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.findAll('button')[0].trigger('click')
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('omits interpolated candidates for point-only series between samples', () => {
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, 100, 50)).toEqual([])
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 { ComputedRef, Ref } from 'vue'
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
- interpolateAnnotationPoint,
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 point =
153
- series && draft
154
- ? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType)
155
- : null
156
- if (!draft || !candidate || !track || !point) return
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.y,
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
- '#389e0d',
134
- '#cf1322',
135
- '#531dab',
136
- '#08979c',
137
- '#c41d7f',
138
- '#434343',
139
- '#7cb305',
140
- '#1d39c4',
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
  /**
@@ -1,10 +1,18 @@
1
- import { flushPromises } from '@vue/test-utils'
1
+ import { DOMWrapper, flushPromises } from '@vue/test-utils'
2
2
  import { describe, expect, it } from 'vitest'
3
3
 
4
4
  import { flushAnimationFrames } from '../../test/setup'
5
5
 
6
6
  import { mountSizedChart } from '../../test/waveformChart'
7
7
 
8
+ function getAnnotationEditor() {
9
+ const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
10
+ .filter((element) => element.closest('.waveform-annotation-editor'))
11
+ .at(-1)
12
+ if (!editor) throw new Error('Expected annotation editor modal to be mounted')
13
+ return new DOMWrapper(editor)
14
+ }
15
+
8
16
  describe('WaveformChart', () => {
9
17
  it('edits and immediately deletes existing annotations without mutating props', async () => {
10
18
  const sourceAnnotation = {
@@ -31,8 +39,10 @@ describe('WaveformChart', () => {
31
39
  })
32
40
  await wrapper.get('.waveform-annotation-context-menu button').trigger('click')
33
41
  await flushPromises()
34
- await wrapper.get('textarea[aria-label="标注文本"]').setValue('新文字')
35
- await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
42
+ const editor = getAnnotationEditor()
43
+ await editor.get('textarea[aria-label="标注文本"]').setValue('新文字')
44
+ await editor.get('button.ant-btn-primary').trigger('click')
45
+ await flushPromises()
36
46
 
37
47
  const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
38
48
  Array<{ text: string }> | undefined
@@ -189,6 +189,46 @@ describe('WaveformChart', () => {
189
189
  expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
190
190
  })
191
191
 
192
+ it('clears hover when the pointer leaves the chart and restores it only after a new plot move', async () => {
193
+ const wrapper = await mountSizedChart(
194
+ {
195
+ kind: 'points',
196
+ points: [
197
+ { x: 0, y: 0 },
198
+ { x: 1, y: 5 },
199
+ ],
200
+ },
201
+ { grid: { rowCount: 1, columnCount: 1 } },
202
+ )
203
+ const overlay = wrapper.get('.waveform-chart__overlay')
204
+ const overlayWidth = Number(overlay.attributes('width'))
205
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
206
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
207
+ })
208
+
209
+ overlay.element.dispatchEvent(
210
+ new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
211
+ )
212
+ flushAnimationFrames()
213
+ await flushPromises()
214
+ expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
215
+
216
+ await wrapper.trigger('pointerleave')
217
+ expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
218
+ expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
219
+ expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
220
+
221
+ await wrapper.trigger('pointerenter')
222
+ expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
223
+
224
+ overlay.element.dispatchEvent(
225
+ new MouseEvent('pointermove', { clientX: 0, clientY: 120, bubbles: true }),
226
+ )
227
+ flushAnimationFrames()
228
+ await flushPromises()
229
+ expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
230
+ })
231
+
192
232
  it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
193
233
  const wrapper = await mountSizedChart(
194
234
  {