feat(annotation): 优化标注编辑与交互体验
All checks were successful
Package component / package (push) Successful in 4m23s

标注编辑器支持时间输入与采样点吸附,完善标注交互、默认配色和示例图框样式。
This commit is contained in:
李启源
2026-08-17 12:16:12 +08:00
parent 3692e21dbc
commit dda87f7508
21 changed files with 597 additions and 215 deletions

View File

@@ -1,63 +1,8 @@
.waveform-annotation-editor {
position: absolute;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgb(16 24 40 / 32%);
animation: waveform-annotation-editor-fade-in 160ms ease-out;
}
.waveform-annotation-editor__panel {
.waveform-annotation-editor__content {
display: grid;
gap: 20px;
width: min(440px, 100%);
max-height: 100%;
overflow-y: auto;
padding: 22px;
color: #344054;
font-size: 13px;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 12px;
box-shadow: 0 20px 50px rgb(16 24 40 / 22%);
animation: waveform-annotation-editor-rise-in 180ms ease-out;
}
.waveform-annotation-editor__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.waveform-annotation-editor__header h2 {
margin: 0;
color: #101828;
font-size: 18px;
line-height: 1.3;
}
.waveform-annotation-editor__header p {
margin: 5px 0 0;
color: #667085;
font-size: 12px;
}
.waveform-annotation-editor__close {
display: inline-grid;
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
color: #667085;
font-size: 22px;
line-height: 1;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__close:hover {
color: #101828;
background: #f2f4f7;
}
.waveform-annotation-editor__coordinates {
display: grid;
@@ -78,6 +23,39 @@
.waveform-annotation-editor__coordinates span {
color: #667085;
}
.waveform-annotation-editor__coordinate-input {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 9px 10px;
color: #667085;
background: #f8fafc;
border: 1px solid #eaecf0;
border-radius: 7px;
}
.waveform-annotation-editor__coordinate-input :deep(.ant-input-number) {
width: auto;
min-width: 96px;
color: #344054;
font-family: 'SFMono-Regular', Consolas, monospace;
}
.waveform-annotation-editor__coordinate-value {
display: none;
}
.waveform-annotation-editor__coordinate-error {
grid-column: 1 / -1;
margin: -12px 0 0;
color: #d92d20;
font-size: 11px;
line-height: 1.4;
}
.waveform-annotation-editor__coordinate-hint {
margin: -12px 0 0;
color: #98a2b3;
font-size: 11px;
line-height: 1.4;
}
.waveform-annotation-editor__coordinates b {
color: #1677ff;
font-size: 11px;
@@ -205,56 +183,9 @@
border-color: #98a2b3;
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
}
.waveform-annotation-editor__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #eaecf0;
}
.waveform-annotation-editor__actions button {
height: 34px;
padding: 0 12px;
color: #475467;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__actions button.is-primary {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.waveform-annotation-editor__actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes waveform-annotation-editor-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes waveform-annotation-editor-rise-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 420px) {
.waveform-annotation-editor {
padding: 12px;
}
.waveform-annotation-editor__panel {
.waveform-annotation-editor__content {
gap: 16px;
padding: 18px;
}
.waveform-annotation-editor__coordinates {
grid-template-columns: 1fr;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { InputNumber, Modal } from 'ant-design-vue'
import { ColorPicker } from 'vue3-colorpicker'
import 'vue3-colorpicker/style.css'
@@ -15,24 +16,44 @@ interface Props {
series?: AnnotationSeriesInfo
seriesOptions?: AnnotationSeriesCandidate[]
timeUnit?: TimeUnit
timeError?: string
}
const props = withDefaults(defineProps<Props>(), {
timeUnit: 'ms',
timeError: '',
})
const emit = defineEmits<{
(event: 'confirm', annotation: WaveformAnnotation): void
(event: 'cancel'): void
(event: 'series-change', seriesId: string): void
(event: 'time-change', displayValue: string): void
}>()
const textarea = ref<HTMLTextAreaElement>()
const dialogTitleId = useWaveformInstanceId('waveform-annotation-editor-title')
const text = ref('')
const timeInput = ref('')
const timeValidationRequested = ref(false)
const borderColor = ref('')
const textColor = ref('')
const backgroundColor = ref('')
const canConfirm = computed(() => text.value.trim().length > 0)
const inputTimeError = computed(() => {
if (!timeValidationRequested.value) return ''
if (!timeInput.value.trim() || !Number.isFinite(Number(timeInput.value)))
return '请输入有效的时间'
return ''
})
const timeErrorMessage = computed(() =>
timeValidationRequested.value ? inputTimeError.value || props.timeError || '' : '',
)
const canConfirm = computed(
() =>
text.value.trim().length > 0 &&
timeInput.value.trim().length > 0 &&
Number.isFinite(Number(timeInput.value)) &&
!timeErrorMessage.value,
)
const characterCount = computed(() => text.value.length)
const selectedSeries = computed(() => {
const option = props.seriesOptions?.find(
@@ -46,15 +67,30 @@ const selectedSeries = computed(() => {
function hydrate() {
const style = resolveAnnotationStyle(props.annotation.style)
text.value = props.annotation.text
timeInput.value = formatAnnotationTime(props.annotation.x, props.timeUnit)
timeValidationRequested.value = false
borderColor.value = style.borderColor
textColor.value = style.textColor
backgroundColor.value = style.backgroundColor
void nextTick(() => textarea.value?.focus())
}
watch(() => props.annotation, hydrate, { immediate: true })
watch(() => props.annotation.id, hydrate, { immediate: true })
function confirm() {
function handleTimeInput(value: number | string | null) {
const nextValue = value === null || value === undefined ? '' : String(value)
timeInput.value = nextValue
timeValidationRequested.value = false
}
function commitTimeInput() {
timeValidationRequested.value = true
emit('time-change', timeInput.value)
}
async function confirm() {
commitTimeInput()
await nextTick()
if (!canConfirm.value) return
emit('confirm', {
...props.annotation,
@@ -82,40 +118,60 @@ function handleSeriesChange(event: Event) {
</script>
<template>
<div
class="waveform-annotation-editor"
role="dialog"
aria-modal="true"
:aria-labelledby="dialogTitleId"
@click.self="emit('cancel')"
<Modal
:visible="true"
:width="440"
:mask-closable="true"
:keyboard="true"
cancel-text="取消"
ok-text="保存标注"
:ok-button-props="{ disabled: !canConfirm }"
wrap-class-name="waveform-annotation-editor"
@cancel="emit('cancel')"
@ok="confirm"
>
<section class="waveform-annotation-editor__panel">
<header class="waveform-annotation-editor__header">
<div>
<h2 :id="dialogTitleId">
{{ props.mode === 'add' ? '添加标注' : '编辑标注' }}
</h2>
</div>
<button
type="button"
class="waveform-annotation-editor__close"
aria-label="关闭标注编辑器"
title="关闭"
@click="emit('cancel')"
>
×
</button>
</header>
<template #title>
<span :id="dialogTitleId">{{ props.mode === 'add' ? '添加标注' : '编辑标注' }}</span>
</template>
<div class="waveform-annotation-editor__content">
<div class="waveform-annotation-editor__coordinates" aria-label="标注坐标">
<span
><b>X ({{ props.timeUnit }})</b
><code>{{ formatAnnotationTime(props.annotation.x, props.timeUnit) }}</code></span
<label
class="waveform-annotation-editor__coordinate-input"
:aria-invalid="Boolean(timeErrorMessage)"
>
<b>X</b>
<InputNumber
:value="timeInput === '' ? undefined : Number(timeInput)"
:controls="true"
:step="0.001"
:keyboard="true"
:status="timeErrorMessage ? 'error' : undefined"
aria-label="标注横轴时间"
:aria-invalid="Boolean(timeErrorMessage)"
:aria-describedby="timeErrorMessage ? `${dialogTitleId}-time-error` : undefined"
@blur="commitTimeInput"
@update:value="handleTimeInput"
/>
<code class="waveform-annotation-editor__coordinate-value" aria-hidden="true">{{
timeInput
}}</code>
</label>
<p
v-if="timeErrorMessage"
:id="`${dialogTitleId}-time-error`"
class="waveform-annotation-editor__coordinate-error"
role="alert"
>
{{ timeErrorMessage }}
</p>
<span
><b>Y</b><code>{{ formatPlainNumber(props.annotation.y) }}</code></span
>
</div>
<p class="waveform-annotation-editor__coordinate-hint" role="note">
修改 X 轴后失焦时会自动吸附最近的采样点
</p>
<label
v-if="selectedSeries"
@@ -200,14 +256,8 @@ function handleSeriesChange(event: Event) {
</label>
</fieldset>
<footer class="waveform-annotation-editor__actions">
<button type="button" @click="emit('cancel')">取消</button>
<button type="button" class="is-primary" :disabled="!canConfirm" @click="confirm">
保存标注
</button>
</footer>
</section>
</div>
</div>
</Modal>
</template>
<style scoped src="./WaveformAnnotationEditor.css"></style>

View File

@@ -1,28 +1,93 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { InputNumber } from 'ant-design-vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ColorPicker } from 'vue3-colorpicker'
import { defineComponent, h } from 'vue'
import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue'
import WaveformAnnotationEditor from './WaveformAnnotationEditor.vue'
import WaveformAnnotationLayer from './WaveformAnnotationLayer.vue'
const modalStub = defineComponent({
props: [
'visible',
'width',
'maskClosable',
'keyboard',
'wrapClassName',
'cancelText',
'okText',
'okButtonProps',
],
emits: ['cancel', 'ok'],
setup(props, { emit, slots }) {
return () => {
const title = slots.title?.() ?? []
const titleId = (title[0]?.props as { id?: string } | undefined)?.id
return h('div', { class: 'ant-modal-root' }, [
h(
'div',
{
class: ['ant-modal-wrap', props.wrapClassName],
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': titleId,
onClick: (event: MouseEvent) => {
if (event.target === event.currentTarget && props.maskClosable) emit('cancel')
},
},
[
h('div', { class: 'ant-modal' }, [
h('div', { class: 'ant-modal-header' }, [
h('div', { class: 'ant-modal-title' }, title),
]),
h('button', { class: 'ant-modal-close', onClick: () => emit('cancel') }, '×'),
slots.default?.(),
h('div', { class: 'ant-modal-footer' }, [
h('button', { class: 'ant-btn', onClick: () => emit('cancel') }, props.cancelText),
h(
'button',
{
class: 'ant-btn ant-btn-primary',
disabled: props.okButtonProps?.disabled,
onClick: () => emit('ok'),
},
props.okText,
),
]),
]),
],
),
])
}
},
})
const mountEditor = (options: { props: Record<string, unknown> }) =>
mount(WaveformAnnotationEditor, {
props: options.props as never,
global: { stubs: { Modal: modalStub, AModal: modalStub } },
})
describe('waveform annotation controls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('keeps dialog title ids unique across editor instances', () => {
const first = mount(WaveformAnnotationEditor, {
const first = mountEditor({
props: {
annotation: { id: 'first', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit',
},
})
const second = mount(WaveformAnnotationEditor, {
const second = mountEditor({
props: {
annotation: { id: 'second', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit',
},
})
const firstTitleId = first.get('h2').attributes('id')
const secondTitleId = second.get('h2').attributes('id')
const firstTitleId = first.get('.ant-modal-title [id]').attributes('id')
const secondTitleId = second.get('.ant-modal-title [id]').attributes('id')
expect(firstTitleId).toBeTruthy()
expect(secondTitleId).toBeTruthy()
@@ -31,8 +96,19 @@ describe('waveform annotation controls', () => {
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId)
})
it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
const wrapper = mountEditor({
props: {
annotation: { id: `hint-${mode}`, seriesId: 'a', x: 1, y: 2, text: '说明' },
mode,
},
})
expect(wrapper.get('[role="note"]').text()).toBe('修改 X 轴后失焦时会自动吸附最近的采样点')
})
it('allows changing the annotation series inside the editor', async () => {
const wrapper = mount(WaveformAnnotationEditor, {
const wrapper = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit',
@@ -77,17 +153,15 @@ describe('waveform annotation controls', () => {
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, {
const wrapper = mountEditor({
props: { annotation, mode: 'add' },
})
await flushPromises()
expect(wrapper.get('textarea').attributes('maxlength')).toBe('40')
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain(
'X (ms)1000.000',
)
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('X1000.000')
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('Y2')
expect(wrapper.get('button.is-primary').attributes('disabled')).toBeDefined()
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), {
timeout: 5000,
})
@@ -113,7 +187,7 @@ describe('waveform annotation controls', () => {
colorPickers[1].vm.$emit('update:pureColor', 'rgba(51, 51, 51, 0.8)')
colorPickers[2].vm.$emit('update:pureColor', 'rgba(255, 255, 255, 0.5)')
await wrapper.vm.$nextTick()
await wrapper.get('button.is-primary').trigger('click')
await wrapper.get('button.ant-btn-primary').trigger('click')
const emitted = wrapper.emitted('confirm')?.[0]?.[0] as
| {
@@ -133,7 +207,7 @@ describe('waveform annotation controls', () => {
})
it('formats annotation coordinates using the selected display context', () => {
const wrapper = mount(WaveformAnnotationEditor, {
const wrapper = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 0.0000001, text: '说明' },
mode: 'edit',
@@ -142,13 +216,53 @@ describe('waveform annotation controls', () => {
})
const coordinates = wrapper.get('.waveform-annotation-editor__coordinates').text()
expect(coordinates).toContain('X (s)1.000')
expect(coordinates).toContain('X1.000')
expect(coordinates).toContain('Y0.0000001')
expect(coordinates).not.toContain('e-')
})
it('emits valid manual time input and disables save for invalid input', async () => {
const wrapper = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 2, text: '说明' },
mode: 'edit',
timeUnit: 'ms',
},
})
const input = wrapper.getComponent(InputNumber)
expect(input.props('controls')).toBe(true)
await input.vm.$emit('update:value', 1500)
expect(wrapper.emitted('time-change')).toBeUndefined()
await input.vm.$emit('blur')
expect(wrapper.emitted('time-change')).toEqual([['1500']])
await input.vm.$emit('update:value', null)
await input.vm.$emit('blur')
expect(wrapper.get('[role="alert"]').text()).toBe('请输入有效的时间')
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
})
it('shows time validation errors and blocks confirmation', async () => {
const wrapper = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 2, text: '说明' },
mode: 'edit',
timeError: '时间超出当前波形范围',
},
})
const input = wrapper.getComponent(InputNumber)
await input.vm.$emit('blur')
expect(
wrapper.get('.waveform-annotation-editor__coordinate-input').attributes('aria-invalid'),
).toBe('true')
expect(wrapper.get('[role="alert"]').text()).toBe('时间超出当前波形范围')
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
await wrapper.setProps({ timeError: '' })
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('hydrates hexadecimal and rgba annotation colors', async () => {
const wrapper = mount(WaveformAnnotationEditor, {
const wrapper = mountEditor({
props: {
annotation: {
id: 'colored-note',
@@ -176,7 +290,7 @@ describe('waveform annotation controls', () => {
})
it('supports modal dismissal and live character counting', async () => {
const editor = mount(WaveformAnnotationEditor, {
const editor = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
mode: 'edit',
@@ -184,23 +298,23 @@ describe('waveform annotation controls', () => {
})
expect(editor.get('[role="dialog"]').attributes('aria-modal')).toBe('true')
expect(editor.get('h2').text()).toBe('编辑标注')
expect(editor.get('.ant-modal-title').text()).toBe('编辑标注')
await editor.get('textarea').setValue('三字说明')
expect(editor.get('.waveform-annotation-editor__label-row').text()).toContain('4/40')
await editor.get('textarea').trigger('keydown', { key: 'Escape' })
await editor.get('.waveform-annotation-editor').trigger('click')
await editor.get('.ant-modal-wrap').trigger('click')
expect(editor.emitted('cancel')).toHaveLength(2)
})
it('supports cancellation and context menu actions', async () => {
const editor = mount(WaveformAnnotationEditor, {
const editor = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
mode: 'edit',
},
})
await editor.findAll('button')[0].trigger('click')
await editor.get('.ant-modal-close').trigger('click')
expect(editor.emitted('cancel')).toHaveLength(1)
const menu = mount(WaveformAnnotationContextMenu, {

View File

@@ -56,6 +56,20 @@ describe('waveform annotation markup', () => {
).toBeNull()
})
it('snaps outside and between samples to the nearest complete-data point', () => {
const points = [
{ x: 1, y: 10 },
{ x: 2, y: 20 },
{ x: 4, y: 40 },
]
expect(findNearestPointByX(points, -10)).toEqual(points[0])
expect(findNearestPointByX(points, 3.1)).toEqual(points[2])
expect(findNearestPointByX(points, 10)).toEqual(points[2])
expect(findNearestPointByX(points, 2)).toEqual(points[1])
expect(findNearestPointByX([{ x: 2, y: 20 }], 2.1)).toEqual({ x: 2, y: 20 })
expect(findNearestPointByX(points, Number.NaN)).toBeNull()
})
it('snaps annotations to nearest sample points while using interpolation for distance calculation', () => {
const track = createTrack(0, 'series', 0, [
{ x: 0, y: 0 },
@@ -89,14 +103,16 @@ describe('waveform annotation markup', () => {
expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 })
})
it('omits interpolated candidates for point-only series between samples', () => {
it('uses the nearest screen-space sample for point-only series', () => {
const pointOnly = createTrack(0, 'points', 0, [
{ x: 0, y: 2 },
{ x: 2, y: 10 },
])
pointOnly.series.lineType = 'none'
expect(findAnnotationSeriesCandidates([pointOnly], 1, 100, 50)).toEqual([])
expect(findAnnotationSeriesCandidates([pointOnly], 1, 5, 80)).toMatchObject([
{ point: { x: 0, y: 2 }, screenX: 0, screenY: 80, distance: 5 },
])
})
it('sorts line candidates by screen distance and keeps series metadata', () => {

View File

@@ -25,6 +25,25 @@ export const ANNOTATION_CONNECTOR_LENGTH = 32
const pointBisector = bisector((point: { x: number }) => point.x)
function findNearestPointOnScreen(
track: AnnotationTrackLayout,
pointerX: number,
pointerY: number,
): { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null {
let nearest: { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null =
null
for (const point of track.series.points) {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
const screenX = track.xScale(point.x)
const screenY = track.top + track.yScale(point.y)
const distance = Math.hypot(screenX - pointerX, screenY - pointerY)
if (!nearest || distance < nearest.distance) {
nearest = { point, screenX, screenY, distance }
}
}
return nearest
}
export function findNearestPointByX(
points: Array<{ x: number; y: number }>,
xValue: number,
@@ -74,6 +93,20 @@ export function findAnnotationSeriesCandidates(
): AnnotationSeriesCandidate[] {
return tracks
.flatMap((track): AnnotationSeriesCandidate[] => {
if (track.series.lineType === 'none') {
const nearest = findNearestPointOnScreen(track, pointerX, pointerY)
if (!nearest) return []
return [
{
trackIndex: track.index,
seriesId: track.series.id,
name: track.series.name?.trim() || track.series.id,
color: track.series.color || DEFAULT_ANNOTATION_STYLE.borderColor,
unit: track.series.unit,
...nearest,
},
]
}
const interpolatedPoint = interpolateAnnotationPoint(
track.series.points,
xValue,

View File

@@ -1,5 +1,5 @@
import { pointer, type ScaleLinear } from 'd3'
import type { ComputedRef, Ref } from 'vue'
import { ref, type ComputedRef, type Ref } from 'vue'
import type { WaveformAnnotation, WaveformInteractionMode } from '../data/types'
import { findClosestTrackAtPointer } from '../core/layout'
@@ -9,7 +9,7 @@ import {
ANNOTATION_AMBIGUITY_DISTANCE,
ANNOTATION_HIT_RADIUS,
findAnnotationSeriesCandidates,
interpolateAnnotationPoint,
findNearestPointByX,
} from './markup'
import type {
AnnotationEditorAnchor,
@@ -78,6 +78,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
activeInteractionMode,
isPresentationMode,
} = context
const timeError = ref('')
function toggleSeriesVisibility(seriesId: string) {
if (!chartSeries.value.some((series) => series.id === seriesId)) return
@@ -127,6 +128,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
anchor: AnnotationEditorAnchor,
candidates: AnnotationSeriesCandidate[],
) {
timeError.value = ''
annotationInteraction.openCreate(hit, makeAnnotationId, anchor)
editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value
@@ -140,7 +142,6 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
}
}
}
function changeDraftSeries(seriesId: string) {
if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value
@@ -149,24 +150,61 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
item.seriesList.some((series) => series.id === seriesId),
)
const series = track?.seriesList.find((item) => item.id === seriesId)
const point =
series && draft
? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType)
: null
if (!draft || !candidate || !track || !point) return
const validPoints = series?.points.filter(
(item) => Number.isFinite(item.x) && Number.isFinite(item.y),
)
const point = validPoints && draft ? findNearestPointByX(validPoints, draft.annotation.x) : null
if (!draft || !candidate || !track || !validPoints?.length) {
timeError.value = '当前波形没有有效数据'
return
}
if (draft.annotation.x < validPoints[0].x || draft.annotation.x > validPoints.at(-1)!.x) {
timeError.value = '时间超出当前波形范围'
return
}
timeError.value = ''
draft.annotation = {
...draft.annotation,
seriesId,
y: point.y,
y: point!.y,
style: { ...draft.annotation.style, borderColor: candidate.color },
}
}
function changeDraftTime(displayValue: string) {
if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value
const displayTime = Number(displayValue)
if (!draft || !Number.isFinite(displayTime)) {
timeError.value = '请输入有效的时间'
return
}
const rawTime = props.timeUnit === 'ms' ? displayTime / 1000 : displayTime
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === draft.annotation.seriesId),
)
const series = track?.seriesList.find((item) => item.id === draft.annotation.seriesId)
const validPoints = series?.points.filter(
(point) => Number.isFinite(point.x) && Number.isFinite(point.y),
)
if (!validPoints?.length) {
timeError.value = '当前波形没有有效数据'
return
}
const firstX = validPoints[0].x
const lastX = validPoints[validPoints.length - 1].x
if (rawTime < firstX || rawTime > lastX) {
timeError.value = '时间超出当前波形范围'
return
}
const point = findNearestPointByX(validPoints, rawTime)!
timeError.value = ''
draft.annotation = { ...draft.annotation, x: point.x, y: point.y }
}
function cancelAnnotation() {
timeError.value = ''
annotationInteraction.closeEditor()
editorSeriesOptions.value = []
}
function resolveTrackAtPointer(
pointerX: number,
pointerY: number,
@@ -292,6 +330,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
function editContextAnnotation() {
if (isPresentationMode.value) return
timeError.value = ''
const menu = annotationInteraction.contextMenu.value
const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
if (!annotation) return
@@ -328,7 +367,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
function confirmAnnotation(annotation: WaveformAnnotation) {
if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value
if (!draft) return
if (!draft || timeError.value) return
if (draft.mode === 'add') {
emit('update:annotations', [...props.annotations, annotation])
emit('annotation-create', annotation)
@@ -345,6 +384,8 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
return {
toggleSeriesVisibility,
changeDraftSeries,
changeDraftTime,
timeError,
cancelAnnotation,
resolveTrackAtPointer,
handleAnnotationClick,