feat(chart): improve adaptive waveform layout #1

Merged
admin merged 1 commits from feature-auto-size into main 2026-07-20 11:30:45 +08:00
12 changed files with 563 additions and 59 deletions

57
AGENTS.md Normal file
View File

@@ -0,0 +1,57 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Vue 3 + TypeScript waveform component library with a Vite demo.
Production exports are defined in `src/index.ts`; demo entry points are `src/main.ts` and
`src/App.vue`. The main chart is `src/components/WaveformChart.vue`, with focused modules under
`src/components/{core,data,rendering,interaction,annotation}`. Shared types live in `src/types`,
data normalization and chart logic in `src/core` and `src/utils`, styles in `src/styles.css`, and
sample data in `src/data`. Tests are colocated with implementation files (`*.test.ts`), with shared
setup in `src/test/setup.ts`. `dist/` and `dist-demo/` are generated; do not edit them.
## Build, Test, and Development Commands
Use pnpm (the lockfile is `pnpm-lock.yaml`) and Node.js 22 as CI does.
```bash
pnpm install # Install locked dependencies
pnpm dev # Start the Vite demo server
pnpm typecheck # Run vue-tsc checks
pnpm lint # Run ESLint with zero warnings allowed
pnpm test # Run Vitest once
pnpm test:coverage # Run tests and enforce coverage thresholds
pnpm build # Type-check and build library plus demo bundles
pnpm preview # Preview the production demo build
```
Run `pnpm format` to apply the repository Prettier configuration.
## Coding Style & Naming Conventions
Use TypeScript and Vue 3 Composition API with two-space indentation, single quotes, no semicolons,
and a 100-column print width. Prettier and ESLint are authoritative.
Use PascalCase for Vue components, component filenames, and types; use camelCase for functions,
variables, and composables (for example, `useWaveformData`). Keep public exports deliberate and
preserve stable series IDs for multi-channel data.
## Testing Guidelines
Vitest with `@vue/test-utils` and jsdom is used. Name tests `*.test.ts` beside the
code they cover. Exercise normalization, rendering/layout helpers, formatting, and component
interactions, including empty, non-finite, and multi-series inputs. Coverage thresholds are 80%
for lines/statements/functions and 75% for branches; run `pnpm test:coverage` before submitting.
## Commit & Pull Request Guidelines
The current history contains only `first commit`, so no established convention exists yet. Use short,
imperative messages, preferably scoped (for example, `feat(chart): ...`, `fix(annotation): ...`, or
`test: ...`). Pull requests should explain API or user-visible changes, list verification commands,
link an issue or plan, and include screenshots or a short recording for visual changes. Keep generated
files and unrelated refactors out of the change.
## CI and Configuration
GitHub Actions runs install, typecheck, lint, coverage, build, and `pnpm pack --dry-run` on pushes and
pull requests. Do not commit secrets or local environment files; review the staged file list before
opening a pull request.

View File

@@ -50,6 +50,26 @@ import { WaveformChart } from './index'
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒, 多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。 `timeUnit` 只控制坐标轴和 tooltip 的显示单位。
### 绘图区域尺寸
`width``height` 接收像素数值,并且可以独立设置。指定的维度使用固定尺寸,未指定的
维度自适应填满父容器:
```vue
<div class="chart-container">
<WaveformChart :data="chartData" :width="960" />
</div>
<style scoped>
.chart-container {
height: 520px;
}
</style>
```
自适应高度要求父容器具有明确高度;父容器未定高时,组件使用最低 `180px` 高度。
显式高度同样保留 `180px` 下限。非有限尺寸按未指定处理,负宽度归零。
## 大数据渲染 ## 大数据渲染
组件按不可变数据处理:替换 `data` 引用会重新过滤、排序和缓存坐标域,并重置视口; 组件按不可变数据处理:替换 `data` 引用会重新过滤、排序和缓存坐标域,并重置视口;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { InputNumber, Radio, Tag } from 'ant-design-vue' import { InputNumber, Radio, Tag } from 'ant-design-vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
WaveformChart, WaveformChart,
@@ -43,10 +43,6 @@ const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true) const annotationsVisible = ref(true)
const interactionMode = ref<WaveformInteractionMode>('zoom') const interactionMode = ref<WaveformInteractionMode>('zoom')
const resolveChartHeight = () =>
Math.min(800, Math.max(560, (typeof window === 'undefined' ? 750 : window.innerHeight) - 190))
const chartHeight = ref(resolveChartHeight())
const waveformSeries: WaveformSeries[] = sourceRows.map((row) => { const waveformSeries: WaveformSeries[] = sourceRows.map((row) => {
const pointCount = Math.min(row.time.length, row.data.length) const pointCount = Math.min(row.time.length, row.data.length)
return { return {
@@ -77,10 +73,6 @@ const displayedRange = computed(() => visibleRange.value ?? initialTimeRange)
const formatMilliseconds = (seconds: number) => const formatMilliseconds = (seconds: number) =>
(seconds * 1000).toLocaleString('zh-CN', { maximumFractionDigits: 1 }) (seconds * 1000).toLocaleString('zh-CN', { maximumFractionDigits: 1 })
function updateChartHeight() {
chartHeight.value = resolveChartHeight()
}
watch(displayMode, () => { watch(displayMode, () => {
visibleRange.value = null visibleRange.value = null
}) })
@@ -88,14 +80,6 @@ watch(displayMode, () => {
watch([rowCount, columnCount], () => { watch([rowCount, columnCount], () => {
visibleRange.value = null visibleRange.value = null
}) })
onMounted(() => {
window.addEventListener('resize', updateChartHeight)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateChartHeight)
})
</script> </script>
<template> <template>
@@ -149,7 +133,6 @@ onBeforeUnmount(() => {
:data="chartData" :data="chartData"
:display-mode="displayMode" :display-mode="displayMode"
:grid="{ rowCount, columnCount, showPagination: true }" :grid="{ rowCount, columnCount, showPagination: true }"
:height="chartHeight"
:frame-number="1" :frame-number="1"
v-model:annotations="annotations" v-model:annotations="annotations"
v-model:annotations-visible="annotationsVisible" v-model:annotations-visible="annotationsVisible"

View File

@@ -297,6 +297,64 @@ describe('WaveformChart', () => {
} }
}) })
it('keeps unused compact cells visually empty while preserving bottom X axes', async () => {
const data = gridSeries(4)
if (data.kind === 'series') {
data.series.forEach((series, index) => {
series.data = {
kind: 'points',
points: [
{ x: 100, y: index },
{ x: 200, y: index + 1 },
],
}
})
}
const wrapper = await mountSizedChart(data, {
displayMode: 'compact',
grid: { rowCount: 2, columnCount: 3 },
})
const tracks = wrapper.findAll('.waveform-chart__track')
const emptyTracks = wrapper.findAll('.waveform-chart__track--empty')
const firstTrackTop = Number(tracks[0].attributes('data-track-top'))
const firstTrackHeight = Number(tracks[0].attributes('data-track-height'))
const secondRowFirstTrackTop = Number(tracks[3].attributes('data-track-top'))
expect(tracks).toHaveLength(6)
expect(emptyTracks).toHaveLength(2)
expect(wrapper.find('.waveform-chart__grid-slot-placeholder').exists()).toBe(false)
expect(secondRowFirstTrackTop).toBe(firstTrackTop + firstTrackHeight)
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(3)
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(4)
expect(emptyTracks[0].findAll('.waveform-chart__grid')).toHaveLength(0)
expect(emptyTracks[0].find('.waveform-chart__plot-frame').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__axis--y').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__line').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__y-axis-label').exists()).toBe(false)
expect(emptyTracks[0].find('.waveform-chart__watermark').exists()).toBe(false)
const populatedBottomTrack = tracks[3]
expect(emptyTracks[0].get('.waveform-chart__axis-endpoint--start').text()).toBe(
populatedBottomTrack.get('.waveform-chart__axis-endpoint--start').text(),
)
expect(emptyTracks[0].get('.waveform-chart__axis-endpoint--end').text()).toBe(
populatedBottomTrack.get('.waveform-chart__axis-endpoint--end').text(),
)
})
it('shows the global empty state when compact data has no valid channels', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [1], sampleRate: -1 },
{ displayMode: 'compact', grid: { rowCount: 2, columnCount: 3 } },
)
expect(wrapper.get('.waveform-chart__empty').text()).toContain('暂无有效波形数据')
expect(wrapper.findAll('.waveform-chart__track')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
})
it('resets the page when the grid configuration changes', async () => { it('resets the page when the grid configuration changes', async () => {
const wrapper = await mountSizedChart(gridSeries(5), { const wrapper = await mountSizedChart(gridSeries(5), {
grid: { rowCount: 1, columnCount: 1 }, grid: { rowCount: 1, columnCount: 1 },
@@ -352,6 +410,108 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__line').attributes('d')).not.toBe(initialPath) expect(wrapper.get('.waveform-chart__line').attributes('d')).not.toBe(initialPath)
}) })
it('uses fixed dimensions when both width and height are specified', async () => {
const wrapper = mount(WaveformChart, {
props: {
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
width: 640,
height: 420,
},
})
expect(wrapper.attributes('style')).toContain('width: 640px')
expect(wrapper.attributes('style')).toContain('height: 420px')
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('640')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('420')
resizeObservers.at(-1)?.resize(640, 420)
await flushPromises()
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('640')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('420')
})
it('allows fixed width and adaptive height to work independently', async () => {
const wrapper = mount(WaveformChart, {
props: {
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
width: 640,
},
})
expect(wrapper.attributes('style')).toContain('width: 640px')
expect(wrapper.attributes('style')).toContain('height: 100%')
resizeObservers.at(-1)?.resize(640, 500)
await flushPromises()
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('640')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('500')
})
it('allows adaptive width and fixed height to work independently', async () => {
const wrapper = mount(WaveformChart, {
props: {
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
height: 420,
},
})
expect(wrapper.attributes('style')).toContain('width: 100%')
expect(wrapper.attributes('style')).toContain('height: 420px')
resizeObservers.at(-1)?.resize(900, 420)
await flushPromises()
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('900')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('420')
})
it('fills both dimensions and responds to container size changes by default', async () => {
const wrapper = mount(WaveformChart, {
props: { data: { kind: 'samples', values: [0, 1], sampleRate: 1 } },
})
expect(wrapper.attributes('style')).toContain('width: 100%')
expect(wrapper.attributes('style')).toContain('height: 100%')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('180')
resizeObservers.at(-1)?.resize(800, 520)
await flushPromises()
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('800')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('520')
resizeObservers.at(-1)?.resize(500, 300)
await flushPromises()
expect(wrapper.get('.waveform-chart__svg').attributes('width')).toBe('500')
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
})
it('applies size fallbacks for minimum, negative, and non-finite values', async () => {
const minimumWrapper = mount(WaveformChart, {
props: {
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
width: -20,
height: -20,
},
})
expect(minimumWrapper.attributes('style')).toContain('width: 0px')
expect(minimumWrapper.attributes('style')).toContain('height: 180px')
expect(minimumWrapper.get('.waveform-chart__svg').attributes('height')).toBe('180')
const adaptiveWrapper = mount(WaveformChart, {
props: {
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
width: Number.POSITIVE_INFINITY,
height: Number.NaN,
},
})
expect(adaptiveWrapper.attributes('style')).toContain('width: 100%')
expect(adaptiveWrapper.attributes('style')).toContain('height: 100%')
})
it('keeps a 100k-point SVG path bounded by the plot width', async () => { it('keeps a 100k-point SVG path bounded by the plot width', async () => {
const sourcePoints = Array.from({ length: 100_000 }, (_, index) => ({ const sourcePoints = Array.from({ length: 100_000 }, (_, index) => ({
x: index / 1_000, x: index / 1_000,
@@ -432,6 +592,32 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd') expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
}) })
it('continues minor x-grid lines beyond the final major tick to the exact endpoint', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: -8, y: 0 },
{ x: 4.9903, y: 1 },
],
})
const majorGridPositions = wrapper
.findAll('.waveform-chart__grid--major line')
.map((line) => Number(line.attributes('x1')))
.filter(Number.isFinite)
const minorGridPositions = wrapper
.findAll('.waveform-chart__grid--minor line')
.map((line) => Number(line.attributes('x1')))
.filter(Number.isFinite)
const trackWidth = Number(wrapper.get('.waveform-chart__track').attributes('data-track-width'))
expect(Math.max(...minorGridPositions)).toBeGreaterThan(Math.max(...majorGridPositions))
expect(Math.max(...minorGridPositions)).toBeLessThan(trackWidth)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
String(trackWidth),
)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4,990')
})
it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => { it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
const cases = [ const cases = [
{ values: [0, 50], exponent: null }, { values: [0, 50], exponent: null },
@@ -672,7 +858,7 @@ describe('WaveformChart', () => {
).toEqual(['1,000', '2,000']) ).toEqual(['1,000', '2,000'])
}) })
it('does not duplicate Y-axis boundary labels in compact mode', async () => { it('keeps the zero Y-axis label on upper compact tracks', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
@@ -703,14 +889,170 @@ describe('WaveformChart', () => {
) )
const tracks = wrapper.findAll('.waveform-chart__track') const tracks = wrapper.findAll('.waveform-chart__track')
const firstTrackHeight = Number(tracks[0].attributes('data-track-height'))
const firstAxisTicks = tracks[0].findAll('.waveform-chart__axis--y .tick') const firstAxisTicks = tracks[0].findAll('.waveform-chart__axis--y .tick')
const hasBottomBoundaryTick = firstAxisTicks.some((tick) => { const zeroTicks = firstAxisTicks.filter((tick) => Number(tick.text()) === 0)
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/) const firstTrackHeight = Number(tracks[0].attributes('data-track-height'))
return match ? Math.abs(Number(match[1]) - firstTrackHeight) < 0.1 : false const zeroTickY = Number(
}) zeroTicks[0].attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)?.[1],
)
expect(hasBottomBoundaryTick).toBe(false) expect(zeroTicks).toHaveLength(1)
expect(Math.abs(zeroTickY - firstTrackHeight)).toBeLessThanOrEqual(1)
})
it.each(['independent', 'separated', 'compact'] as const)(
'shows a non-zero Y-axis start value once in %s mode',
async (displayMode) => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
name: 'first',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.11 },
{ x: 1, y: 0.89 },
],
},
},
{
name: 'second',
data: {
kind: 'points',
points: [
{ x: 0, y: 4 },
{ x: 1, y: 5 },
],
},
},
],
},
{ displayMode },
)
const firstTrack = wrapper.findAll('.waveform-chart__track')[0]
const firstTrackHeight = Number(firstTrack.attributes('data-track-height'))
const startTicks = firstTrack.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
return match ? Math.abs(Number(match[1]) - firstTrackHeight) <= 1 : false
})
expect(startTicks).toHaveLength(1)
expect(Number(startTicks[0].text())).toBe(0.1)
},
)
it.each(['independent', 'separated'] as const)(
'shows the Y-axis end value once on every track in %s mode',
async (displayMode) => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
name: 'first',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.11 },
{ x: 1, y: 0.89 },
],
},
},
{
name: 'second',
data: {
kind: 'points',
points: [
{ x: 0, y: 4.11 },
{ x: 1, y: 4.89 },
],
},
},
],
},
{ displayMode },
)
const expectedEndValues = [0.9, 4.9]
wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
return match ? Math.abs(Number(match[1])) <= 1 : false
})
expect(endTicks).toHaveLength(1)
expect(Number(endTicks[0].text())).toBe(expectedEndValues[index])
})
},
)
it('shows Y-axis end values only on the top row in compact mode', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
name: 'top-left',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.11 },
{ x: 1, y: 0.89 },
],
},
},
{
name: 'top-right',
data: {
kind: 'points',
points: [
{ x: 0, y: 1.11 },
{ x: 1, y: 1.89 },
],
},
},
{
name: 'bottom-left',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
{
name: 'bottom-right',
data: {
kind: 'points',
points: [
{ x: 0, y: 4 },
{ x: 1, y: 5 },
],
},
},
],
},
{ displayMode: 'compact', grid: { rowCount: 2, columnCount: 2 } },
)
const tracks = wrapper.findAll('.waveform-chart__track')
const endTicks = tracks.map((track) =>
track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
return match ? Math.abs(Number(match[1])) <= 1 : false
}),
)
expect(endTicks[0]).toHaveLength(1)
expect(Number(endTicks[0][0].text())).toBe(0.9)
expect(endTicks[1]).toHaveLength(1)
expect(Number(endTicks[1][0].text())).toBe(1.9)
expect(endTicks[2]).toHaveLength(0)
expect(endTicks[3]).toHaveLength(0)
}) })
it('keeps the shared exponent on the top visible tick in compact mode', async () => { it('keeps the shared exponent on the top visible tick in compact mode', async () => {

View File

@@ -65,6 +65,7 @@ const props = withDefaults(
defineProps<{ defineProps<{
data: WaveformData data: WaveformData
displayMode?: WaveformDisplayMode displayMode?: WaveformDisplayMode
width?: number
height?: number height?: number
xLabel?: string xLabel?: string
yLabel?: string yLabel?: string
@@ -82,7 +83,6 @@ const props = withDefaults(
}>(), }>(),
{ {
displayMode: 'independent', displayMode: 'independent',
height: 360,
yLabel: '幅值', yLabel: '幅值',
lineColor: '#0960bd', lineColor: '#0960bd',
showTooltip: true, showTooltip: true,
@@ -115,7 +115,8 @@ const minimumHeight = chartMinimumHeight
const container = ref<HTMLDivElement>() const container = ref<HTMLDivElement>()
const svgElement = ref<SVGSVGElement>() const svgElement = ref<SVGSVGElement>()
const sharedOverlayElement = ref<SVGRectElement>() const sharedOverlayElement = ref<SVGRectElement>()
const width = ref(0) const observedWidth = ref(0)
const observedHeight = ref(0)
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity) const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
const independentTransforms = shallowRef<ZoomTransform[]>([]) const independentTransforms = shallowRef<ZoomTransform[]>([])
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([]) const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
@@ -141,9 +142,22 @@ interface TooltipSeriesPoint {
point: WaveformPoint point: WaveformPoint
} }
const chartHeight = computed(() => const fixedWidth = computed(() =>
Number.isFinite(props.height) ? Math.max(minimumHeight, props.height) : 360, Number.isFinite(props.width) ? Math.max(0, props.width ?? 0) : undefined,
) )
const fixedHeight = computed(() =>
Number.isFinite(props.height) ? Math.max(minimumHeight, props.height ?? 0) : undefined,
)
const chartWidth = computed(() =>
observedWidth.value > 0 ? observedWidth.value : (fixedWidth.value ?? 0),
)
const chartHeight = computed(() =>
observedHeight.value > 0 ? observedHeight.value : (fixedHeight.value ?? minimumHeight),
)
const containerStyle = computed(() => ({
width: fixedWidth.value === undefined ? '100%' : `${fixedWidth.value}px`,
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
}))
const innerHeight = computed(() => Math.max(0, chartHeight.value - margin.top - margin.bottom)) const innerHeight = computed(() => Math.max(0, chartHeight.value - margin.top - margin.bottom))
const chartSeries = computed<DisplaySeries[]>(() => const chartSeries = computed<DisplaySeries[]>(() =>
preparedSeries.value.map((series, index: number): DisplaySeries => ({ preparedSeries.value.map((series, index: number): DisplaySeries => ({
@@ -203,7 +217,9 @@ const chartLeftMargin = computed(() =>
: 0, : 0,
), ),
) )
const innerWidth = computed(() => Math.max(0, width.value - chartLeftMargin.value - margin.right)) const innerWidth = computed(() =>
Math.max(0, chartWidth.value - chartLeftMargin.value - margin.right),
)
const yAxisLayout = computed(() => { const yAxisLayout = computed(() => {
const baseGap = getGridGap(props.displayMode) const baseGap = getGridGap(props.displayMode)
const columnCount = gridOptions.value.columnCount const columnCount = gridOptions.value.columnCount
@@ -277,6 +293,7 @@ const trackLayouts = computed<TrackLayout[]>(() =>
rendering: renderingOptions.value, rendering: renderingOptions.value,
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels, hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX, yAxisLabelX: yAxisMetrics.value.labelCenterX,
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
}), }),
) )
@@ -445,7 +462,7 @@ function resolvePointerEditorAnchor(
trackIndex?: number, trackIndex?: number,
): AnnotationEditorAnchor { ): AnnotationEditorAnchor {
const overlay = event.currentTarget as SVGRectElement | null const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return { x: width.value / 2, y: chartHeight.value / 2 } if (!overlay) return { x: chartWidth.value / 2, y: chartHeight.value / 2 }
const [pointerX, pointerY] = pointer(event, overlay) const [pointerX, pointerY] = pointer(event, overlay)
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex] const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
return { return {
@@ -457,7 +474,9 @@ function resolvePointerEditorAnchor(
function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor { function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor {
const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId) const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId)
return { return {
x: track ? chartLeftMargin.value + track.left + track.xScale(annotation.x) : width.value / 2, x: track
? chartLeftMargin.value + track.left + track.xScale(annotation.x)
: chartWidth.value / 2,
y: track ? margin.top + track.top + track.yScale(annotation.y) : chartHeight.value / 2, y: track ? margin.top + track.top + track.yScale(annotation.y) : chartHeight.value / 2,
} }
} }
@@ -600,12 +619,12 @@ function handleExistingAnnotationContextMenu(annotationId: string, event: MouseE
if (!annotation) return if (!annotation) return
const bounds = container.value.getBoundingClientRect() const bounds = container.value.getBoundingClientRect()
const editorAnchor = { const editorAnchor = {
x: Math.max(0, Math.min(event.clientX - bounds.left, width.value)), x: Math.max(0, Math.min(event.clientX - bounds.left, chartWidth.value)),
y: Math.max(0, Math.min(event.clientY - bounds.top, chartHeight.value)), y: Math.max(0, Math.min(event.clientY - bounds.top, chartHeight.value)),
} }
annotationInteraction.openContextMenu({ annotationInteraction.openContextMenu({
annotationId, annotationId,
x: Math.max(4, Math.min(event.clientX - bounds.left, width.value - 120)), x: Math.max(4, Math.min(event.clientX - bounds.left, chartWidth.value - 120)),
y: Math.max(4, Math.min(event.clientY - bounds.top, chartHeight.value - 110)), y: Math.max(4, Math.min(event.clientY - bounds.top, chartHeight.value - 110)),
editorAnchor, editorAnchor,
}) })
@@ -799,7 +818,8 @@ watch(
onMounted(() => { onMounted(() => {
if (!container.value) return if (!container.value) return
resizeObserver.value = new ResizeObserver(([entry]) => { resizeObserver.value = new ResizeObserver(([entry]) => {
width.value = Math.max(0, entry?.contentRect.width ?? 0) observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
}) })
resizeObserver.value.observe(container.value) resizeObserver.value.observe(container.value)
}) })
@@ -819,7 +839,7 @@ onBeforeUnmount(() => {
`waveform-chart--${displayMode}`, `waveform-chart--${displayMode}`,
`waveform-chart--interaction-${activeInteractionMode}`, `waveform-chart--interaction-${activeInteractionMode}`,
]" ]"
:style="{ height: `${chartHeight}px` }" :style="containerStyle"
:data-display-mode="displayMode" :data-display-mode="displayMode"
:data-interaction-mode="activeInteractionMode" :data-interaction-mode="activeInteractionMode"
:data-chart-left-margin="chartLeftMargin" :data-chart-left-margin="chartLeftMargin"
@@ -827,7 +847,7 @@ onBeforeUnmount(() => {
<svg <svg
ref="svgElement" ref="svgElement"
class="waveform-chart__svg" class="waveform-chart__svg"
:width="width" :width="chartWidth"
:height="chartHeight" :height="chartHeight"
role="img" role="img"
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'" :aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
@@ -844,7 +864,7 @@ onBeforeUnmount(() => {
</defs> </defs>
<g :transform="`translate(${chartLeftMargin}, ${margin.top})`"> <g :transform="`translate(${chartLeftMargin}, ${margin.top})`">
<g class="waveform-chart__grid-slots" aria-hidden="true"> <g v-if="displayMode !== 'compact'" class="waveform-chart__grid-slots" aria-hidden="true">
<g <g
v-for="cell in gridCells" v-for="cell in gridCells"
:key="`grid-slot-${cell.slotIndex}`" :key="`grid-slot-${cell.slotIndex}`"
@@ -915,7 +935,7 @@ onBeforeUnmount(() => {
<text <text
v-if="hasChartArea && !hasWaveformData" v-if="hasChartArea && !hasWaveformData"
class="waveform-chart__empty" class="waveform-chart__empty"
:x="width / 2" :x="chartWidth / 2"
:y="chartHeight / 2" :y="chartHeight / 2"
text-anchor="middle" text-anchor="middle"
> >
@@ -972,7 +992,7 @@ onBeforeUnmount(() => {
:time-unit="timeUnit" :time-unit="timeUnit"
:hovered-point="hoveredPoint" :hovered-point="hoveredPoint"
:series-points="tooltipSeriesPoints" :series-points="tooltipSeriesPoints"
:container-width="width" :container-width="chartWidth"
:container-height="chartHeight" :container-height="chartHeight"
/> />
</div> </div>
@@ -980,9 +1000,10 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.waveform-chart { .waveform-chart {
box-sizing: border-box;
position: relative; position: relative;
width: 100%;
min-width: 0; min-width: 0;
min-height: 180px;
overflow: hidden; overflow: hidden;
color: #475467; color: #475467;
background: #fff; background: #fff;

View File

@@ -81,6 +81,10 @@ export function resolveGridCellGeometry(
const axisRows = new Set<number>() const axisRows = new Set<number>()
if (displayMode === 'independent') { if (displayMode === 'independent') {
for (let row = 0; row < options.rowCount; row += 1) axisRows.add(row) 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
// only beneath the final grid row, including when that row is empty.
axisRows.add(options.rowCount - 1)
} else { } else {
for (let column = 0; column < options.columnCount; column += 1) { for (let column = 0; column < options.columnCount; column += 1) {
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) { for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) {

View File

@@ -24,6 +24,7 @@ export interface BuildTrackLayoutsOptions {
rendering: ResolvedWaveformRenderingOptions rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean hideSecondaryLabels: boolean
yAxisLabelX: number yAxisLabelX: number
showCompactEmptyTracks: boolean
} }
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] { export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
@@ -31,8 +32,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount) const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
return visibleCells.flatMap((cell, index) => { return visibleCells.flatMap((cell, index) => {
const series = cell.series const isEmpty = !cell.series
if (!series) return [] if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return []
const series: DisplaySeries = cell.series ?? {
id: `empty-grid-slot-${cell.slotIndex}`,
name: '',
color: 'transparent',
points: [],
xDomain: [0, 1],
yDomain: [0, 1],
}
const baseXScale = const baseXScale =
options.displayMode === 'independent' options.displayMode === 'independent'
? scaleLinear(series.xDomain, [0, cell.width]) ? scaleLinear(series.xDomain, [0, cell.width])
@@ -45,10 +54,14 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const yScale = scaleLinear(series.yDomain, [cell.plotHeight, 0]).nice() const yScale = scaleLinear(series.yDomain, [cell.plotHeight, 0]).nice()
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100))) const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55))) const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const yAxisTickValues = const [yAxisStart, yAxisEnd] = yScale.domain()
options.displayMode === 'compact' && cell.row < options.grid.rowCount - 1 const showYAxisEnd = options.displayMode !== 'compact' || cell.row === 0
? yMajorTicks.slice(1) const visibleYMajorTicks = showYAxisEnd
: yMajorTicks ? yMajorTicks
: yMajorTicks.filter((tick) => tick !== yAxisEnd)
const yAxisTickValues = Array.from(
new Set([yAxisStart, ...visibleYMajorTicks, ...(showYAxisEnd ? [yAxisEnd] : [])]),
)
const domain = xScale.domain() as [number, number] const domain = xScale.domain() as [number, number]
const endpointLabels = { const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit), start: formatEndpointTime(domain[0], domain, options.timeUnit),
@@ -70,6 +83,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
return { return {
index, index,
series, series,
isEmpty,
column: cell.column, column: cell.column,
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0, showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
yAxisLabelX: options.yAxisLabelX, yAxisLabelX: options.yAxisLabelX,
@@ -80,16 +94,22 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
xScale, xScale,
yScale, yScale,
xMajorTicks, xMajorTicks,
xMinorTicks: buildMinorTicks(xMajorTicks), xMinorTicks: buildMinorTicks(xMajorTicks, 5, domain),
yMajorTicks, yMajorTicks,
yMinorTicks: buildMinorTicks(yMajorTicks), yMinorTicks: buildMinorTicks(yMajorTicks),
yAxisTickValues, yAxisTickValues,
xAxisTickValues, xAxisTickValues,
endpointLabels, endpointLabels,
path: line<WaveformPoint>() path: isEmpty
.x((point) => xScale(point.x)) ? null
.y((point) => yScale(point.y))(renderPoints), : line<WaveformPoint>()
showXAxis: options.displayMode === 'independent' || bottomCells.has(cell.slotIndex), .x((point) => xScale(point.x))
.y((point) => yScale(point.y))(renderPoints),
showXAxis:
options.displayMode === 'independent' ||
(options.displayMode === 'compact'
? cell.row === options.grid.rowCount - 1
: bottomCells.has(cell.slotIndex)),
} }
}) })
} }

View File

@@ -28,6 +28,7 @@ export interface HoveredSeriesPoint extends DisplaySeries {
export interface TrackLayout { export interface TrackLayout {
index: number index: number
series: DisplaySeries series: DisplaySeries
isEmpty: boolean
column: number column: number
showYAxisLabel: boolean showYAxisLabel: boolean
yAxisLabelX: number yAxisLabelX: number

View File

@@ -145,7 +145,9 @@ watch(
<template> <template>
<g <g
class="waveform-track waveform-chart__track" class="waveform-track waveform-chart__track"
:class="{ 'waveform-track--empty waveform-chart__track--empty': track.isEmpty }"
:data-track-index="track.index" :data-track-index="track.index"
:data-track-empty="track.isEmpty || undefined"
:data-track-left="track.left" :data-track-left="track.left"
:data-track-width="track.width" :data-track-width="track.width"
:data-y-axis-label-x="track.yAxisLabelX" :data-y-axis-label-x="track.yAxisLabelX"
@@ -154,7 +156,7 @@ watch(
:transform="`translate(${track.left ?? 0}, ${track.top})`" :transform="`translate(${track.left ?? 0}, ${track.top})`"
> >
<!-- 网格和背景 --> <!-- 网格和背景 -->
<g :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true"> <g v-if="!track.isEmpty" :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true">
<g <g
class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor" class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor"
> >
@@ -199,7 +201,7 @@ watch(
<!-- 帧编号水印 --> <!-- 帧编号水印 -->
<text <text
v-if="frameNumber !== undefined" v-if="!track.isEmpty && frameNumber !== undefined"
class="waveform-track__watermark waveform-chart__watermark" class="waveform-track__watermark waveform-chart__watermark"
:x="(track.width ?? innerWidth) / 2" :x="(track.width ?? innerWidth) / 2"
:y="track.height / 2" :y="track.height / 2"
@@ -248,6 +250,7 @@ watch(
<!-- Y --> <!-- Y -->
<g <g
v-if="!track.isEmpty"
ref="yAxisElement" ref="yAxisElement"
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y" class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
/> />
@@ -255,6 +258,7 @@ watch(
<!-- Y 轴标签 --> <!-- Y 轴标签 -->
<g <g
v-if=" v-if="
!track.isEmpty &&
track.showYAxisLabel && track.showYAxisLabel &&
resolveYAxisLabel(track.series) && resolveYAxisLabel(track.series) &&
shouldShowYAxisLabel(track.height, track.index) shouldShowYAxisLabel(track.height, track.index)
@@ -281,6 +285,7 @@ watch(
<!-- 轨道边框 --> <!-- 轨道边框 -->
<rect <rect
v-if="!track.isEmpty"
class="waveform-track__plot-frame waveform-chart__plot-frame" class="waveform-track__plot-frame waveform-chart__plot-frame"
:width="track.width ?? innerWidth" :width="track.width ?? innerWidth"
:height="track.height" :height="track.height"
@@ -289,6 +294,7 @@ watch(
<!-- 波形线 --> <!-- 波形线 -->
<path <path
v-if="!track.isEmpty"
class="waveform-track__line waveform-chart__line" class="waveform-track__line waveform-chart__line"
:data-series-id="track.series.id" :data-series-id="track.series.id"
:data-series-name="track.series.name || undefined" :data-series-name="track.series.name || undefined"
@@ -299,7 +305,7 @@ watch(
<!-- 十字线 --> <!-- 十字线 -->
<g <g
v-if="hasCrosshair()" v-if="!track.isEmpty && hasCrosshair()"
class="waveform-track__crosshair waveform-chart__crosshair" class="waveform-track__crosshair waveform-chart__crosshair"
:clip-path="`url(#${clipPathId}-${track.index})`" :clip-path="`url(#${clipPathId}-${track.index})`"
> >
@@ -310,7 +316,7 @@ watch(
<!-- 交互覆盖层(仅在独立模式下) --> <!-- 交互覆盖层(仅在独立模式下) -->
<rect <rect
v-if="displayMode === 'independent'" v-if="!track.isEmpty && displayMode === 'independent'"
class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent" class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent"
:class="{ :class="{
'is-zoomable': zoomable && interactionMode === 'zoom', 'is-zoomable': zoomable && interactionMode === 'zoom',

View File

@@ -102,6 +102,7 @@ h1 {
} }
.chart-panel { .chart-panel {
height: clamp(560px, calc(100vh - 190px), 800px);
background: transparent; background: transparent;
} }

28
src/utils/domain.test.ts Normal file
View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { buildMinorTicks } from './domain'
describe('buildMinorTicks', () => {
it('preserves the existing behavior when no domain is provided', () => {
expect(buildMinorTicks([0, 10, 20], 5)).toEqual([2, 4, 6, 8, 12, 14, 16, 18])
})
it('fills the partial interval after the final major tick', () => {
const majorTicks = Array.from({ length: 13 }, (_, index) => -8000 + index * 1000)
expect(buildMinorTicks(majorTicks, 5, [-8000, 4990.3]).slice(-4)).toEqual([
4200, 4400, 4600, 4800,
])
})
it('fills both edge intervals and excludes the domain endpoints', () => {
const ticks = buildMinorTicks([-7000, -6000, -5000], 5, [-7950, -4100])
expect(ticks).toEqual([
-7800, -7600, -7400, -7200, -6800, -6600, -6400, -6200, -5800, -5600, -5400,
-5200, -4800, -4600, -4400, -4200,
])
expect(ticks).not.toContain(-7950)
expect(ticks).not.toContain(-4100)
})
})

View File

@@ -17,13 +17,34 @@ export function paddedDomain(values: number[]): [number, number] {
* 在主刻度之间生成次要刻度 * 在主刻度之间生成次要刻度
* @param values 主刻度值数组 * @param values 主刻度值数组
* @param subdivisions 细分数量,默认 5 * @param subdivisions 细分数量,默认 5
* @param domain 可选的可见域,用于补齐首尾不完整主刻度区间内的次要刻度
* @returns 次要刻度值数组 * @returns 次要刻度值数组
*/ */
export function buildMinorTicks(values: number[], subdivisions = 5): number[] { export function buildMinorTicks(
return values.flatMap((value, index) => { values: number[],
const nextValue = values[index + 1] subdivisions = 5,
domain?: readonly [number, number],
): number[] {
let intervalBoundaries = values
if (domain && values.length >= 2) {
const first = values[0]
const second = values[1]
const last = values[values.length - 1]
const previous = values[values.length - 2]
intervalBoundaries = [first - (second - first), ...values, last + (last - previous)]
}
const minorTicks = intervalBoundaries.flatMap((value, index) => {
const nextValue = intervalBoundaries[index + 1]
if (nextValue === undefined) return [] if (nextValue === undefined) return []
const step = (nextValue - value) / subdivisions const step = (nextValue - value) / subdivisions
return Array.from({ length: subdivisions - 1 }, (_, minorIndex) => value + step * (minorIndex + 1)) return Array.from({ length: subdivisions - 1 }, (_, minorIndex) => value + step * (minorIndex + 1))
}) })
if (!domain) return minorTicks
const domainMinimum = Math.min(...domain)
const domainMaximum = Math.max(...domain)
return minorTicks.filter((tick) => tick > domainMinimum && tick < domainMaximum)
} }