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`。内部时间坐标始终使用秒,
|
多通道数据应为每个 `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` 引用会重新过滤、排序和缓存坐标域,并重置视口;
|
||||||
|
|||||||
19
src/App.vue
19
src/App.vue
@@ -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"
|
||||||
|
|||||||
@@ -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(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 () => {
|
it('keeps the shared exponent on the top visible tick in compact mode', async () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
? null
|
||||||
|
: line<WaveformPoint>()
|
||||||
.x((point) => xScale(point.x))
|
.x((point) => xScale(point.x))
|
||||||
.y((point) => yScale(point.y))(renderPoints),
|
.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 {
|
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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
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 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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user