From 3ed17eb0612e5bfdbbc92b8ce0bac46a44797092 Mon Sep 17 00:00:00 2001 From: liqiyuan Date: Wed, 22 Jul 2026 21:22:26 +0800 Subject: [PATCH] feat(chart): add axis and dotted frame controls --- README.md | 29 +++++++- package.json | 2 +- src/App.test.ts | 26 +++++++ src/App.vue | 21 +++++- src/components/WaveformChart.test.ts | 80 ++++++++++++++++++++++ src/components/WaveformChart.vue | 29 ++++++-- src/components/data/types.ts | 1 + src/components/index.ts | 1 + src/components/rendering/WaveformTrack.vue | 38 ++++++++-- src/index.ts | 1 + src/styles.css | 4 ++ src/types/chart.ts | 12 +++- src/types/index.ts | 1 + 13 files changed, 228 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0bc6036..99fa100 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ const data = ref({ | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | | `initialXDomains` | `Record` | 未设置 | 按 track/series ID 配置初始范围 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | +| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | @@ -121,7 +122,8 @@ const data = ref({ 所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、 `WaveformAnnotation`、`WaveformLineStyle`、`WaveformRenderingOptions`、 -`WaveformZeroLineOptions`、`WaveformGridOptions` 和 `WaveformGridTrackLines`。 +`WaveformAxesOptions`、`WaveformZeroLineOptions`、`WaveformGridOptions` 和 +`WaveformGridTrackLines`。 ### 数据结构 @@ -340,6 +342,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐 /> ``` +`frameStyle.borderStyle` 支持 `solid`(实线)、`dashed`(虚线)和 `dotted`(点虚线)。 + 对应的公开类型为 `WaveformFrameStyle`。默认边框颜色为 `#1f2937`、线宽为 `1`、线型为 `solid`,背景透明。`borderWidth` 为 `0` 时隐藏边框;非有限值或负数会回退到默认线宽。 @@ -441,6 +445,29 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失 /> ``` +`axes` 可以分别隐藏 X/Y 轴的基线,同时保留刻度短线、刻度数字、科学计数倍率、单位和轴标题。 +与关闭网格线、设置 `frameStyle` 组合后,可以只使用图框边框围住绘图区: + +```vue + +``` + `interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开 标注编辑器,无需切换交互模式。`zoomable`、`pannable` 和 `showTooltip` 可分别控制缩放、 空格拖拽平移和 tooltip;平移默认关闭。 diff --git a/package.json b/package.json index 13eb7cf..29bffc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.15", + "version": "0.1.16", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/App.test.ts b/src/App.test.ts index aa243a5..eeda190 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -46,6 +46,8 @@ describe('App workspace layout', { timeout: 20_000 }, () => { expect(gridSizeInputs[1]?.props('value')).toBe(1) expect(panel.find('[aria-label="显示水平网格线"]').exists()).toBe(true) expect(panel.find('[aria-label="显示垂直网格线"]').exists()).toBe(true) + expect(panel.find('[aria-label="显示横轴线"]').exists()).toBe(true) + expect(panel.find('[aria-label="显示纵轴线"]').exists()).toBe(true) expect(panel.find('[aria-label="水平网格线颜色"]').exists()).toBe(true) expect(panel.find('[aria-label="垂直网格线颜色"]').exists()).toBe(true) expect(panel.find('[aria-label="净图模式"]').exists()).toBe(true) @@ -62,6 +64,9 @@ describe('App workspace layout', { timeout: 20_000 }, () => { expect(frameControls.text()).toContain('背景颜色') expect(frameControls.find('[aria-label="图框线宽"]').exists()).toBe(true) expect(frameControls.find('[aria-label="图框线型"]').exists()).toBe(true) + expect( + frameControls.get('[aria-label="图框线型"]').getComponent(Select).props('options'), + ).toContainEqual({ label: '点虚线', value: 'dotted' }) expect(frameControls.find('[aria-label="显示图框水印"]').exists()).toBe(true) expect(frameControls.get('.frame-style-control--switch .ant-switch').classes()).toContain( 'ant-switch-small', @@ -129,6 +134,27 @@ describe('App workspace layout', { timeout: 20_000 }, () => { wrapper.unmount() }) + it('passes independent X and Y axis-line controls to the chart', async () => { + const wrapper = mount(App) + await flushPromises() + const chart = wrapper.getComponent(WaveformChart) + + expect(chart.props('axes')).toEqual({ + x: { lineVisible: true }, + y: { lineVisible: true }, + }) + + await wrapper.get('[aria-label="显示横轴线"]').trigger('click') + await wrapper.get('[aria-label="显示纵轴线"]').trigger('click') + await flushPromises() + + expect(chart.props('axes')).toEqual({ + x: { lineVisible: false }, + y: { lineVisible: false }, + }) + wrapper.unmount() + }) + it('passes tooltip and per-series line-style controls to the chart', async () => { const wrapper = mount(App) await flushPromises() diff --git a/src/App.vue b/src/App.vue index e700f67..b1db5c5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,6 +7,7 @@ import 'vue3-colorpicker/style.css' import { WaveformChart, type WaveformAnnotation, + type WaveformAxesOptions, type WaveformData, type WaveformDisplayMode, type WaveformFrameStyle, @@ -60,13 +61,15 @@ const rowCount = ref(4) const columnCount = ref(1) const frameBorderColor = ref('#1f2937') const frameBorderWidth = ref(1) -const frameBorderStyle = ref<'solid' | 'dashed'>('solid') +const frameBorderStyle = ref>('solid') const frameBackgroundColor = ref('rgba(255, 255, 255, 0)') const frameWatermarkVisible = ref(true) const horizontalGridVisible = ref(true) const horizontalGridColor = ref('#dfe5ef') const verticalGridVisible = ref(true) const verticalGridColor = ref('#dfe5ef') +const xAxisLineVisible = ref(true) +const yAxisLineVisible = ref(true) const annotations = ref([]) const annotationsVisible = ref(true) const cleanView = ref(false) @@ -109,6 +112,7 @@ const legendOrientationOptions: Array<{ label: string; value: WaveformLegendOrie const frameBorderStyleOptions = [ { label: '实线', value: 'solid' }, { label: '虚线', value: 'dashed' }, + { label: '点虚线', value: 'dotted' }, ] const zeroLineDashOptions = [ { label: '虚线', value: '6 4' }, @@ -136,6 +140,10 @@ const frameStyle = computed(() => ({ borderStyle: frameBorderStyle.value, backgroundColor: frameBackgroundColor.value, })) +const axes = computed(() => ({ + x: { lineVisible: xAxisLineVisible.value }, + y: { lineVisible: yAxisLineVisible.value }, +})) const zeroLine = computed(() => ({ visible: zeroLineVisible.value, color: zeroLineColor.value, @@ -510,7 +518,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
-

网格线

+

网格与轴线

水平网格 @@ -548,6 +556,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown) />
+
+ 横轴线 + +
+
+ 纵轴线 + +
@@ -773,6 +789,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown) interactive: true, }" :frame-style="frameStyle" + :axes="axes" :clean-view="cleanView" :show-tooltip="showTooltip" :zero-line="zeroLine" diff --git a/src/components/WaveformChart.test.ts b/src/components/WaveformChart.test.ts index e09a798..42776e1 100644 --- a/src/components/WaveformChart.test.ts +++ b/src/components/WaveformChart.test.ts @@ -349,6 +349,75 @@ describe('WaveformChart', () => { expect(zeroLines[0].attributes('y1')).not.toBe(zeroLines[1].attributes('y1')) }) + it('hides X and Y axis lines independently while preserving axis text and the frame', async () => { + const wrapper = await mountSizedChart({ + kind: 'series', + series: [ + { + id: 'channel', + name: 'Voltage', + data: { + kind: 'points', + points: [ + { x: 0, y: -1 }, + { x: 1, y: 1 }, + ], + }, + }, + ], + }) + const xAxis = wrapper.get('.waveform-chart__axis--x') + const yAxis = wrapper.get('.waveform-chart__axis--y') + + expect(xAxis.classes()).not.toContain('waveform-track__axis--line-hidden') + expect(yAxis.classes()).not.toContain('waveform-track__axis--line-hidden') + + await wrapper.setProps({ axes: { x: { lineVisible: false } } }) + await flushPromises() + expect(xAxis.classes()).toContain('waveform-track__axis--line-hidden') + expect(yAxis.classes()).not.toContain('waveform-track__axis--line-hidden') + expect(xAxis.get('path.domain').attributes('display')).toBe('none') + expect( + xAxis.findAll('.tick line').every((line) => line.attributes('display') === undefined), + ).toBe(true) + expect(yAxis.get('path.domain').attributes('display')).toBeUndefined() + + await wrapper.setProps({ + axes: { + x: { lineVisible: false }, + y: { lineVisible: false }, + }, + grid: { + rowCount: 1, + columnCount: 1, + trackLines: { channel: { horizontal: false, vertical: false } }, + }, + frameStyle: { borderColor: '#dc2626', borderWidth: 2 }, + }) + await flushPromises() + + expect(xAxis.classes()).toContain('waveform-track__axis--line-hidden') + expect(yAxis.classes()).toContain('waveform-track__axis--line-hidden') + expect(yAxis.get('path.domain').attributes('display')).toBe('none') + expect( + yAxis.findAll('.tick line').every((line) => line.attributes('display') === undefined), + ).toBe(true) + expect(xAxis.findAll('text').length).toBeGreaterThan(0) + expect(yAxis.findAll('text').length).toBeGreaterThan(0) + expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2) + expect(wrapper.get('.waveform-chart__y-axis-label').text()).toBe('Voltage') + expect(wrapper.findAll('[data-grid-direction]')).toHaveLength(0) + expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({ + stroke: '#dc2626', + 'stroke-width': '2', + }) + + await wrapper.setProps({ axes: { x: { lineVisible: true }, y: { lineVisible: true } } }) + await flushPromises() + expect(xAxis.get('path.domain').attributes('display')).toBeUndefined() + expect(yAxis.get('path.domain').attributes('display')).toBeUndefined() + }) + it('preserves a titled multi-axis plot and hides auxiliary layers in clean view', async () => { const data: WaveformData = { kind: 'series', @@ -2384,6 +2453,17 @@ describe('WaveformChart', () => { expect(wrapper.get('.waveform-chart__plot-frame').attributes('stroke-width')).toBe('1') }) + it('renders a dotted frame with rounded dots', async () => { + const wrapper = await mountSizedChart(gridSeries(1), { + frameStyle: { borderStyle: 'dotted' }, + }) + + expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({ + 'stroke-dasharray': '1 3', + 'stroke-linecap': 'round', + }) + }) + it('continues minor x-grid lines beyond the final major tick to the exact endpoint', async () => { const wrapper = await mountSizedChart({ kind: 'points', diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index f8e353a..2844349 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -28,6 +28,7 @@ import { import { type WaveformAnnotation, + type WaveformAxesOptions, type WaveformData, type WaveformDisplayMode, type WaveformFrameStyle, @@ -121,6 +122,7 @@ const props = withDefaults( timeUnit?: 's' | 'ms' frameNumber?: string | number frameStyle?: WaveformFrameStyle + axes?: WaveformAxesOptions annotations?: WaveformAnnotation[] annotationsVisible?: boolean interactionMode?: WaveformInteractionMode @@ -251,7 +253,9 @@ function handleInteractionKeyDown(event: KeyboardEvent) { const target = event.target if ( target instanceof Element && - target.closest('button, input, select, textarea, [contenteditable]:not([contenteditable="false"])') + target.closest( + 'button, input, select, textarea, [contenteditable]:not([contenteditable="false"])', + ) ) { return } @@ -296,7 +300,10 @@ const resolvedZeroLine = computed(() => { return { visible: props.zeroLine?.visible === true, color: props.zeroLine?.color || ZERO_LINE_DEFAULTS.COLOR, - width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : ZERO_LINE_DEFAULTS.WIDTH, + width: + typeof width === 'number' && Number.isFinite(width) && width > 0 + ? width + : ZERO_LINE_DEFAULTS.WIDTH, dash: props.zeroLine?.dash ?? ZERO_LINE_DEFAULTS.DASH, } }) @@ -329,7 +336,9 @@ const titleAreaReserved = computed( const titleVisible = computed(() => titleAreaReserved.value && !isCleanView.value) const titleFontSize = computed(() => { const fontSize = props.title?.textStyle?.fontSize - return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : TITLE_DEFAULT_FONT_SIZE + return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 + ? (fontSize as number) + : TITLE_DEFAULT_FONT_SIZE }) const titleRotation = computed(() => { const rotation = props.title?.textStyle?.rotation @@ -354,7 +363,10 @@ const estimatedTitleWidth = computed(() => { const spacingWidth = Number.isFinite(letterSpacing) ? Math.max(0, resolvedTitleText.value.length - 1) * letterSpacing : 0 - return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * TITLE_CHAR_WIDTH_RATIO + spacingWidth) + return Math.max( + 1, + resolvedTitleText.value.length * titleFontSize.value * TITLE_CHAR_WIDTH_RATIO + spacingWidth, + ) }) const titleAvailableWidth = computed(() => { const measuredAvailableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2 @@ -866,10 +878,14 @@ function clearZoomBindings() { function resolveMaximumZoomScale(domain: [number, number]): number { const minZoomSpan = props.minZoomSpan - if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE + if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) + return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE const domainSpan = Math.abs(domain[1] - domain[0]) if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE - return Math.min(ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE, Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan))) + return Math.min( + ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE, + Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)), + ) } function canZoomTrack(track: TrackLayout): boolean { @@ -1929,6 +1945,7 @@ onBeforeUnmount(() => { :interaction-mode="activeInteractionMode" :frame-number="resolveFrameNumber(track.index)" :frame-style="frameStyle" + :axes="axes" :clean-view="isCleanView" :zero-line="resolvedZeroLine" :time-unit="timeUnit" diff --git a/src/components/data/types.ts b/src/components/data/types.ts index bef8a2a..6b29381 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -19,6 +19,7 @@ export type { WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, + WaveformAxesOptions, WaveformZeroLineOptions, SingleWaveformData, WaveformLineType, diff --git a/src/components/index.ts b/src/components/index.ts index 8e9e424..a9090c9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,6 +17,7 @@ export type { WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, + WaveformAxesOptions, WaveformZeroLineOptions, WaveformPoint, WaveformSeries, diff --git a/src/components/rendering/WaveformTrack.vue b/src/components/rendering/WaveformTrack.vue index 0af65d0..c4690c9 100644 --- a/src/components/rendering/WaveformTrack.vue +++ b/src/components/rendering/WaveformTrack.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue' import { axisBottom, axisLeft, axisRight, select } from 'd3' import { formatAxisTime, formatScientificAxisLabel } from '../../utils' -import type { WaveformFrameStyle, WaveformZeroLineOptions } from '../../types' +import type { WaveformAxesOptions, WaveformFrameStyle, WaveformZeroLineOptions } from '../../types' import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types' import type { DisplaySeries, @@ -31,6 +31,8 @@ interface Props { frameNumber?: string | number /** 图框样式 */ frameStyle?: WaveformFrameStyle + /** 坐标轴线显示选项 */ + axes?: WaveformAxesOptions /** 时间单位 */ timeUnit: 's' | 'ms' /** 悬浮点(用于显示十字线) */ @@ -72,7 +74,10 @@ const resolvedFrameStyle = computed(() => { typeof borderWidth === 'number' && Number.isFinite(borderWidth) && borderWidth >= 0 ? borderWidth : 1, - borderStyle: props.frameStyle?.borderStyle === 'dashed' ? 'dashed' : 'solid', + borderStyle: + props.frameStyle?.borderStyle === 'dashed' || props.frameStyle?.borderStyle === 'dotted' + ? props.frameStyle.borderStyle + : 'solid', backgroundColor: props.frameStyle?.backgroundColor || 'transparent', } }) @@ -138,11 +143,16 @@ function renderAxes() { yAxis.tickValues(axis.tickValues) - select(element).call(yAxis) + const selection = select(element) + selection.call(yAxis) + selection + .selectAll('path.domain') + .attr('display', props.axes?.y?.lineVisible === false ? 'none' : null) }) if (xAxisElement.value) { - select(xAxisElement.value).call( + const selection = select(xAxisElement.value) + selection.call( axisBottom(props.track.xScale) .tickValues(props.track.xAxisTickValues) .tickFormat((value) => @@ -156,6 +166,9 @@ function renderAxes() { .tickPadding(7) .tickSizeOuter(0), ) + selection + .selectAll('path.domain') + .attr('display', props.axes?.x?.lineVisible === false ? 'none' : null) } } @@ -171,6 +184,8 @@ watch( () => props.track.xAxisTickValues, () => props.track.yAxisTickValues, () => props.timeUnit, + () => props.axes?.x?.lineVisible, + () => props.axes?.y?.lineVisible, ], async () => { await nextTick() @@ -307,6 +322,7 @@ watch( v-if="track.showXAxis && !cleanView" ref="xAxisElement" class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x" + :class="{ 'waveform-track__axis--line-hidden': axes?.x?.lineVisible === false }" :transform="`translate(0, ${track.height})`" />