feat(annotation): 优化标注编辑与交互体验
All checks were successful
Package component / package (push) Successful in 4m23s
All checks were successful
Package component / package (push) Successful in 4m23s
标注编辑器支持时间输入与采样点吸附,完善标注交互、默认配色和示例图框样式。
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { DOMWrapper, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames } from '../../test/setup'
|
||||
|
||||
import { mountSizedChart } from '../../test/waveformChart'
|
||||
|
||||
function getAnnotationEditor() {
|
||||
const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||
.filter((element) => element.closest('.waveform-annotation-editor'))
|
||||
.at(-1)
|
||||
if (!editor) throw new Error('Expected annotation editor modal to be mounted')
|
||||
return new DOMWrapper(editor)
|
||||
}
|
||||
|
||||
describe('WaveformChart', () => {
|
||||
it('edits and immediately deletes existing annotations without mutating props', async () => {
|
||||
const sourceAnnotation = {
|
||||
@@ -31,8 +39,10 @@ describe('WaveformChart', () => {
|
||||
})
|
||||
await wrapper.get('.waveform-annotation-context-menu button').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('textarea[aria-label="标注文本"]').setValue('新文字')
|
||||
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
|
||||
const editor = getAnnotationEditor()
|
||||
await editor.get('textarea[aria-label="标注文本"]').setValue('新文字')
|
||||
await editor.get('button.ant-btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
|
||||
Array<{ text: string }> | undefined
|
||||
|
||||
@@ -189,6 +189,46 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('clears hover when the pointer leaves the chart and restores it only after a new plot move', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ grid: { rowCount: 1, columnCount: 1 } },
|
||||
)
|
||||
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
|
||||
})
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
|
||||
await wrapper.trigger('pointerleave')
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
|
||||
await wrapper.trigger('pointerenter')
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 0, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { DOMWrapper, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames, pendingAnimationFrameCount } from '../../test/setup'
|
||||
|
||||
import { mountSizedChart } from '../../test/waveformChart'
|
||||
|
||||
function getAnnotationEditor() {
|
||||
const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||
.filter((element) => element.closest('.waveform-annotation-editor'))
|
||||
.at(-1)
|
||||
if (!editor) throw new Error('Expected annotation editor modal to be mounted')
|
||||
return new DOMWrapper(editor)
|
||||
}
|
||||
|
||||
describe('WaveformChart', () => {
|
||||
it('zooms only the active independent track and resets when the mode changes', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
@@ -172,9 +180,10 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
|
||||
await wrapper.get('textarea[aria-label="标注文本"]').setValue('峰值点')
|
||||
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
|
||||
const editor = getAnnotationEditor()
|
||||
await editor.get('textarea[aria-label="标注文本"]').setValue('峰值点')
|
||||
await editor.get('button.ant-btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const annotations = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
|
||||
Array<{ seriesId: string; x: number; y: number; text: string }> | undefined
|
||||
@@ -209,10 +218,10 @@ describe('WaveformChart', () => {
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
|
||||
expect(wrapper.get('.waveform-annotation-editor').attributes('aria-modal')).toBe('true')
|
||||
expect(wrapper.find('.waveform-annotation-editor__panel').exists()).toBe(true)
|
||||
const textarea = wrapper.get('textarea[aria-label="标注文本"]')
|
||||
const editor = getAnnotationEditor()
|
||||
expect(editor.attributes('role')).toBe('dialog')
|
||||
expect(editor.find('.waveform-annotation-editor__content').exists()).toBe(true)
|
||||
const textarea = editor.get('textarea[aria-label="标注文本"]')
|
||||
const textareaContextMenu = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
@@ -220,7 +229,8 @@ describe('WaveformChart', () => {
|
||||
expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true)
|
||||
expect(textareaContextMenu.defaultPrevented).toBe(false)
|
||||
await textarea.setValue('右键标注')
|
||||
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
|
||||
await editor.get('button.ant-btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Annotation snaps to nearest sample point (x=1, y=5)
|
||||
expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([
|
||||
@@ -278,20 +288,20 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
await flushPromises()
|
||||
expect(
|
||||
wrapper
|
||||
.find('select[aria-label="选择标注波形"]')
|
||||
getAnnotationEditor()
|
||||
.get('select[aria-label="选择标注波形"]')
|
||||
.findAll('option')
|
||||
.map((item) => item.text()),
|
||||
).toEqual(['通道 A'])
|
||||
|
||||
await wrapper.get('button[aria-label="关闭标注编辑器"]').trigger('click')
|
||||
await getAnnotationEditor().get('.ant-modal-close').trigger('click')
|
||||
overlays[0].element.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { clientX: 356, clientY: 230, bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(
|
||||
wrapper
|
||||
.find('select[aria-label="选择标注波形"]')
|
||||
getAnnotationEditor()
|
||||
.get('select[aria-label="选择标注波形"]')
|
||||
.findAll('option')
|
||||
.map((item) => item.text()),
|
||||
).toEqual(['通道 B'])
|
||||
|
||||
@@ -7,6 +7,14 @@ import WaveformChartView from '../WaveformChartView.vue'
|
||||
|
||||
import { gridSeries, mountSizedChart, visibilitySeries } from '../../test/waveformChart'
|
||||
|
||||
function annotationEditorExists() {
|
||||
return Boolean(
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||
.filter((element) => element.closest('.waveform-annotation-editor'))
|
||||
.at(-1),
|
||||
)
|
||||
}
|
||||
|
||||
describe('WaveformChart', () => {
|
||||
it('resolves legend positions by stable track id across pages', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
@@ -273,7 +281,7 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
|
||||
expect(annotationEditorExists()).toBe(true)
|
||||
|
||||
const controller = wrapper.getComponent(WaveformChartView).props('controller') as unknown as {
|
||||
annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } }
|
||||
@@ -287,7 +295,7 @@ describe('WaveformChart', () => {
|
||||
expect(item).toBeDefined()
|
||||
await item!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false)
|
||||
expect(annotationEditorExists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders independent cells with separate x axes and overlays', async () => {
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('WaveformChart', () => {
|
||||
legend
|
||||
.findAll('.waveform-legend__swatch')
|
||||
.map((swatch) => swatch.get('path').attributes('stroke')),
|
||||
).toEqual(['#0960bd', '#389e0d'])
|
||||
).toEqual(['#0960bd', '#2ca02c'])
|
||||
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
|
||||
@@ -5,6 +5,14 @@ import { flushAnimationFrames } from '../../test/setup'
|
||||
import { mountSizedChart } from '../../test/waveformChart'
|
||||
import type { WaveformData, WaveformDisplayMode } from '../data/types'
|
||||
|
||||
function annotationEditorExists() {
|
||||
return Boolean(
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
|
||||
.filter((element) => element.closest('.waveform-annotation-editor'))
|
||||
.at(-1),
|
||||
)
|
||||
}
|
||||
|
||||
const presentationData: WaveformData = {
|
||||
kind: 'series',
|
||||
series: [
|
||||
@@ -282,13 +290,13 @@ describe('WaveformChart presentation mode', () => {
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
|
||||
expect(annotationEditorExists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({ presentationMode: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false)
|
||||
expect(annotationEditorExists()).toBe(false)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
|
||||
await wrapper.setProps({ presentationMode: false })
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('WaveformChart', () => {
|
||||
])
|
||||
expect(stepSwatchPaths[0]?.attributes()).toMatchObject({
|
||||
d: 'M1 8H25',
|
||||
stroke: '#389e0d',
|
||||
stroke: '#2ca02c',
|
||||
'stroke-width': '1.5',
|
||||
})
|
||||
expect(stepSwatchPaths[1]?.attributes()).toMatchObject({
|
||||
|
||||
@@ -93,4 +93,64 @@ describe('WaveformChart x domain strategy', () => {
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('10000')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('15000')
|
||||
})
|
||||
|
||||
it('keeps an explicit viewport wider than the data and leaves uncovered time blank', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 1 },
|
||||
],
|
||||
},
|
||||
{ timeUnit: 's', initialXDomain: [-5, 20] },
|
||||
)
|
||||
|
||||
const track = wrapper.get('.waveform-chart__track')
|
||||
const width = Number(track.attributes('data-track-width'))
|
||||
const path = track.get('.waveform-chart__line').attributes('d') ?? ''
|
||||
const xCoordinates = Array.from(path.matchAll(/(?:M|L)([\d.-]+)/g)).map((match) =>
|
||||
Number(match[1]),
|
||||
)
|
||||
|
||||
expect(track.get('.waveform-chart__axis-endpoint--start').text()).toBe('-5')
|
||||
expect(track.get('.waveform-chart__axis-endpoint--end').text()).toBe('20')
|
||||
expect(xCoordinates.length).toBeGreaterThan(0)
|
||||
expect(Math.min(...xCoordinates)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.max(...xCoordinates)).toBeLessThanOrEqual(width)
|
||||
})
|
||||
|
||||
it('applies independent explicit viewports per track, including empty time regions', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'first',
|
||||
name: '第一轨',
|
||||
data: { kind: 'points', points: [{ x: 0, y: 0 }, { x: 10, y: 1 }] },
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
name: '第二轨',
|
||||
data: { kind: 'points', points: [{ x: 100, y: 0 }, { x: 110, y: 1 }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayMode: 'independent',
|
||||
grid: { rowCount: 1, columnCount: 2 },
|
||||
timeUnit: 's',
|
||||
initialXDomains: { first: [-5, 20], second: [95, 120] },
|
||||
},
|
||||
)
|
||||
|
||||
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-5')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('20')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('95')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('120')
|
||||
expect(tracks[0]?.find('.waveform-chart__line').exists()).toBe(true)
|
||||
expect(tracks[1]?.find('.waveform-chart__line').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user