Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6a7e5c26 | ||
|
|
dda87f7508 | ||
|
|
3692e21dbc | ||
|
|
54477ecbbd |
@@ -15,14 +15,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
server_url="${{ gitea.server_url }}"
|
server_url="${{ gitea.server_url }}"
|
||||||
repository_url="${server_url%/}/${{ gitea.repository }}.git"
|
|
||||||
tag_name="${{ gitea.ref_name }}"
|
tag_name="${{ gitea.ref_name }}"
|
||||||
git init .
|
archive_url="${server_url%/}/api/v1/repos/${{ gitea.repository }}/archive/$tag_name.tar.gz"
|
||||||
git remote add origin "$repository_url"
|
curl --fail --show-error --silent --location "$archive_url" --output source.tar.gz
|
||||||
# Gitea can return an invalid shallow pack for tag-only fetches.
|
mkdir source
|
||||||
# Fetch the tag without depth limiting and force protocol v0 for compatibility.
|
tar -xzf source.tar.gz --strip-components=1 -C source
|
||||||
git -c protocol.version=0 fetch --no-tags origin "refs/tags/$tag_name:refs/tags/$tag_name"
|
cp -a source/. .
|
||||||
git checkout --detach "$tag_name^{commit}"
|
rm -rf source source.tar.gz
|
||||||
|
|
||||||
- name: Set up pnpm
|
- name: Set up pnpm
|
||||||
run: |
|
run: |
|
||||||
@@ -216,7 +215,7 @@ jobs:
|
|||||||
: "${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
|
: "${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
|
||||||
server_url="${{ gitea.server_url }}"
|
server_url="${{ gitea.server_url }}"
|
||||||
api_url="$server_url/api/v1/repos/${{ gitea.repository }}/releases"
|
api_url="$server_url/api/v1/repos/${{ gitea.repository }}/releases"
|
||||||
target_commitish="$(git rev-parse HEAD)"
|
target_commitish="$TAG_NAME"
|
||||||
|
|
||||||
node - "$TAG_NAME" "$PACKAGE_VERSION" "$target_commitish" "$IS_PRERELEASE" > release.json <<'NODE'
|
node - "$TAG_NAME" "$PACKAGE_VERSION" "$target_commitish" "$IS_PRERELEASE" > release.json <<'NODE'
|
||||||
const [tagName, packageVersion, targetCommitish, prerelease] = process.argv.slice(2)
|
const [tagName, packageVersion, targetCommitish, prerelease] = process.argv.slice(2)
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -97,7 +97,7 @@ 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 轴模式 |
|
||||||
@@ -109,8 +109,8 @@ const data = ref<WaveformData>({
|
|||||||
| `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 配置固定范围 |
|
||||||
@@ -163,8 +163,8 @@ const points: WaveformData = {
|
|||||||
const chartData: WaveformData = {
|
const chartData: WaveformData = {
|
||||||
kind: 'series',
|
kind: 'series',
|
||||||
series: [
|
series: [
|
||||||
{ id: 'ch-a', name: '通道 A', trackId: 'group-1', data: samples },
|
{ id: 'ch-a', shotNo: '13300', name: '通道 A', trackId: 'group-1', data: samples },
|
||||||
{ id: 'ch-b', name: '通道 B', trackId: 'group-1', data: points },
|
{ id: 'ch-b', shotNo: '13300', name: '通道 B', trackId: 'group-1', data: points },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -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`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
||||||
@@ -281,6 +281,9 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
|
|||||||
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
||||||
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
|
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
|
||||||
|
|
||||||
|
Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时,
|
||||||
|
炮号显示为“未配置炮号”;Tooltip 不显示单位和误差附加文本。
|
||||||
|
|
||||||
### 线型、点型与误差棒
|
### 线型、点型与误差棒
|
||||||
|
|
||||||
每条序列可以独立设置连线方式、数据点符号和误差棒:
|
每条序列可以独立设置连线方式、数据点符号和误差棒:
|
||||||
@@ -288,6 +291,7 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
|
|||||||
```ts
|
```ts
|
||||||
const series = {
|
const series = {
|
||||||
id: 'temperature',
|
id: 'temperature',
|
||||||
|
shotNo: '13300',
|
||||||
name: '温度',
|
name: '温度',
|
||||||
lineType: 'step-end',
|
lineType: 'step-end',
|
||||||
lineStyle: 'dashed',
|
lineStyle: 'dashed',
|
||||||
@@ -411,7 +415,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐
|
|||||||
|
|
||||||
### 图例与曲线显隐
|
### 图例与曲线显隐
|
||||||
|
|
||||||
`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值,
|
每个图框独立管理自己的图例:图框内有两条或更多曲线时显示图例,只有一条曲线时不显示。
|
||||||
|
`legend.backgroundColor` 设置图例的背景颜色。该字段接受任意有效 CSS 颜色值,
|
||||||
可通过 `rgba(...)` 或 `hsla(...)` 中的 alpha 通道调整透明度:
|
可通过 `rgba(...)` 或 `hsla(...)` 中的 alpha 通道调整透明度:
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.34",
|
"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",
|
||||||
|
|||||||
@@ -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)')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<button type="button" @click="emit('cancel')">取消</button>
|
|
||||||
<button type="button" class="is-primary" :disabled="!canConfirm" @click="confirm">
|
|
||||||
保存标注
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped src="./WaveformAnnotationEditor.css"></style>
|
<style scoped src="./WaveformAnnotationEditor.css"></style>
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
29
src/components/core/constants.test.ts
Normal file
29
src/components/core/constants.test.ts
Normal 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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { NormalizedWaveformGridLineOptions } from './grid'
|
|||||||
*/
|
*/
|
||||||
export interface DisplaySeries {
|
export interface DisplaySeries {
|
||||||
id: string
|
id: string
|
||||||
|
shotNo?: string
|
||||||
trackId?: string
|
trackId?: string
|
||||||
name: string
|
name: string
|
||||||
unit?: string
|
unit?: string
|
||||||
@@ -63,6 +64,7 @@ export interface WaveformYAxisLayout {
|
|||||||
*/
|
*/
|
||||||
export interface HoveredSeriesPoint {
|
export interface HoveredSeriesPoint {
|
||||||
id: string
|
id: string
|
||||||
|
shotNo?: string
|
||||||
name: string
|
name: string
|
||||||
unit?: string
|
unit?: string
|
||||||
color: string
|
color: string
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('WaveformTooltip', () => {
|
|||||||
expect(tooltip.attributes('style')).toContain('max-width: 280px')
|
expect(tooltip.attributes('style')).toContain('max-width: 280px')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows resolved asymmetric errors beside the hovered value', () => {
|
it('omits units and errors from the strict x/y value format', () => {
|
||||||
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
||||||
const wrapper = mount(WaveformTooltip, {
|
const wrapper = mount(WaveformTooltip, {
|
||||||
props: {
|
props: {
|
||||||
@@ -102,10 +102,12 @@ describe('WaveformTooltip', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('.waveform-tooltip__series small').text()).toBe('(+2 / -1)')
|
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1 y:12)')
|
||||||
|
expect(wrapper.get('.waveform-tooltip__value').text()).not.toContain('C')
|
||||||
|
expect(wrapper.get('.waveform-tooltip__value').text()).not.toContain('+2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps a formatted value and its unit in one value container', () => {
|
it('renders the configured shot number, channel, and formatted coordinates', () => {
|
||||||
const hoveredPoint = { x: 1, y: -1405.4932 }
|
const hoveredPoint = { x: 1, y: -1405.4932 }
|
||||||
const wrapper = mount(WaveformTooltip, {
|
const wrapper = mount(WaveformTooltip, {
|
||||||
props: {
|
props: {
|
||||||
@@ -117,6 +119,7 @@ describe('WaveformTooltip', () => {
|
|||||||
{
|
{
|
||||||
trackIndex: 0,
|
trackIndex: 0,
|
||||||
name: 'ENG8KJXAc(10001)',
|
name: 'ENG8KJXAc(10001)',
|
||||||
|
shotNo: '炮 7',
|
||||||
color: '#ffb43b',
|
color: '#ffb43b',
|
||||||
unit: 'A',
|
unit: 'A',
|
||||||
point: hoveredPoint,
|
point: hoveredPoint,
|
||||||
@@ -127,26 +130,31 @@ describe('WaveformTooltip', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A')
|
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('炮 7: ENG8KJXAc(10001)(A)')
|
||||||
|
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1,000 y:-1,405.4932)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats tooltip time with at most four decimal places', () => {
|
it('renders each series row with the formatted x coordinate and series label', () => {
|
||||||
const wrapper = mount(WaveformTooltip, {
|
const wrapper = mount(WaveformTooltip, {
|
||||||
props: {
|
props: {
|
||||||
visible: true,
|
visible: true,
|
||||||
position: { x: 10, y: 10 },
|
position: { x: 10, y: 10 },
|
||||||
timeUnit: 's',
|
timeUnit: 's',
|
||||||
hoveredPoint: { x: 1.234567, y: 12 },
|
hoveredPoint: { x: 1.234567, y: 12 },
|
||||||
seriesPoints: [],
|
seriesPoints: [
|
||||||
|
{ trackIndex: 0, name: '温度', color: '#f00', point: { x: 1.234567, y: 12 } },
|
||||||
|
],
|
||||||
containerWidth: 400,
|
containerWidth: 400,
|
||||||
containerHeight: 300,
|
containerHeight: 300,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346')
|
expect(wrapper.find('.waveform-tooltip__time').exists()).toBe(false)
|
||||||
|
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('未配置炮号: 温度')
|
||||||
|
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1.2346 y:12)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('omits the error label when both resolved errors are zero', () => {
|
it('uses the fallback shot number when shotNo is blank', () => {
|
||||||
const point = { x: 1, y: 12 }
|
const point = { x: 1, y: 12 }
|
||||||
const wrapper = mount(WaveformTooltip, {
|
const wrapper = mount(WaveformTooltip, {
|
||||||
props: {
|
props: {
|
||||||
@@ -160,6 +168,6 @@ describe('WaveformTooltip', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.find('.waveform-tooltip__series small').exists()).toBe(false)
|
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('未配置炮号: 温度')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { resolveWaveformPointErrors } from '../../core'
|
|
||||||
import { formatTooltipNumber, formatTooltipTime } from '../../utils'
|
import { formatTooltipNumber, formatTooltipTime } from '../../utils'
|
||||||
import type { WaveformPoint } from '../data/types'
|
import type { WaveformPoint } from '../data/types'
|
||||||
|
|
||||||
interface SeriesPoint {
|
interface SeriesPoint {
|
||||||
trackIndex: number
|
trackIndex: number
|
||||||
name: string
|
name: string
|
||||||
|
shotNo?: string
|
||||||
color: string
|
color: string
|
||||||
unit?: string
|
unit?: string
|
||||||
point: WaveformPoint
|
point: WaveformPoint
|
||||||
@@ -34,7 +34,7 @@ const props = defineProps<Props>()
|
|||||||
const tooltipGap = 12
|
const tooltipGap = 12
|
||||||
const containerPadding = 8
|
const containerPadding = 8
|
||||||
const tooltipPlacementWidth = 238
|
const tooltipPlacementWidth = 238
|
||||||
const tooltipMaxWidth = 320
|
const tooltipMaxWidth = 560
|
||||||
const tooltipHorizontalPadding = 20
|
const tooltipHorizontalPadding = 20
|
||||||
const tooltipLineHeight = 20
|
const tooltipLineHeight = 20
|
||||||
|
|
||||||
@@ -45,18 +45,18 @@ function estimateLineCount(text: string, width: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatSeriesText(seriesPoint: SeriesPoint): string {
|
function formatSeriesText(seriesPoint: SeriesPoint): string {
|
||||||
const error = formatError(seriesPoint.point)
|
const timeText = formatTooltipTime(props.hoveredPoint?.x ?? seriesPoint.point.x, props.timeUnit)
|
||||||
return `${seriesPoint.name ? `${seriesPoint.name}: ` : ''}${formatTooltipNumber(seriesPoint.point.y)}${
|
return `${seriesPoint.shotNo?.trim() || '未配置炮号'}: ${seriesPoint.name}${
|
||||||
seriesPoint.unit ? ` ${seriesPoint.unit}` : ''
|
seriesPoint.unit ? `(${seriesPoint.unit})` : ''
|
||||||
}${error ? ` ${error}` : ''}`
|
} (x:${timeText} y:${formatTooltipNumber(seriesPoint.point.y)})`
|
||||||
}
|
}
|
||||||
|
|
||||||
function estimateTooltipHeight(width: number, timeText: string): number {
|
function estimateTooltipHeight(width: number): number {
|
||||||
const seriesLines = props.seriesPoints.reduce(
|
const seriesLines = props.seriesPoints.reduce(
|
||||||
(total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
|
(total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
return 16 + tooltipLineHeight * (estimateLineCount(timeText, width) + seriesLines) + 5
|
return 16 + tooltipLineHeight * seriesLines + 5
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipStyle = computed(() => {
|
const tooltipStyle = computed(() => {
|
||||||
@@ -84,25 +84,18 @@ const tooltipStyle = computed(() => {
|
|||||||
: { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
|
: { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
|
||||||
|
|
||||||
const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
|
const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
|
||||||
const timeText = `${props.timeUnit}: ${formatTooltipTime(props.hoveredPoint.x, props.timeUnit)}`
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...horizontalStyle,
|
...horizontalStyle,
|
||||||
top: `${Math.max(
|
top: `${Math.max(
|
||||||
8,
|
8,
|
||||||
Math.min(
|
Math.min(
|
||||||
props.position.y - 18,
|
props.position.y - 18,
|
||||||
props.containerHeight - estimateTooltipHeight(maxWidth, timeText) - 8,
|
props.containerHeight - estimateTooltipHeight(maxWidth) - 8,
|
||||||
),
|
),
|
||||||
)}px`,
|
)}px`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatError(point: WaveformPoint): string | null {
|
|
||||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
|
||||||
if (lower === 0 && upper === 0) return null
|
|
||||||
return `(+${formatTooltipNumber(upper)} / -${formatTooltipNumber(lower)})`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -111,9 +104,6 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
class="waveform-tooltip waveform-chart__tooltip"
|
class="waveform-tooltip waveform-chart__tooltip"
|
||||||
:style="tooltipStyle"
|
:style="tooltipStyle"
|
||||||
>
|
>
|
||||||
<span class="waveform-tooltip__time waveform-chart__tooltip-time">
|
|
||||||
{{ timeUnit }}: {{ formatTooltipTime(hoveredPoint.x, timeUnit) }}
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
v-for="seriesPoint in seriesPoints"
|
v-for="seriesPoint in seriesPoints"
|
||||||
:key="`${seriesPoint.trackIndex}-${seriesPoint.name}`"
|
:key="`${seriesPoint.trackIndex}-${seriesPoint.name}`"
|
||||||
@@ -121,12 +111,12 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
>
|
>
|
||||||
<i :style="{ backgroundColor: seriesPoint.color }" />
|
<i :style="{ backgroundColor: seriesPoint.color }" />
|
||||||
<span class="waveform-tooltip__series-content">
|
<span class="waveform-tooltip__series-content">
|
||||||
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
<span class="waveform-tooltip__series-label">{{ seriesPoint.shotNo?.trim() || '未配置炮号' }}: {{
|
||||||
<span class="waveform-tooltip__value">
|
seriesPoint.name
|
||||||
{{ formatTooltipNumber(seriesPoint.point.y)
|
}}<template v-if="seriesPoint.unit">({{ seriesPoint.unit }})</template></span>
|
||||||
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
|
<span class="waveform-tooltip__value">(x:{{ formatTooltipTime(hoveredPoint.x, timeUnit) }} y:{{
|
||||||
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
formatTooltipNumber(seriesPoint.point.y)
|
||||||
</span>
|
}})</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,26 +128,17 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: min(320px, calc(100% - 16px));
|
max-width: min(560px, calc(100% - 16px));
|
||||||
padding: 8px 10px;
|
padding: 9px 12px;
|
||||||
color: #333;
|
color: #505050;
|
||||||
font:
|
font: 14px/1.35 Arial, sans-serif;
|
||||||
12px/1.45 ui-monospace,
|
|
||||||
SFMono-Regular,
|
|
||||||
Consolas,
|
|
||||||
monospace;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #e3e7eb;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 4px 12px rgb(16 24 40 / 12%);
|
box-shadow: 0 3px 10px rgb(16 24 40 / 15%);
|
||||||
}
|
|
||||||
|
|
||||||
.waveform-tooltip__time {
|
|
||||||
padding-bottom: 4px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series {
|
.waveform-tooltip__series {
|
||||||
@@ -176,9 +157,24 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series-content {
|
.waveform-tooltip__series-content {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: baseline;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
}
|
||||||
white-space: normal;
|
|
||||||
|
.waveform-tooltip__series-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-tooltip__value {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series strong {
|
.waveform-tooltip__series strong {
|
||||||
@@ -186,7 +182,7 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__value {
|
.waveform-tooltip__value {
|
||||||
overflow-wrap: anywhere;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series small {
|
.waveform-tooltip__series small {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function useWaveformHover(context: HoverContext) {
|
|||||||
point: WaveformPoint,
|
point: WaveformPoint,
|
||||||
): HoveredSeriesPoint => ({
|
): HoveredSeriesPoint => ({
|
||||||
id: series.id,
|
id: series.id,
|
||||||
|
shotNo: series.shotNo,
|
||||||
name: series.name,
|
name: series.name,
|
||||||
color: series.color,
|
color: series.color,
|
||||||
unit: series.unit,
|
unit: series.unit,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -179,10 +179,10 @@ describe('WaveformChart', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const tooltip = wrapper.get('.waveform-chart__tooltip')
|
const tooltip = wrapper.get('.waveform-chart__tooltip')
|
||||||
expect(tooltip.text()).toContain('BT2_2M:')
|
expect(tooltip.text()).toContain('未配置炮号: BT2_2M')
|
||||||
expect(tooltip.text()).toContain('2 T')
|
expect(tooltip.text()).toContain('(x:1,000 y:2)')
|
||||||
expect(tooltip.text()).toContain('BT1_2M:')
|
expect(tooltip.text()).toContain('未配置炮号: BT1_2M')
|
||||||
expect(tooltip.text()).toContain('4 T')
|
expect(tooltip.text()).toContain('(x:1,000 y:4)')
|
||||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||||
expect(crosshairLines).toHaveLength(2)
|
expect(crosshairLines).toHaveLength(2)
|
||||||
crosshairLines.forEach((line) => {
|
crosshairLines.forEach((line) => {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||||
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
|
expect(wrapper.get('.waveform-tooltip__series-label').text()).toContain('未配置炮号:')
|
||||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||||
expect(crosshairLines).toHaveLength(1)
|
expect(crosshairLines).toHaveLength(1)
|
||||||
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
|
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
|
||||||
@@ -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(
|
||||||
{
|
{
|
||||||
@@ -303,7 +343,7 @@ describe('WaveformChart', () => {
|
|||||||
flushAnimationFrames()
|
flushAnimationFrames()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
|
expect(wrapper.get('.waveform-tooltip__series-label').text()).toContain('未配置炮号:')
|
||||||
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
|
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
|
||||||
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
|
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
|
||||||
expect(chartUpdate).not.toHaveBeenCalled()
|
expect(chartUpdate).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -102,4 +102,26 @@ describe('waveform data normalization', () => {
|
|||||||
'solid',
|
'solid',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves trimmed shot numbers and omits blank values', () => {
|
||||||
|
const result = normalizeWaveformSeries({
|
||||||
|
kind: 'series',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
id: 'with-shot',
|
||||||
|
shotNo: ' 13300 ',
|
||||||
|
name: '通道 A',
|
||||||
|
data: { kind: 'points', points: [{ x: 0, y: 1 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'without-shot',
|
||||||
|
shotNo: ' ',
|
||||||
|
name: '通道 B',
|
||||||
|
data: { kind: 'points', points: [{ x: 0, y: 2 }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.map((series) => series.shotNo)).toEqual(['13300', undefined])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformS
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: uniqueId,
|
id: uniqueId,
|
||||||
|
shotNo: series.shotNo?.trim() || undefined,
|
||||||
trackId: series.trackId?.trim() || undefined,
|
trackId: series.trackId?.trim() || undefined,
|
||||||
name: series.name,
|
name: series.name,
|
||||||
unit: series.unit,
|
unit: series.unit,
|
||||||
|
|||||||
@@ -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,13 @@ 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']))
|
||||||
first.series.forEach((series) => {
|
first.series.forEach((series) => {
|
||||||
expect(series.data.kind).toBe('points')
|
expect(series.data.kind).toBe('points')
|
||||||
if (series.data.kind !== 'points') return
|
if (series.data.kind !== 'points') return
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type ErrorGenerator = (
|
|||||||
|
|
||||||
interface SimulatedSeriesDefinition extends Pick<
|
interface SimulatedSeriesDefinition extends Pick<
|
||||||
WaveformSeries,
|
WaveformSeries,
|
||||||
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
|
'id' | 'trackId' | 'shotNo' | 'name' | 'unit' | 'color' | 'lineType' | 'pointType' | 'errorBar'
|
||||||
> {
|
> {
|
||||||
signal: SignalGenerator
|
signal: SignalGenerator
|
||||||
errors?: ErrorGenerator
|
errors?: ErrorGenerator
|
||||||
@@ -46,6 +46,7 @@ function createPoints(
|
|||||||
const seriesDefinitions: SimulatedSeriesDefinition[] = [
|
const seriesDefinitions: SimulatedSeriesDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 'simulated-sine',
|
id: 'simulated-sine',
|
||||||
|
shotNo: '13300',
|
||||||
name: '正弦基波',
|
name: '正弦基波',
|
||||||
unit: 'V',
|
unit: 'V',
|
||||||
lineType: 'none',
|
lineType: 'none',
|
||||||
@@ -59,6 +60,8 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'simulated-harmonic',
|
id: 'simulated-harmonic',
|
||||||
|
trackId: 'simulated-harmonic-frame',
|
||||||
|
shotNo: '13300',
|
||||||
name: '谐波扰动',
|
name: '谐波扰动',
|
||||||
unit: 'V',
|
unit: 'V',
|
||||||
lineType: 'linear',
|
lineType: 'linear',
|
||||||
@@ -66,8 +69,21 @@ 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',
|
||||||
name: '阻尼振荡',
|
name: '阻尼振荡',
|
||||||
unit: 'A',
|
unit: 'A',
|
||||||
lineType: 'linear',
|
lineType: 'linear',
|
||||||
@@ -79,6 +95,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'simulated-step',
|
id: 'simulated-step',
|
||||||
|
shotNo: '13300',
|
||||||
name: '阶跃响应',
|
name: '阶跃响应',
|
||||||
unit: 'V',
|
unit: 'V',
|
||||||
lineType: 'linear',
|
lineType: 'linear',
|
||||||
@@ -87,6 +104,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'simulated-pulse',
|
id: 'simulated-pulse',
|
||||||
|
shotNo: '13300',
|
||||||
name: '脉冲响应',
|
name: '脉冲响应',
|
||||||
unit: 'V',
|
unit: 'V',
|
||||||
lineType: 'linear',
|
lineType: 'linear',
|
||||||
@@ -95,6 +113,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'simulated-noise',
|
id: 'simulated-noise',
|
||||||
|
shotNo: '13300',
|
||||||
name: '带噪信号',
|
name: '带噪信号',
|
||||||
unit: 'A',
|
unit: 'A',
|
||||||
lineType: 'linear',
|
lineType: 'linear',
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export type SingleWaveformData =
|
|||||||
*/
|
*/
|
||||||
export interface WaveformSeries {
|
export interface WaveformSeries {
|
||||||
id?: string
|
id?: string
|
||||||
|
/** 炮号,未提供时 Tooltip 显示“未配置炮号”。 */
|
||||||
|
shotNo?: string
|
||||||
/** 相同 trackId 的系列叠加在同一图框中;默认每个系列独占一个图框。 */
|
/** 相同 trackId 的系列叠加在同一图框中;默认每个系列独占一个图框。 */
|
||||||
trackId?: string
|
trackId?: string
|
||||||
name: string
|
name: string
|
||||||
@@ -74,6 +76,7 @@ export type WaveformData =
|
|||||||
*/
|
*/
|
||||||
export interface NormalizedWaveformSeries {
|
export interface NormalizedWaveformSeries {
|
||||||
id: string
|
id: string
|
||||||
|
shotNo?: string
|
||||||
trackId?: string
|
trackId?: string
|
||||||
name: string
|
name: string
|
||||||
unit?: string
|
unit?: string
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const chartData: WaveformData = {
|
|||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
id: 'voltage',
|
id: 'voltage',
|
||||||
|
shotNo: '13300',
|
||||||
name: '电压',
|
name: '电压',
|
||||||
unit: 'V',
|
unit: 'V',
|
||||||
color: '#1677ff',
|
color: '#1677ff',
|
||||||
@@ -33,6 +34,7 @@ const chartData: WaveformData = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'current',
|
id: 'current',
|
||||||
|
shotNo: '13300',
|
||||||
name: '电流',
|
name: '电流',
|
||||||
unit: 'mA',
|
unit: 'mA',
|
||||||
color: '#d4380d',
|
color: '#d4380d',
|
||||||
|
|||||||
Reference in New Issue
Block a user