feat(chart): add axis and dotted frame controls
All checks were successful
Package component / package (push) Successful in 5m34s

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

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