Merge pull request 'feat(chart): add configurable chart presentation' (#2) from feature-auto-size into main
Some checks failed
Package component / package (push) Failing after 31s

Reviewed-on: admin/af05acffeef924e67ec2e4d8#2
This commit is contained in:
2026-07-20 14:09:56 +08:00
24 changed files with 2242 additions and 201 deletions

View File

@@ -70,6 +70,102 @@ import { WaveformChart } from './index'
自适应高度要求父容器具有明确高度;父容器未定高时,组件使用最低 `180px` 高度。 自适应高度要求父容器具有明确高度;父容器未定高时,组件使用最低 `180px` 高度。
显式高度同样保留 `180px` 下限。非有限尺寸按未指定处理,负宽度归零。 显式高度同样保留 `180px` 下限。非有限尺寸按未指定处理,负宽度归零。
### 图表标题
`title` 在整个波形网格上方渲染一次,支持显隐、对齐、字体样式和旋转:
```vue
<WaveformChart
:data="chartData"
:title="{
visible: true,
text: 'Shot:4712',
align: 'center',
textStyle: {
color: '#1f2937',
fontSize: 14,
fontFamily: '"Microsoft YaHei", "微软雅黑", sans-serif',
rotation: 0,
fontWeight: 400,
fontStyle: 'normal',
textDecoration: 'none',
letterSpacing: '1px',
},
}"
/>
```
对应的公开类型为 `WaveformTitleOptions``WaveformTitleTextStyle`。未传 `title`
`visible``false`,或 `text` 去除首尾空格后为空时,标题不渲染且不占高度。标题默认
居中、字号 `14px`、颜色 `#1f2937`、微软雅黑、常规字重且不旋转。标题区域高度按文字及旋转角度
`44px``160px` 之间计算;超长文字会省略,悬浮可查看完整内容。
`width``height` 始终表示组件总尺寸。标题显示后会从总高度中扣除标题区域,剩余高度
用于 SVG 绘图区,因此启用标题不会扩大组件或破坏父容器布局。
宿主已有全局标题配置时,可以在未来接入组件库绘图链路时按以下方式映射:
```ts
const waveformTitle = {
visible: hasQueried && !cleanViewEnabled && titleStyle.enabled,
text: titleStyle.titleName.trim() || defaultTitleText,
align: titleStyle.align,
textStyle: {
color: titleStyle.color,
fontSize: titleStyle.fontSize,
fontFamily: titleStyle.fontFamily,
rotation: titleStyle.rotation,
fontWeight: titleStyle.bold ? 700 : 400,
fontStyle: titleStyle.italic ? 'italic' : 'normal',
textDecoration: titleStyle.underline ? 'underline' : 'none',
},
} satisfies WaveformTitleOptions
```
宿主的抽屉折叠状态不需要传给组件。替换绘图链路时应同步移除宿主外层标题,避免重复
渲染;当前宿主实现无需修改。
Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐、对齐、字体、字号、粗体、
斜体、下划线、旋转和颜色。字号范围为 `872px`,旋转范围为 `-180180°`;样式栏中的
`A` 用于恢复常规字重、非斜体和无下划线,关闭标题不会清除已经填写的配置。
### 图框样式
`frameStyle` 统一设置所有非空图框的边框和背景,颜色支持带 alpha 的 CSS 颜色值:
```vue
<WaveformChart
:data="chartData"
:frame-style="{
borderColor: 'rgba(31, 41, 55, 0.8)',
borderWidth: 2,
borderStyle: 'dashed',
backgroundColor: 'rgba(14, 165, 233, 0.08)',
}"
/>
```
对应的公开类型为 `WaveformFrameStyle`。默认边框颜色为 `#1f2937`、线宽为 `1`、线型为
`solid`,背景透明。`borderWidth``0` 时隐藏边框;非有限值或负数会回退到默认线宽。
### 图例样式
`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值,
可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度:
```vue
<WaveformChart
:data="chartData"
:legend="{
position: 'top-right',
orientation: 'auto',
backgroundColor: 'rgba(255, 255, 255, 0.45)',
}"
/>
```
未配置或传入空字符串时,图例背景默认使用 `rgba(255, 255, 255, 0.7)`
## 大数据渲染 ## 大数据渲染
组件按不可变数据处理:替换 `data` 引用会重新过滤、排序和缓存坐标域,并重置视口; 组件按不可变数据处理:替换 `data` 引用会重新过滤、排序和缓存坐标域,并重置视口;

184
src/App.test.ts Normal file
View File

@@ -0,0 +1,184 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber, Select } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import { ColorPicker } from 'vue3-colorpicker'
import App from './App.vue'
describe('App workspace layout', () => {
it('places controls in the sidebar beside the chart', async () => {
const wrapper = mount(App)
await flushPromises()
const panel = wrapper.get('#waveform-control-panel')
const frameControls = panel.get('.frame-style-controls')
expect(panel.find('h1').exists()).toBe(false)
expect(panel.find('[aria-label="波形展示方式"]').exists()).toBe(true)
expect(panel.find('[aria-label="波形网格尺寸"]').exists()).toBe(true)
expect(frameControls.findAllComponents(ColorPicker)).toHaveLength(2)
expect(frameControls.text()).toContain('边框颜色')
expect(frameControls.text()).toContain('背景颜色')
expect(frameControls.find('[aria-label="图框线宽"]').exists()).toBe(true)
expect(frameControls.find('[aria-label="图框线型"]').exists()).toBe(true)
expect(frameControls.find('[aria-label="显示图框水印"]').exists()).toBe(true)
expect(frameControls.get('.frame-style-control--switch .ant-switch').classes()).toContain(
'ant-switch-small',
)
const titleControls = panel.get('.title-controls')
expect(panel.find('[aria-label="显示标题"]').exists()).toBe(true)
expect(titleControls.find('[aria-label="标题名称"]').exists()).toBe(true)
expect(titleControls.find('[aria-label="标题对齐方式"]').exists()).toBe(true)
expect(titleControls.find('[aria-label="标题字体"]').exists()).toBe(true)
expect(titleControls.find('[aria-label="标题字号"]').exists()).toBe(true)
expect(titleControls.find('[aria-label="标题旋转角度"]').exists()).toBe(true)
expect(titleControls.findAllComponents(ColorPicker)).toHaveLength(1)
expect(panel.find('[aria-label="图例位置"]').exists()).toBe(true)
expect(panel.find('[aria-label="图例排列"]').exists()).toBe(true)
const legendColorControl = panel.get('.legend-color-control')
const legendColorPicker = legendColorControl.getComponent(ColorPicker)
expect(legendColorControl.text()).toContain('背景')
expect(legendColorPicker.props('pureColor')).toBe('rgba(255, 255, 255, 0.7)')
expect(legendColorPicker.props('disableAlpha')).toBe(false)
expect(panel.text()).not.toContain('数据摘要')
expect(wrapper.get('.chart-panel').find('.waveform-chart').exists()).toBe(true)
wrapper.unmount()
})
it('updates title content, text styles, and visibility', async () => {
const wrapper = mount(App)
await flushPromises()
const renderedTitle = () => wrapper.get('.waveform-chart__title-text')
expect(renderedTitle().text()).toMatch(/^Shot:\d+$/)
expect(renderedTitle().attributes('style')).toContain('Microsoft YaHei')
expect(renderedTitle().attributes('style')).toContain('font-size: 14px')
expect(renderedTitle().attributes('style')).toContain('font-weight: 400')
await wrapper.get('input[aria-label="标题名称"]').setValue('实验标题')
await wrapper.get('[aria-label="标题粗体"]').trigger('click')
await wrapper.get('[aria-label="标题斜体"]').trigger('click')
await wrapper.get('[aria-label="标题下划线"]').trigger('click')
await flushPromises()
expect(renderedTitle().text()).toBe('实验标题')
expect(renderedTitle().attributes('style')).toContain('font-weight: 700')
expect(renderedTitle().attributes('style')).toContain('font-style: italic')
expect(renderedTitle().attributes('style')).toContain('text-decoration: underline')
await wrapper.get('[aria-label="恢复标题常规样式"]').trigger('click')
await flushPromises()
expect(renderedTitle().attributes('style')).toContain('font-weight: 400')
expect(renderedTitle().attributes('style')).toContain('font-style: normal')
expect(renderedTitle().attributes('style')).toContain('text-decoration: none')
await wrapper.get('[aria-label="显示标题"]').trigger('click')
await flushPromises()
expect(wrapper.find('.waveform-chart__title-area').exists()).toBe(false)
wrapper.unmount()
})
it('updates every visible frame from the frame style controls', async () => {
const wrapper = mount(App)
await flushPromises()
const frameControls = wrapper.get('.frame-style-controls')
const colorPickers = frameControls.findAllComponents(ColorPicker)
const widthInput = frameControls.findAllComponents(InputNumber)[0]
const styleSelect = frameControls.findAllComponents(Select)[0]
expect(colorPickers).toHaveLength(2)
expect(widthInput).toBeDefined()
expect(styleSelect).toBeDefined()
colorPickers[0].vm.$emit('update:pureColor', 'rgba(220, 38, 38, 0.8)')
colorPickers[1].vm.$emit('update:pureColor', 'rgba(14, 165, 233, 0.25)')
widthInput?.vm.$emit('update:value', 3)
styleSelect?.vm.$emit('update:value', 'dashed')
await flushPromises()
const frames = wrapper.findAll('.waveform-chart__plot-frame')
const backgrounds = wrapper.findAll('.waveform-chart__plot-background')
expect(frames.length).toBeGreaterThan(1)
expect(backgrounds).toHaveLength(frames.length)
frames.forEach((frame) => {
expect(frame.attributes()).toMatchObject({
stroke: 'rgba(220, 38, 38, 0.8)',
'stroke-width': '3',
'stroke-dasharray': '6 4',
})
})
backgrounds.forEach((background) => {
expect(background.attributes('fill')).toBe('rgba(14, 165, 233, 0.25)')
})
wrapper.unmount()
})
it('shows and hides every frame watermark from the frame style controls', async () => {
const wrapper = mount(App)
await flushPromises()
const watermarkToggle = wrapper.get('[aria-label="显示图框水印"]')
const initialWatermarks = wrapper.findAll('.waveform-chart__watermark')
const initialFrameNumbers = initialWatermarks.map((watermark) => watermark.text())
expect(initialWatermarks.length).toBeGreaterThan(1)
await watermarkToggle.trigger('click')
await flushPromises()
expect(wrapper.findAll('.waveform-chart__watermark')).toHaveLength(0)
await watermarkToggle.trigger('click')
await flushPromises()
expect(
wrapper.findAll('.waveform-chart__watermark').map((watermark) => watermark.text()),
).toEqual(initialFrameNumbers)
wrapper.unmount()
})
it('updates every visible legend from the alpha-enabled background picker', async () => {
const wrapper = mount(App)
await flushPromises()
const colorPicker = wrapper.get('.legend-color-control').getComponent(ColorPicker)
colorPicker.vm.$emit('update:pureColor', 'rgba(15, 118, 110, 0.35)')
await flushPromises()
const legendPanels = wrapper.findAll('.waveform-legend__panel')
expect(legendPanels.length).toBeGreaterThan(0)
legendPanels.forEach((panel) => {
expect(panel.attributes('style')).toContain('background-color: rgba(15, 118, 110, 0.35)')
})
wrapper.unmount()
})
it('opens and closes the mobile control drawer', async () => {
const wrapper = mount(App)
const toggle = wrapper.get('.mobile-control-toggle')
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(wrapper.get('.control-panel').classes()).not.toContain('is-open')
expect(wrapper.find('.control-backdrop').exists()).toBe(false)
await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(wrapper.get('.control-panel').classes()).toContain('is-open')
expect(wrapper.find('.control-backdrop').exists()).toBe(true)
await wrapper.get('.control-backdrop').trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(wrapper.get('.control-panel').classes()).not.toContain('is-open')
await toggle.trigger('click')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await flushPromises()
expect(toggle.attributes('aria-expanded')).toBe('false')
expect(wrapper.find('.control-backdrop').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,14 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { InputNumber, Radio, Tag } from 'ant-design-vue' import { Button, Input, InputNumber, Radio, Select, Switch } from 'ant-design-vue'
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ColorPicker } from 'vue3-colorpicker'
import 'vue3-colorpicker/style.css'
import { import {
WaveformChart, WaveformChart,
type WaveformAnnotation, type WaveformAnnotation,
type WaveformData, type WaveformData,
type WaveformDisplayMode, type WaveformDisplayMode,
type WaveformFrameStyle,
type WaveformInteractionMode, type WaveformInteractionMode,
type WaveformLegendOrientation,
type WaveformLegendPosition,
type WaveformSeries, type WaveformSeries,
type WaveformTitleOptions,
} from './components' } from './components'
import waveformJson from './data/wData.json' import waveformJson from './data/wData.json'
@@ -35,18 +41,78 @@ const testChannelRows: WaveformSourceRow[] = importedSourceRows.slice(0, 2).map(
), ),
})) }))
const sourceRows = [...importedSourceRows, ...testChannelRows] const sourceRows = [...importedSourceRows, ...testChannelRows]
const visibleRange = ref<[number, number] | null>(null)
const displayMode = ref<WaveformDisplayMode>('independent') const displayMode = ref<WaveformDisplayMode>('independent')
const rowCount = ref(2) const rowCount = ref(2)
const columnCount = ref(1) const columnCount = ref(1)
const frameBorderColor = ref('#1f2937')
const frameBorderWidth = ref(1)
const frameBorderStyle = ref<'solid' | 'dashed'>('solid')
const frameBackgroundColor = ref('rgba(255, 255, 255, 0)')
const frameWatermarkVisible = ref(true)
const annotations = ref<WaveformAnnotation[]>([]) const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true) const annotationsVisible = ref(true)
const interactionMode = ref<WaveformInteractionMode>('zoom') const interactionMode = ref<WaveformInteractionMode>('zoom')
const legendPosition = ref<WaveformLegendPosition>('top-right')
const legendOrientation = ref<WaveformLegendOrientation>('auto')
const legendBackgroundColor = ref('rgba(255, 255, 255, 0.7)')
const titleVisible = ref(true)
const titleText = ref(`Shot:${sourceRows[0]?.shot ?? 4712}`)
const titleAlign = ref<NonNullable<WaveformTitleOptions['align']>>('center')
const titleFontFamily = ref('"Microsoft YaHei", "微软雅黑", sans-serif')
const titleFontSize = ref(14)
const titleRotation = ref(0)
const titleColor = ref('#1f2937')
const titleBold = ref(false)
const titleItalic = ref(false)
const titleUnderline = ref(false)
const controlsOpen = ref(false)
const legendPositionOptions: Array<{ label: string; value: WaveformLegendPosition }> = [
{ label: '左上', value: 'top-left' },
{ label: '上', value: 'top' },
{ label: '右上', value: 'top-right' },
{ label: '右', value: 'right' },
{ label: '右下', value: 'bottom-right' },
{ label: '下', value: 'bottom' },
{ label: '左下', value: 'bottom-left' },
{ label: '左', value: 'left' },
]
const legendOrientationOptions: Array<{ label: string; value: WaveformLegendOrientation }> = [
{ label: '自动', value: 'auto' },
{ label: '水平', value: 'horizontal' },
{ label: '垂直', value: 'vertical' },
]
const frameBorderStyleOptions = [
{ label: '实线', value: 'solid' },
{ label: '虚线', value: 'dashed' },
]
const titleAlignOptions: Array<{
label: string
value: NonNullable<WaveformTitleOptions['align']>
}> = [
{ label: '左对齐', value: 'left' },
{ label: '居中', value: 'center' },
{ label: '右对齐', value: 'right' },
]
const titleFontFamilyOptions = [
{ label: '微软雅黑', value: '"Microsoft YaHei", "微软雅黑", sans-serif' },
{ label: '宋体', value: 'SimSun, serif' },
{ label: '黑体', value: 'SimHei, sans-serif' },
{ label: 'Arial', value: 'Arial, sans-serif' },
{ label: 'Consolas', value: 'Consolas, monospace' },
]
const frameStyle = computed<WaveformFrameStyle>(() => ({
borderColor: frameBorderColor.value,
borderWidth: frameBorderWidth.value,
borderStyle: frameBorderStyle.value,
backgroundColor: frameBackgroundColor.value,
}))
const waveformSeries: WaveformSeries[] = sourceRows.map((row) => { const waveformSeries: WaveformSeries[] = sourceRows.map((row) => {
const pointCount = Math.min(row.time.length, row.data.length) const pointCount = Math.min(row.time.length, row.data.length)
return { return {
id: String(row.chnl_id), id: String(row.chnl_id),
trackId:
row.chnl === 'TEST_CH_1' ? String(importedSourceRows[0]?.chnl_id ?? row.chnl_id) : undefined,
name: row.chnl, name: row.chnl,
unit: row.dat_unit, unit: row.dat_unit,
data: { data: {
@@ -60,40 +126,71 @@ const waveformSeries: WaveformSeries[] = sourceRows.map((row) => {
}) })
const chartData: WaveformData = { kind: 'series', series: waveformSeries } const chartData: WaveformData = { kind: 'series', series: waveformSeries }
const totalPointCount = waveformSeries.reduce((total, series) => { const titleOptions = computed<WaveformTitleOptions>(() => ({
const seriesPointCount = visible: titleVisible.value,
series.data.kind === 'points' ? series.data.points.length : series.data.values.length text: titleText.value,
return total + seriesPointCount align: titleAlign.value,
}, 0) textStyle: {
const initialTimeRange: [number, number] = [ color: titleColor.value,
Math.min(...sourceRows.flatMap((row) => row.time)) / 1000, fontSize: titleFontSize.value,
Math.max(...sourceRows.flatMap((row) => row.time)) / 1000, fontFamily: titleFontFamily.value,
] rotation: titleRotation.value,
const displayedRange = computed(() => visibleRange.value ?? initialTimeRange) fontWeight: titleBold.value ? 700 : 400,
const formatMilliseconds = (seconds: number) => fontStyle: titleItalic.value ? 'italic' : 'normal',
(seconds * 1000).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) textDecoration: titleUnderline.value ? 'underline' : 'none',
},
}))
function closeControls() {
controlsOpen.value = false
}
watch(displayMode, () => { function resetTitleTextStyle() {
visibleRange.value = null titleBold.value = false
}) titleItalic.value = false
titleUnderline.value = false
}
watch([rowCount, columnCount], () => { function handleWindowKeydown(event: KeyboardEvent) {
visibleRange.value = null if (event.key === 'Escape') closeControls()
}) }
onMounted(() => window.addEventListener('keydown', handleWindowKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown))
</script> </script>
<template> <template>
<main class="workspace"> <main class="workspace">
<header class="workspace__header"> <Button
<div> class="mobile-control-toggle"
<p class="workspace__eyebrow">Vue 3 · TypeScript · D3</p> size="small"
<h1>波形分析组件</h1> :aria-expanded="controlsOpen"
</div> aria-controls="waveform-control-panel"
</header> @click="controlsOpen = true"
>
控制面板
</Button>
<section class="control-bar" aria-label="波形图控制与摘要"> <button
<div class="control-bar__leading"> v-if="controlsOpen"
<Tag color="blue">真实 + 测试数据</Tag> type="button"
class="control-backdrop"
aria-label="关闭控制面板"
@click="closeControls"
/>
<aside
id="waveform-control-panel"
class="control-panel"
:class="{ 'is-open': controlsOpen }"
aria-label="波形图控制"
>
<div class="control-panel__scroll">
<Button class="control-panel__close" type="text" size="small" @click="closeControls">
关闭
</Button>
<section class="control-section">
<h2>显示方式</h2>
<Radio.Group <Radio.Group
v-model:value="displayMode" v-model:value="displayMode"
class="display-mode-control" class="display-mode-control"
@@ -105,39 +202,239 @@ watch([rowCount, columnCount], () => {
<Radio.Button value="separated">多道分离</Radio.Button> <Radio.Button value="separated">多道分离</Radio.Button>
<Radio.Button value="compact">多道紧凑</Radio.Button> <Radio.Button value="compact">多道紧凑</Radio.Button>
</Radio.Group> </Radio.Group>
</section>
<section class="control-section">
<h2>图框布局</h2>
<div class="grid-size-control" aria-label="波形网格尺寸"> <div class="grid-size-control" aria-label="波形网格尺寸">
<span>网格</span>
<InputNumber v-model:value="rowCount" :min="1" :max="10" size="small" /> <InputNumber v-model:value="rowCount" :min="1" :max="10" size="small" />
<span> ×</span> <span></span>
<span class="control-separator">×</span>
<InputNumber v-model:value="columnCount" :min="1" :max="10" size="small" /> <InputNumber v-model:value="columnCount" :min="1" :max="10" size="small" />
<span></span> <span></span>
</div> </div>
</div> </section>
<div class="metrics">
<span>{{ sourceRows.length }} 通道</span> <section class="control-section">
<span>{{ totalPointCount.toLocaleString() }} 数据点</span> <h2>图框样式</h2>
<span>{{ annotations.length }} 标注</span> <div class="frame-style-controls">
<span>炮号 {{ sourceRows[0]?.shot }}</span> <label class="frame-style-control">
<span>设备 {{ sourceRows[0]?.dev }}</span> <span>边框颜色</span>
<span> <ColorPicker
范围 {{ formatMilliseconds(displayedRange[0]) }}{{ v-model:pure-color="frameBorderColor"
formatMilliseconds(displayedRange[1]) aria-label="图框边框颜色"
}} use-type="pure"
ms picker-type="chrome"
</span> format="rgb"
:disable-alpha="false"
:blur-close="true"
/>
</label>
<label class="frame-style-control">
<span>背景颜色</span>
<ColorPicker
v-model:pure-color="frameBackgroundColor"
aria-label="图框背景颜色"
use-type="pure"
picker-type="chrome"
format="rgb"
:disable-alpha="false"
:blur-close="true"
/>
</label>
<label class="frame-style-control">
<span>线宽</span>
<InputNumber
v-model:value="frameBorderWidth"
:min="0"
:max="10"
:step="0.5"
size="small"
aria-label="图框线宽"
/>
</label>
<label class="frame-style-control">
<span>线型</span>
<Select
v-model:value="frameBorderStyle"
:options="frameBorderStyleOptions"
size="small"
aria-label="图框线型"
/>
</label>
<label class="frame-style-control frame-style-control--switch">
<span>水印</span>
<Switch
v-model:checked="frameWatermarkVisible"
size="small"
aria-label="显示图框水印"
/>
</label>
</div> </div>
</section> </section>
<section class="control-section title-control-section">
<div class="control-section__header">
<h2>标题</h2>
<Switch v-model:checked="titleVisible" size="small" aria-label="显示标题" />
</div>
<div class="title-controls">
<label class="title-control title-control--wide">
<span>标题名称</span>
<Input v-model:value="titleText" size="small" aria-label="标题名称" />
</label>
<label class="title-control title-control--wide">
<span>对齐方式</span>
<Select
v-model:value="titleAlign"
:options="titleAlignOptions"
size="small"
aria-label="标题对齐方式"
/>
</label>
<label class="title-control">
<span>字体</span>
<Select
v-model:value="titleFontFamily"
:options="titleFontFamilyOptions"
size="small"
aria-label="标题字体"
/>
</label>
<label class="title-control">
<span>字号</span>
<InputNumber
v-model:value="titleFontSize"
:min="8"
:max="72"
:step="1"
size="small"
aria-label="标题字号"
/>
</label>
<div class="title-control">
<span>字体样式</span>
<div class="title-style-controls" role="group" aria-label="标题字体样式">
<button
type="button"
aria-label="恢复标题常规样式"
title="恢复常规样式"
@click="resetTitleTextStyle"
>
A
</button>
<button
type="button"
:class="{ 'is-active': titleBold }"
:aria-pressed="titleBold"
aria-label="标题粗体"
title="粗体"
@click="titleBold = !titleBold"
>
<b>B</b>
</button>
<button
type="button"
:class="{ 'is-active': titleItalic }"
:aria-pressed="titleItalic"
aria-label="标题斜体"
title="斜体"
@click="titleItalic = !titleItalic"
>
<i>I</i>
</button>
<button
type="button"
:class="{ 'is-active': titleUnderline }"
:aria-pressed="titleUnderline"
aria-label="标题下划线"
title="下划线"
@click="titleUnderline = !titleUnderline"
>
<u>U</u>
</button>
</div>
</div>
<label class="title-control">
<span>旋转</span>
<InputNumber
v-model:value="titleRotation"
:min="-180"
:max="180"
:step="1"
addon-after="°"
size="small"
aria-label="标题旋转角度"
/>
</label>
<div class="title-control title-control--color">
<span>颜色</span>
<ColorPicker
v-model:pure-color="titleColor"
aria-label="标题颜色"
use-type="pure"
picker-type="chrome"
format="hex"
:disable-alpha="true"
:blur-close="true"
/>
</div>
</div>
</section>
<section class="control-section">
<h2>图例</h2>
<label class="select-control">
<span>位置</span>
<Select
v-model:value="legendPosition"
:options="legendPositionOptions"
size="small"
aria-label="图例位置"
/>
</label>
<label class="select-control">
<span>排列</span>
<Select
v-model:value="legendOrientation"
:options="legendOrientationOptions"
size="small"
aria-label="图例排列"
/>
</label>
<label class="legend-color-control">
<span>背景</span>
<ColorPicker
v-model:pure-color="legendBackgroundColor"
aria-label="图例背景颜色"
use-type="pure"
picker-type="chrome"
format="rgb"
:disable-alpha="false"
:blur-close="true"
/>
</label>
</section>
</div>
</aside>
<section class="chart-panel"> <section class="chart-panel">
<WaveformChart <WaveformChart
:data="chartData" :data="chartData"
:display-mode="displayMode" :display-mode="displayMode"
:grid="{ rowCount, columnCount, showPagination: true }" :grid="{ rowCount, columnCount, showPagination: true }"
:frame-number="1" :title="titleOptions"
:legend="{
position: legendPosition,
orientation: legendOrientation,
backgroundColor: legendBackgroundColor,
}"
:frame-style="frameStyle"
:frame-number="frameWatermarkVisible ? 1 : undefined"
v-model:annotations="annotations" v-model:annotations="annotations"
v-model:annotations-visible="annotationsVisible" v-model:annotations-visible="annotationsVisible"
v-model:interaction-mode="interactionMode" v-model:interaction-mode="interactionMode"
@zoom-change="visibleRange = $event"
/> />
</section> </section>
</main> </main>

View File

@@ -56,6 +56,7 @@ describe('normalizeWaveformData', () => {
kind: 'series', kind: 'series',
series: [ series: [
{ {
trackId: 'comparison-track',
name: 'BT2_2M', name: 'BT2_2M',
unit: 'T', unit: 'T',
data: { kind: 'points', points: [{ x: 1, y: 2 }] }, data: { kind: 'points', points: [{ x: 1, y: 2 }] },
@@ -69,6 +70,7 @@ describe('normalizeWaveformData', () => {
).toEqual([ ).toEqual([
{ {
id: 'series-0', id: 'series-0',
trackId: 'comparison-track',
name: 'BT2_2M', name: 'BT2_2M',
unit: 'T', unit: 'T',
color: undefined, color: undefined,
@@ -128,6 +130,217 @@ describe('WaveformChart', () => {
expect(wrapper.get('.ant-pagination-next').classes()).toContain('ant-pagination-disabled') expect(wrapper.get('.ant-pagination-next').classes()).toContain('ant-pagination-disabled')
}) })
it('overlays series with the same track ID without changing the next frame', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'primary',
trackId: 'frame-1',
name: 'BT2_2M',
data: {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
},
{
id: 'second-frame',
name: 'BT1_2M',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
{
id: 'comparison',
trackId: 'frame-1',
name: 'TEST_CH_1',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.5 },
{ x: 1, y: 1.5 },
],
},
},
],
},
{ frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } },
)
const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks).toHaveLength(2)
expect(
tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['primary', 'comparison'])
expect(
tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['second-frame'])
expect(tracks[0].find('.waveform-chart__y-axis-label').exists()).toBe(false)
expect(tracks[0].findAll('.waveform-chart__axis--y .tick').length).toBeGreaterThan(0)
expect(tracks[1].get('.waveform-chart__y-axis-label').text()).toBe('BT1_2M')
expect(tracks[1].find('.waveform-chart__legend').exists()).toBe(false)
const legend = tracks[0].get('.waveform-chart__legend')
expect(legend.attributes('data-position')).toBe('top-right')
expect(legend.attributes('data-orientation')).toBe('vertical')
expect(legend.get('.waveform-legend__panel').attributes('style')).toContain(
'background-color: rgba(255, 255, 255, 0.7)',
)
expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([
'BT2_2M',
'TEST_CH_1',
])
expect(
legend.findAll('.waveform-legend__swatch').map((swatch) => swatch.attributes('style')),
).toEqual(['background-color: rgb(9, 96, 189);', 'background-color: rgb(56, 158, 13);'])
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
'1',
'2',
])
expect(wrapper.find('.ant-pagination').exists()).toBe(false)
const firstTrackOverlay = tracks[0].get('.waveform-chart__overlay')
const overlayWidth = Number(firstTrackOverlay.attributes('width'))
const overlayHeight = Number(firstTrackOverlay.attributes('height'))
Object.defineProperty(firstTrackOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
})
firstTrackOverlay.element.dispatchEvent(
new MouseEvent('pointermove', {
clientX: overlayWidth / 2,
clientY: overlayHeight / 2,
bubbles: true,
}),
)
await flushPromises()
const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
expect(tooltipSeries).toHaveLength(2)
expect(tooltipSeries.map((item) => item.text())).toEqual([
expect.stringContaining('BT2_2M'),
expect.stringContaining('TEST_CH_1'),
])
})
it('resolves automatic legend orientation and supports explicit overrides', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'first',
trackId: 'shared',
name: 'first',
data: {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
},
{
id: 'second',
trackId: 'shared',
name: 'second',
data: {
kind: 'points',
points: [
{ x: 0, y: 1 },
{ x: 1, y: 2 },
],
},
},
],
},
{ grid: { rowCount: 1, columnCount: 1 } },
)
const positions = [
'top-left',
'top',
'top-right',
'right',
'bottom-right',
'bottom',
'bottom-left',
'left',
] as const
for (const position of positions) {
await wrapper.setProps({ legend: { position, orientation: 'auto' } })
const legend = wrapper.get('.waveform-chart__legend')
const expectedOrientation =
position === 'top' || position === 'bottom' ? 'horizontal' : 'vertical'
expect(legend.attributes('data-position')).toBe(position)
expect(legend.attributes('data-orientation')).toBe(expectedOrientation)
expect(legend.get('.waveform-legend__viewport').classes()).toContain(
`waveform-legend__viewport--${position}`,
)
expect(
legend
.get('.waveform-legend__panel')
.classes()
.includes('waveform-legend__panel--vertical'),
).toBe(expectedOrientation === 'vertical')
}
await wrapper.setProps({ legend: { position: 'top', orientation: 'vertical' } })
expect(wrapper.get('.waveform-chart__legend').attributes('data-orientation')).toBe('vertical')
expect(wrapper.get('.waveform-legend__panel').classes()).toContain(
'waveform-legend__panel--vertical',
)
await wrapper.setProps({ legend: { position: 'left', orientation: 'horizontal' } })
expect(wrapper.get('.waveform-chart__legend').attributes('data-orientation')).toBe('horizontal')
expect(wrapper.get('.waveform-legend__panel').classes()).toContain(
'waveform-legend__panel--horizontal',
)
expect(wrapper.attributes('data-chart-left-margin')).toBe('64')
})
it('applies a configurable alpha background to every visible legend', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: Array.from({ length: 4 }, (_, index) => ({
id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`,
name: `series ${index}`,
data: {
kind: 'points',
points: [
{ x: 0, y: index },
{ x: 1, y: index + 1 },
],
},
})),
},
{
grid: { rowCount: 2, columnCount: 1 },
legend: { backgroundColor: 'rgba(14, 165, 233, 0.25)' },
},
)
const legendPanels = wrapper.findAll('.waveform-legend__panel')
expect(legendPanels).toHaveLength(2)
legendPanels.forEach((panel) => {
expect(panel.attributes('style')).toContain('background-color: rgba(14, 165, 233, 0.25)')
})
await wrapper.setProps({ legend: { backgroundColor: '' } })
wrapper.findAll('.waveform-legend__panel').forEach((panel) => {
expect(panel.attributes('style')).toContain('background-color: rgba(255, 255, 255, 0.7)')
})
})
it('renders independent cells with separate x axes and overlays', async () => { it('renders independent cells with separate x axes and overlays', async () => {
const wrapper = await mountSizedChart(gridSeries(4), { const wrapper = await mountSizedChart(gridSeries(4), {
displayMode: 'independent', displayMode: 'independent',
@@ -328,6 +541,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(3) expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(3)
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(4) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(4)
expect(emptyTracks[0].findAll('.waveform-chart__grid')).toHaveLength(0) expect(emptyTracks[0].findAll('.waveform-chart__grid')).toHaveLength(0)
expect(emptyTracks[0].find('.waveform-chart__plot-background').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__plot-frame').exists()).toBe(false) expect(emptyTracks[0].find('.waveform-chart__plot-frame').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__axis--y').exists()).toBe(false) expect(emptyTracks[0].find('.waveform-chart__axis--y').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__line').exists()).toBe(false) expect(emptyTracks[0].find('.waveform-chart__line').exists()).toBe(false)
@@ -487,6 +701,247 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300') expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
}) })
it('does not render or reserve space for missing, hidden, or blank titles', async () => {
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
title ? { title } : {},
)
expect(wrapper.find('.waveform-chart__title-area').exists()).toBe(false)
expect(wrapper.attributes('data-title-area-height')).toBe('0')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('360')
}
})
it.each(['independent', 'separated', 'compact'] as const)(
'keeps the titled empty state inside the drawing area in %s mode',
async (displayMode) => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [1], sampleRate: -1 },
{ displayMode, title: { text: '空数据标题' } },
)
expect(wrapper.get('.waveform-chart__title-text').text()).toBe('空数据标题')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('316')
expect(wrapper.get('.waveform-chart__empty').attributes('y')).toBe('158')
},
)
it('renders one chart title with alignment and all supported text styles', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
title: {
text: ' shot: #4712 ',
align: 'right',
textStyle: {
color: '#c026d3',
fontSize: 18,
fontFamily: 'Consolas',
rotation: 0,
fontWeight: 700,
fontStyle: 'italic',
textDecoration: 'underline',
letterSpacing: '2px',
},
},
},
)
const area = wrapper.get('.waveform-chart__title-area')
const visual = wrapper.get('.waveform-chart__title-visual')
const title = wrapper.get('.waveform-chart__title-text')
expect(area.attributes('role')).toBe('heading')
expect(area.attributes('style')).toContain('justify-content: flex-end')
expect(title.text()).toBe('shot: #4712')
expect(title.attributes('style')).toContain('color: rgb(192, 38, 211)')
expect(title.attributes('style')).toContain('font-size: 18px')
expect(title.attributes('style')).toContain('font-family: Consolas')
expect(title.attributes('style')).toContain('font-weight: 700')
expect(title.attributes('style')).toContain('font-style: italic')
expect(title.attributes('style')).toContain('text-decoration: underline')
expect(title.attributes('style')).toContain('letter-spacing: 2px')
expect(visual.attributes('style')).toContain('width: 752px')
expect(title.attributes('style')).toContain('width: 752px')
expect(title.attributes('style')).toContain('rotate(0deg)')
expect(wrapper.attributes('data-title-area-height')).toBe('44')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('316')
})
it('normalizes invalid title numbers and wraps long titles at narrow widths', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
title: {
text: '这是一个用于验证窄屏省略行为的很长波形分析标题',
textStyle: { fontSize: Number.NaN, rotation: Number.POSITIVE_INFINITY },
},
},
)
resizeObservers.at(-1)?.resize(160, 360)
await flushPromises()
const title = wrapper.get('.waveform-chart__title-text')
expect(title.attributes('style')).toContain('font-size: 14px')
expect(title.attributes('style')).toContain('Microsoft YaHei')
expect(title.attributes('style')).toContain('font-weight: 400')
expect(title.attributes('style')).toContain('rotate(0deg)')
expect(title.attributes('style')).toContain('white-space: normal')
expect(title.attributes('style')).toContain('overflow-wrap: anywhere')
expect(title.attributes('title')).toBeUndefined()
expect(title.attributes('data-title-wrapped')).toBe('true')
expect(Number(wrapper.attributes('data-title-area-height'))).toBeGreaterThan(44)
})
it.each([45, 90, -90, 180])(
'scales a complete long title into the rotated title area at %s degrees',
async (rotation) => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
title: {
text: '这是一个用于验证旋转缩放行为的完整波形分析标题',
textStyle: { rotation },
},
},
)
const titleHeight = Number(wrapper.attributes('data-title-area-height'))
const title = wrapper.get('.waveform-chart__title-text')
expect(titleHeight).toBeGreaterThanOrEqual(44)
expect(titleHeight).toBeLessThanOrEqual(160)
expect(Number(wrapper.get('.waveform-chart__svg').attributes('height'))).toBe(360 - titleHeight)
expect(title.text()).toBe('这是一个用于验证旋转缩放行为的完整波形分析标题')
expect(title.attributes('style')).toContain(`rotate(${rotation}deg)`)
expect(title.attributes('style')).toContain('white-space: nowrap')
expect(Number(title.attributes('data-title-scale'))).toBeLessThanOrEqual(1)
expect(title.attributes('data-title-wrapped')).toBeUndefined()
},
)
it('updates fixed and adaptive drawing heights when the title changes', async () => {
const fixedWrapper = mount(WaveformChart, {
props: {
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
height: 420,
title: { text: '固定高度标题' },
},
})
expect(fixedWrapper.get('.waveform-chart__svg').attributes('height')).toBe('376')
await fixedWrapper.setProps({ title: { visible: false, text: '固定高度标题' } })
expect(fixedWrapper.get('.waveform-chart__svg').attributes('height')).toBe('420')
const adaptiveWrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{ title: { text: '自适应高度标题' } },
)
expect(adaptiveWrapper.get('.waveform-chart__svg').attributes('height')).toBe('316')
resizeObservers.at(-1)?.resize(800, 500)
await flushPromises()
expect(adaptiveWrapper.get('.waveform-chart__svg').attributes('height')).toBe('456')
})
it('includes the title offset in root-relative tooltip positioning', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
},
{ title: { text: 'shot: #4712' }, 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: 246 }),
})
overlay.element.dispatchEvent(
new MouseEvent('pointermove', { clientX: overlayWidth / 2, clientY: 100, bubbles: true }),
)
await flushPromises()
const tooltipTop = Number.parseFloat(
(wrapper.get('.waveform-chart__tooltip').element as HTMLElement).style.top,
)
expect(tooltipTop).toBeGreaterThanOrEqual(44)
})
it('includes the title offset in annotation editor anchors', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
},
{ title: { text: 'shot: #4712' }, 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: 246 }),
})
overlay.element.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: overlayWidth / 2,
clientY: 100,
bubbles: true,
}),
)
await flushPromises()
const component = wrapper.vm as typeof wrapper.vm & {
annotationInteraction: {
editorDraft: {
value: { anchor: { x: number; y: number } } | null
}
}
}
expect(component.annotationInteraction.editorDraft.value?.anchor.y).toBe(162)
})
it('captures and suppresses descendant context menus across the waveform svg', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
})
for (const selector of ['.waveform-chart__grid', '.waveform-chart__overlay']) {
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
const dispatched = wrapper.get(selector).element.dispatchEvent(event)
expect(dispatched).toBe(false)
expect(event.defaultPrevented).toBe(true)
}
const sharedWrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated' },
)
const sharedEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
const sharedDispatched = sharedWrapper
.get('.waveform-chart__overlay--shared')
.element.dispatchEvent(sharedEvent)
expect(sharedDispatched).toBe(false)
expect(sharedEvent.defaultPrevented).toBe(true)
})
it('applies size fallbacks for minimum, negative, and non-finite values', async () => { it('applies size fallbacks for minimum, negative, and non-finite values', async () => {
const minimumWrapper = mount(WaveformChart, { const minimumWrapper = mount(WaveformChart, {
props: { props: {
@@ -588,10 +1043,66 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(0) expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(0)
expect(wrapper.findAll('.waveform-chart__grid--minor line').length).toBeGreaterThan(0) expect(wrapper.findAll('.waveform-chart__grid--minor line').length).toBeGreaterThan(0)
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(true) expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(true)
expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
fill: 'none',
stroke: '#1f2937',
'stroke-width': '1',
})
expect(
wrapper.get('.waveform-chart__plot-frame').attributes('stroke-dasharray'),
).toBeUndefined()
expect(wrapper.get('.waveform-chart__plot-background').attributes('fill')).toBe('transparent')
expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12') expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12')
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd') expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
}) })
it('applies one custom frame style to every non-empty track', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
grid: { rowCount: 2, columnCount: 1 },
frameStyle: {
borderColor: 'rgba(255, 0, 0, 0.7)',
borderWidth: 2.5,
borderStyle: 'dashed',
backgroundColor: 'rgba(16, 185, 129, 0.2)',
},
})
const tracks = wrapper.findAll('.waveform-chart__track')
const frames = wrapper.findAll('.waveform-chart__plot-frame')
const backgrounds = wrapper.findAll('.waveform-chart__plot-background')
expect(frames).toHaveLength(2)
expect(backgrounds).toHaveLength(2)
frames.forEach((frame) => {
expect(frame.attributes()).toMatchObject({
stroke: 'rgba(255, 0, 0, 0.7)',
'stroke-width': '2.5',
'stroke-dasharray': '6 4',
})
})
backgrounds.forEach((background) => {
expect(background.attributes('fill')).toBe('rgba(16, 185, 129, 0.2)')
})
tracks.forEach((track) => {
const renderingLayers = track.findAll(
'.waveform-chart__plot-background, .waveform-chart__grid',
)
expect(renderingLayers[0].classes()).toContain('waveform-chart__plot-background')
})
})
it('falls back to the default width for invalid frame widths', async () => {
const wrapper = await mountSizedChart(gridSeries(1), {
frameStyle: { borderWidth: -1 },
})
expect(wrapper.get('.waveform-chart__plot-frame').attributes('stroke-width')).toBe('1')
await wrapper.setProps({ frameStyle: { borderWidth: Number.NaN } })
expect(wrapper.get('.waveform-chart__plot-frame').attributes('stroke-width')).toBe('1')
})
it('continues minor x-grid lines beyond the final major tick to the exact endpoint', async () => { it('continues minor x-grid lines beyond the final major tick to the exact endpoint', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
kind: 'points', kind: 'points',
@@ -1467,7 +1978,14 @@ describe('WaveformChart', () => {
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
expect(wrapper.get('.waveform-annotation-editor').attributes('aria-modal')).toBe('true') expect(wrapper.get('.waveform-annotation-editor').attributes('aria-modal')).toBe('true')
expect(wrapper.find('.waveform-annotation-editor__panel').exists()).toBe(true) expect(wrapper.find('.waveform-annotation-editor__panel').exists()).toBe(true)
await wrapper.get('textarea[aria-label="标注文本"]').setValue('右键标注') const textarea = wrapper.get('textarea[aria-label="标注文本"]')
const textareaContextMenu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
})
expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true)
expect(textareaContextMenu.defaultPrevented).toBe(false)
await textarea.setValue('右键标注')
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([ expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([

View File

@@ -13,15 +13,30 @@ import {
} from 'd3' } from 'd3'
import { resolveWaveformRenderingOptions } from '../core' import { resolveWaveformRenderingOptions } from '../core'
import { formatScientificYAxisLabel, paddedDomain } from '../utils' import { formatScientificYAxisLabel, paddedDomain } from '../utils'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from 'vue' import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useId,
watch,
type CSSProperties,
} from 'vue'
import { import {
type WaveformAnnotation, type WaveformAnnotation,
type WaveformData, type WaveformData,
type WaveformDisplayMode, type WaveformDisplayMode,
type WaveformFrameStyle,
type WaveformInteractionMode, type WaveformInteractionMode,
type WaveformLegendOptions,
type WaveformLegendOrientation,
type WaveformLegendPosition,
type WaveformPoint, type WaveformPoint,
type WaveformRenderingOptions, type WaveformRenderingOptions,
type WaveformTitleOptions,
} from './data/types' } from './data/types'
import { import {
ANNOTATION_AMBIGUITY_DISTANCE, ANNOTATION_AMBIGUITY_DISTANCE,
@@ -56,8 +71,9 @@ import {
X_AXIS_BAND, X_AXIS_BAND,
type WaveformGridOptions, type WaveformGridOptions,
} from './core/grid' } from './core/grid'
import type { DisplaySeries, HoveredSeriesPoint, TrackLayout } from './core/types' import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
import { buildTrackLayouts } from './core/layout' import { buildTrackLayouts } from './core/layout'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title'
import { usePreparedWaveformSeries } from './core/useWaveformData' import { usePreparedWaveformSeries } from './core/useWaveformData'
import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue' import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
@@ -74,12 +90,15 @@ const props = withDefaults(
zoomable?: boolean zoomable?: boolean
timeUnit?: 's' | 'ms' timeUnit?: 's' | 'ms'
frameNumber?: string | number frameNumber?: string | number
frameStyle?: WaveformFrameStyle
annotations?: WaveformAnnotation[] annotations?: WaveformAnnotation[]
annotationsVisible?: boolean annotationsVisible?: boolean
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
showAnnotationToolbar?: boolean showAnnotationToolbar?: boolean
grid?: WaveformGridOptions grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions rendering?: WaveformRenderingOptions
title?: WaveformTitleOptions
legend?: WaveformLegendOptions
}>(), }>(),
{ {
displayMode: 'independent', displayMode: 'independent',
@@ -95,6 +114,7 @@ const props = withDefaults(
showAnnotationToolbar: false, showAnnotationToolbar: false,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }), grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}), rendering: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }),
}, },
) )
@@ -114,9 +134,12 @@ const margin = chartMargin
const minimumHeight = chartMinimumHeight const minimumHeight = chartMinimumHeight
const container = ref<HTMLDivElement>() const container = ref<HTMLDivElement>()
const svgElement = ref<SVGSVGElement>() const svgElement = ref<SVGSVGElement>()
const titleMeasureElement = ref<HTMLSpanElement>()
const sharedOverlayElement = ref<SVGRectElement>() const sharedOverlayElement = ref<SVGRectElement>()
const observedWidth = ref(0) const observedWidth = ref(0)
const observedHeight = ref(0) const observedHeight = ref(0)
const measuredTitleWidth = ref(0)
const measuredTitleHeight = ref(0)
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity) const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
const independentTransforms = shallowRef<ZoomTransform[]>([]) const independentTransforms = shallowRef<ZoomTransform[]>([])
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([]) const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
@@ -158,7 +181,95 @@ const containerStyle = computed(() => ({
width: fixedWidth.value === undefined ? '100%' : `${fixedWidth.value}px`, width: fixedWidth.value === undefined ? '100%' : `${fixedWidth.value}px`,
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`, height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
})) }))
const innerHeight = computed(() => Math.max(0, chartHeight.value - margin.top - margin.bottom)) const legendPosition = computed<WaveformLegendPosition>(() => props.legend?.position ?? 'top-right')
const legendBackgroundColor = computed(
() => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)',
)
const legendOrientation = computed<Exclude<WaveformLegendOrientation, 'auto'>>(() => {
const orientation = props.legend?.orientation ?? 'auto'
if (orientation !== 'auto') return orientation
return legendPosition.value === 'top' || legendPosition.value === 'bottom'
? 'horizontal'
: 'vertical'
})
const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
const titleVisible = computed(
() =>
Boolean(props.title) && props.title?.visible !== false && resolvedTitleText.value.length > 0,
)
const titleFontSize = computed(() => {
const fontSize = props.title?.textStyle?.fontSize
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : 14
})
const titleRotation = computed(() => {
const rotation = props.title?.textStyle?.rotation
return Number.isFinite(rotation) ? (rotation as number) : 0
})
const titleIsRotated = computed(() => {
const normalizedRotation = ((titleRotation.value % 360) + 360) % 360
return normalizedRotation > 1e-6 && Math.abs(normalizedRotation - 360) > 1e-6
})
const titlePresentationStyle = computed<CSSProperties>(() => ({
color: props.title?.textStyle?.color ?? '#1f2937',
fontSize: `${titleFontSize.value}px`,
fontFamily: props.title?.textStyle?.fontFamily || '"Microsoft YaHei", "微软雅黑", sans-serif',
fontWeight: props.title?.textStyle?.fontWeight ?? 400,
fontStyle: props.title?.textStyle?.fontStyle ?? 'normal',
textDecoration: props.title?.textStyle?.textDecoration ?? 'none',
letterSpacing: props.title?.textStyle?.letterSpacing ?? 'normal',
lineHeight: '1.2',
}))
const estimatedTitleWidth = computed(() => {
const letterSpacing = Number.parseFloat(props.title?.textStyle?.letterSpacing ?? '')
const spacingWidth = Number.isFinite(letterSpacing)
? Math.max(0, resolvedTitleText.value.length - 1) * letterSpacing
: 0
return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * 0.62 + spacingWidth)
})
const titleAvailableWidth = computed(() => {
const measuredAvailableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2
return measuredAvailableWidth > 0 ? measuredAvailableWidth : estimatedTitleWidth.value
})
const titleMeasureStyle = computed<CSSProperties>(() => ({
...titlePresentationStyle.value,
width: 'max-content',
maxWidth: titleIsRotated.value ? 'none' : `${titleAvailableWidth.value}px`,
whiteSpace: titleIsRotated.value ? 'nowrap' : 'normal',
overflowWrap: titleIsRotated.value ? 'normal' : 'anywhere',
}))
const titleLayout = computed(() =>
calculateRotatedTitleLayout({
naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
naturalHeight: measuredTitleHeight.value || titleFontSize.value * 1.2,
availableWidth: titleAvailableWidth.value,
rotation: titleRotation.value,
}),
)
const titleAreaHeight = computed(() => (titleVisible.value ? titleLayout.value.areaHeight : 0))
const drawingHeight = computed(() => Math.max(0, chartHeight.value - titleAreaHeight.value))
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`,
justifyContent:
props.title?.align === 'left'
? 'flex-start'
: props.title?.align === 'right'
? 'flex-end'
: 'center',
}))
const titleVisualStyle = computed<CSSProperties>(() => ({
width: `${titleLayout.value.visualWidth}px`,
height: `${titleLayout.value.visualHeight}px`,
}))
const titleTextStyle = computed<CSSProperties>(() => ({
...titlePresentationStyle.value,
width: `${titleLayout.value.textWidth}px`,
minHeight: `${titleLayout.value.textHeight}px`,
textAlign: props.title?.align ?? 'center',
whiteSpace: titleIsRotated.value ? 'nowrap' : 'normal',
overflowWrap: titleIsRotated.value ? 'normal' : 'anywhere',
transform: `translate(-50%, -50%) rotate(${titleRotation.value}deg) scale(${titleLayout.value.scale})`,
}))
const chartSeries = computed<DisplaySeries[]>(() => const chartSeries = computed<DisplaySeries[]>(() =>
preparedSeries.value.map((series, index: number): DisplaySeries => ({ preparedSeries.value.map((series, index: number): DisplaySeries => ({
...series, ...series,
@@ -166,11 +277,26 @@ const chartSeries = computed<DisplaySeries[]>(() =>
series.color ?? (index === 0 ? props.lineColor : channelColors[index % channelColors.length]), series.color ?? (index === 0 ? props.lineColor : channelColors[index % channelColors.length]),
})), })),
) )
const chartTracks = computed<DisplayTrack[]>(() => {
const groupedSeries = new Map<string, DisplaySeries[]>()
chartSeries.value.forEach((series) => {
const trackId = series.trackId || series.id
const trackSeries = groupedSeries.get(trackId)
if (trackSeries) trackSeries.push(series)
else groupedSeries.set(trackId, [series])
})
return Array.from(groupedSeries, ([id, series]) => ({
id,
series,
xDomain: paddedDomain(series.flatMap((item) => item.xDomain)),
yDomain: paddedDomain(series.flatMap((item) => item.yDomain)),
}))
})
const gridOptions = computed(() => normalizeGridOptions(props.grid)) const gridOptions = computed(() => normalizeGridOptions(props.grid))
const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering)) const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
const pageCount = computed(() => getPageCount(chartSeries.value.length, gridOptions.value)) const pageCount = computed(() => getPageCount(chartTracks.value.length, gridOptions.value))
const pagedSeries = computed(() => const pagedTracks = computed(() =>
paginateSeries(chartSeries.value, currentPage.value, gridOptions.value), paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
) )
const yAxisCharacterWidth = 7 const yAxisCharacterWidth = 7
@@ -181,8 +307,8 @@ const yAxisLabelBandWidth = 24
const minimumPlotWidth = 120 const minimumPlotWidth = 120
const yAxisMetrics = computed(() => { const yAxisMetrics = computed(() => {
const formattedTickLabels = chartSeries.value.flatMap((series) => { const formattedTickLabels = chartTracks.value.flatMap((track) => {
const scale = scaleLinear(series.yDomain, [1, 0]).nice() const scale = scaleLinear(track.yDomain, [1, 0]).nice()
const [axisMin, axisMax] = scale.domain() const [axisMin, axisMax] = scale.domain()
const values = scale.ticks(10) const values = scale.ticks(10)
const topTickValue = values.reduce<number | undefined>((closestTick, tickValue) => { const topTickValue = values.reduce<number | undefined>((closestTick, tickValue) => {
@@ -205,7 +331,9 @@ const yAxisMetrics = computed(() => {
return { tickClearance, fullClearance, labelCenterX } return { tickClearance, fullClearance, labelCenterX }
}) })
const hasYAxisLabels = computed(() => const hasYAxisLabels = computed(() =>
chartSeries.value.some((series) => Boolean(series.name.trim() || props.yLabel)), chartTracks.value.some(
(track) => track.series.length === 1 && Boolean(track.series[0]?.name.trim() || props.yLabel),
),
) )
const chartLeftMargin = computed(() => const chartLeftMargin = computed(() =>
Math.max( Math.max(
@@ -261,7 +389,7 @@ const tooltipSeriesPoints = computed<TooltipSeriesPoint[]>(() => {
}) })
const sharedXDomain = computed(() => const sharedXDomain = computed(() =>
paddedDomain(chartSeries.value.flatMap((series) => series.xDomain)), paddedDomain(chartTracks.value.flatMap((track) => track.xDomain)),
) )
const sharedZoomDomain = computed( const sharedZoomDomain = computed(
() => () =>
@@ -276,10 +404,10 @@ const gridCells = computed(() => {
innerHeight.value, innerHeight.value,
gridOptions.value, gridOptions.value,
props.displayMode, props.displayMode,
pagedSeries.value.map(Boolean), pagedTracks.value.map(Boolean),
yAxisLayout.value.horizontalGap, yAxisLayout.value.horizontalGap,
) )
return cells.map((cell, index) => ({ ...cell, series: pagedSeries.value[index] })) return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
}) })
const trackLayouts = computed<TrackLayout[]>(() => const trackLayouts = computed<TrackLayout[]>(() =>
@@ -297,11 +425,19 @@ const trackLayouts = computed<TrackLayout[]>(() =>
}), }),
) )
function annotationLayoutsForTrack(track: TrackLayout): AnnotationTrackLayout[] {
return track.seriesList.map((series) => ({ ...track, series }))
}
const annotationTrackLayouts = computed<AnnotationTrackLayout[]>(() =>
trackLayouts.value.flatMap(annotationLayoutsForTrack),
)
const renderedAnnotations = computed(() => const renderedAnnotations = computed(() =>
props.annotationsVisible props.annotationsVisible
? layoutAnnotations( ? layoutAnnotations(
props.annotations, props.annotations,
trackLayouts.value as AnnotationTrackLayout[], annotationTrackLayouts.value,
innerWidth.value, innerWidth.value,
innerHeight.value, innerHeight.value,
) )
@@ -324,7 +460,7 @@ const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
function resolveFrameNumber(trackIndex: number): string | number | undefined { function resolveFrameNumber(trackIndex: number): string | number | undefined {
if (props.frameNumber === undefined || props.frameNumber === null) return undefined if (props.frameNumber === undefined || props.frameNumber === null) return undefined
if (chartSeries.value.length === 1) return props.frameNumber if (chartTracks.value.length === 1) return props.frameNumber
return typeof props.frameNumber === 'number' return typeof props.frameNumber === 'number'
? props.frameNumber + trackIndex ? props.frameNumber + trackIndex
: `${props.frameNumber}-${trackIndex + 1}` : `${props.frameNumber}-${trackIndex + 1}`
@@ -467,17 +603,21 @@ function resolvePointerEditorAnchor(
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex] const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
return { return {
x: chartLeftMargin.value + (track ? track.left + pointerX : pointerX), x: chartLeftMargin.value + (track ? track.left + pointerX : pointerX),
y: margin.top + (track ? track.top + pointerY : pointerY), y: titleAreaHeight.value + margin.top + (track ? track.top + pointerY : pointerY),
} }
} }
function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor { function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor {
const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId) const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === annotation.seriesId),
)
return { return {
x: track x: track
? chartLeftMargin.value + track.left + track.xScale(annotation.x) ? chartLeftMargin.value + track.left + track.xScale(annotation.x)
: chartWidth.value / 2, : chartWidth.value / 2,
y: track ? margin.top + track.top + track.yScale(annotation.y) : chartHeight.value / 2, y: track
? titleAreaHeight.value + margin.top + track.top + track.yScale(annotation.y)
: chartHeight.value / 2,
} }
} }
@@ -490,9 +630,10 @@ function beginCreate(
editorSeriesOptions.value = candidates editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
const track = trackLayouts.value.find((item) => item.index === hit.trackIndex) const track = trackLayouts.value.find((item) => item.index === hit.trackIndex)
const series = track?.seriesList.find((item) => item.id === hit.seriesId)
if (draft?.mode === 'add') { if (draft?.mode === 'add') {
draft.annotation.style = { draft.annotation.style = {
borderColor: track?.series.color || '#1677ff', borderColor: series?.color || '#1677ff',
textColor: '#333333', textColor: '#333333',
backgroundColor: 'rgba(255, 255, 255, 0.92)', backgroundColor: 'rgba(255, 255, 255, 0.92)',
} }
@@ -502,9 +643,12 @@ function beginCreate(
function changeDraftSeries(seriesId: string) { function changeDraftSeries(seriesId: string) {
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId) const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId)
const track = trackLayouts.value.find((item) => item.series.id === seriesId) const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === seriesId),
)
const series = track?.seriesList.find((item) => item.id === seriesId)
const point = const point =
track && draft ? interpolateAnnotationPoint(track.series.points, draft.annotation.x) : null series && draft ? interpolateAnnotationPoint(series.points, draft.annotation.x) : null
if (!draft || !candidate || !track || !point) return if (!draft || !candidate || !track || !point) return
draft.annotation = { draft.annotation = {
...draft.annotation, ...draft.annotation,
@@ -569,7 +713,7 @@ function resolveAnnotationCandidates(
const xValue = referenceTrack.xScale.invert(localPointerX) const xValue = referenceTrack.xScale.invert(localPointerX)
return { return {
candidates: findAnnotationSeriesCandidates( candidates: findAnnotationSeriesCandidates(
[referenceTrack] as AnnotationTrackLayout[], annotationLayoutsForTrack(referenceTrack),
xValue, xValue,
localPointerX, localPointerX,
sharedPointerY, sharedPointerY,
@@ -635,10 +779,12 @@ function editContextAnnotation() {
const annotationId = context?.annotationId const annotationId = context?.annotationId
const annotation = props.annotations.find((item) => item.id === annotationId) const annotation = props.annotations.find((item) => item.id === annotationId)
if (annotation) { if (annotation) {
const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId) const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === annotation.seriesId),
)
editorSeriesOptions.value = track editorSeriesOptions.value = track
? findAnnotationSeriesCandidates( ? findAnnotationSeriesCandidates(
[track] as AnnotationTrackLayout[], annotationLayoutsForTrack(track),
annotation.x, annotation.x,
track.xScale(annotation.x), track.xScale(annotation.x),
track.top + track.yScale(annotation.y), track.top + track.yScale(annotation.y),
@@ -685,14 +831,16 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
if (!overlay || !track) return if (!overlay || !track) return
const [pointerX, pointerY] = pointer(event, overlay) const [pointerX, pointerY] = pointer(event, overlay)
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX))) const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
const point = nearestPoint(track.series, xValue) hoveredSeriesPoints.value = track.seriesList.flatMap((series) => {
hoveredSeriesPoints.value = point ? [{ ...track.series, trackIndex, point }] : [] const point = nearestPoint(series, xValue)
return point ? [{ ...series, trackIndex, point }] : []
})
hoveredTrackIndex.value = trackIndex hoveredTrackIndex.value = trackIndex
hoverPosition.value = { hoverPosition.value = {
x: chartLeftMargin.value + track.left + pointerX, x: chartLeftMargin.value + track.left + pointerX,
y: margin.top + track.top + pointerY, y: titleAreaHeight.value + margin.top + track.top + pointerY,
} }
emit('point-hover', point ?? null) emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null)
} }
function handleSharedPointerMove(event: PointerEvent) { function handleSharedPointerMove(event: PointerEvent) {
@@ -702,21 +850,23 @@ function handleSharedPointerMove(event: PointerEvent) {
if (!referenceTrack) return if (!referenceTrack) return
const localPointerX = Math.max(0, Math.min(referenceTrack.width, pointerX - referenceTrack.left)) const localPointerX = Math.max(0, Math.min(referenceTrack.width, pointerX - referenceTrack.left))
const xValue = referenceTrack.xScale.invert(localPointerX) const xValue = referenceTrack.xScale.invert(localPointerX)
hoveredSeriesPoints.value = trackLayouts.value.flatMap((track) => { hoveredSeriesPoints.value = trackLayouts.value.flatMap((track) =>
const point = nearestPoint(track.series, xValue) track.seriesList.flatMap((series) => {
return point ? [{ ...track.series, trackIndex: track.index, point }] : [] const point = nearestPoint(series, xValue)
}) return point ? [{ ...series, trackIndex: track.index, point }] : []
}),
)
hoveredTrackIndex.value = null hoveredTrackIndex.value = null
hoverPosition.value = { hoverPosition.value = {
x: chartLeftMargin.value + pointerX, x: chartLeftMargin.value + pointerX,
y: margin.top + pointerY, y: titleAreaHeight.value + margin.top + pointerY,
} }
emit('point-hover', hoveredPoint.value) emit('point-hover', hoveredPoint.value)
} }
function resetViewport() { function resetViewport() {
sharedTransform.value = zoomIdentity sharedTransform.value = zoomIdentity
independentTransforms.value = chartSeries.value.map(() => zoomIdentity) independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
clearHover() clearHover()
editorSeriesOptions.value = [] editorSeriesOptions.value = []
void nextTick(configureZoom) void nextTick(configureZoom)
@@ -730,7 +880,7 @@ function goToPage(page: number) {
annotationInteraction.closeContextMenu() annotationInteraction.closeContextMenu()
cancelAnnotation() cancelAnnotation()
if (props.displayMode === 'independent') { if (props.displayMode === 'independent') {
independentTransforms.value = pagedSeries.value.map(() => zoomIdentity) independentTransforms.value = pagedTracks.value.map(() => zoomIdentity)
} }
void nextTick(configureZoom) void nextTick(configureZoom)
emit('page-change', nextPage, pageCount.value) emit('page-change', nextPage, pageCount.value)
@@ -742,7 +892,7 @@ watch(
innerHeight, innerHeight,
() => props.zoomable, () => props.zoomable,
() => props.displayMode, () => props.displayMode,
() => chartSeries.value.length, () => chartTracks.value.length,
() => currentPage.value, () => currentPage.value,
() => gridOptions.value.rowCount, () => gridOptions.value.rowCount,
() => gridOptions.value.columnCount, () => gridOptions.value.columnCount,
@@ -815,11 +965,34 @@ watch(
{ deep: true }, { deep: true },
) )
function measureTitle() {
if (!titleVisible.value || !titleMeasureElement.value) {
measuredTitleWidth.value = 0
measuredTitleHeight.value = 0
return
}
const bounds = titleMeasureElement.value.getBoundingClientRect()
measuredTitleWidth.value = titleMeasureElement.value.scrollWidth || bounds.width
measuredTitleHeight.value = titleMeasureElement.value.scrollHeight || bounds.height
}
watch(
[resolvedTitleText, titleVisible, titleMeasureStyle],
async () => {
measuredTitleWidth.value = 0
measuredTitleHeight.value = 0
await nextTick()
measureTitle()
},
{ immediate: true },
)
onMounted(() => { onMounted(() => {
if (!container.value) return if (!container.value) return
resizeObserver.value = new ResizeObserver(([entry]) => { resizeObserver.value = new ResizeObserver(([entry]) => {
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0) observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0) observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
void nextTick(measureTitle)
}) })
resizeObserver.value.observe(container.value) resizeObserver.value.observe(container.value)
}) })
@@ -843,14 +1016,43 @@ onBeforeUnmount(() => {
:data-display-mode="displayMode" :data-display-mode="displayMode"
:data-interaction-mode="activeInteractionMode" :data-interaction-mode="activeInteractionMode"
:data-chart-left-margin="chartLeftMargin" :data-chart-left-margin="chartLeftMargin"
:data-title-area-height="titleAreaHeight"
> >
<div
v-if="titleVisible"
class="waveform-chart__title-area"
:style="titleAreaStyle"
role="heading"
aria-level="2"
>
<span
ref="titleMeasureElement"
class="waveform-chart__title-measure"
:style="titleMeasureStyle"
aria-hidden="true"
>
{{ resolvedTitleText }}
</span>
<span class="waveform-chart__title-visual" :style="titleVisualStyle">
<span
class="waveform-chart__title-text"
:style="titleTextStyle"
:data-title-scale="titleLayout.scale"
:data-title-wrapped="titleLayout.wrapped || undefined"
>
{{ resolvedTitleText }}
</span>
</span>
</div>
<svg <svg
ref="svgElement" ref="svgElement"
class="waveform-chart__svg" class="waveform-chart__svg"
:width="chartWidth" :width="chartWidth"
:height="chartHeight" :height="drawingHeight"
role="img" role="img"
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'" :aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
@contextmenu.capture.prevent
> >
<defs> <defs>
<clipPath <clipPath
@@ -890,8 +1092,12 @@ onBeforeUnmount(() => {
:display-mode="displayMode" :display-mode="displayMode"
:interaction-mode="activeInteractionMode" :interaction-mode="activeInteractionMode"
:frame-number="resolveFrameNumber(track.index)" :frame-number="resolveFrameNumber(track.index)"
:frame-style="frameStyle"
:time-unit="timeUnit" :time-unit="timeUnit"
:y-label="yLabel" :y-label="yLabel"
:legend-position="legendPosition"
:legend-orientation="legendOrientation"
:legend-background-color="legendBackgroundColor"
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)" :hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
@pointer-move="handleIndependentPointerMove($event, track.index)" @pointer-move="handleIndependentPointerMove($event, track.index)"
@pointer-leave="clearHover" @pointer-leave="clearHover"
@@ -936,7 +1142,7 @@ onBeforeUnmount(() => {
v-if="hasChartArea && !hasWaveformData" v-if="hasChartArea && !hasWaveformData"
class="waveform-chart__empty" class="waveform-chart__empty"
:x="chartWidth / 2" :x="chartWidth / 2"
:y="chartHeight / 2" :y="drawingHeight / 2"
text-anchor="middle" text-anchor="middle"
> >
暂无有效波形数据 暂无有效波形数据
@@ -949,7 +1155,7 @@ onBeforeUnmount(() => {
aria-label="波形分页" aria-label="波形分页"
:current="currentPage" :current="currentPage"
:page-size="getPageSize(gridOptions)" :page-size="getPageSize(gridOptions)"
:total="chartSeries.length" :total="chartTracks.length"
:show-size-changer="false" :show-size-changer="false"
:show-quick-jumper="false" :show-quick-jumper="false"
@change="goToPage" @change="goToPage"
@@ -1018,6 +1224,41 @@ onBeforeUnmount(() => {
z-index: 2; z-index: 2;
} }
.waveform-chart__title-area {
position: relative;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
padding: 0 24px;
overflow: hidden;
background: #fff;
}
.waveform-chart__title-measure {
position: absolute;
display: block;
visibility: hidden;
pointer-events: none;
}
.waveform-chart__title-visual {
position: relative;
flex: 0 0 auto;
min-width: 0;
overflow: visible;
}
.waveform-chart__title-text {
position: absolute;
top: 50%;
left: 50%;
display: block;
overflow: visible;
line-height: 1.2;
transform-origin: center;
}
.waveform-chart__pagination :deep(.ant-pagination-item), .waveform-chart__pagination :deep(.ant-pagination-item),
.waveform-chart__pagination :deep(.ant-pagination-prev .ant-pagination-item-link), .waveform-chart__pagination :deep(.ant-pagination-prev .ant-pagination-item-link),
.waveform-chart__pagination :deep(.ant-pagination-next .ant-pagination-item-link) { .waveform-chart__pagination :deep(.ant-pagination-next .ant-pagination-item-link) {

View File

@@ -457,7 +457,7 @@ function handleSeriesChange(event: Event) {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
padding-top: 2px; padding-top: 8px;
border-top: 1px solid #eaecf0; border-top: 1px solid #eaecf0;
} }

View File

@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { ColorPicker } from 'vue3-colorpicker' import { ColorPicker } from 'vue3-colorpicker'
import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue' import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue'
@@ -77,8 +77,11 @@ describe('waveform annotation controls', () => {
) )
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.is-primary').attributes('disabled')).toBeDefined()
await vi.waitFor(
() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3),
{ timeout: 5000 },
)
const colorPickers = wrapper.findAllComponents(ColorPicker) const colorPickers = wrapper.findAllComponents(ColorPicker)
expect(colorPickers).toHaveLength(3)
expect(wrapper.findAll('.waveform-annotation-editor__color-field')).toHaveLength(3) expect(wrapper.findAll('.waveform-annotation-editor__color-field')).toHaveLength(3)
expect(colorPickers.map((picker) => picker.props('pureColor'))).toEqual([ expect(colorPickers.map((picker) => picker.props('pureColor'))).toEqual([
'#1677ff', '#1677ff',
@@ -153,6 +156,10 @@ describe('waveform annotation controls', () => {
}, },
}) })
await flushPromises() await flushPromises()
await vi.waitFor(
() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3),
{ timeout: 5000 },
)
expect( expect(
wrapper.findAllComponents(ColorPicker).map((picker) => picker.props('pureColor')), wrapper.findAllComponents(ColorPicker).map((picker) => picker.props('pureColor')),

View File

@@ -8,10 +8,10 @@ import {
type GridCellGeometry, type GridCellGeometry,
type NormalizedWaveformGridOptions, type NormalizedWaveformGridOptions,
} from './grid' } from './grid'
import type { DisplaySeries, TrackLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
interface SeriesGridCell extends GridCellGeometry { interface SeriesGridCell extends GridCellGeometry {
series?: DisplaySeries series?: DisplayTrack
} }
export interface BuildTrackLayoutsOptions { export interface BuildTrackLayoutsOptions {
@@ -34,7 +34,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
return visibleCells.flatMap((cell, index) => { return visibleCells.flatMap((cell, index) => {
const isEmpty = !cell.series const isEmpty = !cell.series
if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return [] if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return []
const series: DisplaySeries = cell.series ?? { const emptySeries: DisplaySeries = {
id: `empty-grid-slot-${cell.slotIndex}`, id: `empty-grid-slot-${cell.slotIndex}`,
name: '', name: '',
color: 'transparent', color: 'transparent',
@@ -42,16 +42,23 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
xDomain: [0, 1], xDomain: [0, 1],
yDomain: [0, 1], yDomain: [0, 1],
} }
const displayTrack: DisplayTrack = cell.series ?? {
id: emptySeries.id,
series: [emptySeries],
xDomain: emptySeries.xDomain,
yDomain: emptySeries.yDomain,
}
const series = displayTrack.series[0]
const baseXScale = const baseXScale =
options.displayMode === 'independent' options.displayMode === 'independent'
? scaleLinear(series.xDomain, [0, cell.width]) ? scaleLinear(displayTrack.xDomain, [0, cell.width])
: scaleLinear(options.sharedZoomDomain, [0, cell.width]) : scaleLinear(options.sharedZoomDomain, [0, cell.width])
const transform = const transform =
options.displayMode === 'independent' options.displayMode === 'independent'
? (options.independentTransforms[index] ?? zoomIdentity) ? (options.independentTransforms[index] ?? zoomIdentity)
: zoomIdentity : zoomIdentity
const xScale = transform.rescaleX(baseXScale) const xScale = transform.rescaleX(baseXScale)
const yScale = scaleLinear(series.yDomain, [cell.plotHeight, 0]).nice() const yScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100))) const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55))) const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const [yAxisStart, yAxisEnd] = yScale.domain() const [yAxisStart, yAxisEnd] = yScale.domain()
@@ -73,16 +80,27 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const position = xScale(tick) const position = xScale(tick)
return position > leftClearance && position < cell.width - rightClearance return position > leftClearance && position < cell.width - rightClearance
}) })
const seriesPaths = displayTrack.series.map((trackSeries) => {
const renderPoints = selectRenderablePoints( const renderPoints = selectRenderablePoints(
series.points, trackSeries.points,
domain, domain,
cell.width, cell.width,
options.rendering, options.rendering,
) )
return {
series: trackSeries,
path: isEmpty
? null
: line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => yScale(point.y))(renderPoints),
}
})
return { return {
index, index,
series, series,
seriesList: displayTrack.series,
isEmpty, isEmpty,
column: cell.column, column: cell.column,
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0, showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
@@ -100,11 +118,8 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
yAxisTickValues, yAxisTickValues,
xAxisTickValues, xAxisTickValues,
endpointLabels, endpointLabels,
path: isEmpty path: seriesPaths[0]?.path ?? null,
? null seriesPaths,
: line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => yScale(point.y))(renderPoints),
showXAxis: showXAxis:
options.displayMode === 'independent' || options.displayMode === 'independent' ||
(options.displayMode === 'compact' (options.displayMode === 'compact'

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { calculateRotatedTitleLayout, TITLE_AREA_MAX_HEIGHT, TITLE_AREA_MIN_HEIGHT } from './title'
describe('calculateRotatedTitleLayout', () => {
it('reserves the available width for an unrotated title that fits', () => {
expect(
calculateRotatedTitleLayout({
naturalWidth: 200,
naturalHeight: 20,
availableWidth: 300,
rotation: 0,
}),
).toEqual({
textWidth: 300,
textHeight: 20,
visualWidth: 300,
visualHeight: 20,
areaHeight: TITLE_AREA_MIN_HEIGHT,
scale: 1,
wrapped: false,
})
})
it.each([45, 90, -90, 180])('keeps a %s degree title inside the maximum area', (rotation) => {
const layout = calculateRotatedTitleLayout({
naturalWidth: 400,
naturalHeight: 20,
availableWidth: 600,
rotation,
})
expect(layout.areaHeight).toBeGreaterThanOrEqual(TITLE_AREA_MIN_HEIGHT)
expect(layout.areaHeight).toBeLessThanOrEqual(TITLE_AREA_MAX_HEIGHT)
expect(layout.visualHeight).toBeLessThanOrEqual(TITLE_AREA_MAX_HEIGHT)
expect(layout.textWidth).toBe(400)
expect(layout.scale).toBeLessThanOrEqual(1)
expect(layout.wrapped).toBe(false)
})
it('wraps an unrotated title to the available width without scaling', () => {
const layout = calculateRotatedTitleLayout({
naturalWidth: 1_000,
naturalHeight: 20,
availableWidth: 120,
rotation: 0,
})
expect(layout.textWidth).toBe(120)
expect(layout.textHeight).toBe(180)
expect(layout.visualWidth).toBe(120)
expect(layout.visualHeight).toBe(180)
expect(layout.areaHeight).toBe(198)
expect(layout.scale).toBe(1)
expect(layout.wrapped).toBe(true)
})
it('falls back safely for non-finite dimensions and rotation', () => {
const layout = calculateRotatedTitleLayout({
naturalWidth: Number.NaN,
naturalHeight: Number.POSITIVE_INFINITY,
availableWidth: Number.NaN,
rotation: Number.NaN,
})
expect(layout).toEqual({
textWidth: 1,
textHeight: 1,
visualWidth: 1,
visualHeight: 1,
areaHeight: TITLE_AREA_MIN_HEIGHT,
scale: 1,
wrapped: false,
})
})
})

View File

@@ -0,0 +1,88 @@
export const TITLE_AREA_MIN_HEIGHT = 44
export const TITLE_AREA_MAX_HEIGHT = 160
export const TITLE_AREA_HORIZONTAL_PADDING = 24
export const TITLE_AREA_VERTICAL_PADDING = 18
const TRIGONOMETRY_EPSILON = 1e-6
export interface RotatedTitleLayout {
textWidth: number
textHeight: number
visualWidth: number
visualHeight: number
areaHeight: number
scale: number
wrapped: boolean
}
export interface RotatedTitleLayoutOptions {
naturalWidth: number
naturalHeight: number
availableWidth: number
rotation: number
}
function clampPositive(value: number): number {
return Number.isFinite(value) ? Math.max(1, value) : 1
}
export function calculateRotatedTitleLayout({
naturalWidth,
naturalHeight,
availableWidth,
rotation,
}: RotatedTitleLayoutOptions): RotatedTitleLayout {
const safeNaturalWidth = clampPositive(naturalWidth)
const safeNaturalHeight = clampPositive(naturalHeight)
const safeAvailableWidth = clampPositive(availableWidth)
const safeRotation = Number.isFinite(rotation) ? rotation : 0
const radians = (safeRotation * Math.PI) / 180
const absoluteCosine = Math.abs(Math.cos(radians))
const absoluteSine = Math.abs(Math.sin(radians))
const normalizedRotation = ((safeRotation % 360) + 360) % 360
const isRotated =
normalizedRotation > TRIGONOMETRY_EPSILON &&
Math.abs(normalizedRotation - 360) > TRIGONOMETRY_EPSILON
if (!isRotated) {
const lineCount = Math.max(1, Math.ceil(safeNaturalWidth / safeAvailableWidth))
const textWidth = safeAvailableWidth
const textHeight = safeNaturalHeight * lineCount
return {
textWidth,
textHeight,
visualWidth: textWidth,
visualHeight: textHeight,
areaHeight: Math.max(TITLE_AREA_MIN_HEIGHT, textHeight + TITLE_AREA_VERTICAL_PADDING),
scale: 1,
wrapped: lineCount > 1,
}
}
const maximumVisualHeight = TITLE_AREA_MAX_HEIGHT - TITLE_AREA_VERTICAL_PADDING
const naturalVisualWidth =
safeNaturalWidth * absoluteCosine + safeNaturalHeight * absoluteSine
const naturalVisualHeight =
safeNaturalWidth * absoluteSine + safeNaturalHeight * absoluteCosine
const scale = Math.min(
1,
safeAvailableWidth / naturalVisualWidth,
maximumVisualHeight / naturalVisualHeight,
)
const visualWidth = naturalVisualWidth * scale
const visualHeight = naturalVisualHeight * scale
const areaHeight = Math.min(
TITLE_AREA_MAX_HEIGHT,
Math.max(TITLE_AREA_MIN_HEIGHT, visualHeight + TITLE_AREA_VERTICAL_PADDING),
)
return {
textWidth: safeNaturalWidth,
textHeight: safeNaturalHeight,
visualWidth,
visualHeight,
areaHeight,
scale,
wrapped: false,
}
}

View File

@@ -6,6 +6,7 @@ import type { WaveformPoint } from '../../types'
*/ */
export interface DisplaySeries { export interface DisplaySeries {
id: string id: string
trackId?: string
name: string name: string
unit?: string unit?: string
color: string color: string
@@ -14,6 +15,18 @@ export interface DisplaySeries {
yDomain: [number, number] yDomain: [number, number]
} }
export interface DisplayTrack {
id: string
series: DisplaySeries[]
xDomain: [number, number]
yDomain: [number, number]
}
export interface TrackSeriesPath {
series: DisplaySeries
path: string | null
}
/** /**
* 悬浮的系列点 * 悬浮的系列点
*/ */
@@ -28,6 +41,7 @@ export interface HoveredSeriesPoint extends DisplaySeries {
export interface TrackLayout { export interface TrackLayout {
index: number index: number
series: DisplaySeries series: DisplaySeries
seriesList: DisplaySeries[]
isEmpty: boolean isEmpty: boolean
column: number column: number
showYAxisLabel: boolean showYAxisLabel: boolean
@@ -46,6 +60,7 @@ export interface TrackLayout {
xAxisTickValues: number[] xAxisTickValues: number[]
endpointLabels: { start: string; end: string } endpointLabels: { start: string; end: string }
path: string | null path: string | null
seriesPaths: TrackSeriesPath[]
showXAxis: boolean showXAxis: boolean
} }

View File

@@ -6,6 +6,7 @@ import { paddedDomain } from '../../utils'
export interface PreparedWaveformSeries { export interface PreparedWaveformSeries {
id: string id: string
trackId?: string
name: string name: string
unit?: string unit?: string
color?: string color?: string

View File

@@ -11,6 +11,12 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
SingleWaveformData, SingleWaveformData,
WaveformSeries, WaveformSeries,
WaveformData, WaveformData,

View File

@@ -9,6 +9,12 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformPoint, WaveformPoint,
WaveformSeries, WaveformSeries,
WaveformGridOptions, WaveformGridOptions,

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import type { WaveformLegendPosition } from '../../types'
import type { DisplaySeries } from '../core/types'
interface Props {
series: DisplaySeries[]
position: WaveformLegendPosition
orientation: 'horizontal' | 'vertical'
backgroundColor: string
width: number
height: number
}
defineProps<Props>()
</script>
<template>
<foreignObject
class="waveform-legend waveform-chart__legend"
x="0"
y="0"
:width="width"
:height="height"
:data-position="position"
:data-orientation="orientation"
aria-label="曲线图例"
>
<div
xmlns="http://www.w3.org/1999/xhtml"
class="waveform-legend__viewport"
:class="`waveform-legend__viewport--${position}`"
>
<div
class="waveform-legend__panel"
:class="`waveform-legend__panel--${orientation}`"
:style="{ backgroundColor }"
role="list"
>
<div
v-for="item in series"
:key="item.id"
class="waveform-legend__item waveform-chart__legend-item"
role="listitem"
>
<i class="waveform-legend__swatch" :style="{ backgroundColor: item.color }" />
<span class="waveform-legend__label" :title="item.name">{{ item.name }}</span>
</div>
</div>
</div>
</foreignObject>
</template>
<style scoped>
.waveform-legend {
overflow: hidden;
pointer-events: none;
}
.waveform-legend__viewport {
box-sizing: border-box;
display: flex;
width: 100%;
height: 100%;
padding: 8px;
overflow: hidden;
}
.waveform-legend__viewport--top-left {
align-items: flex-start;
justify-content: flex-start;
}
.waveform-legend__viewport--top {
align-items: flex-start;
justify-content: center;
}
.waveform-legend__viewport--top-right {
align-items: flex-start;
justify-content: flex-end;
}
.waveform-legend__viewport--right {
align-items: center;
justify-content: flex-end;
}
.waveform-legend__viewport--bottom-right {
align-items: flex-end;
justify-content: flex-end;
}
.waveform-legend__viewport--bottom {
align-items: flex-end;
justify-content: center;
}
.waveform-legend__viewport--bottom-left {
align-items: flex-end;
justify-content: flex-start;
}
.waveform-legend__viewport--left {
align-items: center;
justify-content: flex-start;
}
.waveform-legend__panel {
display: flex;
flex: 0 1 auto;
gap: 5px 12px;
max-width: 100%;
max-height: 100%;
padding: 5px 7px;
overflow: hidden;
color: #344054;
font: 12px/1.35 sans-serif;
border: 1px solid rgb(208 213 221 / 90%);
border-radius: 4px;
}
.waveform-legend__panel--horizontal {
flex-flow: row wrap;
align-items: center;
}
.waveform-legend__panel--vertical {
flex-flow: column nowrap;
align-items: stretch;
}
.waveform-legend__item {
display: flex;
min-width: 0;
max-width: 160px;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.waveform-legend__swatch {
flex: 0 0 18px;
width: 18px;
height: 2px;
}
.waveform-legend__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -1,9 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { axisBottom, axisLeft, select } from 'd3' import { axisBottom, axisLeft, select } from 'd3'
import { formatAxisTime, formatScientificYAxisLabel } from '../../utils' import { formatAxisTime, formatScientificYAxisLabel } from '../../utils'
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types' import type { WaveformFrameStyle } from '../../types'
import type {
WaveformDisplayMode,
WaveformInteractionMode,
WaveformLegendPosition,
} from '../data/types'
import type { DisplaySeries, HoveredSeriesPoint, TrackLayout } from '../core/types' import type { DisplaySeries, HoveredSeriesPoint, TrackLayout } from '../core/types'
import WaveformLegend from './WaveformLegend.vue'
interface Props { interface Props {
/** 轨道布局信息 */ /** 轨道布局信息 */
@@ -22,12 +28,20 @@ interface Props {
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
/** 帧编号 */ /** 帧编号 */
frameNumber?: string | number frameNumber?: string | number
/** 图框样式 */
frameStyle?: WaveformFrameStyle
/** 时间单位 */ /** 时间单位 */
timeUnit: 's' | 'ms' timeUnit: 's' | 'ms'
/** 悬浮点(用于显示十字线) */ /** 悬浮点(用于显示十字线) */
hoveredPoint?: HoveredSeriesPoint hoveredPoint?: HoveredSeriesPoint
/** Y 轴标签回退值 */ /** Y 轴标签回退值 */
yLabel?: string yLabel?: string
/** 多曲线图例位置 */
legendPosition?: WaveformLegendPosition
/** 多曲线图例排列方向 */
legendOrientation?: 'horizontal' | 'vertical'
/** 多曲线图例背景颜色 */
legendBackgroundColor?: string
} }
interface Emits { interface Emits {
@@ -39,11 +53,26 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
interactionMode: 'zoom', interactionMode: 'zoom',
legendPosition: 'top-right',
legendOrientation: 'vertical',
legendBackgroundColor: 'rgba(255, 255, 255, 0.7)',
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const xAxisElement = ref<SVGGElement>() const xAxisElement = ref<SVGGElement>()
const yAxisElement = ref<SVGGElement>() const yAxisElement = ref<SVGGElement>()
const resolvedFrameStyle = computed(() => {
const borderWidth = props.frameStyle?.borderWidth
return {
borderColor: props.frameStyle?.borderColor || '#1f2937',
borderWidth:
typeof borderWidth === 'number' && Number.isFinite(borderWidth) && borderWidth >= 0
? borderWidth
: 1,
borderStyle: props.frameStyle?.borderStyle === 'dashed' ? 'dashed' : 'solid',
backgroundColor: props.frameStyle?.backgroundColor || 'transparent',
}
})
function resolveYAxisLabel(series: DisplaySeries): string { function resolveYAxisLabel(series: DisplaySeries): string {
return series.name.trim() || props.yLabel || '' return series.name.trim() || props.yLabel || ''
@@ -155,6 +184,15 @@ watch(
:data-track-height="track.height" :data-track-height="track.height"
:transform="`translate(${track.left ?? 0}, ${track.top})`" :transform="`translate(${track.left ?? 0}, ${track.top})`"
> >
<rect
v-if="!track.isEmpty"
class="waveform-track__plot-background waveform-chart__plot-background"
:width="track.width ?? innerWidth"
:height="track.height"
:fill="resolvedFrameStyle.backgroundColor"
aria-hidden="true"
/>
<!-- 网格和背景 --> <!-- 网格和背景 -->
<g v-if="!track.isEmpty" :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true"> <g v-if="!track.isEmpty" :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true">
<g <g
@@ -259,6 +297,7 @@ watch(
<g <g
v-if=" v-if="
!track.isEmpty && !track.isEmpty &&
track.seriesList.length === 1 &&
track.showYAxisLabel && track.showYAxisLabel &&
resolveYAxisLabel(track.series) && resolveYAxisLabel(track.series) &&
shouldShowYAxisLabel(track.height, track.index) shouldShowYAxisLabel(track.height, track.index)
@@ -289,19 +328,36 @@ watch(
class="waveform-track__plot-frame waveform-chart__plot-frame" class="waveform-track__plot-frame waveform-chart__plot-frame"
:width="track.width ?? innerWidth" :width="track.width ?? innerWidth"
:height="track.height" :height="track.height"
fill="none"
:stroke="resolvedFrameStyle.borderColor"
:stroke-width="resolvedFrameStyle.borderWidth"
:stroke-dasharray="resolvedFrameStyle.borderStyle === 'dashed' ? '6 4' : undefined"
aria-hidden="true" aria-hidden="true"
/> />
<!-- 波形线 --> <!-- 波形线 -->
<g v-if="!track.isEmpty" class="waveform-track__lines">
<path <path
v-if="!track.isEmpty" v-for="seriesPath in track.seriesPaths"
:key="seriesPath.series.id"
class="waveform-track__line waveform-chart__line" class="waveform-track__line waveform-chart__line"
:data-series-id="track.series.id" :data-series-id="seriesPath.series.id"
:data-series-name="track.series.name || undefined" :data-series-name="seriesPath.series.name || undefined"
:d="track.path ?? undefined" :d="seriesPath.path ?? undefined"
:stroke="track.series.color" :stroke="seriesPath.series.color"
:clip-path="`url(#${clipPathId}-${track.index})`" :clip-path="`url(#${clipPathId}-${track.index})`"
/> />
</g>
<WaveformLegend
v-if="!track.isEmpty && track.seriesList.length > 1"
:series="track.seriesList"
:position="legendPosition"
:orientation="legendOrientation"
:background-color="legendBackgroundColor"
:width="track.width ?? innerWidth"
:height="track.height"
/>
<!-- 十字线 --> <!-- 十字线 -->
<g <g
@@ -372,9 +428,10 @@ watch(
} }
.waveform-track__plot-frame { .waveform-track__plot-frame {
fill: none; pointer-events: none;
stroke: #1f2937; }
stroke-width: 1;
.waveform-track__plot-background {
pointer-events: none; pointer-events: none;
} }

View File

@@ -1 +1,2 @@
export { default as WaveformTrack } from './WaveformTrack.vue' export { default as WaveformTrack } from './WaveformTrack.vue'
export { default as WaveformLegend } from './WaveformLegend.vue'

View File

@@ -11,6 +11,9 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformFrameStyle,
SingleWaveformData, SingleWaveformData,
WaveformSeries, WaveformSeries,
WaveformData, WaveformData,

View File

@@ -49,6 +49,7 @@ export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformS
return { return {
id: uniqueId, id: uniqueId,
trackId: series.trackId?.trim() || undefined,
name: series.name, name: series.name,
unit: series.unit, unit: series.unit,
color: series.color, color: series.color,

View File

@@ -15,6 +15,12 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
// 数据类型 // 数据类型
SingleWaveformData, SingleWaveformData,
WaveformSeries, WaveformSeries,

View File

@@ -17,115 +17,327 @@
box-sizing: border-box; box-sizing: border-box;
} }
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}
body { body {
min-width: 320px; min-width: 320px;
min-height: 100vh;
margin: 0; margin: 0;
} }
.workspace { .workspace {
width: min(1440px, 100%); display: grid;
margin: 0 auto; grid-template-columns: 280px minmax(0, 1fr);
padding: 28px clamp(16px, 4vw, 56px) 48px; gap: 12px;
width: 100%;
height: 100vh;
height: 100dvh;
padding: 12px;
overflow: hidden;
} }
.workspace__header { .control-panel {
display: flex; min-width: 0;
align-items: flex-end; min-height: 0;
justify-content: space-between; overflow: hidden;
gap: 24px;
margin-bottom: 24px;
}
.workspace__eyebrow {
margin: 0 0 6px;
color: #1677ff;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
h1,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: 28px;
font-weight: 650;
}
.control-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 16px;
background: #fff; background: #fff;
border: 1px solid #e4e7ec; border: 1px solid #e4e7ec;
border-bottom: 0; border-radius: 6px;
} }
.control-bar__leading { .control-panel__scroll {
height: 100%;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #d0d5dd transparent;
}
.control-panel__scroll::-webkit-scrollbar {
width: 6px;
}
.control-panel__scroll::-webkit-scrollbar-thumb {
background: #d0d5dd;
border-radius: 3px;
}
.control-section {
padding: 16px 18px;
border-top: 1px solid #eaecf0;
}
.control-section h2 {
margin: 0 0 12px;
color: #344054;
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
}
.control-section__header {
display: flex; display: flex;
flex: 0 0 auto; gap: 10px;
gap: 12px;
align-items: center; align-items: center;
justify-content: space-between;
}
.control-section__header h2 {
margin-bottom: 0;
} }
.display-mode-control { .display-mode-control {
display: inline-flex; display: flex;
width: 100%;
white-space: nowrap; white-space: nowrap;
} }
.display-mode-control .ant-radio-button-wrapper {
flex: 1 1 0;
padding-inline: 7px;
text-align: center;
}
.grid-size-control { .grid-size-control {
display: inline-flex; display: grid;
align-items: center; grid-template-columns: 58px auto 14px 58px auto;
gap: 6px; gap: 6px;
align-items: center;
color: #475467; color: #475467;
font-size: 12px; font-size: 12px;
} }
.grid-size-control .ant-input-number { .grid-size-control .ant-input-number {
width: 54px; width: 58px;
} }
.metrics { .control-separator {
display: flex; color: #98a2b3;
flex-wrap: wrap; text-align: center;
justify-content: flex-end; }
gap: 8px 18px;
color: #667085; .select-control {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
align-items: center;
color: #475467;
font-size: 12px; font-size: 12px;
font-variant-numeric: tabular-nums; }
.select-control + .select-control {
margin-top: 10px;
}
.select-control .ant-select {
width: 100%;
}
.legend-color-control {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
align-items: center;
margin-top: 10px;
color: #475467;
font-size: 12px;
}
.legend-color-control .vc-color-wrap {
width: 100%;
height: 24px;
margin: 0;
border: 1px solid #d0d5dd;
border-radius: 4px;
}
.frame-style-controls {
display: grid;
gap: 10px;
}
.frame-style-control {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px;
align-items: center;
color: #475467;
font-size: 12px;
}
.frame-style-control .ant-input-number,
.frame-style-control .ant-select {
width: 100%;
}
.frame-style-control--switch .ant-switch {
width: auto;
justify-self: start;
}
.frame-style-control .vc-color-wrap {
width: 100%;
height: 24px;
margin: 0;
border: 1px solid #d0d5dd;
border-radius: 4px;
box-shadow: none;
}
.title-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 10px;
margin-top: 14px;
}
.title-control {
display: grid;
gap: 6px;
min-width: 0;
color: #475467;
font-size: 12px;
}
.title-control--wide,
.title-control--color {
grid-column: 1 / -1;
}
.title-control .ant-select,
.title-control .ant-input-number {
width: 100%;
}
.title-style-controls {
display: grid;
grid-template-columns: repeat(4, 28px);
gap: 4px;
}
.title-style-controls button {
display: inline-grid;
width: 28px;
height: 28px;
padding: 0;
color: #475467;
font: inherit;
font-size: 14px;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 4px;
cursor: pointer;
place-items: center;
}
.title-style-controls button:hover,
.title-style-controls button:focus-visible,
.title-style-controls button.is-active {
color: #0958d9;
border-color: #1677ff;
}
.title-style-controls button.is-active {
background: #e6f4ff;
}
.title-control--color .vc-color-wrap {
width: 40px;
height: 28px;
margin: 0;
border: 1px solid #d0d5dd;
border-radius: 4px;
box-shadow: none;
} }
.chart-panel { .chart-panel {
height: clamp(560px, calc(100vh - 190px), 800px); min-width: 0;
background: transparent; min-height: 0;
height: 100%;
overflow: hidden;
} }
@media (max-width: 700px) { .chart-panel > .waveform-chart {
min-height: 0;
}
.mobile-control-toggle,
.control-backdrop,
.control-panel__close {
display: none;
}
@media (max-width: 900px) {
.workspace { .workspace {
padding-top: 20px; display: block;
padding: 0;
} }
.workspace__header, .chart-panel {
.control-bar { width: 100%;
align-items: stretch; height: 100%;
flex-direction: column;
} }
.control-bar__leading { .chart-panel > .waveform-chart {
flex-wrap: wrap; border-radius: 0;
} }
.display-mode-control { .mobile-control-toggle {
max-width: 100%; position: fixed;
top: 10px;
left: 10px;
z-index: 20;
display: inline-flex;
box-shadow: 0 2px 8px rgb(16 24 40 / 14%);
} }
.metrics { .control-backdrop {
justify-content: flex-start; position: fixed;
inset: 0 0 0 min(300px, calc(100vw - 48px));
z-index: 24;
display: block;
width: auto;
height: 100%;
padding: 0;
background: rgb(16 24 40 / 35%);
border: 0;
}
.control-panel {
position: fixed;
inset: 0 auto 0 0;
z-index: 30;
width: min(300px, calc(100vw - 48px));
visibility: hidden;
border-radius: 0 6px 6px 0;
box-shadow: 12px 0 32px rgb(16 24 40 / 18%);
transform: translateX(-100%);
transition:
transform 180ms ease,
visibility 0s linear 180ms;
}
.control-panel.is-open {
visibility: visible;
transform: translateX(0);
transition-delay: 0s;
}
.control-panel__close {
position: absolute;
top: 10px;
right: 10px;
display: inline-flex;
}
}
@media (prefers-reduced-motion: reduce) {
.control-panel {
transition: none;
} }
} }

View File

@@ -44,3 +44,53 @@ export interface WaveformRenderingOptions {
/** Upper bound for rendered points per horizontal CSS pixel. */ /** Upper bound for rendered points per horizontal CSS pixel. */
maxPointsPerPixel?: number maxPointsPerPixel?: number
} }
/** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle {
color?: string
fontSize?: number
fontFamily?: string
rotation?: number
fontWeight?: number | string
fontStyle?: 'normal' | 'italic'
textDecoration?: 'none' | 'underline'
letterSpacing?: string
}
/** Options for the title rendered once above the complete waveform grid. */
export interface WaveformTitleOptions {
visible?: boolean
text: string
align?: 'left' | 'center' | 'right'
textStyle?: WaveformTitleTextStyle
}
/** Preset positions for legends rendered inside multi-series tracks. */
export type WaveformLegendPosition =
| 'top-left'
| 'top'
| 'top-right'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left'
/** Controls whether legend items follow the position default or a fixed direction. */
export type WaveformLegendOrientation = 'auto' | 'horizontal' | 'vertical'
/** Options shared by legends in every multi-series track. */
export interface WaveformLegendOptions {
position?: WaveformLegendPosition
orientation?: WaveformLegendOrientation
/** CSS color used by the legend panel; alpha controls background transparency. */
backgroundColor?: string
}
/** Styling shared by every non-empty waveform frame. */
export interface WaveformFrameStyle {
borderColor?: string
borderWidth?: number
borderStyle?: 'solid' | 'dashed'
backgroundColor?: string
}

View File

@@ -20,6 +20,8 @@ export type SingleWaveformData =
*/ */
export interface WaveformSeries { export interface WaveformSeries {
id?: string id?: string
/** 相同 trackId 的系列叠加在同一图框中;默认每个系列独占一个图框。 */
trackId?: string
name: string name: string
unit?: string unit?: string
color?: string color?: string
@@ -41,6 +43,7 @@ export type WaveformData =
*/ */
export interface NormalizedWaveformSeries { export interface NormalizedWaveformSeries {
id: string id: string
trackId?: string
name: string name: string
unit?: string unit?: string
color?: string color?: string

View File

@@ -10,6 +10,12 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
} from './chart' } from './chart'
// 数据类型 // 数据类型