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
Some checks failed
Package component / package (push) Failing after 31s
Reviewed-on: admin/af05acffeef924e67ec2e4d8#2
This commit is contained in:
96
README.md
96
README.md
@@ -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 左侧控制面板提供标题实时预览,可配置标题名称、显隐、对齐、字体、字号、粗体、
|
||||||
|
斜体、下划线、旋转和颜色。字号范围为 `8–72px`,旋转范围为 `-180–180°`;样式栏中的
|
||||||
|
`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
184
src/App.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
391
src/App.vue
391
src/App.vue
@@ -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>
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
76
src/components/core/title.test.ts
Normal file
76
src/components/core/title.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
88
src/components/core/title.ts
Normal file
88
src/components/core/title.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformTitleTextStyle,
|
||||||
|
WaveformTitleOptions,
|
||||||
|
WaveformLegendPosition,
|
||||||
|
WaveformLegendOrientation,
|
||||||
|
WaveformLegendOptions,
|
||||||
|
WaveformFrameStyle,
|
||||||
SingleWaveformData,
|
SingleWaveformData,
|
||||||
WaveformSeries,
|
WaveformSeries,
|
||||||
WaveformData,
|
WaveformData,
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformTitleTextStyle,
|
||||||
|
WaveformTitleOptions,
|
||||||
|
WaveformLegendPosition,
|
||||||
|
WaveformLegendOrientation,
|
||||||
|
WaveformLegendOptions,
|
||||||
|
WaveformFrameStyle,
|
||||||
WaveformPoint,
|
WaveformPoint,
|
||||||
WaveformSeries,
|
WaveformSeries,
|
||||||
WaveformGridOptions,
|
WaveformGridOptions,
|
||||||
|
|||||||
152
src/components/rendering/WaveformLegend.vue
Normal file
152
src/components/rendering/WaveformLegend.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as WaveformTrack } from './WaveformTrack.vue'
|
export { default as WaveformTrack } from './WaveformTrack.vue'
|
||||||
|
export { default as WaveformLegend } from './WaveformLegend.vue'
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformTitleTextStyle,
|
||||||
|
WaveformTitleOptions,
|
||||||
|
WaveformFrameStyle,
|
||||||
SingleWaveformData,
|
SingleWaveformData,
|
||||||
WaveformSeries,
|
WaveformSeries,
|
||||||
WaveformData,
|
WaveformData,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformTitleTextStyle,
|
||||||
|
WaveformTitleOptions,
|
||||||
|
WaveformLegendPosition,
|
||||||
|
WaveformLegendOrientation,
|
||||||
|
WaveformLegendOptions,
|
||||||
|
WaveformFrameStyle,
|
||||||
// 数据类型
|
// 数据类型
|
||||||
SingleWaveformData,
|
SingleWaveformData,
|
||||||
WaveformSeries,
|
WaveformSeries,
|
||||||
|
|||||||
344
src/styles.css
344
src/styles.css
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformTitleTextStyle,
|
||||||
|
WaveformTitleOptions,
|
||||||
|
WaveformLegendPosition,
|
||||||
|
WaveformLegendOrientation,
|
||||||
|
WaveformLegendOptions,
|
||||||
|
WaveformFrameStyle,
|
||||||
} from './chart'
|
} from './chart'
|
||||||
|
|
||||||
// 数据类型
|
// 数据类型
|
||||||
|
|||||||
Reference in New Issue
Block a user