feat(chart): add zero line and clean view

This commit is contained in:
李启源
2026-07-21 20:44:08 +08:00
parent 07e9855eac
commit 6a6a387868
13 changed files with 401 additions and 38 deletions

View File

@@ -40,7 +40,7 @@ pnpm add waveform-analysis vue d3 ant-design-vue vue3-colorpicker
组件库支持以下运行时版本:
| 依赖 | 支持版本 |
| --- | --- |
| -------------- | ------------- |
| Vue | `>=3.2.33 <4` |
| Ant Design Vue | `>=3.2.20 <4` |
@@ -104,12 +104,15 @@ const data = ref<WaveformData>({
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 仅保留波形的净图模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformRenderingOptions``WaveformGridOptions`
`WaveformAnnotation``WaveformRenderingOptions``WaveformZeroLineOptions`
`WaveformGridOptions`
### 数据结构
@@ -381,6 +384,35 @@ const hiddenSeriesIds = ref<string[]>([])
显隐状态以规范化后的 `series.id` 为键。要在数据刷新和重新排序后稳定保留状态,每个系列都应
提供全图唯一且稳定的显式 `id`;自动生成的索引 ID 或重复 ID 添加的后缀不保证跨排序稳定。
### 零值参考线与净图
`zeroLine` 用于绘制 `y = 0` 的水平参考线,默认隐藏。参考线只在对应 Y 轴的当前 domain
包含 0 时渲染,不会为了显示参考线而扩展数据范围。多值轴模式下,每根可见 Y 轴分别按自身
scale 定位零线:
```vue
<WaveformChart
:data="chartData"
:zero-line="{
visible: true,
color: '#98a2b3',
width: 1,
dash: '6 4',
}"
/>
```
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
`width` 会回退到 `1`
设置 `cleanView` 后,组件隐藏标题、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、
零值参考线、标注和分页器,并取消这些元素预留的边距,仅保留波形数据层。缩放、悬浮、十字线和
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失:
```vue
<WaveformChart :data="chartData" :clean-view="cleanViewEnabled" />
```
### 网格、分页与交互模式
`grid` 控制独立图框的行列数(范围 `110`)以及是否显示分页器。默认值为 `2` 行、

View File

@@ -41,6 +41,12 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
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)
const zeroLineControls = panel.get('.zero-line-controls')
expect(zeroLineControls.findAllComponents(ColorPicker)).toHaveLength(1)
expect(zeroLineControls.find('[aria-label="零值参考线线宽"]').exists()).toBe(true)
expect(zeroLineControls.find('[aria-label="零值参考线线型"]').exists()).toBe(true)
expect(frameControls.findAllComponents(ColorPicker)).toHaveLength(2)
expect(frameControls.text()).toContain('边框颜色')
expect(frameControls.text()).toContain('背景颜色')
@@ -71,6 +77,23 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
wrapper.unmount()
})
it('passes clean view and zero-line controls to the chart', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
expect(chart.props('cleanView')).toBe(false)
expect(chart.props('zeroLine')).toMatchObject({ visible: false, color: '#98a2b3', width: 1 })
await wrapper.get('[aria-label="净图模式"]').trigger('click')
await wrapper.get('[aria-label="显示零值参考线"]').trigger('click')
await flushPromises()
expect(chart.props('cleanView')).toBe(true)
expect(chart.props('zeroLine')).toMatchObject({ visible: true, color: '#98a2b3', width: 1 })
wrapper.unmount()
})
it('switches overlaid tracks between single-axis and multi-axis rendering', async () => {
const wrapper = mount(App)
await flushPromises()

View File

@@ -17,6 +17,7 @@ import {
type WaveformSeries,
type WaveformTitleOptions,
type WaveformZoomEndPayload,
type WaveformZeroLineOptions,
} from './components'
import chartWaveformsJson from './data/chartWaveforms.json'
import demoWaveformsJson from './data/demoWaveforms.json'
@@ -53,6 +54,11 @@ const frameBackgroundColor = ref('rgba(255, 255, 255, 0)')
const frameWatermarkVisible = ref(true)
const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true)
const cleanView = ref(false)
const zeroLineVisible = ref(false)
const zeroLineColor = ref('#98a2b3')
const zeroLineWidth = ref(1)
const zeroLineDash = ref('6 4')
const interactionMode = ref<WaveformInteractionMode>('zoom')
const legendPosition = ref<WaveformLegendPosition>('top-right')
const legendOrientation = ref<WaveformLegendOrientation>('auto')
@@ -88,6 +94,11 @@ const frameBorderStyleOptions = [
{ label: '实线', value: 'solid' },
{ label: '虚线', value: 'dashed' },
]
const zeroLineDashOptions = [
{ label: '虚线', value: '6 4' },
{ label: '点划线', value: '2 3' },
{ label: '实线', value: '' },
]
const titleAlignOptions: Array<{
label: string
value: NonNullable<WaveformTitleOptions['align']>
@@ -109,6 +120,12 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
borderStyle: frameBorderStyle.value,
backgroundColor: frameBackgroundColor.value,
}))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
visible: zeroLineVisible.value,
color: zeroLineColor.value,
width: zeroLineWidth.value,
dash: zeroLineDash.value,
}))
const seriesStylePresets: Array<Pick<WaveformSeries, 'lineType' | 'pointType' | 'errorBar'>> = [
{ lineType: 'none', pointType: 'triangle', errorBar: { visible: true } },
@@ -353,6 +370,53 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
<section class="control-section">
<h2>视图</h2>
<Button block aria-label="重置波形视图" @click="resetWaveformViewport">重置视图</Button>
<div class="auxiliary-style-controls" style="margin-top: 10px">
<label class="frame-style-control frame-style-control--switch">
<span>净图</span>
<Switch v-model:checked="cleanView" size="small" aria-label="净图模式" />
</label>
</div>
</section>
<section class="control-section">
<div class="control-section__header">
<h2>零值参考线</h2>
<Switch v-model:checked="zeroLineVisible" size="small" aria-label="显示零值参考线" />
</div>
<div class="auxiliary-style-controls zero-line-controls" style="margin-top: 10px">
<label class="frame-style-control">
<span>颜色</span>
<ColorPicker
v-model:pure-color="zeroLineColor"
aria-label="零值参考线颜色"
use-type="pure"
picker-type="chrome"
format="hex"
:disable-alpha="true"
:blur-close="true"
/>
</label>
<label class="frame-style-control">
<span>线宽</span>
<InputNumber
v-model:value="zeroLineWidth"
:min="0.5"
:max="10"
:step="0.5"
size="small"
aria-label="零值参考线线宽"
/>
</label>
<label class="frame-style-control">
<span>线型</span>
<Select
v-model:value="zeroLineDash"
:options="zeroLineDashOptions"
size="small"
aria-label="零值参考线线型"
/>
</label>
</div>
</section>
<section class="control-section">
@@ -602,6 +666,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
interactive: true,
}"
:frame-style="frameStyle"
:clean-view="cleanView"
:zero-line="zeroLine"
:frame-number="frameWatermarkVisible ? 1 : undefined"
v-model:annotations="annotations"
v-model:annotations-visible="annotationsVisible"

View File

@@ -220,6 +220,152 @@ describe('WaveformChart', () => {
second.unmount()
})
it('renders a configurable zero line only when the Y domain contains zero', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: -2 },
{ x: 1, y: 4 },
],
},
{ zeroLine: { visible: true, color: '#475467', width: 2, dash: '3 2' } },
)
const zeroLine = wrapper.get('.waveform-chart__zero-line')
expect(zeroLine.attributes()).toMatchObject({
stroke: '#475467',
'stroke-width': '2',
'stroke-dasharray': '3 2',
'data-y-axis-index': '0',
})
expect(zeroLine.attributes('y1')).toBe(zeroLine.attributes('y2'))
await wrapper.setProps({ zeroLine: { visible: false } })
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
const positive = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 4 },
],
},
{ zeroLine: { visible: true } },
)
expect(positive.find('.waveform-chart__zero-line').exists()).toBe(false)
})
it('renders zero lines from each visible Y axis in multi-axis mode', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'small',
trackId: 'overlay',
name: 'small',
data: {
kind: 'points',
points: [
{ x: 0, y: -1 },
{ x: 1, y: 3 },
],
},
},
{
id: 'large',
trackId: 'overlay',
name: 'large',
data: {
kind: 'points',
points: [
{ x: 0, y: -10 },
{ x: 1, y: 2 },
],
},
},
],
},
{ overlayMode: 'multi-axis', zeroLine: { visible: true } },
)
const zeroLines = wrapper.findAll('.waveform-chart__zero-line')
expect(zeroLines).toHaveLength(2)
expect(zeroLines.map((line) => line.attributes('data-y-axis-index'))).toEqual(['0', '1'])
expect(zeroLines[0].attributes('y1')).not.toBe(zeroLines[1].attributes('y1'))
})
it('uses the full drawing area and hides auxiliary layers in clean view', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'first',
trackId: 'overlay',
name: 'first',
data: {
kind: 'points',
points: [
{ x: 0, y: -1 },
{ x: 1, y: 1 },
],
},
},
{
id: 'second',
trackId: 'overlay',
name: 'second',
data: {
kind: 'points',
points: [
{ x: 0, y: 1 },
{ x: 1, y: 2 },
],
},
},
{
id: 'third',
name: 'third',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
],
},
{
cleanView: true,
grid: { rowCount: 1, columnCount: 1, showPagination: true },
title: { text: 'hidden title' },
frameNumber: 1,
annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
zeroLine: { visible: true },
},
)
expect(wrapper.get('.waveform-chart').attributes('data-chart-left-margin')).toBe('0')
expect(wrapper.get('.waveform-chart__track').attributes('data-track-height')).toBe('360')
expect(wrapper.findAll('.waveform-chart__series')).toHaveLength(2)
expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
expect(wrapper.find('.waveform-chart__title-area').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__axis').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__label').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
expect(wrapper.find('.ant-pagination').exists()).toBe(false)
})
it('places start, middle, and end step transitions at the expected X positions', async () => {
const lineTypes = ['step-start', 'step-middle', 'step-end', 'step-after'] as const
const wrapper = await mountSizedChart({

View File

@@ -38,6 +38,7 @@ import {
type WaveformPoint,
type WaveformRenderingOptions,
type WaveformTitleOptions,
type WaveformZeroLineOptions,
type WaveformZoomEndPayload,
} from './data/types'
import {
@@ -109,6 +110,8 @@ const props = withDefaults(
legend?: WaveformLegendOptions
hiddenSeriesIds?: string[]
defaultHiddenSeriesIds?: string[]
cleanView?: boolean
zeroLine?: WaveformZeroLineOptions
}>(),
{
displayMode: 'independent',
@@ -128,6 +131,8 @@ const props = withDefaults(
rendering: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }),
defaultHiddenSeriesIds: () => [],
cleanView: false,
zeroLine: () => ({ visible: false }),
},
)
@@ -255,6 +260,16 @@ const containerStyle = computed(() => ({
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
}))
const legendPosition = computed<WaveformLegendPosition>(() => props.legend?.position ?? 'top-right')
const isCleanView = computed(() => props.cleanView === true)
const resolvedZeroLine = computed(() => {
const width = props.zeroLine?.width
return {
visible: props.zeroLine?.visible === true,
color: props.zeroLine?.color || '#98a2b3',
width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : 1,
dash: props.zeroLine?.dash ?? '6 4',
}
})
const legendBackgroundColor = computed(
() => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)',
)
@@ -275,7 +290,10 @@ const legendOrientation = computed<Exclude<WaveformLegendOrientation, 'auto'>>((
const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
const titleVisible = computed(
() =>
Boolean(props.title) && props.title?.visible !== false && resolvedTitleText.value.length > 0,
!isCleanView.value &&
Boolean(props.title) &&
props.title?.visible !== false &&
resolvedTitleText.value.length > 0,
)
const titleFontSize = computed(() => {
const fontSize = props.title?.textStyle?.fontSize
@@ -326,8 +344,11 @@ const titleLayout = computed(() =>
}),
)
const titleAreaHeight = computed(() => (titleVisible.value ? titleLayout.value.areaHeight : 0))
const chartTopMargin = computed(() => (isCleanView.value ? 0 : margin.top))
const drawingHeight = computed(() => Math.max(0, chartHeight.value - titleAreaHeight.value))
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
const innerHeight = computed(() =>
Math.max(0, drawingHeight.value - (isCleanView.value ? 0 : margin.top + margin.bottom)),
)
const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`,
justifyContent:
@@ -440,7 +461,7 @@ const hasVisibleWaveformData = computed(() =>
)
const chartLeftMargin = computed(() =>
Math.max(
margin.left,
isCleanView.value ? 0 : margin.left,
hasYAxisLabels.value
? yAxisMetrics.value.fullClearance
: hasVisibleWaveformData.value
@@ -461,13 +482,19 @@ const multiAxisClearance = computed(() =>
),
)
const resolvedChartLeftMargin = computed(() =>
props.overlayMode === 'multi-axis'
isCleanView.value
? 0
: props.overlayMode === 'multi-axis'
? Math.max(chartLeftMargin.value, multiAxisClearance.value.left)
: chartLeftMargin.value,
)
const chartRightMargin = computed(() =>
props.overlayMode === 'multi-axis'
? Math.max(margin.right, multiAxisClearance.value.right)
? isCleanView.value
? 0
: Math.max(margin.right, multiAxisClearance.value.right)
: isCleanView.value
? 0
: margin.right,
)
const innerWidth = computed(() =>
@@ -568,6 +595,7 @@ const gridCells = computed(() => {
props.displayMode,
pagedTracks.value.map(Boolean),
yAxisLayout.value.horizontalGap,
!isCleanView.value,
)
return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
})
@@ -593,7 +621,7 @@ const trackLayouts = computed<TrackLayout[]>(() =>
: sharedYDomains.value,
timeUnit: props.timeUnit,
rendering: renderingOptions.value,
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX,
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
}),
@@ -1004,7 +1032,7 @@ function resolvePointerEditorAnchor(
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
return {
x: resolvedChartLeftMargin.value + (track ? track.left + pointerX : pointerX),
y: titleAreaHeight.value + margin.top + (track ? track.top + pointerY : pointerY),
y: titleAreaHeight.value + chartTopMargin.value + (track ? track.top + pointerY : pointerY),
}
}
@@ -1018,7 +1046,7 @@ function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): Annotati
: chartWidth.value / 2,
y: track
? titleAreaHeight.value +
margin.top +
chartTopMargin.value +
track.top +
resolveSeriesYScale(track, annotation.seriesId)(annotation.y)
: chartHeight.value / 2,
@@ -1293,7 +1321,7 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
})
commitHover(nextPoints, trackIndex, {
x: resolvedChartLeftMargin.value + track.left + pointerX,
y: titleAreaHeight.value + margin.top + track.top + pointerY,
y: titleAreaHeight.value + chartTopMargin.value + track.top + pointerY,
})
})
}
@@ -1324,7 +1352,7 @@ function handleSharedPointerMove(event: PointerEvent) {
)
commitHover(nextPoints, null, {
x: resolvedChartLeftMargin.value + pointerX,
y: titleAreaHeight.value + margin.top + pointerY,
y: titleAreaHeight.value + chartTopMargin.value + pointerY,
})
})
}
@@ -1842,8 +1870,12 @@ onBeforeUnmount(() => {
</clipPath>
</defs>
<g :transform="`translate(${resolvedChartLeftMargin}, ${margin.top})`">
<g v-if="displayMode !== 'compact'" class="waveform-chart__grid-slots" aria-hidden="true">
<g :transform="`translate(${resolvedChartLeftMargin}, ${chartTopMargin})`">
<g
v-if="displayMode !== 'compact' && !isCleanView"
class="waveform-chart__grid-slots"
aria-hidden="true"
>
<g
v-for="cell in gridCells"
:key="`grid-slot-${cell.slotIndex}`"
@@ -1889,6 +1921,8 @@ onBeforeUnmount(() => {
:interaction-mode="activeInteractionMode"
:frame-number="resolveFrameNumber(track.index)"
:frame-style="frameStyle"
:clean-view="isCleanView"
:zero-line="resolvedZeroLine"
:time-unit="timeUnit"
:y-label="yLabel"
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
@@ -1912,6 +1946,7 @@ onBeforeUnmount(() => {
/>
<WaveformAnnotationLayer
v-if="!isCleanView"
:annotations="renderedAnnotations"
:visible="annotationsVisible"
@contextmenu="handleExistingAnnotationContextMenu"
@@ -1920,7 +1955,7 @@ onBeforeUnmount(() => {
@drag-end="endAnnotationDrag"
/>
<g class="waveform-chart__legend-layer">
<g v-if="!isCleanView" class="waveform-chart__legend-layer">
<g
v-for="track in trackLayouts"
:key="`legend-${track.index}-${track.series.name}`"
@@ -1944,7 +1979,7 @@ onBeforeUnmount(() => {
</g>
<text
v-if="resolvedXLabel"
v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label"
:x="innerWidth / 2"
:y="xAxisTitleY"
@@ -1966,7 +2001,7 @@ onBeforeUnmount(() => {
</svg>
<Pagination
v-if="gridOptions.showPagination && pageCount > 1"
v-if="gridOptions.showPagination && pageCount > 1 && !isCleanView"
class="waveform-chart__pagination"
aria-label="波形分页"
:current="currentPage"
@@ -1978,7 +2013,7 @@ onBeforeUnmount(() => {
/>
<WaveformAnnotationToolbar
v-if="showAnnotationToolbar"
v-if="showAnnotationToolbar && !isCleanView"
:interaction-mode="activeInteractionMode"
:annotations-visible="annotationsVisible"
@update:interaction-mode="setInteractionMode"
@@ -1986,7 +2021,7 @@ onBeforeUnmount(() => {
/>
<WaveformAnnotationEditor
v-if="annotationInteraction.editorDraft.value"
v-if="annotationInteraction.editorDraft.value && !isCleanView"
:annotation="annotationInteraction.editorDraft.value.annotation"
:mode="annotationInteraction.editorDraft.value.mode"
:series="editorSeries"
@@ -1998,6 +2033,7 @@ onBeforeUnmount(() => {
/>
<WaveformAnnotationContextMenu
v-if="!isCleanView"
:visible="annotationInteraction.contextMenu.value !== null"
:x="annotationInteraction.contextMenu.value?.x || 0"
:y="annotationInteraction.contextMenu.value?.y || 0"

View File

@@ -74,6 +74,7 @@ export function resolveGridCellGeometry(
displayMode: WaveformDisplayMode,
slotHasSeries: boolean[] = [],
horizontalGap?: number,
showXAxis = true,
): GridCellGeometry[] {
const defaultGap = getGridGap(displayMode)
const columnGap = Number.isFinite(horizontalGap)
@@ -81,7 +82,9 @@ export function resolveGridCellGeometry(
: defaultGap
const totalHorizontalGap = Math.max(0, options.columnCount - 1) * columnGap
const axisRows = new Set<number>()
if (displayMode === 'independent') {
if (!showXAxis) {
// Net view uses the full drawing area for waveform pixels.
} else if (displayMode === 'independent') {
for (let row = 0; row < options.rowCount; row += 1) axisRows.add(row)
} else if (displayMode === 'compact') {
// Compact tracks share one continuous plot stack. Reserve the X-axis band

View File

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

View File

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

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 } from '../../types'
import type { WaveformFrameStyle, WaveformZeroLineOptions } from '../../types'
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
import type {
DisplaySeries,
@@ -37,6 +37,12 @@ interface Props {
hoveredPoint?: HoveredSeriesPoint
/** Y 轴标签回退值 */
yLabel?: string
/** Hide visual aids while keeping chart interaction active. */
cleanView?: boolean
/** Resolved zero reference line style. */
zeroLine?: Required<Pick<WaveformZeroLineOptions, 'color' | 'width' | 'dash'>> & {
visible: boolean
}
}
interface Emits {
@@ -51,6 +57,8 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
interactionMode: 'zoom',
cleanView: false,
zeroLine: () => ({ visible: false, color: '#98a2b3', width: 1, dash: '6 4' }),
})
const emit = defineEmits<Emits>()
@@ -111,6 +119,12 @@ function hasCrosshair(): boolean {
)
}
function zeroLineY(axis: WaveformYAxisLayout): number | null {
const [minimum, maximum] = axis.scale.domain()
if (!props.zeroLine.visible || minimum > 0 || maximum < 0) return null
return axis.scale(0)
}
function renderAxes() {
props.track.yAxes.forEach((axis, index) => {
const element = yAxisElements.value[index]
@@ -180,7 +194,7 @@ watch(
:transform="`translate(${track.left ?? 0}, ${track.top})`"
>
<rect
v-if="!track.isEmpty"
v-if="!track.isEmpty && !cleanView"
class="waveform-track__plot-background waveform-chart__plot-background"
:width="track.width ?? innerWidth"
:height="track.height"
@@ -190,7 +204,7 @@ watch(
<!-- 网格和背景 -->
<g
v-if="!track.isEmpty && track.hasVisibleSeries"
v-if="!track.isEmpty && track.hasVisibleSeries && !cleanView"
:clip-path="`url(#${clipPathId}-${track.index})`"
aria-hidden="true"
>
@@ -236,9 +250,31 @@ watch(
</g>
</g>
<g
v-if="!track.isEmpty && track.hasVisibleSeries && zeroLine.visible && !cleanView"
class="waveform-track__zero-lines waveform-chart__zero-lines"
:clip-path="`url(#${clipPathId}-${track.index})`"
aria-hidden="true"
>
<template v-for="axis in track.yAxes" :key="`zero-line-${track.index}-${axis.index}`">
<line
v-if="zeroLineY(axis) !== null"
class="waveform-track__zero-line waveform-chart__zero-line"
:data-y-axis-index="axis.index"
x1="0"
:x2="track.width ?? innerWidth"
:y1="zeroLineY(axis) ?? 0"
:y2="zeroLineY(axis) ?? 0"
:stroke="zeroLine.color"
:stroke-width="zeroLine.width"
:stroke-dasharray="zeroLine.dash || undefined"
/>
</template>
</g>
<!-- 帧编号水印 -->
<text
v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined"
v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined && !cleanView"
class="waveform-track__watermark waveform-chart__watermark"
:x="(track.width ?? innerWidth) / 2"
:y="track.height / 2"
@@ -252,13 +288,13 @@ watch(
<!-- X -->
<g
v-if="track.showXAxis"
v-if="track.showXAxis && !cleanView"
ref="xAxisElement"
class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x"
:transform="`translate(0, ${track.height})`"
/>
<g
v-if="track.showXAxis"
v-if="track.showXAxis && !cleanView"
class="waveform-track__axis-endpoints waveform-chart__axis-endpoints"
:transform="`translate(0, ${track.height})`"
font-family="sans-serif"
@@ -285,7 +321,7 @@ watch(
</text>
</g>
<text
v-if="track.showXAxis && track.xAxisExponent"
v-if="track.showXAxis && track.xAxisExponent && !cleanView"
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
:x="track.width ?? innerWidth"
:y="track.height + 27"
@@ -297,7 +333,7 @@ watch(
<!-- Y -->
<g
v-for="axis in track.isEmpty ? [] : track.yAxes"
v-for="axis in track.isEmpty || cleanView ? [] : track.yAxes"
: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"
@@ -307,7 +343,9 @@ watch(
:transform="`translate(${axis.x}, 0)`"
/>
<text
v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
v-for="axis in track.isEmpty || cleanView
? []
: track.yAxes.filter((item) => item.exponentLabel)"
:key="`y-axis-exponent-${track.index}-${axis.index}`"
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
:data-y-axis-index="axis.index"
@@ -323,6 +361,7 @@ watch(
<!-- Y 轴标签 -->
<g
v-if="
!cleanView &&
!track.isEmpty &&
track.hasVisibleSeries &&
track.seriesList.length === 1 &&
@@ -351,7 +390,7 @@ watch(
</g>
<g
v-for="axis in track.yAxes.length > 1 ? track.yAxes.filter(hasYAxisTitle) : []"
v-for="axis in !cleanView && track.yAxes.length > 1 ? track.yAxes.filter(hasYAxisTitle) : []"
:key="`y-axis-title-${track.index}-${axis.index}`"
class="waveform-track__multi-axis-title"
:data-y-axis-title-index="axis.index"
@@ -377,7 +416,7 @@ watch(
<!-- 轨道边框 -->
<rect
v-if="!track.isEmpty"
v-if="!track.isEmpty && !cleanView"
class="waveform-track__plot-frame waveform-chart__plot-frame"
:width="track.width ?? innerWidth"
:height="track.height"
@@ -421,7 +460,7 @@ watch(
/>
<text
v-if="!track.isEmpty && !track.hasVisibleSeries"
v-if="!track.isEmpty && !track.hasVisibleSeries && !cleanView"
class="waveform-track__no-visible-series"
:x="(track.width ?? innerWidth) / 2"
:y="track.height / 2"
@@ -516,6 +555,11 @@ watch(
stroke-dasharray: 4 3;
}
.waveform-track__zero-line {
fill: none;
pointer-events: none;
}
.waveform-track__axis-endpoint {
fill: #667085;
font-size: 11px;

View File

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

View File

@@ -156,7 +156,8 @@ body {
border-radius: 4px;
}
.frame-style-controls {
.frame-style-controls,
.auxiliary-style-controls {
display: grid;
gap: 10px;
}

View File

@@ -118,3 +118,11 @@ export interface WaveformFrameStyle {
borderStyle?: 'solid' | 'dashed'
backgroundColor?: string
}
/** Styling and visibility options for the horizontal zero-value reference line. */
export interface WaveformZeroLineOptions {
visible?: boolean
color?: string
width?: number
dash?: string
}

View File

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