-
- Vue 3 · TypeScript · D3
-波形分析组件
-
- 真实 + 测试数据
-
- 单独坐标
- 多道分离
- 多道紧凑
-
-
diff --git a/src/components/WaveformChart.test.ts b/src/components/WaveformChart.test.ts
index 541dd23..c2acc95 100644
--- a/src/components/WaveformChart.test.ts
+++ b/src/components/WaveformChart.test.ts
@@ -56,6 +56,7 @@ describe('normalizeWaveformData', () => {
kind: 'series',
series: [
{
+ trackId: 'comparison-track',
name: 'BT2_2M',
unit: 'T',
data: { kind: 'points', points: [{ x: 1, y: 2 }] },
@@ -69,6 +70,7 @@ describe('normalizeWaveformData', () => {
).toEqual([
{
id: 'series-0',
+ trackId: 'comparison-track',
name: 'BT2_2M',
unit: 'T',
color: undefined,
@@ -128,6 +130,217 @@ describe('WaveformChart', () => {
expect(wrapper.get('.ant-pagination-next').classes()).toContain('ant-pagination-disabled')
})
+ it('overlays series with the same track ID without changing the next frame', async () => {
+ const wrapper = await mountSizedChart(
+ {
+ kind: 'series',
+ series: [
+ {
+ id: 'primary',
+ trackId: 'frame-1',
+ name: 'BT2_2M',
+ data: {
+ kind: 'points',
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 1 },
+ ],
+ },
+ },
+ {
+ id: 'second-frame',
+ name: 'BT1_2M',
+ data: {
+ kind: 'points',
+ points: [
+ { x: 0, y: 2 },
+ { x: 1, y: 3 },
+ ],
+ },
+ },
+ {
+ id: 'comparison',
+ trackId: 'frame-1',
+ name: 'TEST_CH_1',
+ data: {
+ kind: 'points',
+ points: [
+ { x: 0, y: 0.5 },
+ { x: 1, y: 1.5 },
+ ],
+ },
+ },
+ ],
+ },
+ { frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } },
+ )
+
+ const tracks = wrapper.findAll('.waveform-chart__track')
+ expect(tracks).toHaveLength(2)
+ expect(
+ tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
+ ).toEqual(['primary', 'comparison'])
+ expect(
+ tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
+ ).toEqual(['second-frame'])
+ expect(tracks[0].find('.waveform-chart__y-axis-label').exists()).toBe(false)
+ expect(tracks[0].findAll('.waveform-chart__axis--y .tick').length).toBeGreaterThan(0)
+ expect(tracks[1].get('.waveform-chart__y-axis-label').text()).toBe('BT1_2M')
+ expect(tracks[1].find('.waveform-chart__legend').exists()).toBe(false)
+ const legend = tracks[0].get('.waveform-chart__legend')
+ expect(legend.attributes('data-position')).toBe('top-right')
+ expect(legend.attributes('data-orientation')).toBe('vertical')
+ expect(legend.get('.waveform-legend__panel').attributes('style')).toContain(
+ 'background-color: rgba(255, 255, 255, 0.7)',
+ )
+ expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([
+ 'BT2_2M',
+ 'TEST_CH_1',
+ ])
+ expect(
+ legend.findAll('.waveform-legend__swatch').map((swatch) => swatch.attributes('style')),
+ ).toEqual(['background-color: rgb(9, 96, 189);', 'background-color: rgb(56, 158, 13);'])
+ expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
+ '1',
+ '2',
+ ])
+ expect(wrapper.find('.ant-pagination').exists()).toBe(false)
+
+ const firstTrackOverlay = tracks[0].get('.waveform-chart__overlay')
+ const overlayWidth = Number(firstTrackOverlay.attributes('width'))
+ const overlayHeight = Number(firstTrackOverlay.attributes('height'))
+ Object.defineProperty(firstTrackOverlay.element, 'getBoundingClientRect', {
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
+ })
+ firstTrackOverlay.element.dispatchEvent(
+ new MouseEvent('pointermove', {
+ clientX: overlayWidth / 2,
+ clientY: overlayHeight / 2,
+ bubbles: true,
+ }),
+ )
+ await flushPromises()
+
+ const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
+ expect(tooltipSeries).toHaveLength(2)
+ expect(tooltipSeries.map((item) => item.text())).toEqual([
+ expect.stringContaining('BT2_2M'),
+ expect.stringContaining('TEST_CH_1'),
+ ])
+ })
+
+ it('resolves automatic legend orientation and supports explicit overrides', async () => {
+ const wrapper = await mountSizedChart(
+ {
+ kind: 'series',
+ series: [
+ {
+ id: 'first',
+ trackId: 'shared',
+ name: 'first',
+ data: {
+ kind: 'points',
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 1 },
+ ],
+ },
+ },
+ {
+ id: 'second',
+ trackId: 'shared',
+ name: 'second',
+ data: {
+ kind: 'points',
+ points: [
+ { x: 0, y: 1 },
+ { x: 1, y: 2 },
+ ],
+ },
+ },
+ ],
+ },
+ { grid: { rowCount: 1, columnCount: 1 } },
+ )
+ const positions = [
+ 'top-left',
+ 'top',
+ 'top-right',
+ 'right',
+ 'bottom-right',
+ 'bottom',
+ 'bottom-left',
+ 'left',
+ ] as const
+
+ for (const position of positions) {
+ await wrapper.setProps({ legend: { position, orientation: 'auto' } })
+ const legend = wrapper.get('.waveform-chart__legend')
+ const expectedOrientation =
+ position === 'top' || position === 'bottom' ? 'horizontal' : 'vertical'
+ expect(legend.attributes('data-position')).toBe(position)
+ expect(legend.attributes('data-orientation')).toBe(expectedOrientation)
+ expect(legend.get('.waveform-legend__viewport').classes()).toContain(
+ `waveform-legend__viewport--${position}`,
+ )
+ expect(
+ legend
+ .get('.waveform-legend__panel')
+ .classes()
+ .includes('waveform-legend__panel--vertical'),
+ ).toBe(expectedOrientation === 'vertical')
+ }
+
+ await wrapper.setProps({ legend: { position: 'top', orientation: 'vertical' } })
+ expect(wrapper.get('.waveform-chart__legend').attributes('data-orientation')).toBe('vertical')
+ expect(wrapper.get('.waveform-legend__panel').classes()).toContain(
+ 'waveform-legend__panel--vertical',
+ )
+
+ await wrapper.setProps({ legend: { position: 'left', orientation: 'horizontal' } })
+ expect(wrapper.get('.waveform-chart__legend').attributes('data-orientation')).toBe('horizontal')
+ expect(wrapper.get('.waveform-legend__panel').classes()).toContain(
+ 'waveform-legend__panel--horizontal',
+ )
+
+ expect(wrapper.attributes('data-chart-left-margin')).toBe('64')
+ })
+
+ it('applies a configurable alpha background to every visible legend', async () => {
+ const wrapper = await mountSizedChart(
+ {
+ kind: 'series',
+ series: Array.from({ length: 4 }, (_, index) => ({
+ id: `series-${index}`,
+ trackId: `frame-${Math.floor(index / 2)}`,
+ name: `series ${index}`,
+ data: {
+ kind: 'points',
+ points: [
+ { x: 0, y: index },
+ { x: 1, y: index + 1 },
+ ],
+ },
+ })),
+ },
+ {
+ grid: { rowCount: 2, columnCount: 1 },
+ legend: { backgroundColor: 'rgba(14, 165, 233, 0.25)' },
+ },
+ )
+
+ const legendPanels = wrapper.findAll('.waveform-legend__panel')
+ expect(legendPanels).toHaveLength(2)
+ legendPanels.forEach((panel) => {
+ expect(panel.attributes('style')).toContain('background-color: rgba(14, 165, 233, 0.25)')
+ })
+
+ await wrapper.setProps({ legend: { backgroundColor: '' } })
+ wrapper.findAll('.waveform-legend__panel').forEach((panel) => {
+ expect(panel.attributes('style')).toContain('background-color: rgba(255, 255, 255, 0.7)')
+ })
+ })
+
it('renders independent cells with separate x axes and overlays', async () => {
const wrapper = await mountSizedChart(gridSeries(4), {
displayMode: 'independent',
@@ -328,6 +541,7 @@ describe('WaveformChart', () => {
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-background').exists()).toBe(false)
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)
@@ -487,6 +701,247 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
})
+ it('does not render or reserve space for missing, hidden, or blank titles', async () => {
+ for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
+ const wrapper = await mountSizedChart(
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
+ title ? { title } : {},
+ )
+
+ expect(wrapper.find('.waveform-chart__title-area').exists()).toBe(false)
+ expect(wrapper.attributes('data-title-area-height')).toBe('0')
+ expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('360')
+ }
+ })
+
+ it.each(['independent', 'separated', 'compact'] as const)(
+ 'keeps the titled empty state inside the drawing area in %s mode',
+ async (displayMode) => {
+ const wrapper = await mountSizedChart(
+ { kind: 'samples', values: [1], sampleRate: -1 },
+ { displayMode, title: { text: '空数据标题' } },
+ )
+
+ expect(wrapper.get('.waveform-chart__title-text').text()).toBe('空数据标题')
+ expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('316')
+ expect(wrapper.get('.waveform-chart__empty').attributes('y')).toBe('158')
+ },
+ )
+
+ it('renders one chart title with alignment and all supported text styles', async () => {
+ const wrapper = await mountSizedChart(
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
+ {
+ title: {
+ text: ' shot: #4712 ',
+ align: 'right',
+ textStyle: {
+ color: '#c026d3',
+ fontSize: 18,
+ fontFamily: 'Consolas',
+ rotation: 0,
+ fontWeight: 700,
+ fontStyle: 'italic',
+ textDecoration: 'underline',
+ letterSpacing: '2px',
+ },
+ },
+ },
+ )
+
+ const area = wrapper.get('.waveform-chart__title-area')
+ const visual = wrapper.get('.waveform-chart__title-visual')
+ const title = wrapper.get('.waveform-chart__title-text')
+ expect(area.attributes('role')).toBe('heading')
+ expect(area.attributes('style')).toContain('justify-content: flex-end')
+ expect(title.text()).toBe('shot: #4712')
+ expect(title.attributes('style')).toContain('color: rgb(192, 38, 211)')
+ expect(title.attributes('style')).toContain('font-size: 18px')
+ expect(title.attributes('style')).toContain('font-family: Consolas')
+ expect(title.attributes('style')).toContain('font-weight: 700')
+ expect(title.attributes('style')).toContain('font-style: italic')
+ expect(title.attributes('style')).toContain('text-decoration: underline')
+ expect(title.attributes('style')).toContain('letter-spacing: 2px')
+ expect(visual.attributes('style')).toContain('width: 752px')
+ expect(title.attributes('style')).toContain('width: 752px')
+ expect(title.attributes('style')).toContain('rotate(0deg)')
+ expect(wrapper.attributes('data-title-area-height')).toBe('44')
+ expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('316')
+ })
+
+ it('normalizes invalid title numbers and wraps long titles at narrow widths', async () => {
+ const wrapper = await mountSizedChart(
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
+ {
+ title: {
+ text: '这是一个用于验证窄屏省略行为的很长波形分析标题',
+ textStyle: { fontSize: Number.NaN, rotation: Number.POSITIVE_INFINITY },
+ },
+ },
+ )
+ resizeObservers.at(-1)?.resize(160, 360)
+ await flushPromises()
+
+ const title = wrapper.get('.waveform-chart__title-text')
+ expect(title.attributes('style')).toContain('font-size: 14px')
+ expect(title.attributes('style')).toContain('Microsoft YaHei')
+ expect(title.attributes('style')).toContain('font-weight: 400')
+ expect(title.attributes('style')).toContain('rotate(0deg)')
+ expect(title.attributes('style')).toContain('white-space: normal')
+ expect(title.attributes('style')).toContain('overflow-wrap: anywhere')
+ expect(title.attributes('title')).toBeUndefined()
+ expect(title.attributes('data-title-wrapped')).toBe('true')
+ expect(Number(wrapper.attributes('data-title-area-height'))).toBeGreaterThan(44)
+ })
+
+ it.each([45, 90, -90, 180])(
+ 'scales a complete long title into the rotated title area at %s degrees',
+ async (rotation) => {
+ const wrapper = await mountSizedChart(
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
+ {
+ title: {
+ text: '这是一个用于验证旋转缩放行为的完整波形分析标题',
+ textStyle: { rotation },
+ },
+ },
+ )
+ const titleHeight = Number(wrapper.attributes('data-title-area-height'))
+ const title = wrapper.get('.waveform-chart__title-text')
+
+ expect(titleHeight).toBeGreaterThanOrEqual(44)
+ expect(titleHeight).toBeLessThanOrEqual(160)
+ expect(Number(wrapper.get('.waveform-chart__svg').attributes('height'))).toBe(360 - titleHeight)
+ expect(title.text()).toBe('这是一个用于验证旋转缩放行为的完整波形分析标题')
+ expect(title.attributes('style')).toContain(`rotate(${rotation}deg)`)
+ expect(title.attributes('style')).toContain('white-space: nowrap')
+ expect(Number(title.attributes('data-title-scale'))).toBeLessThanOrEqual(1)
+ expect(title.attributes('data-title-wrapped')).toBeUndefined()
+ },
+ )
+
+ it('updates fixed and adaptive drawing heights when the title changes', async () => {
+ const fixedWrapper = mount(WaveformChart, {
+ props: {
+ data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
+ height: 420,
+ title: { text: '固定高度标题' },
+ },
+ })
+ expect(fixedWrapper.get('.waveform-chart__svg').attributes('height')).toBe('376')
+
+ await fixedWrapper.setProps({ title: { visible: false, text: '固定高度标题' } })
+ expect(fixedWrapper.get('.waveform-chart__svg').attributes('height')).toBe('420')
+
+ const adaptiveWrapper = await mountSizedChart(
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
+ { title: { text: '自适应高度标题' } },
+ )
+ expect(adaptiveWrapper.get('.waveform-chart__svg').attributes('height')).toBe('316')
+ resizeObservers.at(-1)?.resize(800, 500)
+ await flushPromises()
+ expect(adaptiveWrapper.get('.waveform-chart__svg').attributes('height')).toBe('456')
+ })
+
+ it('includes the title offset in root-relative tooltip positioning', async () => {
+ const wrapper = await mountSizedChart(
+ {
+ kind: 'points',
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 5 },
+ ],
+ },
+ { title: { text: 'shot: #4712' }, grid: { rowCount: 1, columnCount: 1 } },
+ )
+ const overlay = wrapper.get('.waveform-chart__overlay')
+ const overlayWidth = Number(overlay.attributes('width'))
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 246 }),
+ })
+
+ overlay.element.dispatchEvent(
+ new MouseEvent('pointermove', { clientX: overlayWidth / 2, clientY: 100, bubbles: true }),
+ )
+ await flushPromises()
+
+ const tooltipTop = Number.parseFloat(
+ (wrapper.get('.waveform-chart__tooltip').element as HTMLElement).style.top,
+ )
+ expect(tooltipTop).toBeGreaterThanOrEqual(44)
+ })
+
+ it('includes the title offset in annotation editor anchors', async () => {
+ const wrapper = await mountSizedChart(
+ {
+ kind: 'points',
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 5 },
+ ],
+ },
+ { title: { text: 'shot: #4712' }, grid: { rowCount: 1, columnCount: 1 } },
+ )
+ const overlay = wrapper.get('.waveform-chart__overlay')
+ const overlayWidth = Number(overlay.attributes('width'))
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 246 }),
+ })
+ overlay.element.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ clientX: overlayWidth / 2,
+ clientY: 100,
+ bubbles: true,
+ }),
+ )
+ await flushPromises()
+
+ const component = wrapper.vm as typeof wrapper.vm & {
+ annotationInteraction: {
+ editorDraft: {
+ value: { anchor: { x: number; y: number } } | null
+ }
+ }
+ }
+ expect(component.annotationInteraction.editorDraft.value?.anchor.y).toBe(162)
+ })
+
+ it('captures and suppresses descendant context menus across the waveform svg', async () => {
+ const wrapper = await mountSizedChart({
+ kind: 'points',
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 1 },
+ ],
+ })
+
+ for (const selector of ['.waveform-chart__grid', '.waveform-chart__overlay']) {
+ const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
+ const dispatched = wrapper.get(selector).element.dispatchEvent(event)
+
+ expect(dispatched).toBe(false)
+ expect(event.defaultPrevented).toBe(true)
+ }
+
+ const sharedWrapper = await mountSizedChart(
+ {
+ kind: 'points',
+ points: [
+ { x: 0, y: 0 },
+ { x: 1, y: 1 },
+ ],
+ },
+ { displayMode: 'separated' },
+ )
+ const sharedEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
+ const sharedDispatched = sharedWrapper
+ .get('.waveform-chart__overlay--shared')
+ .element.dispatchEvent(sharedEvent)
+
+ expect(sharedDispatched).toBe(false)
+ expect(sharedEvent.defaultPrevented).toBe(true)
+ })
+
it('applies size fallbacks for minimum, negative, and non-finite values', async () => {
const minimumWrapper = mount(WaveformChart, {
props: {
@@ -588,10 +1043,66 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(0)
expect(wrapper.findAll('.waveform-chart__grid--minor line').length).toBeGreaterThan(0)
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(true)
+ expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
+ fill: 'none',
+ stroke: '#1f2937',
+ 'stroke-width': '1',
+ })
+ expect(
+ wrapper.get('.waveform-chart__plot-frame').attributes('stroke-dasharray'),
+ ).toBeUndefined()
+ expect(wrapper.get('.waveform-chart__plot-background').attributes('fill')).toBe('transparent')
expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12')
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
})
+ it('applies one custom frame style to every non-empty track', async () => {
+ const wrapper = await mountSizedChart(gridSeries(2), {
+ grid: { rowCount: 2, columnCount: 1 },
+ frameStyle: {
+ borderColor: 'rgba(255, 0, 0, 0.7)',
+ borderWidth: 2.5,
+ borderStyle: 'dashed',
+ backgroundColor: 'rgba(16, 185, 129, 0.2)',
+ },
+ })
+
+ const tracks = wrapper.findAll('.waveform-chart__track')
+ const frames = wrapper.findAll('.waveform-chart__plot-frame')
+ const backgrounds = wrapper.findAll('.waveform-chart__plot-background')
+
+ expect(frames).toHaveLength(2)
+ expect(backgrounds).toHaveLength(2)
+ frames.forEach((frame) => {
+ expect(frame.attributes()).toMatchObject({
+ stroke: 'rgba(255, 0, 0, 0.7)',
+ 'stroke-width': '2.5',
+ 'stroke-dasharray': '6 4',
+ })
+ })
+ backgrounds.forEach((background) => {
+ expect(background.attributes('fill')).toBe('rgba(16, 185, 129, 0.2)')
+ })
+
+ tracks.forEach((track) => {
+ const renderingLayers = track.findAll(
+ '.waveform-chart__plot-background, .waveform-chart__grid',
+ )
+ expect(renderingLayers[0].classes()).toContain('waveform-chart__plot-background')
+ })
+ })
+
+ it('falls back to the default width for invalid frame widths', async () => {
+ const wrapper = await mountSizedChart(gridSeries(1), {
+ frameStyle: { borderWidth: -1 },
+ })
+
+ expect(wrapper.get('.waveform-chart__plot-frame').attributes('stroke-width')).toBe('1')
+
+ await wrapper.setProps({ frameStyle: { borderWidth: Number.NaN } })
+ expect(wrapper.get('.waveform-chart__plot-frame').attributes('stroke-width')).toBe('1')
+ })
+
it('continues minor x-grid lines beyond the final major tick to the exact endpoint', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
@@ -1467,7 +1978,14 @@ describe('WaveformChart', () => {
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
expect(wrapper.get('.waveform-annotation-editor').attributes('aria-modal')).toBe('true')
expect(wrapper.find('.waveform-annotation-editor__panel').exists()).toBe(true)
- await wrapper.get('textarea[aria-label="标注文本"]').setValue('右键标注')
+ const textarea = wrapper.get('textarea[aria-label="标注文本"]')
+ const textareaContextMenu = new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ })
+ expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true)
+ expect(textareaContextMenu.defaultPrevented).toBe(false)
+ await textarea.setValue('右键标注')
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([
diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue
index c19e85f..eb8287f 100644
--- a/src/components/WaveformChart.vue
+++ b/src/components/WaveformChart.vue
@@ -13,15 +13,30 @@ import {
} from 'd3'
import { resolveWaveformRenderingOptions } from '../core'
import { formatScientificYAxisLabel, paddedDomain } from '../utils'
-import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from 'vue'
+import {
+ computed,
+ nextTick,
+ onBeforeUnmount,
+ onMounted,
+ ref,
+ shallowRef,
+ useId,
+ watch,
+ type CSSProperties,
+} from 'vue'
import {
type WaveformAnnotation,
type WaveformData,
type WaveformDisplayMode,
+ type WaveformFrameStyle,
type WaveformInteractionMode,
+ type WaveformLegendOptions,
+ type WaveformLegendOrientation,
+ type WaveformLegendPosition,
type WaveformPoint,
type WaveformRenderingOptions,
+ type WaveformTitleOptions,
} from './data/types'
import {
ANNOTATION_AMBIGUITY_DISTANCE,
@@ -56,8 +71,9 @@ import {
X_AXIS_BAND,
type WaveformGridOptions,
} from './core/grid'
-import type { DisplaySeries, HoveredSeriesPoint, TrackLayout } from './core/types'
+import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
import { buildTrackLayouts } from './core/layout'
+import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title'
import { usePreparedWaveformSeries } from './core/useWaveformData'
import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
@@ -74,12 +90,15 @@ const props = withDefaults(
zoomable?: boolean
timeUnit?: 's' | 'ms'
frameNumber?: string | number
+ frameStyle?: WaveformFrameStyle
annotations?: WaveformAnnotation[]
annotationsVisible?: boolean
interactionMode?: WaveformInteractionMode
showAnnotationToolbar?: boolean
grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions
+ title?: WaveformTitleOptions
+ legend?: WaveformLegendOptions
}>(),
{
displayMode: 'independent',
@@ -95,6 +114,7 @@ const props = withDefaults(
showAnnotationToolbar: false,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}),
+ legend: () => ({ position: 'top-right', orientation: 'auto' }),
},
)
@@ -114,9 +134,12 @@ const margin = chartMargin
const minimumHeight = chartMinimumHeight
const container = ref()
const svgElement = ref()
+const titleMeasureElement = ref()
const sharedOverlayElement = ref()
const observedWidth = ref(0)
const observedHeight = ref(0)
+const measuredTitleWidth = ref(0)
+const measuredTitleHeight = ref(0)
const sharedTransform = shallowRef(zoomIdentity)
const independentTransforms = shallowRef([])
const hoveredSeriesPoints = ref([])
@@ -158,7 +181,95 @@ 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 legendPosition = computed(() => props.legend?.position ?? 'top-right')
+const legendBackgroundColor = computed(
+ () => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)',
+)
+const legendOrientation = computed>(() => {
+ const orientation = props.legend?.orientation ?? 'auto'
+ if (orientation !== 'auto') return orientation
+ return legendPosition.value === 'top' || legendPosition.value === 'bottom'
+ ? 'horizontal'
+ : 'vertical'
+})
+const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
+const titleVisible = computed(
+ () =>
+ Boolean(props.title) && props.title?.visible !== false && resolvedTitleText.value.length > 0,
+)
+const titleFontSize = computed(() => {
+ const fontSize = props.title?.textStyle?.fontSize
+ return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : 14
+})
+const titleRotation = computed(() => {
+ const rotation = props.title?.textStyle?.rotation
+ return Number.isFinite(rotation) ? (rotation as number) : 0
+})
+const titleIsRotated = computed(() => {
+ const normalizedRotation = ((titleRotation.value % 360) + 360) % 360
+ return normalizedRotation > 1e-6 && Math.abs(normalizedRotation - 360) > 1e-6
+})
+const titlePresentationStyle = computed(() => ({
+ color: props.title?.textStyle?.color ?? '#1f2937',
+ fontSize: `${titleFontSize.value}px`,
+ fontFamily: props.title?.textStyle?.fontFamily || '"Microsoft YaHei", "微软雅黑", sans-serif',
+ fontWeight: props.title?.textStyle?.fontWeight ?? 400,
+ fontStyle: props.title?.textStyle?.fontStyle ?? 'normal',
+ textDecoration: props.title?.textStyle?.textDecoration ?? 'none',
+ letterSpacing: props.title?.textStyle?.letterSpacing ?? 'normal',
+ lineHeight: '1.2',
+}))
+const estimatedTitleWidth = computed(() => {
+ const letterSpacing = Number.parseFloat(props.title?.textStyle?.letterSpacing ?? '')
+ const spacingWidth = Number.isFinite(letterSpacing)
+ ? Math.max(0, resolvedTitleText.value.length - 1) * letterSpacing
+ : 0
+ return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * 0.62 + spacingWidth)
+})
+const titleAvailableWidth = computed(() => {
+ const measuredAvailableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2
+ return measuredAvailableWidth > 0 ? measuredAvailableWidth : estimatedTitleWidth.value
+})
+const titleMeasureStyle = computed(() => ({
+ ...titlePresentationStyle.value,
+ width: 'max-content',
+ maxWidth: titleIsRotated.value ? 'none' : `${titleAvailableWidth.value}px`,
+ whiteSpace: titleIsRotated.value ? 'nowrap' : 'normal',
+ overflowWrap: titleIsRotated.value ? 'normal' : 'anywhere',
+}))
+const titleLayout = computed(() =>
+ calculateRotatedTitleLayout({
+ naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
+ naturalHeight: measuredTitleHeight.value || titleFontSize.value * 1.2,
+ availableWidth: titleAvailableWidth.value,
+ rotation: titleRotation.value,
+ }),
+)
+const titleAreaHeight = computed(() => (titleVisible.value ? titleLayout.value.areaHeight : 0))
+const drawingHeight = computed(() => Math.max(0, chartHeight.value - titleAreaHeight.value))
+const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
+const titleAreaStyle = computed(() => ({
+ height: `${titleAreaHeight.value}px`,
+ justifyContent:
+ props.title?.align === 'left'
+ ? 'flex-start'
+ : props.title?.align === 'right'
+ ? 'flex-end'
+ : 'center',
+}))
+const titleVisualStyle = computed(() => ({
+ width: `${titleLayout.value.visualWidth}px`,
+ height: `${titleLayout.value.visualHeight}px`,
+}))
+const titleTextStyle = computed(() => ({
+ ...titlePresentationStyle.value,
+ width: `${titleLayout.value.textWidth}px`,
+ minHeight: `${titleLayout.value.textHeight}px`,
+ textAlign: props.title?.align ?? 'center',
+ whiteSpace: titleIsRotated.value ? 'nowrap' : 'normal',
+ overflowWrap: titleIsRotated.value ? 'normal' : 'anywhere',
+ transform: `translate(-50%, -50%) rotate(${titleRotation.value}deg) scale(${titleLayout.value.scale})`,
+}))
const chartSeries = computed(() =>
preparedSeries.value.map((series, index: number): DisplaySeries => ({
...series,
@@ -166,11 +277,26 @@ const chartSeries = computed(() =>
series.color ?? (index === 0 ? props.lineColor : channelColors[index % channelColors.length]),
})),
)
+const chartTracks = computed(() => {
+ const groupedSeries = new Map()
+ chartSeries.value.forEach((series) => {
+ const trackId = series.trackId || series.id
+ const trackSeries = groupedSeries.get(trackId)
+ if (trackSeries) trackSeries.push(series)
+ else groupedSeries.set(trackId, [series])
+ })
+ return Array.from(groupedSeries, ([id, series]) => ({
+ id,
+ series,
+ xDomain: paddedDomain(series.flatMap((item) => item.xDomain)),
+ yDomain: paddedDomain(series.flatMap((item) => item.yDomain)),
+ }))
+})
const gridOptions = computed(() => normalizeGridOptions(props.grid))
const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
-const pageCount = computed(() => getPageCount(chartSeries.value.length, gridOptions.value))
-const pagedSeries = computed(() =>
- paginateSeries(chartSeries.value, currentPage.value, gridOptions.value),
+const pageCount = computed(() => getPageCount(chartTracks.value.length, gridOptions.value))
+const pagedTracks = computed(() =>
+ paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
)
const yAxisCharacterWidth = 7
@@ -181,8 +307,8 @@ const yAxisLabelBandWidth = 24
const minimumPlotWidth = 120
const yAxisMetrics = computed(() => {
- const formattedTickLabels = chartSeries.value.flatMap((series) => {
- const scale = scaleLinear(series.yDomain, [1, 0]).nice()
+ const formattedTickLabels = chartTracks.value.flatMap((track) => {
+ const scale = scaleLinear(track.yDomain, [1, 0]).nice()
const [axisMin, axisMax] = scale.domain()
const values = scale.ticks(10)
const topTickValue = values.reduce((closestTick, tickValue) => {
@@ -205,7 +331,9 @@ const yAxisMetrics = computed(() => {
return { tickClearance, fullClearance, labelCenterX }
})
const hasYAxisLabels = computed(() =>
- chartSeries.value.some((series) => Boolean(series.name.trim() || props.yLabel)),
+ chartTracks.value.some(
+ (track) => track.series.length === 1 && Boolean(track.series[0]?.name.trim() || props.yLabel),
+ ),
)
const chartLeftMargin = computed(() =>
Math.max(
@@ -261,7 +389,7 @@ const tooltipSeriesPoints = computed(() => {
})
const sharedXDomain = computed(() =>
- paddedDomain(chartSeries.value.flatMap((series) => series.xDomain)),
+ paddedDomain(chartTracks.value.flatMap((track) => track.xDomain)),
)
const sharedZoomDomain = computed(
() =>
@@ -276,10 +404,10 @@ const gridCells = computed(() => {
innerHeight.value,
gridOptions.value,
props.displayMode,
- pagedSeries.value.map(Boolean),
+ pagedTracks.value.map(Boolean),
yAxisLayout.value.horizontalGap,
)
- return cells.map((cell, index) => ({ ...cell, series: pagedSeries.value[index] }))
+ return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
})
const trackLayouts = computed(() =>
@@ -297,11 +425,19 @@ const trackLayouts = computed(() =>
}),
)
+function annotationLayoutsForTrack(track: TrackLayout): AnnotationTrackLayout[] {
+ return track.seriesList.map((series) => ({ ...track, series }))
+}
+
+const annotationTrackLayouts = computed(() =>
+ trackLayouts.value.flatMap(annotationLayoutsForTrack),
+)
+
const renderedAnnotations = computed(() =>
props.annotationsVisible
? layoutAnnotations(
props.annotations,
- trackLayouts.value as AnnotationTrackLayout[],
+ annotationTrackLayouts.value,
innerWidth.value,
innerHeight.value,
)
@@ -324,7 +460,7 @@ const editorSeries = computed(() => {
function resolveFrameNumber(trackIndex: number): string | number | undefined {
if (props.frameNumber === undefined || props.frameNumber === null) return undefined
- if (chartSeries.value.length === 1) return props.frameNumber
+ if (chartTracks.value.length === 1) return props.frameNumber
return typeof props.frameNumber === 'number'
? props.frameNumber + trackIndex
: `${props.frameNumber}-${trackIndex + 1}`
@@ -467,17 +603,21 @@ function resolvePointerEditorAnchor(
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
return {
x: chartLeftMargin.value + (track ? track.left + pointerX : pointerX),
- y: margin.top + (track ? track.top + pointerY : pointerY),
+ y: titleAreaHeight.value + margin.top + (track ? track.top + pointerY : pointerY),
}
}
function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor {
- const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId)
+ const track = trackLayouts.value.find((item) =>
+ item.seriesList.some((series) => series.id === annotation.seriesId),
+ )
return {
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
+ ? titleAreaHeight.value + margin.top + track.top + track.yScale(annotation.y)
+ : chartHeight.value / 2,
}
}
@@ -490,9 +630,10 @@ function beginCreate(
editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value
const track = trackLayouts.value.find((item) => item.index === hit.trackIndex)
+ const series = track?.seriesList.find((item) => item.id === hit.seriesId)
if (draft?.mode === 'add') {
draft.annotation.style = {
- borderColor: track?.series.color || '#1677ff',
+ borderColor: series?.color || '#1677ff',
textColor: '#333333',
backgroundColor: 'rgba(255, 255, 255, 0.92)',
}
@@ -502,9 +643,12 @@ function beginCreate(
function changeDraftSeries(seriesId: string) {
const draft = annotationInteraction.editorDraft.value
const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId)
- const track = trackLayouts.value.find((item) => item.series.id === seriesId)
+ const track = trackLayouts.value.find((item) =>
+ item.seriesList.some((series) => series.id === seriesId),
+ )
+ const series = track?.seriesList.find((item) => item.id === seriesId)
const point =
- track && draft ? interpolateAnnotationPoint(track.series.points, draft.annotation.x) : null
+ series && draft ? interpolateAnnotationPoint(series.points, draft.annotation.x) : null
if (!draft || !candidate || !track || !point) return
draft.annotation = {
...draft.annotation,
@@ -569,7 +713,7 @@ function resolveAnnotationCandidates(
const xValue = referenceTrack.xScale.invert(localPointerX)
return {
candidates: findAnnotationSeriesCandidates(
- [referenceTrack] as AnnotationTrackLayout[],
+ annotationLayoutsForTrack(referenceTrack),
xValue,
localPointerX,
sharedPointerY,
@@ -635,10 +779,12 @@ function editContextAnnotation() {
const annotationId = context?.annotationId
const annotation = props.annotations.find((item) => item.id === annotationId)
if (annotation) {
- const track = trackLayouts.value.find((item) => item.series.id === annotation.seriesId)
+ const track = trackLayouts.value.find((item) =>
+ item.seriesList.some((series) => series.id === annotation.seriesId),
+ )
editorSeriesOptions.value = track
? findAnnotationSeriesCandidates(
- [track] as AnnotationTrackLayout[],
+ annotationLayoutsForTrack(track),
annotation.x,
track.xScale(annotation.x),
track.top + track.yScale(annotation.y),
@@ -685,14 +831,16 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
if (!overlay || !track) return
const [pointerX, pointerY] = pointer(event, overlay)
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
- const point = nearestPoint(track.series, xValue)
- hoveredSeriesPoints.value = point ? [{ ...track.series, trackIndex, point }] : []
+ hoveredSeriesPoints.value = track.seriesList.flatMap((series) => {
+ const point = nearestPoint(series, xValue)
+ return point ? [{ ...series, trackIndex, point }] : []
+ })
hoveredTrackIndex.value = trackIndex
hoverPosition.value = {
x: chartLeftMargin.value + track.left + pointerX,
- y: margin.top + track.top + pointerY,
+ y: titleAreaHeight.value + margin.top + track.top + pointerY,
}
- emit('point-hover', point ?? null)
+ emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null)
}
function handleSharedPointerMove(event: PointerEvent) {
@@ -702,21 +850,23 @@ function handleSharedPointerMove(event: PointerEvent) {
if (!referenceTrack) return
const localPointerX = Math.max(0, Math.min(referenceTrack.width, pointerX - referenceTrack.left))
const xValue = referenceTrack.xScale.invert(localPointerX)
- hoveredSeriesPoints.value = trackLayouts.value.flatMap((track) => {
- const point = nearestPoint(track.series, xValue)
- return point ? [{ ...track.series, trackIndex: track.index, point }] : []
- })
+ hoveredSeriesPoints.value = trackLayouts.value.flatMap((track) =>
+ track.seriesList.flatMap((series) => {
+ const point = nearestPoint(series, xValue)
+ return point ? [{ ...series, trackIndex: track.index, point }] : []
+ }),
+ )
hoveredTrackIndex.value = null
hoverPosition.value = {
x: chartLeftMargin.value + pointerX,
- y: margin.top + pointerY,
+ y: titleAreaHeight.value + margin.top + pointerY,
}
emit('point-hover', hoveredPoint.value)
}
function resetViewport() {
sharedTransform.value = zoomIdentity
- independentTransforms.value = chartSeries.value.map(() => zoomIdentity)
+ independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
@@ -730,7 +880,7 @@ function goToPage(page: number) {
annotationInteraction.closeContextMenu()
cancelAnnotation()
if (props.displayMode === 'independent') {
- independentTransforms.value = pagedSeries.value.map(() => zoomIdentity)
+ independentTransforms.value = pagedTracks.value.map(() => zoomIdentity)
}
void nextTick(configureZoom)
emit('page-change', nextPage, pageCount.value)
@@ -742,7 +892,7 @@ watch(
innerHeight,
() => props.zoomable,
() => props.displayMode,
- () => chartSeries.value.length,
+ () => chartTracks.value.length,
() => currentPage.value,
() => gridOptions.value.rowCount,
() => gridOptions.value.columnCount,
@@ -815,11 +965,34 @@ watch(
{ deep: true },
)
+function measureTitle() {
+ if (!titleVisible.value || !titleMeasureElement.value) {
+ measuredTitleWidth.value = 0
+ measuredTitleHeight.value = 0
+ return
+ }
+ const bounds = titleMeasureElement.value.getBoundingClientRect()
+ measuredTitleWidth.value = titleMeasureElement.value.scrollWidth || bounds.width
+ measuredTitleHeight.value = titleMeasureElement.value.scrollHeight || bounds.height
+}
+
+watch(
+ [resolvedTitleText, titleVisible, titleMeasureStyle],
+ async () => {
+ measuredTitleWidth.value = 0
+ measuredTitleHeight.value = 0
+ await nextTick()
+ measureTitle()
+ },
+ { immediate: true },
+)
+
onMounted(() => {
if (!container.value) return
resizeObserver.value = new ResizeObserver(([entry]) => {
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
+ void nextTick(measureTitle)
})
resizeObserver.value.observe(container.value)
})
@@ -843,14 +1016,43 @@ onBeforeUnmount(() => {
:data-display-mode="displayMode"
:data-interaction-mode="activeInteractionMode"
:data-chart-left-margin="chartLeftMargin"
+ :data-title-area-height="titleAreaHeight"
>
+
- 网格
-
- 行 ×
-
- 列
-
+
+
+
+
+ {{ resolvedTitleText }}
+
+
+
+ {{ resolvedTitleText }}
+
+
+
+