2 Commits

Author SHA1 Message Date
李启源
7a6a7e5c26 feat(data): 扩展示例波形数据与测试覆盖
All checks were successful
Package component / package (push) Successful in 4m26s
新增示例谐波对比数据,完善图例、多轴和演示测试,并同步文档说明。
2026-08-17 14:34:15 +08:00
李启源
dda87f7508 feat(annotation): 优化标注编辑与交互体验
All checks were successful
Package component / package (push) Successful in 4m23s
标注编辑器支持时间输入与采样点吸附,完善标注交互、默认配色和示例图框样式。
2026-08-17 12:16:12 +08:00
24 changed files with 740 additions and 272 deletions

View File

@@ -96,37 +96,37 @@ const data = ref<WaveformData>({
### Props ### Props
| Prop | 类型 | 默认值 | 说明 | | Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- | | --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 | | `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | | `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | | `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 | | `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
| `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 | | `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 |
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 | | `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 | | `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 | | `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 | | `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 | | `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | | `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围(可超出数据,空白显示) |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 | | `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围(可超出数据) |
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | | `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 | | `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | | `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 | | `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 | | `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | | `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | | `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 | | `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID | | `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 | | `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries` 所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions` `WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
@@ -247,7 +247,7 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用 调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用
`AbortController` 取消过时的请求。 `AbortController` 取消过时的请求。
`initialXDomain` 固定首次完整数据的 X 轴缩放边界,不要将它改成后端返回的当前窗口;独立图框有不同时间范围时,可通过 `initialXDomain` 固定首次及重置时的 X 轴视口范围,不要将它改成后端返回的当前窗口;范围可以超出数据的实际时间,超出部分保留空白。独立图框有不同时间范围时,可通过
`initialXDomains` 按 track ID 或 series ID 分别配置。`minZoomSpan` 使用原始 X 数据单位, `initialXDomains` 按 track ID 或 series ID 分别配置。`minZoomSpan` 使用原始 X 数据单位,
可防止每次区间数据回填后重新累计放大。双击图框会 可防止每次区间数据回填后重新累计放大。双击图框会
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。 重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
@@ -415,7 +415,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐
### 图例与曲线显隐 ### 图例与曲线显隐
`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值, 每个图框独立管理自己的图例:图框内有两条或更多曲线时显示图例,只有一条曲线时不显示。
`legend.backgroundColor` 设置图例的背景颜色。该字段接受任意有效 CSS 颜色值,
可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度: 可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度:
```vue ```vue

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.36", "version": "0.1.38",
"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",

View File

@@ -235,7 +235,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
tracks.map((track) => tracks.map((track) =>
track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')), track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')),
), ),
).toEqual([['正弦基波'], ['谐波扰动'], ['阻尼振荡'], ['阶跃响应']]) ).toEqual([['正弦基波'], ['谐波扰动', '谐波对比'], ['阻尼振荡'], ['阶跃响应']])
expect( expect(
tracks tracks
.slice(1) .slice(1)
@@ -253,6 +253,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
expect(simulatedSeries.map((item) => item.name)).toEqual([ expect(simulatedSeries.map((item) => item.name)).toEqual([
'正弦基波', '正弦基波',
'谐波扰动', '谐波扰动',
'谐波对比',
'阻尼振荡', '阻尼振荡',
'阶跃响应', '阶跃响应',
'脉冲响应', '脉冲响应',
@@ -321,8 +322,8 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
const styleSelect = frameControls.findAllComponents(Select)[0] const styleSelect = frameControls.findAllComponents(Select)[0]
expect(colorPickers).toHaveLength(2) expect(colorPickers).toHaveLength(2)
expect(widthInput).toBeDefined() const initialFrames = wrapper.findAll('.waveform-chart__plot-frame')
expect(styleSelect).toBeDefined() expect(initialFrames.length > 1 && initialFrames.every((frame) => frame.attributes('stroke-width') === '2')).toBe(true)
colorPickers[0].vm.$emit('update:pureColor', 'rgba(220, 38, 38, 0.8)') colorPickers[0].vm.$emit('update:pureColor', 'rgba(220, 38, 38, 0.8)')
colorPickers[1].vm.$emit('update:pureColor', 'rgba(14, 165, 233, 0.25)') colorPickers[1].vm.$emit('update:pureColor', 'rgba(14, 165, 233, 0.25)')

View File

@@ -32,7 +32,7 @@ const overlayMode = ref<WaveformOverlayMode>('single-axis')
const rowCount = ref(4) const rowCount = ref(4)
const columnCount = ref(1) const columnCount = ref(1)
const frameBorderColor = ref('#1f2937') const frameBorderColor = ref('#1f2937')
const frameBorderWidth = ref(1) const frameBorderWidth = ref(2)
const frameBorderStyle = ref<NonNullable<WaveformFrameStyle['borderStyle']>>('solid') const frameBorderStyle = ref<NonNullable<WaveformFrameStyle['borderStyle']>>('solid')
const frameBackgroundColor = ref('rgba(255, 255, 255, 0)') const frameBackgroundColor = ref('rgba(255, 255, 255, 0)')
const frameWatermarkVisible = ref(true) const frameWatermarkVisible = ref(true)

View File

@@ -87,9 +87,11 @@ const {
contextMenu, contextMenu,
editorSeries, editorSeries,
editorSeriesOptions, editorSeriesOptions,
timeError,
confirmAnnotation, confirmAnnotation,
cancelAnnotation, cancelAnnotation,
changeDraftSeries, changeDraftSeries,
changeDraftTime,
editContextAnnotation, editContextAnnotation,
deleteContextAnnotation, deleteContextAnnotation,
chartHeight, chartHeight,
@@ -98,6 +100,11 @@ const {
setTitleMeasureElement, setTitleMeasureElement,
setSharedOverlayElement, setSharedOverlayElement,
} = toRefs(props.controller) } = toRefs(props.controller)
function handleChartPointerLeave() {
pointerInsideChart.value = false
handlePointerLeave.value()
}
</script> </script>
<template> <template>
@@ -123,7 +130,7 @@ const {
:data-plot-margin-bottom="resolvedPlotMargin.bottom" :data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight" :data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true" @pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false" @pointerleave="handleChartPointerLeave"
@contextmenu.capture="handleNativeContextMenu" @contextmenu.capture="handleNativeContextMenu"
> >
<div <div
@@ -332,9 +339,11 @@ const {
:series="editorSeries" :series="editorSeries"
:series-options="editorSeriesOptions" :series-options="editorSeriesOptions"
:time-unit="timeUnit" :time-unit="timeUnit"
:time-error="timeError"
@confirm="confirmAnnotation" @confirm="confirmAnnotation"
@cancel="cancelAnnotation" @cancel="cancelAnnotation"
@series-change="changeDraftSeries" @series-change="changeDraftSeries"
@time-change="changeDraftTime"
/> />
<WaveformAnnotationContextMenu <WaveformAnnotationContextMenu

View File

@@ -1,63 +1,8 @@
.waveform-annotation-editor { .waveform-annotation-editor__content {
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 {
display: grid; display: grid;
gap: 20px; gap: 20px;
width: min(440px, 100%);
max-height: 100%;
overflow-y: auto;
padding: 22px;
color: #344054; color: #344054;
font-size: 13px; 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 { .waveform-annotation-editor__coordinates {
display: grid; display: grid;
@@ -78,6 +23,39 @@
.waveform-annotation-editor__coordinates span { .waveform-annotation-editor__coordinates span {
color: #667085; 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 { .waveform-annotation-editor__coordinates b {
color: #1677ff; color: #1677ff;
font-size: 11px; font-size: 11px;
@@ -205,56 +183,9 @@
border-color: #98a2b3; border-color: #98a2b3;
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%); 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) { @media (max-width: 420px) {
.waveform-annotation-editor { .waveform-annotation-editor__content {
padding: 12px;
}
.waveform-annotation-editor__panel {
gap: 16px; gap: 16px;
padding: 18px;
} }
.waveform-annotation-editor__coordinates { .waveform-annotation-editor__coordinates {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

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

View File

@@ -1,28 +1,93 @@
import { flushPromises, mount } from '@vue/test-utils' 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 { ColorPicker } from 'vue3-colorpicker'
import { defineComponent, h } from 'vue'
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'
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', () => { describe('waveform annotation controls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('keeps dialog title ids unique across editor instances', () => { it('keeps dialog title ids unique across editor instances', () => {
const first = mount(WaveformAnnotationEditor, { const first = mountEditor({
props: { props: {
annotation: { id: 'first', seriesId: 'a', x: 1, y: 2, text: '说明' }, annotation: { id: 'first', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit', mode: 'edit',
}, },
}) })
const second = mount(WaveformAnnotationEditor, { const second = mountEditor({
props: { props: {
annotation: { id: 'second', seriesId: 'a', x: 1, y: 2, text: '说明' }, annotation: { id: 'second', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit', mode: 'edit',
}, },
}) })
const firstTitleId = first.get('h2').attributes('id') const firstTitleId = first.get('.ant-modal-title [id]').attributes('id')
const secondTitleId = second.get('h2').attributes('id') const secondTitleId = second.get('.ant-modal-title [id]').attributes('id')
expect(firstTitleId).toBeTruthy() expect(firstTitleId).toBeTruthy()
expect(secondTitleId).toBeTruthy() expect(secondTitleId).toBeTruthy()
@@ -31,8 +96,19 @@ describe('waveform annotation controls', () => {
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId) 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 () => { it('allows changing the annotation series inside the editor', async () => {
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '说明' }, annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit', mode: 'edit',
@@ -77,17 +153,15 @@ describe('waveform annotation controls', () => {
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 = mountEditor({
props: { annotation, mode: 'add' }, props: { annotation, mode: 'add' },
}) })
await flushPromises() await flushPromises()
expect(wrapper.get('textarea').attributes('maxlength')).toBe('40') expect(wrapper.get('textarea').attributes('maxlength')).toBe('40')
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain( expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('X1000.000')
'X (ms)1000.000',
)
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('Y2') 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), { await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), {
timeout: 5000, timeout: 5000,
}) })
@@ -113,7 +187,7 @@ describe('waveform annotation controls', () => {
colorPickers[1].vm.$emit('update:pureColor', 'rgba(51, 51, 51, 0.8)') colorPickers[1].vm.$emit('update:pureColor', 'rgba(51, 51, 51, 0.8)')
colorPickers[2].vm.$emit('update:pureColor', 'rgba(255, 255, 255, 0.5)') colorPickers[2].vm.$emit('update:pureColor', 'rgba(255, 255, 255, 0.5)')
await wrapper.vm.$nextTick() 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 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', () => { it('formats annotation coordinates using the selected display context', () => {
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 0.0000001, text: '说明' }, annotation: { id: 'note', seriesId: 'series', x: 1, y: 0.0000001, text: '说明' },
mode: 'edit', mode: 'edit',
@@ -142,13 +216,53 @@ describe('waveform annotation controls', () => {
}) })
const coordinates = wrapper.get('.waveform-annotation-editor__coordinates').text() 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).toContain('Y0.0000001')
expect(coordinates).not.toContain('e-') 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 () => { it('hydrates hexadecimal and rgba annotation colors', async () => {
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { props: {
annotation: { annotation: {
id: 'colored-note', id: 'colored-note',
@@ -176,7 +290,7 @@ describe('waveform annotation controls', () => {
}) })
it('supports modal dismissal and live character counting', async () => { it('supports modal dismissal and live character counting', async () => {
const editor = mount(WaveformAnnotationEditor, { const editor = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' }, annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
mode: 'edit', mode: 'edit',
@@ -184,23 +298,23 @@ describe('waveform annotation controls', () => {
}) })
expect(editor.get('[role="dialog"]').attributes('aria-modal')).toBe('true') 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('三字说明') await editor.get('textarea').setValue('三字说明')
expect(editor.get('.waveform-annotation-editor__label-row').text()).toContain('4/40') expect(editor.get('.waveform-annotation-editor__label-row').text()).toContain('4/40')
await editor.get('textarea').trigger('keydown', { key: 'Escape' }) 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) expect(editor.emitted('cancel')).toHaveLength(2)
}) })
it('supports cancellation and context menu actions', async () => { it('supports cancellation and context menu actions', async () => {
const editor = mount(WaveformAnnotationEditor, { const editor = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' }, annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
mode: 'edit', mode: 'edit',
}, },
}) })
await editor.findAll('button')[0].trigger('click') await editor.get('.ant-modal-close').trigger('click')
expect(editor.emitted('cancel')).toHaveLength(1) expect(editor.emitted('cancel')).toHaveLength(1)
const menu = mount(WaveformAnnotationContextMenu, { const menu = mount(WaveformAnnotationContextMenu, {

View File

@@ -56,6 +56,20 @@ describe('waveform annotation markup', () => {
).toBeNull() ).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', () => { it('snaps annotations to nearest sample points while using interpolation for distance calculation', () => {
const track = createTrack(0, 'series', 0, [ const track = createTrack(0, 'series', 0, [
{ x: 0, y: 0 }, { x: 0, y: 0 },
@@ -89,14 +103,16 @@ describe('waveform annotation markup', () => {
expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 }) 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, [ const pointOnly = createTrack(0, 'points', 0, [
{ x: 0, y: 2 }, { x: 0, y: 2 },
{ x: 2, y: 10 }, { x: 2, y: 10 },
]) ])
pointOnly.series.lineType = 'none' 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', () => { 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) 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( export function findNearestPointByX(
points: Array<{ x: number; y: number }>, points: Array<{ x: number; y: number }>,
xValue: number, xValue: number,
@@ -74,6 +93,20 @@ export function findAnnotationSeriesCandidates(
): AnnotationSeriesCandidate[] { ): AnnotationSeriesCandidate[] {
return tracks return tracks
.flatMap((track): AnnotationSeriesCandidate[] => { .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( const interpolatedPoint = interpolateAnnotationPoint(
track.series.points, track.series.points,
xValue, xValue,

View File

@@ -1,5 +1,5 @@
import { pointer, type ScaleLinear } from 'd3' 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 type { WaveformAnnotation, WaveformInteractionMode } from '../data/types'
import { findClosestTrackAtPointer } from '../core/layout' import { findClosestTrackAtPointer } from '../core/layout'
@@ -9,7 +9,7 @@ import {
ANNOTATION_AMBIGUITY_DISTANCE, ANNOTATION_AMBIGUITY_DISTANCE,
ANNOTATION_HIT_RADIUS, ANNOTATION_HIT_RADIUS,
findAnnotationSeriesCandidates, findAnnotationSeriesCandidates,
interpolateAnnotationPoint, findNearestPointByX,
} from './markup' } from './markup'
import type { import type {
AnnotationEditorAnchor, AnnotationEditorAnchor,
@@ -78,6 +78,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
activeInteractionMode, activeInteractionMode,
isPresentationMode, isPresentationMode,
} = context } = context
const timeError = ref('')
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
@@ -127,6 +128,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
anchor: AnnotationEditorAnchor, anchor: AnnotationEditorAnchor,
candidates: AnnotationSeriesCandidate[], candidates: AnnotationSeriesCandidate[],
) { ) {
timeError.value = ''
annotationInteraction.openCreate(hit, makeAnnotationId, anchor) annotationInteraction.openCreate(hit, makeAnnotationId, anchor)
editorSeriesOptions.value = candidates editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
@@ -140,7 +142,6 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
} }
} }
function changeDraftSeries(seriesId: string) { function changeDraftSeries(seriesId: string) {
if (isPresentationMode.value) return if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
@@ -149,24 +150,61 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
item.seriesList.some((series) => series.id === seriesId), item.seriesList.some((series) => series.id === seriesId),
) )
const series = track?.seriesList.find((item) => item.id === seriesId) const series = track?.seriesList.find((item) => item.id === seriesId)
const point = const validPoints = series?.points.filter(
series && draft (item) => Number.isFinite(item.x) && Number.isFinite(item.y),
? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType) )
: null const point = validPoints && draft ? findNearestPointByX(validPoints, draft.annotation.x) : null
if (!draft || !candidate || !track || !point) return 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 = {
...draft.annotation, ...draft.annotation,
seriesId, seriesId,
y: point.y, y: point!.y,
style: { ...draft.annotation.style, borderColor: candidate.color }, 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() { function cancelAnnotation() {
timeError.value = ''
annotationInteraction.closeEditor() annotationInteraction.closeEditor()
editorSeriesOptions.value = [] editorSeriesOptions.value = []
} }
function resolveTrackAtPointer( function resolveTrackAtPointer(
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@@ -292,6 +330,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
function editContextAnnotation() { function editContextAnnotation() {
if (isPresentationMode.value) return if (isPresentationMode.value) return
timeError.value = ''
const menu = annotationInteraction.contextMenu.value const menu = annotationInteraction.contextMenu.value
const annotation = props.annotations.find((item) => item.id === menu?.annotationId) const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
if (!annotation) return if (!annotation) return
@@ -328,7 +367,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
function confirmAnnotation(annotation: WaveformAnnotation) { function confirmAnnotation(annotation: WaveformAnnotation) {
if (isPresentationMode.value) return if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
if (!draft) return if (!draft || timeError.value) return
if (draft.mode === 'add') { if (draft.mode === 'add') {
emit('update:annotations', [...props.annotations, annotation]) emit('update:annotations', [...props.annotations, annotation])
emit('annotation-create', annotation) emit('annotation-create', annotation)
@@ -345,6 +384,8 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
return { return {
toggleSeriesVisibility, toggleSeriesVisibility,
changeDraftSeries, changeDraftSeries,
changeDraftTime,
timeError,
cancelAnnotation, cancelAnnotation,
resolveTrackAtPointer, resolveTrackAtPointer,
handleAnnotationClick, handleAnnotationClick,

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { channelColors } from './constants'
describe('channelColors', () => {
it('contains the referenced dark palette followed by twenty supplemental colors', () => {
expect(channelColors).toHaveLength(30)
expect(channelColors.slice(0, 10)).toEqual([
'#0960bd',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
])
expect(new Set(channelColors).size).toBe(channelColors.length)
})
it('wraps around after the thirtieth color for automatic series assignment', () => {
expect(channelColors[0]).toBe('#0960bd')
expect(channelColors[29]).toBe('#31572c')
expect(channelColors[30 % channelColors.length]).toBe(channelColors[0])
expect(channelColors[31 % channelColors.length]).toBe(channelColors[1])
})
})

View File

@@ -125,19 +125,41 @@ export const TITLE_LINE_HEIGHT = 1.2
// ==================== 样式常量 ==================== // ==================== 样式常量 ====================
/** /**
* 通道默认颜色列表 * 多系列波形的默认颜色列表
*
* 颜色保持较深并具备足够辨识度,避免大量系列同时绘制时使用浅色导致线条难以观察。
*/ */
export const channelColors = [ export const channelColors = [
'#0960bd', '#0960bd',
'#ff7f0e', '#ff7f0e',
'#389e0d', '#2ca02c',
'#cf1322', '#d62728',
'#531dab', '#9467bd',
'#08979c', '#8c564b',
'#c41d7f', '#e377c2',
'#434343', '#7f7f7f',
'#7cb305', '#bcbd22',
'#1d39c4', '#17becf',
'#005f73',
'#0a9396',
'#ae2012',
'#bb3e03',
'#ca6702',
'#ee9b00',
'#9b2226',
'#3a0ca3',
'#4361ee',
'#4895ef',
'#560bad',
'#7209b7',
'#b5179e',
'#f72585',
'#006d77',
'#2f3e46',
'#354f52',
'#52796f',
'#800f2f',
'#31572c',
] ]
/** /**

View File

@@ -1,10 +1,18 @@
import { flushPromises } from '@vue/test-utils' import { DOMWrapper, flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { flushAnimationFrames } from '../../test/setup' import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart' import { mountSizedChart } from '../../test/waveformChart'
function getAnnotationEditor() {
const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1)
if (!editor) throw new Error('Expected annotation editor modal to be mounted')
return new DOMWrapper(editor)
}
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('edits and immediately deletes existing annotations without mutating props', async () => { it('edits and immediately deletes existing annotations without mutating props', async () => {
const sourceAnnotation = { const sourceAnnotation = {
@@ -31,8 +39,10 @@ describe('WaveformChart', () => {
}) })
await wrapper.get('.waveform-annotation-context-menu button').trigger('click') await wrapper.get('.waveform-annotation-context-menu button').trigger('click')
await flushPromises() await flushPromises()
await wrapper.get('textarea[aria-label="标注文本"]').setValue('新文字') const editor = getAnnotationEditor()
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await editor.get('textarea[aria-label="标注文本"]').setValue('新文字')
await editor.get('button.ant-btn-primary').trigger('click')
await flushPromises()
const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
Array<{ text: string }> | undefined Array<{ text: string }> | undefined

View File

@@ -189,6 +189,46 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
}) })
it('clears hover when the pointer leaves the chart and restores it only after a new plot move', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
},
{ grid: { rowCount: 1, columnCount: 1 } },
)
const overlay = wrapper.get('.waveform-chart__overlay')
const overlayWidth = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
})
overlay.element.dispatchEvent(
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
await wrapper.trigger('pointerleave')
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
await wrapper.trigger('pointerenter')
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
overlay.element.dispatchEvent(
new MouseEvent('pointermove', { clientX: 0, clientY: 120, bubbles: true }),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
})
it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => { it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {

View File

@@ -1,10 +1,18 @@
import { flushPromises } from '@vue/test-utils' import { DOMWrapper, flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { flushAnimationFrames, pendingAnimationFrameCount } from '../../test/setup' import { flushAnimationFrames, pendingAnimationFrameCount } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart' import { mountSizedChart } from '../../test/waveformChart'
function getAnnotationEditor() {
const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1)
if (!editor) throw new Error('Expected annotation editor modal to be mounted')
return new DOMWrapper(editor)
}
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('zooms only the active independent track and resets when the mode changes', async () => { it('zooms only the active independent track and resets when the mode changes', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
@@ -172,9 +180,10 @@ describe('WaveformChart', () => {
) )
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) const editor = getAnnotationEditor()
await wrapper.get('textarea[aria-label="标注文本"]').setValue('峰值点') await editor.get('textarea[aria-label="标注文本"]').setValue('峰值点')
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await editor.get('button.ant-btn-primary').trigger('click')
await flushPromises()
const annotations = wrapper.emitted('update:annotations')?.at(-1)?.[0] as const annotations = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
Array<{ seriesId: string; x: number; y: number; text: string }> | undefined Array<{ seriesId: string; x: number; y: number; text: string }> | undefined
@@ -209,10 +218,10 @@ describe('WaveformChart', () => {
}), }),
) )
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) const editor = getAnnotationEditor()
expect(wrapper.get('.waveform-annotation-editor').attributes('aria-modal')).toBe('true') expect(editor.attributes('role')).toBe('dialog')
expect(wrapper.find('.waveform-annotation-editor__panel').exists()).toBe(true) expect(editor.find('.waveform-annotation-editor__content').exists()).toBe(true)
const textarea = wrapper.get('textarea[aria-label="标注文本"]') const textarea = editor.get('textarea[aria-label="标注文本"]')
const textareaContextMenu = new MouseEvent('contextmenu', { const textareaContextMenu = new MouseEvent('contextmenu', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
@@ -220,7 +229,8 @@ describe('WaveformChart', () => {
expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true) expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true)
expect(textareaContextMenu.defaultPrevented).toBe(false) expect(textareaContextMenu.defaultPrevented).toBe(false)
await textarea.setValue('右键标注') await textarea.setValue('右键标注')
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await editor.get('button.ant-btn-primary').trigger('click')
await flushPromises()
// Annotation snaps to nearest sample point (x=1, y=5) // Annotation snaps to nearest sample point (x=1, y=5)
expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([ expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([
@@ -278,20 +288,20 @@ describe('WaveformChart', () => {
) )
await flushPromises() await flushPromises()
expect( expect(
wrapper getAnnotationEditor()
.find('select[aria-label="选择标注波形"]') .get('select[aria-label="选择标注波形"]')
.findAll('option') .findAll('option')
.map((item) => item.text()), .map((item) => item.text()),
).toEqual(['通道 A']) ).toEqual(['通道 A'])
await wrapper.get('button[aria-label="关闭标注编辑器"]').trigger('click') await getAnnotationEditor().get('.ant-modal-close').trigger('click')
overlays[0].element.dispatchEvent( overlays[0].element.dispatchEvent(
new MouseEvent('contextmenu', { clientX: 356, clientY: 230, bubbles: true }), new MouseEvent('contextmenu', { clientX: 356, clientY: 230, bubbles: true }),
) )
await flushPromises() await flushPromises()
expect( expect(
wrapper getAnnotationEditor()
.find('select[aria-label="选择标注波形"]') .get('select[aria-label="选择标注波形"]')
.findAll('option') .findAll('option')
.map((item) => item.text()), .map((item) => item.text()),
).toEqual(['通道 B']) ).toEqual(['通道 B'])

View File

@@ -7,14 +7,39 @@ import WaveformChartView from '../WaveformChartView.vue'
import { gridSeries, mountSizedChart, visibilitySeries } from '../../test/waveformChart' import { gridSeries, mountSizedChart, visibilitySeries } from '../../test/waveformChart'
function annotationEditorExists() {
return Boolean(
Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1),
)
}
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('shows legends when a track contains at least two series', async () => {
const oneSeries = visibilitySeries()
if (oneSeries.kind === 'series') oneSeries.series = oneSeries.series.slice(0, 1)
const withoutLegend = await mountSizedChart(oneSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withoutLegend.findAll('.waveform-chart__legend')).toHaveLength(0)
const twoSeries = visibilitySeries()
if (twoSeries.kind === 'series') twoSeries.series = twoSeries.series.slice(0, 2)
const withLegend = await mountSizedChart(twoSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withLegend.findAll('.waveform-chart__legend')).toHaveLength(1)
expect(withLegend.find('.waveform-chart__legend').attributes('data-position')).toBe('top-right')
})
it('resolves legend positions by stable track id across pages', async () => { it('resolves legend positions by stable track id across pages', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
series: Array.from({ length: 8 }, (_, index) => ({ series: Array.from({ length: 12 }, (_, index) => ({
id: `series-${index}`, id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`, trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`, name: `series ${index}`,
data: { data: {
kind: 'points' as const, kind: 'points' as const,
@@ -67,9 +92,9 @@ describe('WaveformChart', () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
series: Array.from({ length: 4 }, (_, index) => ({ series: Array.from({ length: 6 }, (_, index) => ({
id: `series-${index}`, id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`, trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`, name: `series ${index}`,
data: { data: {
kind: 'points', kind: 'points',
@@ -104,7 +129,7 @@ describe('WaveformChart', () => {
}) })
const items = wrapper.findAll('.waveform-chart__legend-item') const items = wrapper.findAll('.waveform-chart__legend-item')
expect(items).toHaveLength(2) expect(items).toHaveLength(3)
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true) expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain( expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain(
'waveform-legend__panel--interactive', 'waveform-legend__panel--interactive',
@@ -120,7 +145,7 @@ describe('WaveformChart', () => {
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
}) })
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
const annotationLayer = wrapper.get('.waveform-annotation-layer').element const annotationLayer = wrapper.get('.waveform-annotation-layer').element
const legendLayer = wrapper.get('.waveform-chart__legend-layer').element const legendLayer = wrapper.get('.waveform-chart__legend-layer').element
@@ -135,8 +160,8 @@ describe('WaveformChart', () => {
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden') expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden')
expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe( expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe(
@@ -162,12 +187,12 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程') expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程')
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([ expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
{ seriesId: 'high', visible: true, hiddenSeriesIds: [] }, { seriesId: 'high', visible: true, hiddenSeriesIds: [] },
@@ -183,20 +208,20 @@ describe('WaveformChart', () => {
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([ expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([
['high', 'temporarily-absent', 'low'], ['high', 'temporarily-absent', 'low'],
]) ])
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
await wrapper.setProps({ hiddenSeriesIds: ['low'] }) await wrapper.setProps({ hiddenSeriesIds: ['low'] })
await flushPromises() await flushPromises()
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['high']) ).toEqual(['high', 'mid'])
}) })
it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => { it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => {
@@ -215,7 +240,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['mid', 'low'])
await wrapper.setProps({ await wrapper.setProps({
data: { data: {
@@ -226,12 +251,12 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
await wrapper.setProps({ data: original }) await wrapper.setProps({ data: original })
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
}) })
it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => { it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => {
const wrapper = await mountSizedChart(visibilitySeries(), { const wrapper = await mountSizedChart(visibilitySeries(), {
defaultHiddenSeriesIds: ['low', 'high'], defaultHiddenSeriesIds: ['low', 'high', 'mid'],
grid: { rowCount: 1, columnCount: 1 }, grid: { rowCount: 1, columnCount: 1 },
legend: { interactive: true }, legend: { interactive: true },
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
@@ -240,7 +265,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(3)
expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线') expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线')
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
@@ -273,7 +298,7 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) expect(annotationEditorExists()).toBe(true)
const controller = wrapper.getComponent(WaveformChartView).props('controller') as unknown as { const controller = wrapper.getComponent(WaveformChartView).props('controller') as unknown as {
annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } } annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } }
@@ -287,7 +312,7 @@ describe('WaveformChart', () => {
expect(item).toBeDefined() expect(item).toBeDefined()
await item!.trigger('click') await item!.trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false) expect(annotationEditorExists()).toBe(false)
}) })
it('renders independent cells with separate x axes and overlays', async () => { it('renders independent cells with separate x axes and overlays', async () => {

View File

@@ -177,6 +177,18 @@ describe('WaveformChart', () => {
], ],
}, },
}, },
{
id: 'reference',
trackId: 'frame-1',
name: 'REF_CH_1',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.25 },
{ x: 1, y: 1.25 },
],
},
},
], ],
}, },
{ frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } }, { frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } },
@@ -186,7 +198,7 @@ describe('WaveformChart', () => {
expect(tracks).toHaveLength(2) expect(tracks).toHaveLength(2)
expect( expect(
tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['primary', 'comparison']) ).toEqual(['primary', 'comparison', 'reference'])
expect( expect(
tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['second-frame']) ).toEqual(['second-frame'])
@@ -203,12 +215,13 @@ describe('WaveformChart', () => {
expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([ expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([
'BT2_2M', 'BT2_2M',
'TEST_CH_1', 'TEST_CH_1',
'REF_CH_1',
]) ])
expect( expect(
legend legend
.findAll('.waveform-legend__swatch') .findAll('.waveform-legend__swatch')
.map((swatch) => swatch.get('path').attributes('stroke')), .map((swatch) => swatch.get('path').attributes('stroke')),
).toEqual(['#0960bd', '#389e0d']) ).toEqual(['#0960bd', '#2ca02c', '#d62728'])
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([ expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
'1', '1',
'2', '2',
@@ -232,10 +245,11 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series') const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
expect(tooltipSeries).toHaveLength(2) expect(tooltipSeries).toHaveLength(3)
expect(tooltipSeries.map((item) => item.text())).toEqual([ expect(tooltipSeries.map((item) => item.text())).toEqual([
expect.stringContaining('BT2_2M'), expect.stringContaining('BT2_2M'),
expect.stringContaining('TEST_CH_1'), expect.stringContaining('TEST_CH_1'),
expect.stringContaining('REF_CH_1'),
]) ])
}) })
@@ -268,6 +282,18 @@ describe('WaveformChart', () => {
], ],
}, },
}, },
{
id: 'third',
trackId: 'shared',
name: 'third',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
], ],
}, },
{ grid: { rowCount: 1, columnCount: 1 } }, { grid: { rowCount: 1, columnCount: 1 } },

View File

@@ -5,6 +5,14 @@ import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart' import { mountSizedChart } from '../../test/waveformChart'
import type { WaveformData, WaveformDisplayMode } from '../data/types' import type { WaveformData, WaveformDisplayMode } from '../data/types'
function annotationEditorExists() {
return Boolean(
Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1),
)
}
const presentationData: WaveformData = { const presentationData: WaveformData = {
kind: 'series', kind: 'series',
series: [ series: [
@@ -34,6 +42,19 @@ const presentationData: WaveformData = {
], ],
}, },
}, },
{
id: 'mid',
trackId: 'shared',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 50 },
{ x: 1, y: 100 },
{ x: 2, y: 75 },
],
},
},
{ {
id: 'other', id: 'other',
name: '其他通道', name: '其他通道',
@@ -142,9 +163,9 @@ describe('WaveformChart presentation mode', () => {
cancelable: true, cancelable: true,
}), }),
) )
wrapper.get('.waveform-chart__svg').element.dispatchEvent( wrapper
new MouseEvent('dblclick', { bubbles: true, cancelable: true }), .get('.waveform-chart__svg')
) .element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', { await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', {
clientX: width / 2, clientX: width / 2,
clientY: height / 2, clientY: height / 2,
@@ -282,13 +303,13 @@ describe('WaveformChart presentation mode', () => {
}), }),
) )
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) expect(annotationEditorExists()).toBe(true)
await wrapper.setProps({ presentationMode: true }) await wrapper.setProps({ presentationMode: true })
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false) expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false) expect(annotationEditorExists()).toBe(false)
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
await wrapper.setProps({ presentationMode: false }) await wrapper.setProps({ presentationMode: false })

View File

@@ -181,7 +181,7 @@ describe('WaveformChart', () => {
]) ])
expect(stepSwatchPaths[0]?.attributes()).toMatchObject({ expect(stepSwatchPaths[0]?.attributes()).toMatchObject({
d: 'M1 8H25', d: 'M1 8H25',
stroke: '#389e0d', stroke: '#2ca02c',
'stroke-width': '1.5', 'stroke-width': '1.5',
}) })
expect(stepSwatchPaths[1]?.attributes()).toMatchObject({ expect(stepSwatchPaths[1]?.attributes()).toMatchObject({

View File

@@ -93,4 +93,64 @@ describe('WaveformChart x domain strategy', () => {
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('10000') expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('10000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('15000') expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('15000')
}) })
it('keeps an explicit viewport wider than the data and leaves uncovered time blank', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 10, y: 1 },
],
},
{ timeUnit: 's', initialXDomain: [-5, 20] },
)
const track = wrapper.get('.waveform-chart__track')
const width = Number(track.attributes('data-track-width'))
const path = track.get('.waveform-chart__line').attributes('d') ?? ''
const xCoordinates = Array.from(path.matchAll(/(?:M|L)([\d.-]+)/g)).map((match) =>
Number(match[1]),
)
expect(track.get('.waveform-chart__axis-endpoint--start').text()).toBe('-5')
expect(track.get('.waveform-chart__axis-endpoint--end').text()).toBe('20')
expect(xCoordinates.length).toBeGreaterThan(0)
expect(Math.min(...xCoordinates)).toBeGreaterThanOrEqual(0)
expect(Math.max(...xCoordinates)).toBeLessThanOrEqual(width)
})
it('applies independent explicit viewports per track, including empty time regions', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'first',
name: '第一轨',
data: { kind: 'points', points: [{ x: 0, y: 0 }, { x: 10, y: 1 }] },
},
{
id: 'second',
name: '第二轨',
data: { kind: 'points', points: [{ x: 100, y: 0 }, { x: 110, y: 1 }] },
},
],
},
{
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
timeUnit: 's',
initialXDomains: { first: [-5, 20], second: [95, 120] },
},
)
const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-5')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('20')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('95')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('120')
expect(tracks[0]?.find('.waveform-chart__line').exists()).toBe(true)
expect(tracks[1]?.find('.waveform-chart__line').exists()).toBe(true)
})
}) })

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createSimulatedWaveformData } from './simulatedWaveforms' import { createSimulatedWaveformData } from './simulatedWaveforms'
describe('simulated waveform data', () => { describe('simulated waveform data', () => {
it('creates deterministic, finite six-channel data', () => { it('creates deterministic, finite seven-channel data with a two-series second frame', () => {
const first = createSimulatedWaveformData() const first = createSimulatedWaveformData()
const second = createSimulatedWaveformData() const second = createSimulatedWaveformData()
@@ -11,8 +11,12 @@ describe('simulated waveform data', () => {
expect(first.kind).toBe('series') expect(first.kind).toBe('series')
if (first.kind !== 'series') return if (first.kind !== 'series') return
expect(first.series).toHaveLength(6) expect(first.series).toHaveLength(7)
expect(new Set(first.series.map((series) => series.id)).size).toBe(6) expect(new Set(first.series.map((series) => series.id)).size).toBe(7)
const secondFrame = first.series.filter(
(series) => series.trackId === 'simulated-harmonic-frame',
)
expect(secondFrame).toHaveLength(2)
expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300'])) expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300']))
first.series.forEach((series) => { first.series.forEach((series) => {
expect(series.data.kind).toBe('points') expect(series.data.kind).toBe('points')

View File

@@ -13,7 +13,7 @@ type ErrorGenerator = (
interface SimulatedSeriesDefinition extends Pick< interface SimulatedSeriesDefinition extends Pick<
WaveformSeries, WaveformSeries,
'id' | 'shotNo' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar' 'id' | 'trackId' | 'shotNo' | 'name' | 'unit' | 'color' | 'lineType' | 'pointType' | 'errorBar'
> { > {
signal: SignalGenerator signal: SignalGenerator
errors?: ErrorGenerator errors?: ErrorGenerator
@@ -60,6 +60,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
}, },
{ {
id: 'simulated-harmonic', id: 'simulated-harmonic',
trackId: 'simulated-harmonic-frame',
shotNo: '13300', shotNo: '13300',
name: '谐波扰动', name: '谐波扰动',
unit: 'V', unit: 'V',
@@ -68,6 +69,18 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
signal: (time) => signal: (time) =>
0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4), 0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4),
}, },
{
id: 'simulated-harmonic-reference',
trackId: 'simulated-harmonic-frame',
shotNo: '13300',
name: '谐波对比',
unit: 'V',
color: '#2ca02c',
lineType: 'linear',
pointType: 'none',
signal: (time) =>
0.62 * Math.sin(TWO_PI * 0.55 * time + 0.55) + 0.18 * Math.sin(TWO_PI * 2.2 * time - 0.2),
},
{ {
id: 'simulated-damped', id: 'simulated-damped',
shotNo: '13300', shotNo: '13300',

View File

@@ -58,6 +58,18 @@ export function visibilitySeries(): WaveformData {
], ],
}, },
}, },
{
id: 'mid',
trackId: 'shared-frame',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 100 },
{ x: 1, y: 200 },
],
},
},
], ],
} }
} }