feat(chart): support configurable plot margins
All checks were successful
Package component / package (push) Successful in 7m2s

This commit is contained in:
李启源
2026-08-05 15:16:52 +08:00
parent fbf10fdb88
commit 9eb1f0f137
20 changed files with 184 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.29", "version": "0.1.30",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",

View File

@@ -14,6 +14,7 @@ import {
type WaveformLegendOrientation, type WaveformLegendOrientation,
type WaveformLegendPosition, type WaveformLegendPosition,
type WaveformOverlayMode, type WaveformOverlayMode,
type WaveformPlotMargin,
type WaveformTitleOptions, type WaveformTitleOptions,
type WaveformZoomEndPayload, type WaveformZoomEndPayload,
type WaveformZeroLineOptions, type WaveformZeroLineOptions,
@@ -47,6 +48,8 @@ const annotationsVisible = ref(true)
const cleanView = ref(false) const cleanView = ref(false)
const presentationMode = ref(false) const presentationMode = ref(false)
const showTooltip = ref(true) const showTooltip = ref(true)
const plotMarginTop = ref(18)
const plotMarginBottom = ref(52)
const zeroLineVisible = ref(false) const zeroLineVisible = ref(false)
const zeroLineColor = ref('#98a2b3') const zeroLineColor = ref('#98a2b3')
const zeroLineWidth = ref(1) const zeroLineWidth = ref(1)
@@ -126,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
width: zeroLineWidth.value, width: zeroLineWidth.value,
dash: zeroLineDash.value, dash: zeroLineDash.value,
})) }))
const plotMargin = computed<WaveformPlotMargin>(() => ({
top: plotMarginTop.value,
bottom: plotMarginBottom.value,
}))
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) => const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
series.points.map((point) => point.x), series.points.map((point) => point.x),
@@ -294,6 +301,8 @@ const controlPanelModel = reactive({
displayMode, displayMode,
overlayMode, overlayMode,
showTooltip, showTooltip,
plotMarginTop,
plotMarginBottom,
cleanView, cleanView,
presentationMode, presentationMode,
selectedSeriesId, selectedSeriesId,
@@ -360,6 +369,7 @@ const chartModel = reactive({
cleanView, cleanView,
presentationMode, presentationMode,
showTooltip, showTooltip,
plotMargin,
zeroLine, zeroLine,
frameWatermarkVisible, frameWatermarkVisible,
annotations, annotations,

View File

@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
interactionMode: undefined, interactionMode: undefined,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }), grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}), rendering: () => ({}),
plotMargin: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }), legend: () => ({ position: 'top-right', orientation: 'auto' }),
defaultHiddenSeriesIds: () => [], defaultHiddenSeriesIds: () => [],
cleanView: false, cleanView: false,

View File

@@ -19,6 +19,7 @@ const {
overlayMode, overlayMode,
resolvedChartLeftMargin, resolvedChartLeftMargin,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
pointerInsideChart, pointerInsideChart,
handleNativeContextMenu, handleNativeContextMenu,
titleAreaReserved, titleAreaReserved,
@@ -118,6 +119,8 @@ const {
:data-presentation-mode="isPresentationMode" :data-presentation-mode="isPresentationMode"
:data-overlay-mode="overlayMode" :data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin" :data-chart-left-margin="resolvedChartLeftMargin"
:data-plot-margin-top="resolvedPlotMargin.top"
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight" :data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true" @pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false" @pointerleave="pointerInsideChart = false"
@@ -287,17 +290,17 @@ const {
/> />
</g> </g>
</g> </g>
</g>
<text <text
v-if="resolvedXLabel && !isCleanView" v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label" class="waveform-chart__label waveform-chart__x-label"
:x="innerWidth / 2" :x="resolvedChartLeftMargin + innerWidth / 2"
:y="xAxisTitleY" :y="xAxisTitleY"
text-anchor="middle" text-anchor="middle"
> >
{{ resolvedXLabel }} {{ resolvedXLabel }}
</text> </text>
</g>
<text <text
v-if="hasChartArea && !hasWaveformData" v-if="hasChartArea && !hasWaveformData"

View File

@@ -7,6 +7,9 @@
/** 图表边距 */ /** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 } export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/** Distance from the drawing SVG bottom edge to the X-axis title baseline. */
export const X_AXIS_TITLE_BOTTOM_OFFSET = 12
/** /**
* 图表最小高度(像素) * 图表最小高度(像素)
*/ */

View File

@@ -24,7 +24,6 @@ import {
normalizeGridOptions, normalizeGridOptions,
paginateSeries, paginateSeries,
resolveGridCellGeometry, resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid' } from './grid'
import { import {
buildTrackLayouts, buildTrackLayouts,
@@ -316,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: [], : [],
) )
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => { const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId) const series = chartSeries.value.find((item) => item.id === seriesId)
@@ -360,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
resolveSeriesYScale, resolveSeriesYScale,
annotationTrackLayouts, annotationTrackLayouts,
renderedAnnotations, renderedAnnotations,
xAxisTitleY,
editorSeries, editorSeries,
resolveFrameNumber, resolveFrameNumber,
} }

View File

@@ -7,6 +7,7 @@ import {
TITLE_CHAR_WIDTH_RATIO, TITLE_CHAR_WIDTH_RATIO,
TITLE_DEFAULT_FONT_SIZE, TITLE_DEFAULT_FONT_SIZE,
TITLE_LINE_HEIGHT, TITLE_LINE_HEIGHT,
X_AXIS_TITLE_BOTTOM_OFFSET,
ZERO_LINE_DEFAULTS, ZERO_LINE_DEFAULTS,
} from './constants' } from './constants'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title' import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
@@ -144,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
const titleAreaHeight = computed(() => const titleAreaHeight = computed(() =>
titleAreaReserved.value ? titleLayout.value.areaHeight : 0, titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
) )
const chartTopMargin = computed(() => margin.top) const resolvePlotMargin = (value: number | undefined, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
const resolvedPlotMargin = computed(() => ({
top: resolvePlotMargin(props.plotMargin.top, margin.top),
bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
}))
const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
const drawingHeight = computed(() => const drawingHeight = computed(() =>
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value), Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
) )
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom)) const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
const innerHeight = computed(() =>
Math.max(
0,
drawingHeight.value - resolvedPlotMargin.value.top - resolvedPlotMargin.value.bottom,
),
)
const titleAreaStyle = computed<CSSProperties>(() => ({ const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`, height: `${titleAreaHeight.value}px`,
justifyContent: justifyContent:
@@ -193,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
titleMeasureStyle, titleMeasureStyle,
titleLayout, titleLayout,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
chartTopMargin, chartTopMargin,
drawingHeight, drawingHeight,
xAxisTitleY,
innerHeight, innerHeight,
titleAreaStyle, titleAreaStyle,
titleVisualStyle, titleVisualStyle,

View File

@@ -7,6 +7,7 @@ import type {
WaveformInteractionMode, WaveformInteractionMode,
WaveformLegendOptions, WaveformLegendOptions,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformPoint, WaveformPoint,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleOptions, WaveformTitleOptions,
@@ -42,6 +43,7 @@ export interface WaveformChartProps {
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions rendering?: WaveformRenderingOptions
plotMargin?: WaveformPlotMargin
title?: WaveformTitleOptions title?: WaveformTitleOptions
legend?: WaveformLegendOptions legend?: WaveformLegendOptions
hiddenSeriesIds?: string[] hiddenSeriesIds?: string[]
@@ -65,6 +67,7 @@ type DefaultedProp =
| 'annotationsVisible' | 'annotationsVisible'
| 'grid' | 'grid'
| 'rendering' | 'rendering'
| 'plotMargin'
| 'legend' | 'legend'
| 'defaultHiddenSeriesIds' | 'defaultHiddenSeriesIds'
| 'cleanView' | 'cleanView'

View File

@@ -13,6 +13,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -11,6 +11,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -12,6 +12,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformFrameStyle, WaveformFrameStyle,

View File

@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300') expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
}) })
it('configures plot top and bottom margins and reacts to updates', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: 30, bottom: 70 },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
await wrapper.setProps({ plotMargin: { top: 12 } })
expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
})
it('falls back to default plot margins for invalid values', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: -1, bottom: Number.NaN },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
})
it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
title: { text: '固定标题' },
},
)
const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
const xAxisTitle = wrapper.get('.waveform-chart__x-label')
const initialX = xAxisTitle.attributes('x')
const initialY = xAxisTitle.attributes('y')
await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
})
it('does not render or reserve space for missing, hidden, or blank titles', async () => { it('does not render or reserve space for missing, hidden, or blank titles', async () => {
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) { for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(

View File

@@ -45,6 +45,7 @@ defineExpose({ resetViewport })
:clean-view="model.cleanView" :clean-view="model.cleanView"
:presentation-mode="model.presentationMode" :presentation-mode="model.presentationMode"
:show-tooltip="model.showTooltip" :show-tooltip="model.showTooltip"
:plot-margin="model.plotMargin"
:zero-line="model.zeroLine" :zero-line="model.zeroLine"
:frame-number="model.frameWatermarkVisible ? 1 : undefined" :frame-number="model.frameWatermarkVisible ? 1 : undefined"
:annotations-visible="model.annotationsVisible" :annotations-visible="model.annotationsVisible"

View File

@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
</script> </script>
<template> <template>
<section class="control-section">
<h2>绘图区边距</h2>
<div class="plot-margin-controls">
<label class="frame-style-control">
<span>上边距</span>
<InputNumber
v-model:value="model.plotMarginTop"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区上边距"
/>
</label>
<label class="frame-style-control">
<span>下边距</span>
<InputNumber
v-model:value="model.plotMarginBottom"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区下边距"
/>
</label>
</div>
</section>
<section class="control-section"> <section class="control-section">
<div class="control-section__header"> <div class="control-section__header">
<h2>零值参考线</h2> <h2>零值参考线</h2>

View File

@@ -0,0 +1,29 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
describe('plot margin demo controls', () => {
it('updates the chart top and bottom margins from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
expect(controls).toHaveLength(2)
expect(controls[0]?.props('value')).toBe(18)
expect(controls[1]?.props('value')).toBe(52)
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
controls[0]?.vm.$emit('update:value', 30)
controls[1]?.vm.$emit('update:value', 70)
await flushPromises()
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
wrapper.unmount()
})
})

View File

@@ -10,6 +10,7 @@ import type {
WaveformLegendPosition, WaveformLegendPosition,
WaveformLineStyle, WaveformLineStyle,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformTitleOptions, WaveformTitleOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
WaveformZoomEndPayload, WaveformZoomEndPayload,
@@ -26,6 +27,8 @@ export interface DemoControlPanelModel {
displayMode: WaveformDisplayMode displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode overlayMode: WaveformOverlayMode
showTooltip: boolean showTooltip: boolean
plotMarginTop: number
plotMarginBottom: number
cleanView: boolean cleanView: boolean
presentationMode: boolean presentationMode: boolean
selectedSeriesId: string selectedSeriesId: string
@@ -99,6 +102,7 @@ export interface DemoChartModel {
cleanView: boolean cleanView: boolean
presentationMode: boolean presentationMode: boolean
showTooltip: boolean showTooltip: boolean
plotMargin: WaveformPlotMargin
zeroLine: WaveformZeroLineOptions zeroLine: WaveformZeroLineOptions
frameWatermarkVisible: boolean frameWatermarkVisible: boolean
annotations: WaveformAnnotation[] annotations: WaveformAnnotation[]

View File

@@ -17,6 +17,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -195,6 +195,7 @@ body {
} }
.frame-style-controls, .frame-style-controls,
.plot-margin-controls,
.auxiliary-style-controls { .auxiliary-style-controls {
display: grid; display: grid;
gap: 10px; gap: 10px;

View File

@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
errorBarMinSpacing?: number errorBarMinSpacing?: number
} }
/** Pixel margins reserved above and below the waveform plotting area. */
export interface WaveformPlotMargin {
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
top?: number
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
bottom?: number
}
/** Text styling for the chart-level title. */ /** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle { export interface WaveformTitleTextStyle {
color?: string color?: string

View File

@@ -12,6 +12,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,