Compare commits
2 Commits
ea832229da
...
0af6f460fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 0af6f460fa | |||
|
|
f535e9611a |
57
AGENTS.md
Normal file
57
AGENTS.md
Normal 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.
|
||||
20
README.md
20
README.md
@@ -50,6 +50,26 @@ import { WaveformChart } from './index'
|
||||
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
||||
`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` 引用会重新过滤、排序和缓存坐标域,并重置视口;
|
||||
|
||||
19
src/App.vue
19
src/App.vue
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { InputNumber, Radio, Tag } from 'ant-design-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
WaveformChart,
|
||||
@@ -43,10 +43,6 @@ const annotations = ref<WaveformAnnotation[]>([])
|
||||
const annotationsVisible = ref(true)
|
||||
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 pointCount = Math.min(row.time.length, row.data.length)
|
||||
return {
|
||||
@@ -77,10 +73,6 @@ const displayedRange = computed(() => visibleRange.value ?? initialTimeRange)
|
||||
const formatMilliseconds = (seconds: number) =>
|
||||
(seconds * 1000).toLocaleString('zh-CN', { maximumFractionDigits: 1 })
|
||||
|
||||
function updateChartHeight() {
|
||||
chartHeight.value = resolveChartHeight()
|
||||
}
|
||||
|
||||
watch(displayMode, () => {
|
||||
visibleRange.value = null
|
||||
})
|
||||
@@ -88,14 +80,6 @@ watch(displayMode, () => {
|
||||
watch([rowCount, columnCount], () => {
|
||||
visibleRange.value = null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateChartHeight)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateChartHeight)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -149,7 +133,6 @@ onBeforeUnmount(() => {
|
||||
:data="chartData"
|
||||
:display-mode="displayMode"
|
||||
:grid="{ rowCount, columnCount, showPagination: true }"
|
||||
:height="chartHeight"
|
||||
:frame-number="1"
|
||||
v-model:annotations="annotations"
|
||||
v-model:annotations-visible="annotationsVisible"
|
||||
|
||||
@@ -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 () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(5), {
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
@@ -352,6 +410,108 @@ describe('WaveformChart', () => {
|
||||
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 () => {
|
||||
const sourcePoints = Array.from({ length: 100_000 }, (_, index) => ({
|
||||
x: index / 1_000,
|
||||
@@ -432,6 +592,32 @@ describe('WaveformChart', () => {
|
||||
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 () => {
|
||||
const cases = [
|
||||
{ values: [0, 50], exponent: null },
|
||||
@@ -672,7 +858,7 @@ describe('WaveformChart', () => {
|
||||
).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(
|
||||
{
|
||||
kind: 'series',
|
||||
@@ -703,14 +889,170 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
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 hasBottomBoundaryTick = firstAxisTicks.some((tick) => {
|
||||
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
|
||||
return match ? Math.abs(Number(match[1]) - firstTrackHeight) < 0.1 : false
|
||||
const zeroTicks = firstAxisTicks.filter((tick) => Number(tick.text()) === 0)
|
||||
const firstTrackHeight = Number(tracks[0].attributes('data-track-height'))
|
||||
const zeroTickY = Number(
|
||||
zeroTicks[0].attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)?.[1],
|
||||
)
|
||||
|
||||
expect(zeroTicks).toHaveLength(1)
|
||||
expect(Math.abs(zeroTickY - firstTrackHeight)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
expect(hasBottomBoundaryTick).toBe(false)
|
||||
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 () => {
|
||||
|
||||
@@ -65,6 +65,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
data: WaveformData
|
||||
displayMode?: WaveformDisplayMode
|
||||
width?: number
|
||||
height?: number
|
||||
xLabel?: string
|
||||
yLabel?: string
|
||||
@@ -82,7 +83,6 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'independent',
|
||||
height: 360,
|
||||
yLabel: '幅值',
|
||||
lineColor: '#0960bd',
|
||||
showTooltip: true,
|
||||
@@ -115,7 +115,8 @@ const minimumHeight = chartMinimumHeight
|
||||
const container = ref<HTMLDivElement>()
|
||||
const svgElement = ref<SVGSVGElement>()
|
||||
const sharedOverlayElement = ref<SVGRectElement>()
|
||||
const width = ref(0)
|
||||
const observedWidth = ref(0)
|
||||
const observedHeight = ref(0)
|
||||
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
|
||||
const independentTransforms = shallowRef<ZoomTransform[]>([])
|
||||
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
|
||||
@@ -141,9 +142,22 @@ interface TooltipSeriesPoint {
|
||||
point: WaveformPoint
|
||||
}
|
||||
|
||||
const chartHeight = computed(() =>
|
||||
Number.isFinite(props.height) ? Math.max(minimumHeight, props.height) : 360,
|
||||
const fixedWidth = computed(() =>
|
||||
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 chartSeries = computed<DisplaySeries[]>(() =>
|
||||
preparedSeries.value.map((series, index: number): DisplaySeries => ({
|
||||
@@ -203,7 +217,9 @@ const chartLeftMargin = computed(() =>
|
||||
: 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 baseGap = getGridGap(props.displayMode)
|
||||
const columnCount = gridOptions.value.columnCount
|
||||
@@ -277,6 +293,7 @@ const trackLayouts = computed<TrackLayout[]>(() =>
|
||||
rendering: renderingOptions.value,
|
||||
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
|
||||
yAxisLabelX: yAxisMetrics.value.labelCenterX,
|
||||
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -445,7 +462,7 @@ function resolvePointerEditorAnchor(
|
||||
trackIndex?: number,
|
||||
): AnnotationEditorAnchor {
|
||||
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 track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
|
||||
return {
|
||||
@@ -457,7 +474,9 @@ function resolvePointerEditorAnchor(
|
||||
function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor {
|
||||
const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId)
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -600,12 +619,12 @@ function handleExistingAnnotationContextMenu(annotationId: string, event: MouseE
|
||||
if (!annotation) return
|
||||
const bounds = container.value.getBoundingClientRect()
|
||||
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)),
|
||||
}
|
||||
annotationInteraction.openContextMenu({
|
||||
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)),
|
||||
editorAnchor,
|
||||
})
|
||||
@@ -799,7 +818,8 @@ watch(
|
||||
onMounted(() => {
|
||||
if (!container.value) return
|
||||
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)
|
||||
})
|
||||
@@ -819,7 +839,7 @@ onBeforeUnmount(() => {
|
||||
`waveform-chart--${displayMode}`,
|
||||
`waveform-chart--interaction-${activeInteractionMode}`,
|
||||
]"
|
||||
:style="{ height: `${chartHeight}px` }"
|
||||
:style="containerStyle"
|
||||
:data-display-mode="displayMode"
|
||||
:data-interaction-mode="activeInteractionMode"
|
||||
:data-chart-left-margin="chartLeftMargin"
|
||||
@@ -827,7 +847,7 @@ onBeforeUnmount(() => {
|
||||
<svg
|
||||
ref="svgElement"
|
||||
class="waveform-chart__svg"
|
||||
:width="width"
|
||||
:width="chartWidth"
|
||||
:height="chartHeight"
|
||||
role="img"
|
||||
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
|
||||
@@ -844,7 +864,7 @@ onBeforeUnmount(() => {
|
||||
</defs>
|
||||
|
||||
<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
|
||||
v-for="cell in gridCells"
|
||||
:key="`grid-slot-${cell.slotIndex}`"
|
||||
@@ -915,7 +935,7 @@ onBeforeUnmount(() => {
|
||||
<text
|
||||
v-if="hasChartArea && !hasWaveformData"
|
||||
class="waveform-chart__empty"
|
||||
:x="width / 2"
|
||||
:x="chartWidth / 2"
|
||||
:y="chartHeight / 2"
|
||||
text-anchor="middle"
|
||||
>
|
||||
@@ -972,7 +992,7 @@ onBeforeUnmount(() => {
|
||||
:time-unit="timeUnit"
|
||||
:hovered-point="hoveredPoint"
|
||||
:series-points="tooltipSeriesPoints"
|
||||
:container-width="width"
|
||||
:container-width="chartWidth"
|
||||
:container-height="chartHeight"
|
||||
/>
|
||||
</div>
|
||||
@@ -980,9 +1000,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.waveform-chart {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 180px;
|
||||
overflow: hidden;
|
||||
color: #475467;
|
||||
background: #fff;
|
||||
|
||||
@@ -81,6 +81,10 @@ export function resolveGridCellGeometry(
|
||||
const axisRows = new Set<number>()
|
||||
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
|
||||
// only beneath the final grid row, including when that row is empty.
|
||||
axisRows.add(options.rowCount - 1)
|
||||
} else {
|
||||
for (let column = 0; column < options.columnCount; column += 1) {
|
||||
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface BuildTrackLayoutsOptions {
|
||||
rendering: ResolvedWaveformRenderingOptions
|
||||
hideSecondaryLabels: boolean
|
||||
yAxisLabelX: number
|
||||
showCompactEmptyTracks: boolean
|
||||
}
|
||||
|
||||
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
|
||||
@@ -31,8 +32,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
|
||||
|
||||
return visibleCells.flatMap((cell, index) => {
|
||||
const series = cell.series
|
||||
if (!series) return []
|
||||
const isEmpty = !cell.series
|
||||
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 =
|
||||
options.displayMode === 'independent'
|
||||
? 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 xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
|
||||
const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
|
||||
const yAxisTickValues =
|
||||
options.displayMode === 'compact' && cell.row < options.grid.rowCount - 1
|
||||
? yMajorTicks.slice(1)
|
||||
: yMajorTicks
|
||||
const [yAxisStart, yAxisEnd] = yScale.domain()
|
||||
const showYAxisEnd = options.displayMode !== 'compact' || cell.row === 0
|
||||
const visibleYMajorTicks = showYAxisEnd
|
||||
? yMajorTicks
|
||||
: yMajorTicks.filter((tick) => tick !== yAxisEnd)
|
||||
const yAxisTickValues = Array.from(
|
||||
new Set([yAxisStart, ...visibleYMajorTicks, ...(showYAxisEnd ? [yAxisEnd] : [])]),
|
||||
)
|
||||
const domain = xScale.domain() as [number, number]
|
||||
const endpointLabels = {
|
||||
start: formatEndpointTime(domain[0], domain, options.timeUnit),
|
||||
@@ -70,6 +83,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
return {
|
||||
index,
|
||||
series,
|
||||
isEmpty,
|
||||
column: cell.column,
|
||||
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
|
||||
yAxisLabelX: options.yAxisLabelX,
|
||||
@@ -80,16 +94,22 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
xScale,
|
||||
yScale,
|
||||
xMajorTicks,
|
||||
xMinorTicks: buildMinorTicks(xMajorTicks),
|
||||
xMinorTicks: buildMinorTicks(xMajorTicks, 5, domain),
|
||||
yMajorTicks,
|
||||
yMinorTicks: buildMinorTicks(yMajorTicks),
|
||||
yAxisTickValues,
|
||||
xAxisTickValues,
|
||||
endpointLabels,
|
||||
path: line<WaveformPoint>()
|
||||
path: isEmpty
|
||||
? null
|
||||
: line<WaveformPoint>()
|
||||
.x((point) => xScale(point.x))
|
||||
.y((point) => yScale(point.y))(renderPoints),
|
||||
showXAxis: options.displayMode === 'independent' || bottomCells.has(cell.slotIndex),
|
||||
showXAxis:
|
||||
options.displayMode === 'independent' ||
|
||||
(options.displayMode === 'compact'
|
||||
? cell.row === options.grid.rowCount - 1
|
||||
: bottomCells.has(cell.slotIndex)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface HoveredSeriesPoint extends DisplaySeries {
|
||||
export interface TrackLayout {
|
||||
index: number
|
||||
series: DisplaySeries
|
||||
isEmpty: boolean
|
||||
column: number
|
||||
showYAxisLabel: boolean
|
||||
yAxisLabelX: number
|
||||
|
||||
@@ -145,7 +145,9 @@ watch(
|
||||
<template>
|
||||
<g
|
||||
class="waveform-track waveform-chart__track"
|
||||
:class="{ 'waveform-track--empty waveform-chart__track--empty': track.isEmpty }"
|
||||
:data-track-index="track.index"
|
||||
:data-track-empty="track.isEmpty || undefined"
|
||||
:data-track-left="track.left"
|
||||
:data-track-width="track.width"
|
||||
:data-y-axis-label-x="track.yAxisLabelX"
|
||||
@@ -154,7 +156,7 @@ watch(
|
||||
: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
|
||||
class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor"
|
||||
>
|
||||
@@ -199,7 +201,7 @@ watch(
|
||||
|
||||
<!-- 帧编号水印 -->
|
||||
<text
|
||||
v-if="frameNumber !== undefined"
|
||||
v-if="!track.isEmpty && frameNumber !== undefined"
|
||||
class="waveform-track__watermark waveform-chart__watermark"
|
||||
:x="(track.width ?? innerWidth) / 2"
|
||||
:y="track.height / 2"
|
||||
@@ -248,6 +250,7 @@ watch(
|
||||
|
||||
<!-- Y 轴 -->
|
||||
<g
|
||||
v-if="!track.isEmpty"
|
||||
ref="yAxisElement"
|
||||
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
|
||||
/>
|
||||
@@ -255,6 +258,7 @@ watch(
|
||||
<!-- Y 轴标签 -->
|
||||
<g
|
||||
v-if="
|
||||
!track.isEmpty &&
|
||||
track.showYAxisLabel &&
|
||||
resolveYAxisLabel(track.series) &&
|
||||
shouldShowYAxisLabel(track.height, track.index)
|
||||
@@ -281,6 +285,7 @@ watch(
|
||||
|
||||
<!-- 轨道边框 -->
|
||||
<rect
|
||||
v-if="!track.isEmpty"
|
||||
class="waveform-track__plot-frame waveform-chart__plot-frame"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
@@ -289,6 +294,7 @@ watch(
|
||||
|
||||
<!-- 波形线 -->
|
||||
<path
|
||||
v-if="!track.isEmpty"
|
||||
class="waveform-track__line waveform-chart__line"
|
||||
:data-series-id="track.series.id"
|
||||
:data-series-name="track.series.name || undefined"
|
||||
@@ -299,7 +305,7 @@ watch(
|
||||
|
||||
<!-- 十字线 -->
|
||||
<g
|
||||
v-if="hasCrosshair()"
|
||||
v-if="!track.isEmpty && hasCrosshair()"
|
||||
class="waveform-track__crosshair waveform-chart__crosshair"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
>
|
||||
@@ -310,7 +316,7 @@ watch(
|
||||
|
||||
<!-- 交互覆盖层(仅在独立模式下) -->
|
||||
<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="{
|
||||
'is-zoomable': zoomable && interactionMode === 'zoom',
|
||||
|
||||
@@ -102,6 +102,7 @@ h1 {
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
height: clamp(560px, calc(100vh - 190px), 800px);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
28
src/utils/domain.test.ts
Normal file
28
src/utils/domain.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -17,13 +17,34 @@ export function paddedDomain(values: number[]): [number, number] {
|
||||
* 在主刻度之间生成次要刻度
|
||||
* @param values 主刻度值数组
|
||||
* @param subdivisions 细分数量,默认 5
|
||||
* @param domain 可选的可见域,用于补齐首尾不完整主刻度区间内的次要刻度
|
||||
* @returns 次要刻度值数组
|
||||
*/
|
||||
export function buildMinorTicks(values: number[], subdivisions = 5): number[] {
|
||||
return values.flatMap((value, index) => {
|
||||
const nextValue = values[index + 1]
|
||||
export function buildMinorTicks(
|
||||
values: number[],
|
||||
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 []
|
||||
const step = (nextValue - value) / subdivisions
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user