diff --git a/package.json b/package.json index 5bfe668..570b38f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.29", + "version": "0.1.30", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/App.vue b/src/App.vue index ec27c22..b03177d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,6 +14,7 @@ import { type WaveformLegendOrientation, type WaveformLegendPosition, type WaveformOverlayMode, + type WaveformPlotMargin, type WaveformTitleOptions, type WaveformZoomEndPayload, type WaveformZeroLineOptions, @@ -47,6 +48,8 @@ const annotationsVisible = ref(true) const cleanView = ref(false) const presentationMode = ref(false) const showTooltip = ref(true) +const plotMarginTop = ref(18) +const plotMarginBottom = ref(52) const zeroLineVisible = ref(false) const zeroLineColor = ref('#98a2b3') const zeroLineWidth = ref(1) @@ -126,6 +129,10 @@ const zeroLine = computed(() => ({ width: zeroLineWidth.value, dash: zeroLineDash.value, })) +const plotMargin = computed(() => ({ + top: plotMarginTop.value, + bottom: plotMarginBottom.value, +})) const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) => series.points.map((point) => point.x), @@ -294,6 +301,8 @@ const controlPanelModel = reactive({ displayMode, overlayMode, showTooltip, + plotMarginTop, + plotMarginBottom, cleanView, presentationMode, selectedSeriesId, @@ -360,6 +369,7 @@ const chartModel = reactive({ cleanView, presentationMode, showTooltip, + plotMargin, zeroLine, frameWatermarkVisible, annotations, diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index 3ab8475..3594fa6 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -23,6 +23,7 @@ const props = withDefaults(defineProps(), { interactionMode: undefined, grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }), rendering: () => ({}), + plotMargin: () => ({}), legend: () => ({ position: 'top-right', orientation: 'auto' }), defaultHiddenSeriesIds: () => [], cleanView: false, diff --git a/src/components/WaveformChartView.vue b/src/components/WaveformChartView.vue index 6423482..116e089 100644 --- a/src/components/WaveformChartView.vue +++ b/src/components/WaveformChartView.vue @@ -19,6 +19,7 @@ const { overlayMode, resolvedChartLeftMargin, titleAreaHeight, + resolvedPlotMargin, pointerInsideChart, handleNativeContextMenu, titleAreaReserved, @@ -118,6 +119,8 @@ const { :data-presentation-mode="isPresentationMode" :data-overlay-mode="overlayMode" :data-chart-left-margin="resolvedChartLeftMargin" + :data-plot-margin-top="resolvedPlotMargin.top" + :data-plot-margin-bottom="resolvedPlotMargin.bottom" :data-title-area-height="titleAreaHeight" @pointerenter="pointerInsideChart = true" @pointerleave="pointerInsideChart = false" @@ -287,18 +290,18 @@ const { /> - - - {{ resolvedXLabel }} - + + {{ resolvedXLabel }} + + innerHeight.value + X_AXIS_BAND + 10) const editorSeries = computed(() => { const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId const series = chartSeries.value.find((item) => item.id === seriesId) @@ -360,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) { resolveSeriesYScale, annotationTrackLayouts, renderedAnnotations, - xAxisTitleY, editorSeries, resolveFrameNumber, } diff --git a/src/components/core/useWaveformPresentation.ts b/src/components/core/useWaveformPresentation.ts index 7ab7949..c390186 100644 --- a/src/components/core/useWaveformPresentation.ts +++ b/src/components/core/useWaveformPresentation.ts @@ -7,6 +7,7 @@ import { TITLE_CHAR_WIDTH_RATIO, TITLE_DEFAULT_FONT_SIZE, TITLE_LINE_HEIGHT, + X_AXIS_TITLE_BOTTOM_OFFSET, ZERO_LINE_DEFAULTS, } from './constants' import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title' @@ -144,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) { const titleAreaHeight = computed(() => 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(() => 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(() => ({ height: `${titleAreaHeight.value}px`, justifyContent: @@ -193,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) { titleMeasureStyle, titleLayout, titleAreaHeight, + resolvedPlotMargin, chartTopMargin, drawingHeight, + xAxisTitleY, innerHeight, titleAreaStyle, titleVisualStyle, diff --git a/src/components/core/waveformChartTypes.ts b/src/components/core/waveformChartTypes.ts index 448b2b6..6582322 100644 --- a/src/components/core/waveformChartTypes.ts +++ b/src/components/core/waveformChartTypes.ts @@ -7,6 +7,7 @@ import type { WaveformInteractionMode, WaveformLegendOptions, WaveformOverlayMode, + WaveformPlotMargin, WaveformPoint, WaveformRenderingOptions, WaveformTitleOptions, @@ -42,6 +43,7 @@ export interface WaveformChartProps { interactionMode?: WaveformInteractionMode grid?: WaveformGridOptions rendering?: WaveformRenderingOptions + plotMargin?: WaveformPlotMargin title?: WaveformTitleOptions legend?: WaveformLegendOptions hiddenSeriesIds?: string[] @@ -65,6 +67,7 @@ type DefaultedProp = | 'annotationsVisible' | 'grid' | 'rendering' + | 'plotMargin' | 'legend' | 'defaultHiddenSeriesIds' | 'cleanView' diff --git a/src/components/data/types.ts b/src/components/data/types.ts index 6b29381..ea360c4 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -13,6 +13,7 @@ export type { WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, + WaveformPlotMargin, WaveformTitleTextStyle, WaveformTitleOptions, WaveformLegendPosition, diff --git a/src/components/index.ts b/src/components/index.ts index a9090c9..0d35862 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,6 +11,7 @@ export type { WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, + WaveformPlotMargin, WaveformTitleTextStyle, WaveformTitleOptions, WaveformLegendPosition, diff --git a/src/components/waveform.ts b/src/components/waveform.ts index 7edab65..8b34811 100644 --- a/src/components/waveform.ts +++ b/src/components/waveform.ts @@ -12,6 +12,7 @@ export type { WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, + WaveformPlotMargin, WaveformTitleTextStyle, WaveformTitleOptions, WaveformFrameStyle, diff --git a/src/components/waveformChartCases/sizingAndTitles.test.ts b/src/components/waveformChartCases/sizingAndTitles.test.ts index 15c65ab..b22f548 100644 --- a/src/components/waveformChartCases/sizingAndTitles.test.ts +++ b/src/components/waveformChartCases/sizingAndTitles.test.ts @@ -105,6 +105,65 @@ describe('WaveformChart', () => { 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 () => { for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) { const wrapper = await mountSizedChart( diff --git a/src/demo/DemoChartHost.vue b/src/demo/DemoChartHost.vue index 52d138e..dd0ee76 100644 --- a/src/demo/DemoChartHost.vue +++ b/src/demo/DemoChartHost.vue @@ -45,6 +45,7 @@ defineExpose({ resetViewport }) :clean-view="model.cleanView" :presentation-mode="model.presentationMode" :show-tooltip="model.showTooltip" + :plot-margin="model.plotMargin" :zero-line="model.zeroLine" :frame-number="model.frameWatermarkVisible ? 1 : undefined" :annotations-visible="model.annotationsVisible" diff --git a/src/demo/DemoGridFrameControls.vue b/src/demo/DemoGridFrameControls.vue index f0f13f6..0ca926f 100644 --- a/src/demo/DemoGridFrameControls.vue +++ b/src/demo/DemoGridFrameControls.vue @@ -8,6 +8,35 @@ const model = defineModel('model', { required: true })