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