diff --git a/README.md b/README.md index c4e0179..99145d9 100644 --- a/README.md +++ b/README.md @@ -405,8 +405,8 @@ scale 定位零线: `dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的 `width` 会回退到 `1`。 -设置 `cleanView` 后,组件隐藏标题、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、 -零值参考线、标注和分页器,并取消这些元素预留的边距,仅保留波形数据层。缩放、悬浮、十字线和 +设置 `cleanView` 后,组件隐藏标题内容、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、 +零值参考线、标注和分页器,同时保留原图的标题区域、边距和波形尺寸。缩放、悬浮、十字线和 tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失: ```vue @@ -422,15 +422,13 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失 ``` -`interactionMode` 可选 `zoom` 或 `annotation`。默认不渲染标注工具栏,推荐通过右键 -打开标注编辑器;设置 `showAnnotationToolbar` 可显示兼容工具栏。`zoomable` 和 -`showTooltip` 可分别关闭缩放和 tooltip。空数据或过滤后没有有效点时,组件会保留图框 -布局并显示“暂无有效波形数据”。 +`interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开 +标注编辑器,无需切换交互模式。`zoomable` 和 `showTooltip` 可分别关闭缩放和 tooltip。 +空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。 ## 大数据渲染 @@ -469,7 +467,13 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失 ```vue - - - - diff --git a/src/components/annotation/components.test.ts b/src/components/annotation/components.test.ts index 72d9986..3a1d8ee 100644 --- a/src/components/annotation/components.test.ts +++ b/src/components/annotation/components.test.ts @@ -5,7 +5,6 @@ import { ColorPicker } from 'vue3-colorpicker' import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue' import WaveformAnnotationEditor from './WaveformAnnotationEditor.vue' import WaveformAnnotationLayer from './WaveformAnnotationLayer.vue' -import WaveformAnnotationToolbar from './WaveformAnnotationToolbar.vue' describe('waveform annotation controls', () => { it('keeps dialog title ids unique across editor instances', () => { @@ -76,18 +75,6 @@ describe('waveform annotation controls', () => { expect(wrapper.get('.waveform-annotation-editor__series').text()).toContain('通道 A') }) - it('emits controlled toolbar changes', async () => { - const wrapper = mount(WaveformAnnotationToolbar, { - props: { interactionMode: 'zoom', annotationsVisible: true }, - }) - - await wrapper.get('button[aria-label="添加标注"]').trigger('click') - await wrapper.get('button[aria-label="隐藏标注"]').trigger('click') - - expect(wrapper.emitted('update:interaction-mode')).toEqual([['annotation']]) - expect(wrapper.emitted('update:annotations-visible')).toEqual([[false]]) - }) - it('validates text and emits an immutable edited annotation with style defaults', async () => { const annotation = { id: 'note', seriesId: 'a', x: 1, y: 2, text: '' } const wrapper = mount(WaveformAnnotationEditor, { diff --git a/src/components/annotation/index.ts b/src/components/annotation/index.ts index ca6e31a..b564393 100644 --- a/src/components/annotation/index.ts +++ b/src/components/annotation/index.ts @@ -1,6 +1,6 @@ export { default as WaveformAnnotationLayer } from './WaveformAnnotationLayer.vue' -export { default as WaveformAnnotationToolbar } from './WaveformAnnotationToolbar.vue' export { default as WaveformAnnotationContextMenu } from './WaveformAnnotationContextMenu.vue' export * from './markup' +export * from './serialization' export * from './types' export * from './useWaveformAnnotationInteraction' diff --git a/src/components/annotation/serialization.test.ts b/src/components/annotation/serialization.test.ts new file mode 100644 index 0000000..191fa9b --- /dev/null +++ b/src/components/annotation/serialization.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import type { WaveformAnnotation } from '../../types' +import { parseWaveformAnnotations, serializeWaveformAnnotations } from './serialization' + +describe('waveform annotation serialization', () => { + it('round-trips every annotation field through a versioned document', () => { + const source: WaveformAnnotation[] = [ + { + id: 'note-1', + seriesId: 'channel-a', + x: 1.25, + y: -3.5, + text: '峰值', + labelOffsetX: 12, + labelOffsetY: -8, + createdAt: '2026-07-21T12:00:00.000Z', + style: { + borderColor: '#1677ff', + textColor: '#333333', + backgroundColor: 'rgba(255, 255, 255, 0.92)', + }, + }, + ] + const sourceSnapshot = JSON.parse(JSON.stringify(source)) + + const parsed = parseWaveformAnnotations(serializeWaveformAnnotations(source)) + + expect(JSON.parse(serializeWaveformAnnotations(source))).toMatchObject({ version: 1 }) + expect(source).toEqual(sourceSnapshot) + expect(parsed).toEqual(source) + expect(parsed).not.toBe(source) + expect(parsed[0]).not.toBe(source[0]) + expect(parsed[0].style).not.toBe(source[0].style) + }) + + it('allows annotations for series that are not currently loaded', () => { + expect( + parseWaveformAnnotations( + JSON.stringify({ + version: 1, + annotations: [{ id: 'future', seriesId: 'missing', x: 1, y: 2, text: '稍后显示' }], + }), + ), + ).toEqual([{ id: 'future', seriesId: 'missing', x: 1, y: 2, text: '稍后显示' }]) + }) + + it.each([ + ['invalid JSON', '{'], + ['non-object root', '[]'], + ['unsupported version', JSON.stringify({ version: 2, annotations: [] })], + ['missing annotation array', JSON.stringify({ version: 1 })], + [ + 'invalid annotation entry', + JSON.stringify({ version: 1, annotations: [{ id: 'a', seriesId: 's', x: 1 }] }), + ], + [ + 'non-finite coordinate', + '{"version":1,"annotations":[{"id":"a","seriesId":"s","x":1e400,"y":2,"text":"a"}]}', + ], + [ + 'overlong text', + JSON.stringify({ + version: 1, + annotations: [{ id: 'a', seriesId: 's', x: 1, y: 2, text: 'a'.repeat(41) }], + }), + ], + [ + 'duplicate IDs', + JSON.stringify({ + version: 1, + annotations: [ + { id: 'a', seriesId: 's', x: 1, y: 2, text: 'one' }, + { id: 'a', seriesId: 's', x: 2, y: 3, text: 'two' }, + ], + }), + ], + ])('rejects %s without returning partial data', (_label, json) => { + expect(() => parseWaveformAnnotations(json)).toThrow('Invalid waveform annotation file') + }) + + it('rejects invalid optional fields and serialization input', () => { + expect(() => + parseWaveformAnnotations( + JSON.stringify({ + version: 1, + annotations: [ + { + id: 'a', + seriesId: 's', + x: 1, + y: 2, + text: 'a', + labelOffsetX: '12', + }, + ], + }), + ), + ).toThrow('labelOffsetX') + + expect(() => + parseWaveformAnnotations( + JSON.stringify({ + version: 1, + annotations: [{ id: 'a', seriesId: 's', x: 1, y: 2, text: 'a', style: [] }], + }), + ), + ).toThrow('style must be an object') + + expect(() => + serializeWaveformAnnotations([{ id: 'a', seriesId: 's', x: Number.NaN, y: 2, text: 'a' }]), + ).toThrow('x must be a finite number') + }) +}) diff --git a/src/components/annotation/serialization.ts b/src/components/annotation/serialization.ts new file mode 100644 index 0000000..6e0a7a4 --- /dev/null +++ b/src/components/annotation/serialization.ts @@ -0,0 +1,127 @@ +import type { WaveformAnnotation, WaveformAnnotationStyle } from '../../types' +import { ANNOTATION_MAX_TEXT_LENGTH } from './markup' + +const ANNOTATION_FILE_VERSION = 1 + +type JsonRecord = Record + +function fail(message: string): never { + throw new TypeError(`Invalid waveform annotation file: ${message}`) +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requiredString(record: JsonRecord, key: string, path: string): string { + const value = record[key] + if (typeof value !== 'string' || value.trim().length === 0) { + fail(`${path}.${key} must be a non-empty string`) + } + return value +} + +function optionalString(record: JsonRecord, key: string, path: string): string | undefined { + const value = record[key] + if (value === undefined) return undefined + if (typeof value !== 'string') fail(`${path}.${key} must be a string`) + return value +} + +function requiredFiniteNumber(record: JsonRecord, key: string, path: string): number { + const value = record[key] + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${path}.${key} must be a finite number`) + } + return value +} + +function optionalFiniteNumber(record: JsonRecord, key: string, path: string): number | undefined { + const value = record[key] + if (value === undefined) return undefined + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`${path}.${key} must be a finite number`) + } + return value +} + +function parseStyle(value: unknown, path: string): WaveformAnnotationStyle | undefined { + if (value === undefined) return undefined + if (!isRecord(value)) fail(`${path} must be an object`) + + const borderColor = optionalString(value, 'borderColor', path) + const textColor = optionalString(value, 'textColor', path) + const backgroundColor = optionalString(value, 'backgroundColor', path) + + return { + ...(borderColor !== undefined && { borderColor }), + ...(textColor !== undefined && { textColor }), + ...(backgroundColor !== undefined && { backgroundColor }), + } +} + +function parseAnnotation(value: unknown, index: number): WaveformAnnotation { + const path = `annotations[${index}]` + if (!isRecord(value)) fail(`${path} must be an object`) + + const text = requiredString(value, 'text', path) + if (text.length > ANNOTATION_MAX_TEXT_LENGTH) { + fail(`${path}.text must not exceed ${ANNOTATION_MAX_TEXT_LENGTH} characters`) + } + + const labelOffsetX = optionalFiniteNumber(value, 'labelOffsetX', path) + const labelOffsetY = optionalFiniteNumber(value, 'labelOffsetY', path) + const createdAt = optionalString(value, 'createdAt', path) + const style = parseStyle(value.style, `${path}.style`) + + return { + id: requiredString(value, 'id', path), + seriesId: requiredString(value, 'seriesId', path), + x: requiredFiniteNumber(value, 'x', path), + y: requiredFiniteNumber(value, 'y', path), + text, + ...(labelOffsetX !== undefined && { labelOffsetX }), + ...(labelOffsetY !== undefined && { labelOffsetY }), + ...(style !== undefined && { style }), + ...(createdAt !== undefined && { createdAt }), + } +} + +function normalizeAnnotations(values: readonly unknown[]): WaveformAnnotation[] { + const annotations = values.map(parseAnnotation) + const ids = new Set() + annotations.forEach((annotation, index) => { + if (ids.has(annotation.id)) fail(`annotations[${index}].id must be unique`) + ids.add(annotation.id) + }) + return annotations +} + +/** Serialize annotations to the versioned waveform annotation JSON format. */ +export function serializeWaveformAnnotations(annotations: readonly WaveformAnnotation[]): string { + return JSON.stringify( + { version: ANNOTATION_FILE_VERSION, annotations: normalizeAnnotations(annotations) }, + null, + 2, + ) +} + +/** Parse and validate a versioned waveform annotation JSON document. */ +export function parseWaveformAnnotations(json: string): WaveformAnnotation[] { + if (typeof json !== 'string') fail('input must be a JSON string') + + let document: unknown + try { + document = JSON.parse(json) + } catch { + fail('input is not valid JSON') + } + + if (!isRecord(document)) fail('root must be an object') + if (document.version !== ANNOTATION_FILE_VERSION) { + fail(`version must be ${ANNOTATION_FILE_VERSION}`) + } + if (!Array.isArray(document.annotations)) fail('annotations must be an array') + + return normalizeAnnotations(document.annotations) +} diff --git a/src/components/index.ts b/src/components/index.ts index 4a8029e..a3f1431 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -31,8 +31,4 @@ export type { WaveformGridOptions as WaveformGridConfig } from './core/grid' // 可选:导出各系统的组件(供高级用户使用) export { WaveformTooltip } from './interaction' export { WaveformTrack } from './rendering' -export { - WaveformAnnotationLayer, - WaveformAnnotationToolbar, - WaveformAnnotationContextMenu, -} from './annotation' +export { WaveformAnnotationLayer, WaveformAnnotationContextMenu } from './annotation' diff --git a/src/index.ts b/src/index.ts index e2ae5db..92fb13a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,3 +60,5 @@ export { selectRenderablePoints, type ResolvedWaveformRenderingOptions, } from './core' + +export { parseWaveformAnnotations, serializeWaveformAnnotations } from './components/annotation'