feat(chart): add axis and dotted frame controls

This commit is contained in:
liqiyuan
2026-07-22 21:22:26 +08:00
parent c258c11795
commit e706ed9e05
13 changed files with 228 additions and 17 deletions

View File

@@ -108,6 +108,7 @@ const data = ref<WaveformData>({
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 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>({
所有公开类型均可从包入口导入,例如 `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
<WaveformChart
:data="chartData"
:axes="{
x: { lineVisible: false },
y: { lineVisible: false },
}"
:grid="{
trackLines: {
voltage: { horizontal: false, vertical: false },
},
}"
:frame-style="{
borderColor: '#1f2937',
borderWidth: 1,
borderStyle: 'solid',
}"
/>
```
`interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。

View File

@@ -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",

View File

@@ -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()

View File

@@ -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<NonNullable<WaveformFrameStyle['borderStyle']>>('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<WaveformAnnotation[]>([])
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<WaveformFrameStyle>(() => ({
borderStyle: frameBorderStyle.value,
backgroundColor: frameBackgroundColor.value,
}))
const axes = computed<WaveformAxesOptions>(() => ({
x: { lineVisible: xAxisLineVisible.value },
y: { lineVisible: yAxisLineVisible.value },
}))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
visible: zeroLineVisible.value,
color: zeroLineColor.value,
@@ -510,7 +518,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
</section>
<section class="control-section">
<h2>网格线</h2>
<h2>网格与轴线</h2>
<div class="grid-line-controls">
<div class="grid-line-control">
<span>水平网格</span>
@@ -548,6 +556,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
/>
</span>
</div>
<div class="grid-line-control grid-line-control--switch-only">
<span>横轴线</span>
<Switch v-model:checked="xAxisLineVisible" size="small" aria-label="显示横轴线" />
</div>
<div class="grid-line-control grid-line-control--switch-only">
<span>纵轴线</span>
<Switch v-model:checked="yAxisLineVisible" size="small" aria-label="显示纵轴线" />
</div>
</div>
</section>
@@ -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"

View File

@@ -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',

View File

@@ -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"

View File

@@ -19,6 +19,7 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformAxesOptions,
WaveformZeroLineOptions,
SingleWaveformData,
WaveformLineType,

View File

@@ -17,6 +17,7 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformAxesOptions,
WaveformZeroLineOptions,
WaveformPoint,
WaveformSeries,

View File

@@ -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})`"
/>
<g
@@ -353,7 +369,10 @@ watch(
:key="`y-axis-${track.index}-${axis.index}`"
:ref="(element) => setYAxisElement(element, axis.index)"
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
:class="`waveform-track__axis--${axis.side}`"
:class="[
`waveform-track__axis--${axis.side}`,
{ 'waveform-track__axis--line-hidden': axes?.y?.lineVisible === false },
]"
:data-y-axis-index="axis.index"
:data-y-axis-side="axis.side"
:transform="`translate(${axis.x}, 0)`"
@@ -439,7 +458,14 @@ watch(
fill="none"
:stroke="resolvedFrameStyle.borderColor"
:stroke-width="resolvedFrameStyle.borderWidth"
:stroke-dasharray="resolvedFrameStyle.borderStyle === 'dashed' ? '6 4' : undefined"
:stroke-dasharray="
resolvedFrameStyle.borderStyle === 'dashed'
? '6 4'
: resolvedFrameStyle.borderStyle === 'dotted'
? '1 3'
: undefined
"
:stroke-linecap="resolvedFrameStyle.borderStyle === 'dotted' ? 'round' : undefined"
aria-hidden="true"
/>

View File

@@ -23,6 +23,7 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformAxesOptions,
WaveformZeroLineOptions,
// 数据类型
SingleWaveformData,

View File

@@ -130,6 +130,10 @@ body {
font-size: 12px;
}
.grid-line-control--switch-only {
grid-template-columns: minmax(0, 1fr) auto;
}
.grid-line-color-picker,
.grid-line-color-picker .vc-color-wrap {
width: 48px;

View File

@@ -117,10 +117,20 @@ export interface WaveformLegendOptions {
export interface WaveformFrameStyle {
borderColor?: string
borderWidth?: number
borderStyle?: 'solid' | 'dashed'
borderStyle?: 'solid' | 'dashed' | 'dotted'
backgroundColor?: string
}
/** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions {
x?: {
lineVisible?: boolean
}
y?: {
lineVisible?: boolean
}
}
/** Styling and visibility options for the horizontal zero-value reference line. */
export interface WaveformZeroLineOptions {
visible?: boolean

View File

@@ -18,6 +18,7 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformAxesOptions,
WaveformZeroLineOptions,
} from './chart'