feat(annotation): add serialization support
All checks were successful
Package component / package (push) Successful in 6m3s
All checks were successful
Package component / package (push) Successful in 6m3s
This commit is contained in:
46
README.md
46
README.md
@@ -405,8 +405,8 @@ scale 定位零线:
|
||||
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
|
||||
`width` 会回退到 `1`。
|
||||
|
||||
设置 `cleanView` 后,组件隐藏标题、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、
|
||||
零值参考线、标注和分页器,并取消这些元素预留的边距,仅保留波形数据层。缩放、悬浮、十字线和
|
||||
设置 `cleanView` 后,组件隐藏标题内容、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、
|
||||
零值参考线、标注和分页器,同时保留原图的标题区域、边距和波形尺寸。缩放、悬浮、十字线和
|
||||
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失:
|
||||
|
||||
```vue
|
||||
@@ -422,15 +422,13 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:grid="{ rowCount: 2, columnCount: 2, showPagination: true }"
|
||||
v-model:interaction-mode="interactionMode"
|
||||
:show-annotation-toolbar="true"
|
||||
:interaction-mode="interactionMode"
|
||||
/>
|
||||
```
|
||||
|
||||
`interactionMode` 可选 `zoom` 或 `annotation`。默认不渲染标注工具栏,推荐通过右键
|
||||
打开标注编辑器;设置 `showAnnotationToolbar` 可显示兼容工具栏。`zoomable` 和
|
||||
`showTooltip` 可分别关闭缩放和 tooltip。空数据或过滤后没有有效点时,组件会保留图框
|
||||
布局并显示“暂无有效波形数据”。
|
||||
`interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开
|
||||
标注编辑器,无需切换交互模式。`zoomable` 和 `showTooltip` 可分别关闭缩放和 tooltip。
|
||||
空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。
|
||||
|
||||
## 大数据渲染
|
||||
|
||||
@@ -469,7 +467,13 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { WaveformChart, type WaveformAnnotation, type WaveformInteractionMode } from './index'
|
||||
import {
|
||||
parseWaveformAnnotations,
|
||||
serializeWaveformAnnotations,
|
||||
WaveformChart,
|
||||
type WaveformAnnotation,
|
||||
type WaveformInteractionMode,
|
||||
} from './index'
|
||||
|
||||
const annotations = ref<WaveformAnnotation[]>([])
|
||||
const annotationsVisible = ref(true)
|
||||
@@ -480,16 +484,30 @@ const interactionMode = ref<WaveformInteractionMode>('zoom')
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
v-model:annotations="annotations"
|
||||
v-model:annotations-visible="annotationsVisible"
|
||||
v-model:interaction-mode="interactionMode"
|
||||
:annotations-visible="annotationsVisible"
|
||||
:interaction-mode="interactionMode"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
默认不显示标注工具栏;右键绘图区任意位置即可弹出居中编辑器,标注会吸附到当前 X 位置最近的真实采样点,右键已有标注可以编辑或删除。需要兼容旧工具栏时可显式设置 `showAnnotationToolbar`。
|
||||
标注默认显示。右键绘图区任意位置即可弹出居中编辑器,标注会吸附到当前 X 位置最近的真实采样点,右键已有标注可以编辑或删除。
|
||||
标注框可以直接拖动进行手动避让,拖动只改变标签框位置,不会改变 `x/y` 数据锚点;偏移会以 `labelOffsetX/labelOffsetY` 像素字段保存在标注中。标注文本最多 40 个字符,边框色、文字色和背景色均支持取色与透明度调整。组件只负责内存中的受控数据,
|
||||
业务层负责会话或后端持久化。
|
||||
|
||||
标注可以序列化为带版本号的 JSON,并在解析成功后整体替换当前数据:
|
||||
|
||||
```ts
|
||||
const exportedJson = serializeWaveformAnnotations(annotations.value)
|
||||
|
||||
async function importAnnotationFile(file: File) {
|
||||
annotations.value = parseWaveformAnnotations(await file.text())
|
||||
}
|
||||
```
|
||||
|
||||
导出格式为 `{ version: 1, annotations: [...] }`。解析会验证全部标注;文件格式、版本或任意
|
||||
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
|
||||
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
|
||||
|
||||
X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`。X 轴先按 `timeUnit` 转换为秒或毫秒再判断范围,多 Y 轴则分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
|
||||
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
|
||||
|
||||
@@ -507,8 +525,8 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01,
|
||||
| `series-visibility-change` | 图例切换曲线显隐时触发 |
|
||||
| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 |
|
||||
|
||||
`annotations`、`annotations-visible`、`interaction-mode` 和 `hidden-series-ids` 均支持
|
||||
`v-model`;业务层应负责将标注和显隐状态持久化。
|
||||
`annotations` 和 `hidden-series-ids` 支持 `v-model`;`annotations-visible` 与
|
||||
`interaction-mode` 是受控输入属性。业务层应负责将标注和显隐状态持久化。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "waveform-analysis",
|
||||
"version": "0.1.13",
|
||||
"version": "0.1.14",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
|
||||
@@ -670,8 +670,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
||||
:zero-line="zeroLine"
|
||||
:frame-number="frameWatermarkVisible ? 1 : undefined"
|
||||
v-model:annotations="annotations"
|
||||
v-model:annotations-visible="annotationsVisible"
|
||||
v-model:interaction-mode="interactionMode"
|
||||
:annotations-visible="annotationsVisible"
|
||||
:interaction-mode="interactionMode"
|
||||
v-model:hidden-series-ids="hiddenSeriesIds"
|
||||
@zoom-end="handleZoomEnd"
|
||||
@zoom-reset="resetWaveformViewport"
|
||||
|
||||
@@ -1577,7 +1577,6 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(initialPath).toContain('L')
|
||||
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('800')
|
||||
expect(wrapper.find('.waveform-annotation-toolbar').exists()).toBe(false)
|
||||
expect(wrapper.attributes('data-interaction-mode')).toBeUndefined()
|
||||
|
||||
resizeObservers.at(-1)?.resize(500, 360)
|
||||
@@ -1876,7 +1875,6 @@ describe('WaveformChart', () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||
title: { text: '波形标题' },
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: true },
|
||||
showAnnotationToolbar: true,
|
||||
})
|
||||
|
||||
for (const selector of [
|
||||
@@ -1884,7 +1882,6 @@ describe('WaveformChart', () => {
|
||||
'.waveform-chart__grid',
|
||||
'.waveform-chart__overlay',
|
||||
'.waveform-chart__pagination',
|
||||
'.waveform-annotation-toolbar',
|
||||
]) {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
const dispatched = wrapper.get(selector).element.dispatchEvent(event)
|
||||
@@ -2410,8 +2407,12 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||
flushAnimationFrames()
|
||||
// Wait for zoom-end debounce (internal throttle + flush)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
// The wheel debounce is 200ms: it must not end early, then ends at the boundary.
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
const endEvents = wrapper.emitted('zoom-end') ?? []
|
||||
@@ -3553,23 +3554,6 @@ describe('WaveformChart', () => {
|
||||
expect(overlay.classes()).not.toContain('is-zoomable')
|
||||
})
|
||||
|
||||
it('keeps the annotation toolbar available behind the compatibility prop', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ showAnnotationToolbar: true },
|
||||
)
|
||||
|
||||
expect(wrapper.find('.waveform-annotation-toolbar').exists()).toBe(true)
|
||||
await wrapper.get('button[aria-label="添加标注"]').trigger('click')
|
||||
expect(wrapper.attributes('data-interaction-mode')).toBe('annotation')
|
||||
})
|
||||
|
||||
it('creates a controlled annotation from externally selected annotation mode', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
@@ -3922,7 +3906,6 @@ describe('WaveformChart', () => {
|
||||
},
|
||||
{
|
||||
interactionMode: 'annotation',
|
||||
showAnnotationToolbar: true,
|
||||
annotations: [
|
||||
{ id: 'valid', seriesId: 'series-0', x: 1, y: 5, text: '显示' },
|
||||
{ id: 'unknown', seriesId: 'missing', x: 1, y: 5, text: '不显示' },
|
||||
@@ -3932,10 +3915,8 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(wrapper.attributes('data-interaction-mode')).toBe('annotation')
|
||||
expect(wrapper.findAll('.waveform-annotation')).toHaveLength(1)
|
||||
expect(wrapper.find('.waveform-annotation-toolbar').exists()).toBe(false)
|
||||
expect(wrapper.get('.waveform-chart__overlay').classes()).toContain('is-annotating')
|
||||
await wrapper.get('button[aria-label="隐藏标注"]').trigger('click')
|
||||
expect(wrapper.emitted('update:annotations-visible')?.at(-1)).toEqual([false])
|
||||
|
||||
await wrapper.setProps({ annotationsVisible: false })
|
||||
expect(wrapper.find('.waveform-annotation').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
type AnnotationEditorAnchor,
|
||||
WaveformAnnotationContextMenu,
|
||||
WaveformAnnotationLayer,
|
||||
WaveformAnnotationToolbar,
|
||||
type AnnotationHit,
|
||||
type AnnotationSeriesCandidate,
|
||||
type AnnotationSeriesInfo,
|
||||
@@ -103,7 +102,6 @@ const props = withDefaults(
|
||||
annotations?: WaveformAnnotation[]
|
||||
annotationsVisible?: boolean
|
||||
interactionMode?: WaveformInteractionMode
|
||||
showAnnotationToolbar?: boolean
|
||||
grid?: WaveformGridOptions
|
||||
rendering?: WaveformRenderingOptions
|
||||
title?: WaveformTitleOptions
|
||||
@@ -126,7 +124,6 @@ const props = withDefaults(
|
||||
annotations: () => [],
|
||||
annotationsVisible: true,
|
||||
interactionMode: undefined,
|
||||
showAnnotationToolbar: false,
|
||||
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
|
||||
rendering: () => ({}),
|
||||
legend: () => ({ position: 'top-right', orientation: 'auto' }),
|
||||
@@ -142,8 +139,6 @@ const emit = defineEmits<{
|
||||
'zoom-end': [payload: WaveformZoomEndPayload]
|
||||
'zoom-reset': []
|
||||
'update:annotations': [annotations: WaveformAnnotation[]]
|
||||
'update:annotations-visible': [visible: boolean]
|
||||
'update:interaction-mode': [mode: WaveformInteractionMode]
|
||||
'update:hidden-series-ids': [ids: string[]]
|
||||
'series-visibility-change': [
|
||||
payload: {
|
||||
@@ -180,7 +175,6 @@ const currentPage = ref(1)
|
||||
const resizeObserver = shallowRef<ResizeObserver>()
|
||||
const zoomBehaviors = new Map<number | 'shared', ZoomBehavior<SVGRectElement, unknown>>()
|
||||
const clipPathId = useWaveformInstanceId('waveform-clip')
|
||||
const internalInteractionMode = ref<WaveformInteractionMode | undefined>(undefined)
|
||||
const internalHiddenSeriesIds = ref(new Set(props.defaultHiddenSeriesIds))
|
||||
const annotationInteraction = useWaveformAnnotationInteraction()
|
||||
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
||||
@@ -196,6 +190,8 @@ const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
|
||||
const lastZoomedTrackIndexes = new Set<number>()
|
||||
const zoomThrottle = useAnimationFrameThrottle()
|
||||
const hoverThrottle = useAnimationFrameThrottle()
|
||||
const wheelZoomDebounceMs = 200
|
||||
let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
|
||||
|
||||
interface SelectionState {
|
||||
@@ -519,7 +515,7 @@ const hasWaveformData = computed(() => chartSeries.value.length > 0)
|
||||
const hoveredPoint = computed(() => hoveredSeriesPoints.value[0]?.point ?? null)
|
||||
const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
|
||||
const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`)
|
||||
const activeInteractionMode = computed(() => props.interactionMode ?? internalInteractionMode.value)
|
||||
const activeInteractionMode = computed(() => props.interactionMode)
|
||||
// 当 interactionMode 未定义或为 'zoom' 时启用缩放
|
||||
const isZoomMode = computed(
|
||||
() => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined,
|
||||
@@ -676,6 +672,7 @@ function handleSharedZoom(event: D3ZoomEvent<SVGRectElement, unknown>) {
|
||||
pendingSharedZoomTransform = event.transform
|
||||
pendingSharedZoomGesture = 'wheel'
|
||||
scheduleZoomCommit()
|
||||
scheduleWheelZoomEnd()
|
||||
}
|
||||
|
||||
function handleIndependentZoom(event: D3ZoomEvent<SVGRectElement, unknown>, trackIndex: number) {
|
||||
@@ -684,6 +681,7 @@ function handleIndependentZoom(event: D3ZoomEvent<SVGRectElement, unknown>, trac
|
||||
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
|
||||
pendingIndependentZoomGestures.set(trackIndex, 'wheel')
|
||||
scheduleZoomCommit()
|
||||
scheduleWheelZoomEnd()
|
||||
}
|
||||
|
||||
function commitPendingZoom() {
|
||||
@@ -731,9 +729,20 @@ function scheduleZoomCommit() {
|
||||
function flushPendingZoom() {
|
||||
zoomThrottle.flush()
|
||||
commitPendingZoom()
|
||||
if (lastSharedZoomGesture === 'wheel' || lastIndependentZoomGestures.size) return
|
||||
emitZoomEnd()
|
||||
}
|
||||
|
||||
function scheduleWheelZoomEnd() {
|
||||
if (wheelZoomEndTimer !== undefined) clearTimeout(wheelZoomEndTimer)
|
||||
wheelZoomEndTimer = setTimeout(() => {
|
||||
wheelZoomEndTimer = undefined
|
||||
zoomThrottle.flush()
|
||||
commitPendingZoom()
|
||||
emitZoomEnd()
|
||||
}, wheelZoomDebounceMs)
|
||||
}
|
||||
|
||||
function emitZoomEnd() {
|
||||
if (props.displayMode === 'independent') {
|
||||
lastZoomedTrackIndexes.forEach((trackIndex) => {
|
||||
@@ -790,6 +799,10 @@ function cancelPendingZoom() {
|
||||
lastZoomedTrackIndexes.clear()
|
||||
lastIndependentZoomGestures.clear()
|
||||
zoomThrottle.cancel()
|
||||
if (wheelZoomEndTimer !== undefined) {
|
||||
clearTimeout(wheelZoomEndTimer)
|
||||
wheelZoomEndTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function clearZoomBindings() {
|
||||
@@ -983,22 +996,6 @@ function makeAnnotationId(): string {
|
||||
return `annotation-${Date.now()}-${generatedAnnotationId}`
|
||||
}
|
||||
|
||||
function setInteractionMode(mode: WaveformInteractionMode) {
|
||||
if (props.interactionMode === undefined) internalInteractionMode.value = mode
|
||||
annotationInteraction.closeContextMenu()
|
||||
editorSeriesOptions.value = []
|
||||
emit('update:interaction-mode', mode)
|
||||
}
|
||||
|
||||
function setAnnotationsVisible(visible: boolean) {
|
||||
annotationInteraction.closeContextMenu()
|
||||
if (!visible) {
|
||||
annotationInteraction.closeEditor()
|
||||
editorSeriesOptions.value = []
|
||||
}
|
||||
emit('update:annotations-visible', visible)
|
||||
}
|
||||
|
||||
function toggleSeriesVisibility(seriesId: string) {
|
||||
if (!chartSeries.value.some((series) => series.id === seriesId)) return
|
||||
const nextHiddenSeriesIds = new Set(hiddenSeriesIdSet.value)
|
||||
@@ -2005,14 +2002,6 @@ onBeforeUnmount(() => {
|
||||
@change="goToPage"
|
||||
/>
|
||||
|
||||
<WaveformAnnotationToolbar
|
||||
v-if="showAnnotationToolbar && !isCleanView"
|
||||
:interaction-mode="activeInteractionMode"
|
||||
:annotations-visible="annotationsVisible"
|
||||
@update:interaction-mode="setInteractionMode"
|
||||
@update:annotations-visible="setAnnotationsVisible"
|
||||
/>
|
||||
|
||||
<WaveformAnnotationEditor
|
||||
v-if="annotationInteraction.editorDraft.value && !isCleanView"
|
||||
:annotation="annotationInteraction.editorDraft.value.annotation"
|
||||
|
||||
@@ -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>
|
||||
@@ -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, {
|
||||
|
||||
@@ -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'
|
||||
|
||||
114
src/components/annotation/serialization.test.ts
Normal file
114
src/components/annotation/serialization.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
127
src/components/annotation/serialization.ts
Normal file
127
src/components/annotation/serialization.ts
Normal 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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -60,3 +60,5 @@ export {
|
||||
selectRenderablePoints,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from './core'
|
||||
|
||||
export { parseWaveformAnnotations, serializeWaveformAnnotations } from './components/annotation'
|
||||
|
||||
Reference in New Issue
Block a user