feat(annotation): add serialization support
All checks were successful
Package component / package (push) Successful in 6m3s

This commit is contained in:
李启源
2026-07-21 23:15:06 +08:00
parent 4643a2dbfe
commit 21bf0d803e
12 changed files with 307 additions and 174 deletions

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import type { WaveformInteractionMode } from '../../types'
interface Props {
interactionMode?: WaveformInteractionMode
annotationsVisible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(event: 'update:interaction-mode', mode: WaveformInteractionMode): void
(event: 'update:annotations-visible', visible: boolean): void
}>()
</script>
<template>
<div class="waveform-annotation-toolbar" role="toolbar" aria-label="波形标注工具">
<button
type="button"
:class="{ 'is-active': props.interactionMode === 'zoom' }"
aria-label="缩放模式"
title="缩放模式"
@click="emit('update:interaction-mode', 'zoom')"
>
缩放
</button>
<button
type="button"
:class="{ 'is-active': props.interactionMode === 'annotation' }"
aria-label="添加标注"
title="添加标注"
@click="emit('update:interaction-mode', 'annotation')"
>
标注
</button>
<button
type="button"
:class="{ 'is-active': props.annotationsVisible }"
:aria-pressed="props.annotationsVisible"
:aria-label="props.annotationsVisible ? '隐藏标注' : '显示标注'"
title="显示/隐藏标注"
@click="emit('update:annotations-visible', !props.annotationsVisible)"
>
{{ props.annotationsVisible ? '隐藏' : '显示' }}
</button>
</div>
</template>
<style scoped>
.waveform-annotation-toolbar {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
gap: 4px;
padding: 5px;
background: #fff;
border: 1px solid #dfe5ef;
border-radius: 4px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.waveform-annotation-toolbar button {
min-width: 42px;
height: 28px;
padding: 0 7px;
color: #667085;
font-size: 12px;
background: transparent;
border: 0;
border-radius: 3px;
cursor: pointer;
}
.waveform-annotation-toolbar button:hover,
.waveform-annotation-toolbar button.is-active {
color: #1677ff;
background: #e6f4ff;
}
</style>

View File

@@ -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, {

View File

@@ -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'

View File

@@ -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')
})
})

View File

@@ -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<string, unknown>
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<string>()
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)
}