From dda75b5d136ebaa5d0e1d8b75261369ccf060c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Mon, 20 Jul 2026 14:07:43 +0800 Subject: [PATCH] feat(chart): add configurable chart presentation --- README.md | 96 ++++ src/App.test.ts | 184 +++++++ src/App.vue | 425 +++++++++++--- src/components/WaveformChart.test.ts | 520 +++++++++++++++++- src/components/WaveformChart.vue | 317 +++++++++-- .../annotation/WaveformAnnotationEditor.vue | 2 +- src/components/annotation/components.test.ts | 11 +- src/components/core/layout.ts | 47 +- src/components/core/title.test.ts | 76 +++ src/components/core/title.ts | 88 +++ src/components/core/types.ts | 15 + src/components/core/useWaveformData.ts | 1 + src/components/data/types.ts | 6 + src/components/index.ts | 6 + src/components/rendering/WaveformLegend.vue | 152 +++++ src/components/rendering/WaveformTrack.vue | 83 ++- src/components/rendering/index.ts | 1 + src/components/waveform.ts | 3 + src/core/data.ts | 1 + src/index.ts | 6 + src/styles.css | 344 +++++++++--- src/types/chart.ts | 50 ++ src/types/data.ts | 3 + src/types/index.ts | 6 + 24 files changed, 2242 insertions(+), 201 deletions(-) create mode 100644 src/App.test.ts create mode 100644 src/components/core/title.test.ts create mode 100644 src/components/core/title.ts create mode 100644 src/components/rendering/WaveformLegend.vue diff --git a/README.md b/README.md index bdaa85a..6c7f657 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,102 @@ import { WaveformChart } from './index' 自适应高度要求父容器具有明确高度;父容器未定高时,组件使用最低 `180px` 高度。 显式高度同样保留 `180px` 下限。非有限尺寸按未指定处理,负宽度归零。 +### 图表标题 + +`title` 在整个波形网格上方渲染一次,支持显隐、对齐、字体样式和旋转: + +```vue + +``` + +对应的公开类型为 `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 + +``` + +对应的公开类型为 `WaveformFrameStyle`。默认边框颜色为 `#1f2937`、线宽为 `1`、线型为 +`solid`,背景透明。`borderWidth` 为 `0` 时隐藏边框;非有限值或负数会回退到默认线宽。 + +### 图例样式 + +`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值, +可通过 `rgba(...)` 或 `hsla(...)` 中的 alpha 通道调整透明度: + +```vue + +``` + +未配置或传入空字符串时,图例背景默认使用 `rgba(255, 255, 255, 0.7)`。 + ## 大数据渲染 组件按不可变数据处理:替换 `data` 引用会重新过滤、排序和缓存坐标域,并重置视口; diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 0000000..499c548 --- /dev/null +++ b/src/App.test.ts @@ -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() + }) +}) diff --git a/src/App.vue b/src/App.vue index 9adbf10..8cc4d1b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,14 +1,20 @@ + + diff --git a/src/components/rendering/WaveformTrack.vue b/src/components/rendering/WaveformTrack.vue index 6114732..c861fd0 100644 --- a/src/components/rendering/WaveformTrack.vue +++ b/src/components/rendering/WaveformTrack.vue @@ -1,9 +1,15 @@