feat(chart): add series styling and visibility controls
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resizeObservers } from '../test/setup'
|
||||
import { flushAnimationFrames, pendingAnimationFrameCount, resizeObservers } from '../test/setup'
|
||||
import WaveformChart from './WaveformChart.vue'
|
||||
import { prepareWaveformSeries } from './core/useWaveformData'
|
||||
import { waveformLegendErrorBarPath, waveformLegendLinePath } from './rendering/seriesStyle'
|
||||
import { normalizeWaveformData, normalizeWaveformSeries, type WaveformData } from './waveform'
|
||||
|
||||
async function mountSizedChart(data: WaveformData, extraProps = {}) {
|
||||
@@ -50,6 +52,80 @@ describe('normalizeWaveformData', () => {
|
||||
expect(normalizeWaveformData({ kind: 'samples', values: [1, 2], sampleRate: 0 })).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes errors and preserves a pure error-bar series', () => {
|
||||
const [series] = normalizeWaveformSeries({
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
name: 'styled',
|
||||
lineType: 'none',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: true, width: -1, capWidth: Number.NaN },
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 2, error: 1, lowerError: -1, upperError: 2 },
|
||||
{ x: 1, y: 3, error: Number.NaN },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(series).toMatchObject({
|
||||
lineType: 'none',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: true, width: 1.5, capWidth: 8 },
|
||||
points: [
|
||||
{ x: 0, y: 2, error: 1, upperError: 2 },
|
||||
{ x: 1, y: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a line only when every series visual is disabled', () => {
|
||||
const [series] = normalizeWaveformSeries({
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
name: 'invisible',
|
||||
lineType: 'none',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false },
|
||||
data: { kind: 'points', points: [{ x: 0, y: 1 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(series).toMatchObject({
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('includes visible error bounds in the prepared Y domain', () => {
|
||||
const [series] = prepareWaveformSeries({
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
name: 'errors',
|
||||
errorBar: { visible: true },
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 2, lowerError: 3, upperError: 4 },
|
||||
{ x: 1, y: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(series?.yDomain[0]).toBeLessThanOrEqual(-1)
|
||||
expect(series?.yDomain[1]).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('normalizes multiple named series and removes empty series', () => {
|
||||
expect(
|
||||
normalizeWaveformSeries({
|
||||
@@ -74,12 +150,28 @@ describe('normalizeWaveformData', () => {
|
||||
name: 'BT2_2M',
|
||||
unit: 'T',
|
||||
color: undefined,
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [{ x: 1, y: 2 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('legend series geometry', () => {
|
||||
it('keeps line samples centered and clamps error-bar caps to the swatch', () => {
|
||||
expect(waveformLegendLinePath('linear')).toBe('M1 8H25')
|
||||
expect(waveformLegendLinePath('step-start')).toBe('M1 8H25')
|
||||
expect(waveformLegendLinePath('step-middle')).toBe('M1 8H25')
|
||||
expect(waveformLegendLinePath('step-end')).toBe('M1 8H25')
|
||||
expect(waveformLegendLinePath('step-after')).toBe('M1 8H25')
|
||||
expect(waveformLegendLinePath('none')).toBeNull()
|
||||
expect(waveformLegendErrorBarPath(10)).toBe('M8 2H18M13 2V14M8 14H18')
|
||||
expect(waveformLegendErrorBarPath(100)).toBe('M1 2H25M13 2V14M1 14H25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WaveformChart', () => {
|
||||
const gridSeries = (count: number): WaveformData => ({
|
||||
kind: 'series',
|
||||
@@ -96,6 +188,232 @@ describe('WaveformChart', () => {
|
||||
})),
|
||||
})
|
||||
|
||||
it('places start, middle, and end step transitions at the expected X positions', async () => {
|
||||
const lineTypes = ['step-start', 'step-middle', 'step-end', 'step-after'] as const
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'series',
|
||||
series: lineTypes.map((lineType) => ({
|
||||
id: lineType,
|
||||
trackId: 'steps',
|
||||
name: lineType,
|
||||
lineType,
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 10 },
|
||||
],
|
||||
},
|
||||
})),
|
||||
})
|
||||
const pathCoordinates = (lineType: (typeof lineTypes)[number]) =>
|
||||
Array.from(
|
||||
(
|
||||
wrapper.get(`.waveform-chart__line[data-series-id="${lineType}"]`).attributes('d') ?? ''
|
||||
).matchAll(/[ML]([\d.-]+),([\d.-]+)/g),
|
||||
(match) => ({ x: Number(match[1]), y: Number(match[2]) }),
|
||||
)
|
||||
|
||||
const start = pathCoordinates('step-start')
|
||||
const middle = pathCoordinates('step-middle')
|
||||
const end = pathCoordinates('step-end')
|
||||
const after = pathCoordinates('step-after')
|
||||
expect(start.map((point) => point.x)).toEqual([start[0]?.x, start[0]?.x, start.at(-1)?.x])
|
||||
expect(middle[1]?.x).toBe(middle[2]?.x)
|
||||
expect(middle[1]?.x).toBe((middle[0]!.x + middle.at(-1)!.x) / 2)
|
||||
expect(end.map((point) => point.x)).toEqual([end[0]?.x, end.at(-1)?.x, end.at(-1)?.x])
|
||||
expect(after).toEqual(end)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders per-series lines, point symbols, error bars, and matching legend swatches', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'triangle-errors',
|
||||
trackId: 'styled-track',
|
||||
name: '三角误差',
|
||||
lineType: 'none',
|
||||
pointType: 'triangle',
|
||||
errorBar: { visible: true },
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 1, error: 0.25 },
|
||||
{ x: 1, y: 2, error: 0.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'line-only',
|
||||
trackId: 'styled-track',
|
||||
name: '纯线',
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'step-errors',
|
||||
trackId: 'styled-track',
|
||||
name: '阶梯误差',
|
||||
lineType: 'step-after',
|
||||
pointType: 'circle',
|
||||
errorBar: { visible: true, color: '#222222', width: 2, capWidth: 10 },
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 3, lowerError: 0.5, upperError: 1 },
|
||||
{ x: 1, y: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'errors-only',
|
||||
trackId: 'styled-track',
|
||||
name: '纯误差棒',
|
||||
lineType: 'none',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: true },
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 4, error: 0.5 },
|
||||
{ x: 1, y: 5, error: 0.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(wrapper.find('.waveform-chart__line[data-series-id="triangle-errors"]').exists()).toBe(
|
||||
false,
|
||||
)
|
||||
const stepLine = wrapper.get('.waveform-chart__line[data-series-id="step-errors"]')
|
||||
expect(stepLine.attributes('data-line-type')).toBe('step-after')
|
||||
expect(stepLine.attributes('d')).toMatch(/^M[\d.-]+,([\d.-]+)L[\d.-]+,\1L/)
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__points[data-series-id="triangle-errors"]')
|
||||
.attributes('data-point-type'),
|
||||
).toBe('triangle')
|
||||
expect(wrapper.findAll('.waveform-chart__point')).toHaveLength(2)
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__points[data-series-id="triangle-errors"] .waveform-chart__point')
|
||||
.attributes('d'),
|
||||
).toMatch(/M.*M/)
|
||||
expect(wrapper.findAll('.waveform-chart__error-bar')).toHaveLength(3)
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__error-bars[data-series-id="step-errors"] .waveform-chart__error-bar')
|
||||
.attributes('stroke'),
|
||||
).toBe('#222222')
|
||||
const errorsOnlySeries = wrapper.get('.waveform-chart__series[data-series-id="errors-only"]')
|
||||
expect(errorsOnlySeries.find('.waveform-chart__line').exists()).toBe(false)
|
||||
expect(errorsOnlySeries.find('.waveform-chart__point').exists()).toBe(false)
|
||||
expect(errorsOnlySeries.find('.waveform-chart__error-bar').exists()).toBe(true)
|
||||
|
||||
const swatches = wrapper.findAll('.waveform-legend__swatch')
|
||||
expect(swatches.map((swatch) => swatch.attributes('data-line-type'))).toEqual([
|
||||
'none',
|
||||
'linear',
|
||||
'step-after',
|
||||
'none',
|
||||
])
|
||||
expect(swatches[0]?.attributes('data-error-bar-visible')).toBe('true')
|
||||
expect(swatches[2]?.attributes('data-error-bar-visible')).toBe('true')
|
||||
expect(swatches[3]?.attributes('data-error-bar-visible')).toBe('true')
|
||||
expect(swatches[0]!.findAll('path').map((path) => path.classes())).toEqual([
|
||||
['waveform-legend__error-bar'],
|
||||
['waveform-legend__point'],
|
||||
])
|
||||
const stepSwatchPaths = swatches[2]!.findAll('path')
|
||||
expect(stepSwatchPaths.map((path) => path.classes())).toEqual([
|
||||
['waveform-legend__line'],
|
||||
['waveform-legend__error-bar'],
|
||||
['waveform-legend__point'],
|
||||
])
|
||||
expect(stepSwatchPaths[0]?.attributes()).toMatchObject({
|
||||
d: 'M1 8H25',
|
||||
stroke: '#389e0d',
|
||||
'stroke-width': '1.5',
|
||||
})
|
||||
expect(stepSwatchPaths[1]?.attributes()).toMatchObject({
|
||||
d: 'M8 2H18M13 2V14M8 14H18',
|
||||
stroke: '#222222',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'butt',
|
||||
})
|
||||
expect(stepSwatchPaths[2]?.attributes('transform')).toBe('translate(13 8)')
|
||||
expect(swatches[3]!.findAll('path').map((path) => path.classes())).toEqual([
|
||||
['waveform-legend__error-bar'],
|
||||
])
|
||||
|
||||
const renderedSeriesNodes = wrapper
|
||||
.findAll('.waveform-chart__line, .waveform-chart__point, .waveform-chart__error-bar')
|
||||
.map((node) => node.element)
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
const overlayHeight = Number(overlay.attributes('height'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
|
||||
})
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', {
|
||||
clientX: overlayWidth / 2,
|
||||
clientY: overlayHeight / 2,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.waveform-chart__line, .waveform-chart__point, .waveform-chart__error-bar')
|
||||
.map((node) => node.element),
|
||||
).toEqual(renderedSeriesNodes)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
const visibilitySeries = (): WaveformData => ({
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'low',
|
||||
trackId: 'shared-frame',
|
||||
name: '低量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
trackId: 'shared-frame',
|
||||
name: '高量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 10, y: 1000 },
|
||||
{ x: 20, y: 2000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
it('binds overlaid series to at most four value axes in multi-axis mode', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'series',
|
||||
@@ -353,8 +671,10 @@ describe('WaveformChart', () => {
|
||||
'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);'])
|
||||
legend
|
||||
.findAll('.waveform-legend__swatch')
|
||||
.map((swatch) => swatch.get('path').attributes('stroke')),
|
||||
).toEqual(['#0960bd', '#389e0d'])
|
||||
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
@@ -374,6 +694,7 @@ describe('WaveformChart', () => {
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
|
||||
@@ -496,6 +817,193 @@ describe('WaveformChart', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legends display-only unless interaction is explicitly enabled', async () => {
|
||||
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
})
|
||||
|
||||
const items = wrapper.findAll('.waveform-chart__legend-item')
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain(
|
||||
'waveform-legend__panel--interactive',
|
||||
)
|
||||
expect(wrapper.emitted('update:hidden-series-ids')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('toggles series, axes, tooltips, and annotations from an interactive legend', async () => {
|
||||
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||||
annotations: [{ id: 'high-note', seriesId: 'high', x: 15, y: 1500, text: '高值' }],
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
legend: { interactive: true },
|
||||
overlayMode: 'multi-axis',
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
|
||||
const highLegendItem = wrapper.findAll('.waveform-chart__legend-item')[1]
|
||||
expect(highLegendItem.attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await highLegendItem.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||||
).toEqual(['low'])
|
||||
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden')
|
||||
expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([['high']])
|
||||
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
|
||||
{ seriesId: 'high', visible: false, hiddenSeriesIds: ['high'] },
|
||||
])
|
||||
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
const overlayHeight = Number(overlay.attributes('height'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
|
||||
})
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', {
|
||||
clientX: overlayWidth / 2,
|
||||
clientY: overlayHeight / 2,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程')
|
||||
|
||||
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
|
||||
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
|
||||
{ seriesId: 'high', visible: true, hiddenSeriesIds: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for controlled visibility updates and preserves unknown controlled IDs', async () => {
|
||||
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
hiddenSeriesIds: ['high', 'temporarily-absent'],
|
||||
legend: { interactive: true },
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||||
).toEqual(['low'])
|
||||
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
|
||||
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([
|
||||
['high', 'temporarily-absent', 'low'],
|
||||
])
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||||
).toEqual(['low'])
|
||||
|
||||
await wrapper.setProps({ hiddenSeriesIds: ['low'] })
|
||||
await flushPromises()
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||||
).toEqual(['high'])
|
||||
})
|
||||
|
||||
it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => {
|
||||
const original = visibilitySeries()
|
||||
const wrapper = await mountSizedChart(original, {
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
legend: { interactive: true },
|
||||
})
|
||||
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
|
||||
|
||||
const reversed: WaveformData = {
|
||||
kind: 'series',
|
||||
series: [...(original.kind === 'series' ? original.series : [])].reverse(),
|
||||
}
|
||||
await wrapper.setProps({ data: reversed })
|
||||
await flushPromises()
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||||
).toEqual(['low'])
|
||||
|
||||
await wrapper.setProps({
|
||||
data: {
|
||||
kind: 'series',
|
||||
series: original.kind === 'series' ? [original.series[0]!] : [],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ data: original })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => {
|
||||
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||||
defaultHiddenSeriesIds: ['low', 'high'],
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
legend: { interactive: true },
|
||||
overlayMode: 'multi-axis',
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2)
|
||||
expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线')
|
||||
|
||||
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
|
||||
).toEqual(['low'])
|
||||
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.waveform-chart__overlay--independent')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes an annotation editor when its series is hidden from the legend', async () => {
|
||||
const wrapper = await mountSizedChart(visibilitySeries(), {
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
legend: { interactive: true },
|
||||
})
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
const overlayHeight = Number(overlay.attributes('height'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }),
|
||||
})
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
clientX: overlayWidth * 0.75,
|
||||
clientY: overlayHeight / 2,
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
|
||||
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } }
|
||||
}
|
||||
const draftSeriesId = component.annotationInteraction.editorDraft.value?.annotation.seriesId
|
||||
const item = wrapper
|
||||
.findAll('.waveform-chart__legend-item')
|
||||
.find((legendItem) =>
|
||||
legendItem.text().includes(draftSeriesId === 'high' ? '高量程' : '低量程'),
|
||||
)
|
||||
expect(item).toBeDefined()
|
||||
await item!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders independent cells with separate x axes and overlays', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(4), {
|
||||
displayMode: 'independent',
|
||||
@@ -556,10 +1064,10 @@ describe('WaveformChart', () => {
|
||||
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
|
||||
)
|
||||
|
||||
expect(labelX).toBe(-99)
|
||||
expect(labelX).toBe(-103)
|
||||
expect(labelBackgroundX).toBe(labelX - 12)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(115)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(115)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119)
|
||||
})
|
||||
|
||||
it('keeps a tick-only gutter when channel labels are empty', async () => {
|
||||
@@ -585,8 +1093,8 @@ describe('WaveformChart', () => {
|
||||
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(85)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(85)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89)
|
||||
})
|
||||
|
||||
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
|
||||
@@ -1020,6 +1528,7 @@ describe('WaveformChart', () => {
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth / 2, clientY: 100, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
const tooltipTop = Number.parseFloat(
|
||||
@@ -1165,6 +1674,39 @@ describe('WaveformChart', () => {
|
||||
expect(path).toContain(',0')
|
||||
})
|
||||
|
||||
it('bounds dense decorations by pixel spacing while keeping one SVG path per series', async () => {
|
||||
const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
x: index,
|
||||
y: Math.sin(index / 20),
|
||||
error: 0.1,
|
||||
}))
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'dense-decorations',
|
||||
name: '密集标记',
|
||||
pointType: 'triangle',
|
||||
errorBar: { visible: true },
|
||||
data: { kind: 'points', points: sourcePoints },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ rendering: { pointMinSpacing: 10, errorBarMinSpacing: 12 } },
|
||||
)
|
||||
const overlayWidth = Number(wrapper.get('.waveform-chart__overlay').attributes('width'))
|
||||
const pointPaths = wrapper.findAll('.waveform-chart__point')
|
||||
const errorBarPaths = wrapper.findAll('.waveform-chart__error-bar')
|
||||
const pointCount = pointPaths[0]?.attributes('d')?.match(/M/g)?.length ?? 0
|
||||
const errorBarCount = (errorBarPaths[0]?.attributes('d')?.match(/M/g)?.length ?? 0) / 3
|
||||
|
||||
expect(pointPaths).toHaveLength(1)
|
||||
expect(errorBarPaths).toHaveLength(1)
|
||||
expect(pointCount).toBeLessThanOrEqual(Math.ceil(overlayWidth / 10) + 2)
|
||||
expect(errorBarCount).toBeLessThanOrEqual(Math.ceil(overlayWidth / 12) + 2)
|
||||
})
|
||||
|
||||
it('renders explicit points and supports a single point', async () => {
|
||||
const wrapper = await mountSizedChart({ kind: 'points', points: [{ x: 3, y: 8 }] })
|
||||
|
||||
@@ -1199,15 +1741,73 @@ describe('WaveformChart', () => {
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||
expect(crosshairLines).toHaveLength(1)
|
||||
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
|
||||
expect(crosshairLines[0].attributes('y1')).toBe('0')
|
||||
expect(wrapper.find('.waveform-chart__crosshair circle').exists()).toBe(false)
|
||||
|
||||
await overlay.trigger('pointerleave')
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('coalesces pointer moves per frame and cancels pending hover work', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ 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: 290 }),
|
||||
})
|
||||
const emittedBeforeMove = wrapper.emitted('point-hover')?.length ?? 0
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 0, clientY: 100, bubbles: true }),
|
||||
)
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||
)
|
||||
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
expect(wrapper.emitted('point-hover')?.length ?? 0).toBe(emittedBeforeMove)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')).toHaveLength(emittedBeforeMove + 1)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 0, clientY: 100, bubbles: true }),
|
||||
)
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
await overlay.trigger('pointerleave')
|
||||
const emittedAfterLeave = wrapper.emitted('point-hover')?.length ?? 0
|
||||
expect(pendingAnimationFrameCount()).toBe(0)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')).toHaveLength(emittedAfterLeave)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 100, bubbles: true }),
|
||||
)
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
wrapper.unmount()
|
||||
expect(pendingAnimationFrameCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('renders reference grid styling and an optional frame watermark', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
@@ -1430,6 +2030,7 @@ describe('WaveformChart', () => {
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
const emittedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as
|
||||
@@ -1469,6 +2070,7 @@ describe('WaveformChart', () => {
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
@@ -1853,6 +2455,7 @@ describe('WaveformChart', () => {
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
const tooltip = wrapper.get('.waveform-chart__tooltip')
|
||||
@@ -1860,10 +2463,34 @@ describe('WaveformChart', () => {
|
||||
expect(tooltip.text()).toContain('2 T')
|
||||
expect(tooltip.text()).toContain('BT1_2M:')
|
||||
expect(tooltip.text()).toContain('4 T')
|
||||
expect(wrapper.findAll('.waveform-chart__crosshair circle')).toHaveLength(2)
|
||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||
expect(crosshairLines).toHaveLength(2)
|
||||
crosshairLines.forEach((line) => {
|
||||
expect(line.attributes('x1')).toBe(line.attributes('x2'))
|
||||
expect(line.attributes('y1')).toBe('0')
|
||||
})
|
||||
expect(wrapper.find('.waveform-chart__crosshair circle').exists()).toBe(false)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 2 }])
|
||||
})
|
||||
|
||||
it('keeps synchronized hover feedback in compact mode', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), { displayMode: 'compact' })
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: 712, height: 290 }),
|
||||
})
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2)
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 1 }])
|
||||
})
|
||||
|
||||
it('keeps separated tracks apart while sharing one x-axis and one interaction layer', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
@@ -1998,6 +2625,7 @@ describe('WaveformChart', () => {
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(endpoints()[0]).not.toBe(initialEndpoints[0])
|
||||
@@ -2054,9 +2682,21 @@ describe('WaveformChart', () => {
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -200,
|
||||
clientX: 356,
|
||||
clientY: 145,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
expect(pendingAnimationFrameCount()).toBe(1)
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(initialZoomEventCount)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBeGreaterThan(initialZoomEventCount)
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(initialZoomEventCount + 1)
|
||||
|
||||
await wrapper.setProps({ zoomable: false })
|
||||
await flushPromises()
|
||||
@@ -2070,6 +2710,7 @@ describe('WaveformChart', () => {
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(zoomEventCount)
|
||||
@@ -2375,6 +3016,7 @@ describe('WaveformChart', () => {
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-annotation__arrow').attributes('x2')).not.toBe(initialX)
|
||||
|
||||
|
||||
@@ -73,7 +73,11 @@ import {
|
||||
type WaveformGridOptions,
|
||||
} from './core/grid'
|
||||
import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
|
||||
import { buildTrackLayouts, measureTrackYAxisClearance } from './core/layout'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
measureTrackYAxisClearance,
|
||||
Y_AXIS_EXPONENT_GAP,
|
||||
} from './core/layout'
|
||||
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title'
|
||||
import { usePreparedWaveformSeries } from './core/useWaveformData'
|
||||
import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
|
||||
@@ -101,6 +105,8 @@ const props = withDefaults(
|
||||
rendering?: WaveformRenderingOptions
|
||||
title?: WaveformTitleOptions
|
||||
legend?: WaveformLegendOptions
|
||||
hiddenSeriesIds?: string[]
|
||||
defaultHiddenSeriesIds?: string[]
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'independent',
|
||||
@@ -118,6 +124,7 @@ const props = withDefaults(
|
||||
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
|
||||
rendering: () => ({}),
|
||||
legend: () => ({ position: 'top-right', orientation: 'auto' }),
|
||||
defaultHiddenSeriesIds: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -127,6 +134,14 @@ const emit = defineEmits<{
|
||||
'update:annotations': [annotations: WaveformAnnotation[]]
|
||||
'update:annotations-visible': [visible: boolean]
|
||||
'update:interaction-mode': [mode: WaveformInteractionMode]
|
||||
'update:hidden-series-ids': [ids: string[]]
|
||||
'series-visibility-change': [
|
||||
payload: {
|
||||
seriesId: string
|
||||
visible: boolean
|
||||
hiddenSeriesIds: string[]
|
||||
},
|
||||
]
|
||||
'annotation-create': [annotation: WaveformAnnotation]
|
||||
'annotation-update': [annotation: WaveformAnnotation, previous: WaveformAnnotation]
|
||||
'annotation-delete': [annotation: WaveformAnnotation]
|
||||
@@ -153,10 +168,16 @@ const resizeObserver = shallowRef<ResizeObserver>()
|
||||
const zoomBehaviors = new Map<number | 'shared', ZoomBehavior<SVGRectElement, unknown>>()
|
||||
const clipPathId = `${useId()}-waveform-clip`
|
||||
const internalInteractionMode = ref<WaveformInteractionMode | undefined>(undefined)
|
||||
const internalHiddenSeriesIds = ref(new Set(props.defaultHiddenSeriesIds))
|
||||
const annotationInteraction = useWaveformAnnotationInteraction()
|
||||
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
||||
let generatedAnnotationId = 0
|
||||
let synchronizingZoomTransform = false
|
||||
let zoomAnimationFrame: number | null = null
|
||||
let pendingSharedZoomTransform: ZoomTransform | null = null
|
||||
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
|
||||
let hoverAnimationFrame: number | null = null
|
||||
let pendingHoverUpdate: (() => void) | null = null
|
||||
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
|
||||
|
||||
// 用于传递给 WaveformTooltip 的接口
|
||||
@@ -188,6 +209,13 @@ const legendPosition = computed<WaveformLegendPosition>(() => props.legend?.posi
|
||||
const legendBackgroundColor = computed(
|
||||
() => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)',
|
||||
)
|
||||
const legendInteractive = computed(() => props.legend?.interactive === true)
|
||||
const hiddenSeriesIdSet = computed(() =>
|
||||
props.hiddenSeriesIds === undefined
|
||||
? internalHiddenSeriesIds.value
|
||||
: new Set(props.hiddenSeriesIds),
|
||||
)
|
||||
const resolvedHiddenSeriesIds = computed(() => Array.from(hiddenSeriesIdSet.value))
|
||||
const legendOrientation = computed<Exclude<WaveformLegendOrientation, 'auto'>>(() => {
|
||||
const orientation = props.legend?.orientation ?? 'auto'
|
||||
if (orientation !== 'auto') return orientation
|
||||
@@ -288,12 +316,16 @@ const chartTracks = computed<DisplayTrack[]>(() => {
|
||||
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)),
|
||||
}))
|
||||
return Array.from(groupedSeries, ([id, series]) => {
|
||||
const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id))
|
||||
return {
|
||||
id,
|
||||
series,
|
||||
visibleSeries,
|
||||
xDomain: paddedDomain(visibleSeries.flatMap((item) => item.xDomain)),
|
||||
yDomain: paddedDomain(visibleSeries.flatMap((item) => item.yDomain)),
|
||||
}
|
||||
})
|
||||
})
|
||||
const gridOptions = computed(() => normalizeGridOptions(props.grid))
|
||||
const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
|
||||
@@ -307,19 +339,20 @@ const yAxisTickPadding = 7
|
||||
const yAxisOuterPadding = 4
|
||||
const yAxisLabelGap = 6
|
||||
const yAxisLabelBandWidth = 24
|
||||
const yAxisExponentGap = 4
|
||||
const minimumPlotWidth = 120
|
||||
|
||||
const yAxisMetrics = computed(() => {
|
||||
const axisText = chartTracks.value.map((track) => {
|
||||
const scale = scaleLinear(track.yDomain, [1, 0]).nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
const values = scale.ticks(10)
|
||||
return {
|
||||
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
||||
tickLabels: values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
|
||||
}
|
||||
})
|
||||
const axisText = chartTracks.value
|
||||
.filter((track) => track.visibleSeries.length > 0)
|
||||
.map((track) => {
|
||||
const scale = scaleLinear(track.yDomain, [1, 0]).nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
const values = scale.ticks(10)
|
||||
return {
|
||||
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
||||
tickLabels: values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
|
||||
}
|
||||
})
|
||||
const formattedTickLabels = axisText.flatMap(({ tickLabels }) => tickLabels)
|
||||
const maximumCharacterCount = Math.max(1, ...formattedTickLabels.map((label) => label.length))
|
||||
const tickTextWidth = maximumCharacterCount * yAxisCharacterWidth
|
||||
@@ -327,7 +360,9 @@ const yAxisMetrics = computed(() => {
|
||||
0,
|
||||
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * yAxisCharacterWidth),
|
||||
)
|
||||
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + yAxisExponentGap : 0
|
||||
const exponentClearance = maximumExponentWidth
|
||||
? maximumExponentWidth + Y_AXIS_EXPONENT_GAP
|
||||
: 0
|
||||
const tickClearance = tickTextWidth + yAxisTickPadding + exponentClearance + yAxisOuterPadding
|
||||
const labelCenterX = -(
|
||||
yAxisTickPadding +
|
||||
@@ -348,15 +383,20 @@ const yAxisMetrics = computed(() => {
|
||||
})
|
||||
const hasYAxisLabels = computed(() =>
|
||||
chartTracks.value.some(
|
||||
(track) => track.series.length === 1 && Boolean(track.series[0]?.name.trim() || props.yLabel),
|
||||
(track) =>
|
||||
track.visibleSeries.length === 1 &&
|
||||
Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
|
||||
),
|
||||
)
|
||||
const hasVisibleWaveformData = computed(() =>
|
||||
chartTracks.value.some((track) => track.visibleSeries.length > 0),
|
||||
)
|
||||
const chartLeftMargin = computed(() =>
|
||||
Math.max(
|
||||
margin.left,
|
||||
hasYAxisLabels.value
|
||||
? yAxisMetrics.value.fullClearance
|
||||
: chartSeries.value.length
|
||||
: hasVisibleWaveformData.value
|
||||
? yAxisMetrics.value.tickClearance
|
||||
: 0,
|
||||
),
|
||||
@@ -397,9 +437,9 @@ const yAxisLayout = computed(() => {
|
||||
|
||||
return {
|
||||
horizontalGap:
|
||||
props.overlayMode === 'multi-axis' && hasMultipleColumns && chartSeries.value.length
|
||||
props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
|
||||
? Math.max(baseGap, multiAxisClearance.value.left + multiAxisClearance.value.right)
|
||||
: hasMultipleColumns && chartSeries.value.length
|
||||
: hasMultipleColumns && hasVisibleWaveformData.value
|
||||
? hasYAxisLabels.value && canReserveLabelClearance
|
||||
? fullGap
|
||||
: tickGap
|
||||
@@ -433,7 +473,9 @@ const tooltipSeriesPoints = computed<TooltipSeriesPoint[]>(() => {
|
||||
})
|
||||
|
||||
const sharedXDomain = computed(() =>
|
||||
paddedDomain(chartTracks.value.flatMap((track) => track.xDomain)),
|
||||
paddedDomain(
|
||||
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
|
||||
),
|
||||
)
|
||||
const sharedZoomDomain = computed(
|
||||
() =>
|
||||
@@ -526,27 +568,71 @@ function resolveFrameNumber(trackIndex: number): string | number | undefined {
|
||||
|
||||
function handleSharedZoom(event: D3ZoomEvent<SVGRectElement, unknown>) {
|
||||
if (synchronizingZoomTransform) return
|
||||
const transform = event.transform
|
||||
sharedTransform.value = transform
|
||||
const domain = transform
|
||||
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
|
||||
.domain()
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
cancelPendingHover()
|
||||
pendingSharedZoomTransform = event.transform
|
||||
scheduleZoomCommit()
|
||||
}
|
||||
|
||||
function handleIndependentZoom(event: D3ZoomEvent<SVGRectElement, unknown>, trackIndex: number) {
|
||||
if (synchronizingZoomTransform) return
|
||||
const transform = event.transform
|
||||
cancelPendingHover()
|
||||
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
|
||||
scheduleZoomCommit()
|
||||
}
|
||||
|
||||
function commitPendingZoom() {
|
||||
if (pendingSharedZoomTransform) {
|
||||
const transform = pendingSharedZoomTransform
|
||||
pendingSharedZoomTransform = null
|
||||
sharedTransform.value = transform
|
||||
const domain = transform
|
||||
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
|
||||
.domain()
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
}
|
||||
|
||||
if (!pendingIndependentZoomTransforms.size) return
|
||||
const nextTransforms = [...independentTransforms.value]
|
||||
nextTransforms[trackIndex] = transform
|
||||
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
|
||||
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
|
||||
nextTransforms[trackIndex] = transform
|
||||
})
|
||||
pendingIndependentZoomTransforms.clear()
|
||||
independentTransforms.value = nextTransforms
|
||||
const track = trackLayouts.value[trackIndex]
|
||||
if (!track) return
|
||||
const domain = track.xScale.domain()
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
changedTrackIndexes.forEach((trackIndex) => {
|
||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||
if (!track) return
|
||||
const domain = track.xScale.domain()
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleZoomCommit() {
|
||||
if (zoomAnimationFrame !== null) return
|
||||
zoomAnimationFrame = requestAnimationFrame(() => {
|
||||
zoomAnimationFrame = null
|
||||
commitPendingZoom()
|
||||
})
|
||||
}
|
||||
|
||||
function flushPendingZoom() {
|
||||
if (zoomAnimationFrame !== null) {
|
||||
cancelAnimationFrame(zoomAnimationFrame)
|
||||
zoomAnimationFrame = null
|
||||
}
|
||||
commitPendingZoom()
|
||||
}
|
||||
|
||||
function cancelPendingZoom() {
|
||||
pendingSharedZoomTransform = null
|
||||
pendingIndependentZoomTransforms.clear()
|
||||
if (zoomAnimationFrame === null) return
|
||||
cancelAnimationFrame(zoomAnimationFrame)
|
||||
zoomAnimationFrame = null
|
||||
}
|
||||
|
||||
function clearZoomBindings() {
|
||||
cancelPendingZoom()
|
||||
const svg = svgElement.value
|
||||
if (svg) {
|
||||
const overlays = svg.querySelectorAll<SVGRectElement>('.waveform-chart__overlay')
|
||||
@@ -579,6 +665,7 @@ function configureZoom() {
|
||||
[track.width, track.height],
|
||||
])
|
||||
.on('zoom', (event) => handleIndependentZoom(event, track.index))
|
||||
.on('end', flushPendingZoom)
|
||||
zoomBehaviors.set(track.index, behavior)
|
||||
synchronizingZoomTransform = true
|
||||
try {
|
||||
@@ -604,6 +691,7 @@ function configureZoom() {
|
||||
[innerWidth.value, innerHeight.value],
|
||||
])
|
||||
.on('zoom', handleSharedZoom)
|
||||
.on('end', flushPendingZoom)
|
||||
zoomBehaviors.set('shared', behavior)
|
||||
const overlay = sharedOverlayElement.value
|
||||
if (overlay) {
|
||||
@@ -616,7 +704,51 @@ function configureZoom() {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelPendingHover() {
|
||||
pendingHoverUpdate = null
|
||||
if (hoverAnimationFrame === null) return
|
||||
cancelAnimationFrame(hoverAnimationFrame)
|
||||
hoverAnimationFrame = null
|
||||
}
|
||||
|
||||
function scheduleHover(update: () => void) {
|
||||
pendingHoverUpdate = update
|
||||
if (hoverAnimationFrame !== null) return
|
||||
hoverAnimationFrame = requestAnimationFrame(() => {
|
||||
hoverAnimationFrame = null
|
||||
const nextUpdate = pendingHoverUpdate
|
||||
pendingHoverUpdate = null
|
||||
nextUpdate?.()
|
||||
})
|
||||
}
|
||||
|
||||
function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean {
|
||||
return (
|
||||
hoveredSeriesPoints.value.length === nextPoints.length &&
|
||||
nextPoints.every((point, index) => {
|
||||
const current = hoveredSeriesPoints.value[index]
|
||||
return (
|
||||
current?.id === point.id &&
|
||||
current.trackIndex === point.trackIndex &&
|
||||
current.point === point.point
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function commitHover(
|
||||
nextPoints: HoveredSeriesPoint[],
|
||||
trackIndex: number | null,
|
||||
position: { x: number; y: number },
|
||||
) {
|
||||
if (!hoveredPointsMatch(nextPoints)) hoveredSeriesPoints.value = nextPoints
|
||||
hoveredTrackIndex.value = trackIndex
|
||||
hoverPosition.value = position
|
||||
emit('point-hover', nextPoints[0]?.point ?? null)
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
cancelPendingHover()
|
||||
hoveredSeriesPoints.value = []
|
||||
hoveredTrackIndex.value = null
|
||||
emit('point-hover', null)
|
||||
@@ -651,6 +783,18 @@ function setAnnotationsVisible(visible: boolean) {
|
||||
emit('update:annotations-visible', visible)
|
||||
}
|
||||
|
||||
function toggleSeriesVisibility(seriesId: string) {
|
||||
if (!chartSeries.value.some((series) => series.id === seriesId)) return
|
||||
const nextHiddenSeriesIds = new Set(hiddenSeriesIdSet.value)
|
||||
const visible = nextHiddenSeriesIds.has(seriesId)
|
||||
if (visible) nextHiddenSeriesIds.delete(seriesId)
|
||||
else nextHiddenSeriesIds.add(seriesId)
|
||||
const ids = Array.from(nextHiddenSeriesIds)
|
||||
if (props.hiddenSeriesIds === undefined) internalHiddenSeriesIds.value = nextHiddenSeriesIds
|
||||
emit('update:hidden-series-ids', ids)
|
||||
emit('series-visibility-change', { seriesId, visible, hiddenSeriesIds: ids })
|
||||
}
|
||||
|
||||
function resolvePointerEditorAnchor(
|
||||
event: MouseEvent,
|
||||
trackIndex?: number,
|
||||
@@ -709,7 +853,9 @@ function changeDraftSeries(seriesId: string) {
|
||||
)
|
||||
const series = track?.seriesList.find((item) => item.id === seriesId)
|
||||
const point =
|
||||
series && draft ? interpolateAnnotationPoint(series.points, draft.annotation.x) : null
|
||||
series && draft
|
||||
? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType)
|
||||
: null
|
||||
if (!draft || !candidate || !track || !point) return
|
||||
draft.annotation = {
|
||||
...draft.annotation,
|
||||
@@ -734,8 +880,12 @@ function resolveTrackAtPointer(
|
||||
pointerY: number,
|
||||
trackIndex?: number,
|
||||
): TrackLayout | undefined {
|
||||
if (trackIndex !== undefined) return trackLayouts.value[trackIndex]
|
||||
if (!trackLayouts.value.length) return undefined
|
||||
if (trackIndex !== undefined) {
|
||||
const track = trackLayouts.value[trackIndex]
|
||||
return track?.hasVisibleSeries ? track : undefined
|
||||
}
|
||||
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||
if (!visibleTracks.length) return undefined
|
||||
|
||||
const distanceToTrack = (track: TrackLayout) => {
|
||||
const xDistance =
|
||||
@@ -748,7 +898,7 @@ function resolveTrackAtPointer(
|
||||
if (pointerY > track.top + track.height) return pointerY - (track.top + track.height)
|
||||
return xDistance
|
||||
}
|
||||
return trackLayouts.value.reduce((closest, candidate) => {
|
||||
return visibleTracks.reduce((closest, candidate) => {
|
||||
const distance = distanceToTrack(candidate)
|
||||
const closestDistance = distanceToTrack(closest)
|
||||
if (distance !== closestDistance) return distance < closestDistance ? candidate : closest
|
||||
@@ -899,44 +1049,49 @@ function confirmAnnotation(annotation: WaveformAnnotation) {
|
||||
|
||||
function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
||||
const overlay = event.currentTarget as SVGRectElement | null
|
||||
const track = trackLayouts.value[trackIndex]
|
||||
if (!overlay || !track) return
|
||||
if (!overlay) return
|
||||
const [pointerX, pointerY] = pointer(event, overlay)
|
||||
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
|
||||
hoveredSeriesPoints.value = track.seriesList.flatMap((series) => {
|
||||
const point = nearestPoint(series, xValue)
|
||||
return point ? [{ ...series, trackIndex, point }] : []
|
||||
scheduleHover(() => {
|
||||
const track = trackLayouts.value[trackIndex]
|
||||
if (!track) return
|
||||
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
|
||||
const nextPoints = track.seriesList.flatMap((series) => {
|
||||
const point = nearestPoint(series, xValue)
|
||||
return point ? [{ ...series, trackIndex, point }] : []
|
||||
})
|
||||
commitHover(nextPoints, trackIndex, {
|
||||
x: resolvedChartLeftMargin.value + track.left + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + track.top + pointerY,
|
||||
})
|
||||
})
|
||||
hoveredTrackIndex.value = trackIndex
|
||||
hoverPosition.value = {
|
||||
x: resolvedChartLeftMargin.value + track.left + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + track.top + pointerY,
|
||||
}
|
||||
emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null)
|
||||
}
|
||||
|
||||
function handleSharedPointerMove(event: PointerEvent) {
|
||||
if (!sharedOverlayElement.value || !trackLayouts.value.length) return
|
||||
const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value)
|
||||
const referenceTrack = resolveTrackAtPointer(pointerX, pointerY) ?? trackLayouts.value[0]
|
||||
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) =>
|
||||
track.seriesList.flatMap((series) => {
|
||||
const point = nearestPoint(series, xValue)
|
||||
return point ? [{ ...series, trackIndex: track.index, point }] : []
|
||||
}),
|
||||
)
|
||||
hoveredTrackIndex.value = null
|
||||
hoverPosition.value = {
|
||||
x: resolvedChartLeftMargin.value + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + pointerY,
|
||||
}
|
||||
emit('point-hover', hoveredPoint.value)
|
||||
scheduleHover(() => {
|
||||
const referenceTrack = resolveTrackAtPointer(pointerX, pointerY) ?? trackLayouts.value[0]
|
||||
if (!referenceTrack) return
|
||||
const localPointerX = Math.max(
|
||||
0,
|
||||
Math.min(referenceTrack.width, pointerX - referenceTrack.left),
|
||||
)
|
||||
const xValue = referenceTrack.xScale.invert(localPointerX)
|
||||
const nextPoints = trackLayouts.value.flatMap((track) =>
|
||||
track.seriesList.flatMap((series) => {
|
||||
const point = nearestPoint(series, xValue)
|
||||
return point ? [{ ...series, trackIndex: track.index, point }] : []
|
||||
}),
|
||||
)
|
||||
commitHover(nextPoints, null, {
|
||||
x: resolvedChartLeftMargin.value + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + pointerY,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resetViewport() {
|
||||
cancelPendingZoom()
|
||||
sharedTransform.value = zoomIdentity
|
||||
independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
|
||||
clearHover()
|
||||
@@ -1012,6 +1167,45 @@ watch(activeInteractionMode, () => {
|
||||
editorSeriesOptions.value = []
|
||||
})
|
||||
|
||||
watch(
|
||||
() => chartSeries.value.map((series) => series.id).join('\u0000'),
|
||||
() => {
|
||||
if (props.hiddenSeriesIds !== undefined) return
|
||||
const availableIds = new Set(chartSeries.value.map((series) => series.id))
|
||||
const retainedIds = new Set(
|
||||
Array.from(internalHiddenSeriesIds.value).filter((seriesId) => availableIds.has(seriesId)),
|
||||
)
|
||||
if (
|
||||
retainedIds.size !== internalHiddenSeriesIds.value.size ||
|
||||
Array.from(retainedIds).some((seriesId) => !internalHiddenSeriesIds.value.has(seriesId))
|
||||
) {
|
||||
internalHiddenSeriesIds.value = retainedIds
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() =>
|
||||
chartTracks.value
|
||||
.flatMap((track) => track.visibleSeries.map((series) => series.id))
|
||||
.join('\u0000'),
|
||||
() => {
|
||||
clearHover()
|
||||
editorSeriesOptions.value = []
|
||||
const draftSeriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
|
||||
if (draftSeriesId && hiddenSeriesIdSet.value.has(draftSeriesId)) {
|
||||
annotationInteraction.closeEditor()
|
||||
}
|
||||
const contextAnnotationId = annotationInteraction.contextMenu.value?.annotationId
|
||||
const contextAnnotation = props.annotations.find((item) => item.id === contextAnnotationId)
|
||||
if (contextAnnotation && hiddenSeriesIdSet.value.has(contextAnnotation.seriesId)) {
|
||||
annotationInteraction.closeContextMenu()
|
||||
}
|
||||
void nextTick(configureZoom)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.annotationsVisible,
|
||||
(visible) => {
|
||||
@@ -1070,6 +1264,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelPendingHover()
|
||||
resizeObserver.value?.disconnect()
|
||||
clearZoomBindings()
|
||||
editorSeriesOptions.value = []
|
||||
@@ -1153,6 +1348,22 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<rect
|
||||
v-if="displayMode !== 'independent' && trackLayouts.length && hasVisibleWaveformData"
|
||||
ref="sharedOverlayElement"
|
||||
class="waveform-chart__overlay waveform-chart__overlay--shared"
|
||||
:class="{
|
||||
'is-zoomable': zoomable && isZoomMode,
|
||||
'is-annotating': activeInteractionMode === 'annotation',
|
||||
}"
|
||||
:width="innerWidth"
|
||||
:height="innerHeight"
|
||||
@pointermove="handleSharedPointerMove"
|
||||
@pointerleave="clearHover"
|
||||
@click="handleAnnotationClick"
|
||||
@contextmenu="handleAnnotationContextMenu"
|
||||
/>
|
||||
|
||||
<!-- 轨道渲染 -->
|
||||
<WaveformTrack
|
||||
v-for="track in trackLayouts"
|
||||
@@ -1171,27 +1382,14 @@ onBeforeUnmount(() => {
|
||||
:legend-position="legendPosition"
|
||||
:legend-orientation="legendOrientation"
|
||||
:legend-background-color="legendBackgroundColor"
|
||||
:legend-interactive="legendInteractive"
|
||||
:hidden-series-ids="resolvedHiddenSeriesIds"
|
||||
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
|
||||
@pointer-move="handleIndependentPointerMove($event, track.index)"
|
||||
@pointer-leave="clearHover"
|
||||
@click="handleAnnotationClick($event, track.index)"
|
||||
@contextmenu="handleAnnotationContextMenu($event, track.index)"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="displayMode !== 'independent' && trackLayouts.length"
|
||||
ref="sharedOverlayElement"
|
||||
class="waveform-chart__overlay waveform-chart__overlay--shared"
|
||||
:class="{
|
||||
'is-zoomable': zoomable && isZoomMode,
|
||||
'is-annotating': activeInteractionMode === 'annotation',
|
||||
}"
|
||||
:width="innerWidth"
|
||||
:height="innerHeight"
|
||||
@pointermove="handleSharedPointerMove"
|
||||
@pointerleave="clearHover"
|
||||
@click="handleAnnotationClick"
|
||||
@contextmenu="handleAnnotationContextMenu"
|
||||
@series-visibility-toggle="toggleSeriesVisibility"
|
||||
/>
|
||||
|
||||
<WaveformAnnotationLayer
|
||||
|
||||
@@ -55,6 +55,34 @@ describe('waveform annotation markup', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('interpolates start, middle, and end step lines at their visual transitions', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 2, y: 10 },
|
||||
]
|
||||
|
||||
expect(interpolateAnnotationPoint(points, 0.5, 'step-start')).toEqual({ x: 0.5, y: 10 })
|
||||
expect(interpolateAnnotationPoint(points, 0.5, 'step-middle')).toEqual({ x: 0.5, y: 2 })
|
||||
expect(interpolateAnnotationPoint(points, 1, 'step-middle')).toEqual({ x: 1, y: 10 })
|
||||
expect(interpolateAnnotationPoint(points, 1.5, 'step-middle')).toEqual({ x: 1.5, y: 10 })
|
||||
expect(interpolateAnnotationPoint(points, 1, 'step-end')).toEqual({ x: 1, y: 2 })
|
||||
expect(interpolateAnnotationPoint(points, 1, 'step-after')).toEqual({ x: 1, y: 2 })
|
||||
expect(interpolateAnnotationPoint(points, 2, 'step-start')).toEqual({ x: 2, y: 10 })
|
||||
expect(interpolateAnnotationPoint(points, 2, 'step-after')).toEqual({ x: 2, y: 10 })
|
||||
expect(interpolateAnnotationPoint(points, 1, 'none')).toBeNull()
|
||||
expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 })
|
||||
})
|
||||
|
||||
it('omits interpolated candidates for point-only series between samples', () => {
|
||||
const pointOnly = createTrack(0, 'points', 0, [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 2, y: 10 },
|
||||
])
|
||||
pointOnly.series.lineType = 'none'
|
||||
|
||||
expect(findAnnotationSeriesCandidates([pointOnly], 1, 100, 50)).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts line candidates by screen distance and keeps series metadata', () => {
|
||||
const first = createTrack(0, 'first', 0, [
|
||||
{ x: 0, y: 0 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bisector } from 'd3'
|
||||
|
||||
import type { WaveformAnnotation, WaveformAnnotationStyle } from '../../types'
|
||||
import type { WaveformAnnotation, WaveformAnnotationStyle, WaveformLineType } from '../../types'
|
||||
import type {
|
||||
AnnotationBoxLayout,
|
||||
AnnotationHit,
|
||||
@@ -46,6 +46,7 @@ const pointBisector = bisector((point: { x: number }) => point.x)
|
||||
export function interpolateAnnotationPoint(
|
||||
points: Array<{ x: number; y: number }>,
|
||||
xValue: number,
|
||||
lineType: WaveformLineType = 'linear',
|
||||
): { x: number; y: number } | null {
|
||||
if (!points.length || !Number.isFinite(xValue)) return null
|
||||
const first = points[0]
|
||||
@@ -56,8 +57,16 @@ export function interpolateAnnotationPoint(
|
||||
const rightIndex = pointBisector.left(points, xValue)
|
||||
const right = points[Math.min(rightIndex, points.length - 1)]
|
||||
if (right.x === xValue || rightIndex === 0) return { x: xValue, y: right.y }
|
||||
if (lineType === 'none') return null
|
||||
|
||||
const left = points[rightIndex - 1]
|
||||
if (lineType === 'step-start') return { x: xValue, y: right.y }
|
||||
if (lineType === 'step-middle') {
|
||||
return { x: xValue, y: xValue < (left.x + right.x) / 2 ? left.y : right.y }
|
||||
}
|
||||
if (lineType === 'step-end' || lineType === 'step-after') {
|
||||
return { x: xValue, y: left.y }
|
||||
}
|
||||
const xSpan = right.x - left.x
|
||||
if (xSpan === 0) return { x: xValue, y: right.y }
|
||||
const ratio = (xValue - left.x) / xSpan
|
||||
@@ -72,7 +81,7 @@ export function findAnnotationSeriesCandidates(
|
||||
): AnnotationSeriesCandidate[] {
|
||||
return tracks
|
||||
.flatMap((track): AnnotationSeriesCandidate[] => {
|
||||
const point = interpolateAnnotationPoint(track.series.points, xValue)
|
||||
const point = interpolateAnnotationPoint(track.series.points, xValue, track.series.lineType)
|
||||
if (!point) return []
|
||||
const screenX = track.xScale(point.x)
|
||||
const screenY = track.top + track.yScale(point.y)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ScaleLinear } from 'd3'
|
||||
|
||||
import type { WaveformAnnotation, WaveformPoint } from '../../types'
|
||||
import type { WaveformAnnotation, WaveformLineType, WaveformPoint } from '../../types'
|
||||
|
||||
export interface AnnotationTrackLayout {
|
||||
index: number
|
||||
@@ -9,6 +9,7 @@ export interface AnnotationTrackLayout {
|
||||
name?: string
|
||||
color?: string
|
||||
unit?: string
|
||||
lineType?: WaveformLineType
|
||||
points: WaveformPoint[]
|
||||
}
|
||||
left?: number
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { zoomIdentity } from 'd3'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
|
||||
import type { DisplaySeries, DisplayTrack } from './types'
|
||||
import { buildYAxisSeriesGroups, MAX_MULTI_Y_AXIS_COUNT } from './layout'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
buildYAxisSeriesGroups,
|
||||
MAX_MULTI_Y_AXIS_COUNT,
|
||||
measureYAxisGroupClearance,
|
||||
} from './layout'
|
||||
|
||||
function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
color: '#1677ff',
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [
|
||||
{ x: 0, y: minimum },
|
||||
{ x: 1, y: maximum },
|
||||
@@ -21,11 +31,49 @@ function track(seriesList: DisplaySeries[]): DisplayTrack {
|
||||
return {
|
||||
id: 'track',
|
||||
series: seriesList,
|
||||
visibleSeries: seriesList,
|
||||
xDomain: [0, 1],
|
||||
yDomain: [0, 50],
|
||||
}
|
||||
}
|
||||
|
||||
function layoutForSeries(
|
||||
sourceSeries: DisplaySeries,
|
||||
rendering = DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
transform = zoomIdentity,
|
||||
) {
|
||||
const sourceTrack = track([sourceSeries])
|
||||
sourceTrack.xDomain = sourceSeries.xDomain
|
||||
sourceTrack.yDomain = sourceSeries.yDomain
|
||||
return buildTrackLayouts({
|
||||
cells: [
|
||||
{
|
||||
slotIndex: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 120,
|
||||
height: 100,
|
||||
plotHeight: 100,
|
||||
cellHeight: 130,
|
||||
xAxisBand: 30,
|
||||
series: sourceTrack,
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'single-axis',
|
||||
independentTransforms: [transform],
|
||||
sharedZoomDomain: sourceSeries.xDomain,
|
||||
timeUnit: 'ms',
|
||||
rendering,
|
||||
hideSecondaryLabels: false,
|
||||
yAxisLabelX: -50,
|
||||
showCompactEmptyTracks: false,
|
||||
})[0]!.seriesPaths[0]!
|
||||
}
|
||||
|
||||
describe('multi-value Y-axis grouping', () => {
|
||||
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
||||
const groups = buildYAxisSeriesGroups(
|
||||
@@ -95,4 +143,120 @@ describe('multi-value Y-axis grouping', () => {
|
||||
'right',
|
||||
])
|
||||
})
|
||||
|
||||
it('places left and right scientific exponents eight pixels outside their tick labels', () => {
|
||||
const layout = buildTrackLayouts({
|
||||
cells: [
|
||||
{
|
||||
slotIndex: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 600,
|
||||
height: 300,
|
||||
plotHeight: 300,
|
||||
cellHeight: 330,
|
||||
xAxisBand: 30,
|
||||
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'multi-axis',
|
||||
independentTransforms: [zoomIdentity],
|
||||
sharedZoomDomain: [0, 1],
|
||||
timeUnit: 'ms',
|
||||
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
hideSecondaryLabels: false,
|
||||
yAxisLabelX: -50,
|
||||
showCompactEmptyTracks: false,
|
||||
})[0]
|
||||
|
||||
expect(
|
||||
layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({
|
||||
side,
|
||||
offset: Math.abs(exponentX - x),
|
||||
exponentLabel,
|
||||
})),
|
||||
).toEqual([
|
||||
{ side: 'left', offset: 43, exponentLabel: 'E+02' },
|
||||
{ side: 'right', offset: 43, exponentLabel: 'E+02' },
|
||||
])
|
||||
})
|
||||
|
||||
it('retains enough outer clearance for long scientific exponents', () => {
|
||||
const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis')
|
||||
|
||||
expect(group).toBeDefined()
|
||||
expect(measureYAxisGroupClearance(group!)).toBe(119)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decoration sampling', () => {
|
||||
const denseSeries = (): DisplaySeries => ({
|
||||
...series('dense', -1, 1),
|
||||
pointType: 'circle',
|
||||
errorBar: { visible: true, width: 1.5, capWidth: 8 },
|
||||
points: Array.from({ length: 1_000 }, (_, index) => ({
|
||||
x: index,
|
||||
y: Math.sin(index / 20),
|
||||
error: index % 200 === 1 ? 0.1 : 0,
|
||||
})),
|
||||
xDomain: [0, 999],
|
||||
})
|
||||
|
||||
it('shares prioritized source points between dense symbols and error bars', () => {
|
||||
const sourceSeries = denseSeries()
|
||||
const path = layoutForSeries(sourceSeries, {
|
||||
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
pointMinSpacing: 10,
|
||||
errorBarMinSpacing: 12,
|
||||
})
|
||||
const sourceErrorPoints = sourceSeries.points.filter((point) => point.error !== 0)
|
||||
|
||||
expect(path.errorBarRenderPoints).toEqual(sourceErrorPoints)
|
||||
expect(path.errorBarRenderPoints.every((point) => path.pointRenderPoints.includes(point))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(path.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 12) + 2)
|
||||
})
|
||||
|
||||
it('keeps standalone, zero-error, and non-downsampled decoration behavior', () => {
|
||||
const noErrors = denseSeries()
|
||||
noErrors.points = noErrors.points.map((point) => ({ x: point.x, y: point.y }))
|
||||
const zeroErrorPath = layoutForSeries(noErrors)
|
||||
expect(zeroErrorPath.errorBarRenderPoints).toEqual([])
|
||||
expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
||||
|
||||
const errorsOnly = denseSeries()
|
||||
errorsOnly.pointType = 'none'
|
||||
const errorsOnlyPath = layoutForSeries(errorsOnly)
|
||||
expect(errorsOnlyPath.pointRenderPoints).toEqual([])
|
||||
expect(errorsOnlyPath.errorBarRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 12) + 2)
|
||||
|
||||
const pointsOnly = denseSeries()
|
||||
pointsOnly.errorBar.visible = false
|
||||
const pointsOnlyPath = layoutForSeries(pointsOnly)
|
||||
expect(pointsOnlyPath.errorBarRenderPoints).toEqual([])
|
||||
expect(pointsOnlyPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
||||
|
||||
const completePath = layoutForSeries(denseSeries(), {
|
||||
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
downsample: false,
|
||||
})
|
||||
expect(completePath.pointRenderPoints).toHaveLength(1_000)
|
||||
expect(completePath.errorBarRenderPoints).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('restores every visible source decoration after zooming to sparse spacing', () => {
|
||||
const path = layoutForSeries(
|
||||
denseSeries(),
|
||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
zoomIdentity.scale(200),
|
||||
)
|
||||
|
||||
expect(path.pointRenderPoints.map((point) => point.x)).toEqual([0, 1, 2, 3, 4])
|
||||
expect(path.errorBarRenderPoints.map((point) => point.x)).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { line, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
import {
|
||||
curveStep,
|
||||
curveStepAfter,
|
||||
curveStepBefore,
|
||||
line,
|
||||
scaleLinear,
|
||||
zoomIdentity,
|
||||
type ZoomTransform,
|
||||
} from 'd3'
|
||||
|
||||
import { selectRenderablePoints, type ResolvedWaveformRenderingOptions } from '../../core'
|
||||
import {
|
||||
selectDecorationPoints,
|
||||
selectRenderablePoints,
|
||||
resolveWaveformPointErrors,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from '../../core'
|
||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||
import {
|
||||
buildMinorTicks,
|
||||
@@ -23,7 +36,7 @@ const Y_AXIS_TICK_PADDING = 7
|
||||
const Y_AXIS_OUTER_PADDING = 4
|
||||
const Y_AXIS_LABEL_GAP = 6
|
||||
const Y_AXIS_LABEL_BAND_WIDTH = 24
|
||||
const Y_AXIS_EXPONENT_GAP = 4
|
||||
export const Y_AXIS_EXPONENT_GAP = 8
|
||||
|
||||
interface YAxisSeriesGroup {
|
||||
index: number
|
||||
@@ -57,8 +70,8 @@ export function buildYAxisSeriesGroups(
|
||||
if (cached) return cached
|
||||
const axisCount =
|
||||
overlayMode === 'multi-axis'
|
||||
? Math.min(track.series.length, MAX_MULTI_Y_AXIS_COUNT)
|
||||
: Math.min(track.series.length, 1)
|
||||
? Math.min(track.visibleSeries.length, MAX_MULTI_Y_AXIS_COUNT)
|
||||
: Math.min(track.visibleSeries.length, 1)
|
||||
const sides = resolveAxisSides(axisCount)
|
||||
const grouped = Array.from({ length: axisCount }, (_, index) => ({
|
||||
index,
|
||||
@@ -67,7 +80,7 @@ export function buildYAxisSeriesGroups(
|
||||
domain: [0, 1] as [number, number],
|
||||
}))
|
||||
|
||||
track.series.forEach((series, index) => {
|
||||
track.visibleSeries.forEach((series, index) => {
|
||||
grouped[Math.min(index, axisCount - 1)]?.seriesList.push(series)
|
||||
})
|
||||
grouped.forEach((group) => {
|
||||
@@ -136,7 +149,7 @@ export function measureTrackYAxisClearance(
|
||||
return buildYAxisSeriesGroups(track, overlayMode).reduce(
|
||||
(clearance, group) => {
|
||||
clearance[group.side] +=
|
||||
overlayMode === 'multi-axis' || track.series.length === 1
|
||||
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
||||
? measureYAxisGroupClearance(group)
|
||||
: measureYAxisGroupTickClearance(group)
|
||||
return clearance
|
||||
@@ -174,6 +187,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
id: `empty-grid-slot-${cell.slotIndex}`,
|
||||
name: '',
|
||||
color: 'transparent',
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [],
|
||||
xDomain: [0, 1],
|
||||
yDomain: [0, 1],
|
||||
@@ -181,10 +197,12 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const displayTrack: DisplayTrack = cell.series ?? {
|
||||
id: emptySeries.id,
|
||||
series: [emptySeries],
|
||||
visibleSeries: [emptySeries],
|
||||
xDomain: emptySeries.xDomain,
|
||||
yDomain: emptySeries.yDomain,
|
||||
}
|
||||
const series = displayTrack.series[0]
|
||||
const hasVisibleSeries = !isEmpty && displayTrack.visibleSeries.length > 0
|
||||
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
|
||||
const baseXScale =
|
||||
options.displayMode === 'independent'
|
||||
? scaleLinear(displayTrack.xDomain, [0, cell.width])
|
||||
@@ -220,8 +238,8 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const exponentX =
|
||||
x +
|
||||
(group.side === 'left'
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance)
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
const labelDistance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
@@ -261,24 +279,69 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const position = xScale(tick)
|
||||
return position > leftClearance && position < cell.width - rightClearance
|
||||
})
|
||||
const seriesPaths = displayTrack.series.map((trackSeries) => {
|
||||
const seriesPaths = displayTrack.visibleSeries.map((trackSeries) => {
|
||||
const yAxis = yAxes.find((axis) =>
|
||||
axis.seriesList.some((series) => series.id === trackSeries.id),
|
||||
)
|
||||
const seriesYScale = yAxis?.scale ?? yScale
|
||||
const renderPoints = selectRenderablePoints(
|
||||
const pathPoints = selectRenderablePoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering,
|
||||
)
|
||||
const hasError = (point: WaveformPoint) => {
|
||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
||||
return lower !== 0 || upper !== 0
|
||||
}
|
||||
const hasErrorPoints = trackSeries.errorBar.visible && trackSeries.points.some(hasError)
|
||||
const sharesDecorationPoints = trackSeries.pointType !== 'none' && hasErrorPoints
|
||||
const sharedDecorationPoints = sharesDecorationPoints
|
||||
? selectDecorationPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
Math.max(options.rendering.pointMinSpacing, options.rendering.errorBarMinSpacing),
|
||||
options.rendering.downsample,
|
||||
undefined,
|
||||
hasError,
|
||||
)
|
||||
: undefined
|
||||
const pointRenderPoints =
|
||||
trackSeries.pointType === 'none'
|
||||
? []
|
||||
: (sharedDecorationPoints ??
|
||||
selectDecorationPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering.pointMinSpacing,
|
||||
options.rendering.downsample,
|
||||
))
|
||||
const errorBarRenderPoints = trackSeries.errorBar.visible
|
||||
? (sharedDecorationPoints?.filter(hasError) ??
|
||||
selectDecorationPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering.errorBarMinSpacing,
|
||||
options.rendering.downsample,
|
||||
hasError,
|
||||
))
|
||||
: []
|
||||
const pathGenerator = line<WaveformPoint>()
|
||||
.x((point) => xScale(point.x))
|
||||
.y((point) => seriesYScale(point.y))
|
||||
if (trackSeries.lineType === 'step-start') pathGenerator.curve(curveStepBefore)
|
||||
if (trackSeries.lineType === 'step-middle') pathGenerator.curve(curveStep)
|
||||
if (trackSeries.lineType === 'step-end' || trackSeries.lineType === 'step-after') {
|
||||
pathGenerator.curve(curveStepAfter)
|
||||
}
|
||||
return {
|
||||
series: trackSeries,
|
||||
path: isEmpty
|
||||
? null
|
||||
: line<WaveformPoint>()
|
||||
.x((point) => xScale(point.x))
|
||||
.y((point) => seriesYScale(point.y))(renderPoints),
|
||||
path: isEmpty || trackSeries.lineType === 'none' ? null : pathGenerator(pathPoints),
|
||||
pointRenderPoints,
|
||||
errorBarRenderPoints,
|
||||
yScale: seriesYScale,
|
||||
yAxisIndex: yAxis?.index ?? 0,
|
||||
}
|
||||
@@ -287,8 +350,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
return {
|
||||
index,
|
||||
series,
|
||||
seriesList: displayTrack.series,
|
||||
seriesList: displayTrack.visibleSeries,
|
||||
legendSeries: displayTrack.series,
|
||||
isEmpty,
|
||||
hasVisibleSeries,
|
||||
column: cell.column,
|
||||
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
|
||||
yAxisLabelX: options.yAxisLabelX,
|
||||
@@ -310,10 +375,11 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
path: seriesPaths[0]?.path ?? null,
|
||||
seriesPaths,
|
||||
showXAxis:
|
||||
options.displayMode === 'independent' ||
|
||||
(options.displayMode === 'compact'
|
||||
? cell.row === options.grid.rowCount - 1
|
||||
: bottomCells.has(cell.slotIndex)),
|
||||
(isEmpty || hasVisibleSeries) &&
|
||||
(options.displayMode === 'independent' ||
|
||||
(options.displayMode === 'compact'
|
||||
? cell.row === options.grid.rowCount - 1
|
||||
: bottomCells.has(cell.slotIndex))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ScaleLinear } from 'd3'
|
||||
import type { WaveformPoint } from '../../types'
|
||||
import type {
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformLineType,
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
|
||||
/**
|
||||
* 显示系列
|
||||
@@ -10,6 +15,9 @@ export interface DisplaySeries {
|
||||
name: string
|
||||
unit?: string
|
||||
color: string
|
||||
lineType: WaveformLineType
|
||||
pointType: WaveformPointType
|
||||
errorBar: ResolvedWaveformErrorBarOptions
|
||||
points: WaveformPoint[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
@@ -17,7 +25,10 @@ export interface DisplaySeries {
|
||||
|
||||
export interface DisplayTrack {
|
||||
id: string
|
||||
/** Complete series list retained for legend rendering and visibility restoration. */
|
||||
series: DisplaySeries[]
|
||||
/** Series currently participating in layout, rendering, and interaction. */
|
||||
visibleSeries: DisplaySeries[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
}
|
||||
@@ -25,6 +36,8 @@ export interface DisplayTrack {
|
||||
export interface TrackSeriesPath {
|
||||
series: DisplaySeries
|
||||
path: string | null
|
||||
pointRenderPoints: WaveformPoint[]
|
||||
errorBarRenderPoints: WaveformPoint[]
|
||||
yScale: ScaleLinear<number, number>
|
||||
yAxisIndex: number
|
||||
}
|
||||
@@ -57,8 +70,12 @@ export interface HoveredSeriesPoint extends DisplaySeries {
|
||||
export interface TrackLayout {
|
||||
index: number
|
||||
series: DisplaySeries
|
||||
/** Visible series used by rendering and interaction code. */
|
||||
seriesList: DisplaySeries[]
|
||||
/** Complete series list used by the legend. */
|
||||
legendSeries: DisplaySeries[]
|
||||
isEmpty: boolean
|
||||
hasVisibleSeries: boolean
|
||||
column: number
|
||||
showYAxisLabel: boolean
|
||||
yAxisLabelX: number
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { normalizeWaveformSeries } from '../../core'
|
||||
import type { WaveformData, WaveformPoint } from '../../types'
|
||||
import { normalizeWaveformSeries, resolveWaveformPointErrors } from '../../core'
|
||||
import type {
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformData,
|
||||
WaveformLineType,
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
import { paddedDomain } from '../../utils'
|
||||
|
||||
export interface PreparedWaveformSeries {
|
||||
@@ -10,18 +16,30 @@ export interface PreparedWaveformSeries {
|
||||
name: string
|
||||
unit?: string
|
||||
color?: string
|
||||
lineType: WaveformLineType
|
||||
pointType: WaveformPointType
|
||||
errorBar: ResolvedWaveformErrorBarOptions
|
||||
points: WaveformPoint[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
}
|
||||
|
||||
function pointDomain(points: WaveformPoint[], key: 'x' | 'y'): [number, number] {
|
||||
function pointDomain(
|
||||
points: WaveformPoint[],
|
||||
key: 'x' | 'y',
|
||||
includeErrors = false,
|
||||
): [number, number] {
|
||||
let minimum = Number.POSITIVE_INFINITY
|
||||
let maximum = Number.NEGATIVE_INFINITY
|
||||
points.forEach((point) => {
|
||||
const value = point[key]
|
||||
if (value < minimum) minimum = value
|
||||
if (value > maximum) maximum = value
|
||||
if (key === 'y' && includeErrors) {
|
||||
const errors = resolveWaveformPointErrors(point)
|
||||
minimum = Math.min(minimum, point.y - errors.lower)
|
||||
maximum = Math.max(maximum, point.y + errors.upper)
|
||||
}
|
||||
})
|
||||
return paddedDomain(Number.isFinite(minimum) ? [minimum, maximum] : [])
|
||||
}
|
||||
@@ -30,7 +48,7 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
|
||||
return normalizeWaveformSeries(data).map((series) => ({
|
||||
...series,
|
||||
xDomain: pointDomain(series.points, 'x'),
|
||||
yDomain: pointDomain(series.points, 'y'),
|
||||
yDomain: pointDomain(series.points, 'y', series.errorBar.visible),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export type {
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
SingleWaveformData,
|
||||
WaveformLineType,
|
||||
WaveformPointType,
|
||||
WaveformErrorBarOptions,
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformSeries,
|
||||
WaveformData,
|
||||
NormalizedWaveformSeries,
|
||||
|
||||
@@ -18,6 +18,9 @@ export type {
|
||||
WaveformFrameStyle,
|
||||
WaveformPoint,
|
||||
WaveformSeries,
|
||||
WaveformLineType,
|
||||
WaveformPointType,
|
||||
WaveformErrorBarOptions,
|
||||
WaveformGridOptions,
|
||||
} from './data/types'
|
||||
|
||||
|
||||
85
src/components/interaction/WaveformTooltip.test.ts
Normal file
85
src/components/interaction/WaveformTooltip.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import WaveformTooltip from './WaveformTooltip.vue'
|
||||
|
||||
describe('WaveformTooltip', () => {
|
||||
const point = { x: 1, y: 12 }
|
||||
|
||||
function mountTooltip(positionX: number, containerWidth = 400) {
|
||||
return mount(WaveformTooltip, {
|
||||
props: {
|
||||
visible: true,
|
||||
position: { x: positionX, y: 100 },
|
||||
timeUnit: 's',
|
||||
hoveredPoint: point,
|
||||
seriesPoints: [{ trackIndex: 0, name: 'Temperature', color: '#f00', point }],
|
||||
containerWidth,
|
||||
containerHeight: 300,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('positions the tooltip to the right when enough space remains', () => {
|
||||
const tooltip = mountTooltip(100).get('.waveform-tooltip')
|
||||
|
||||
expect(tooltip.attributes('style')).toContain('left: 112px')
|
||||
expect(tooltip.attributes('style')).not.toContain('right:')
|
||||
})
|
||||
|
||||
it('flips the tooltip to the left near the right boundary', () => {
|
||||
const tooltip = mountTooltip(370).get('.waveform-tooltip')
|
||||
|
||||
expect(tooltip.attributes('style')).toContain('right: 42px')
|
||||
expect(tooltip.attributes('style')).not.toContain('left:')
|
||||
})
|
||||
|
||||
it('keeps the tooltip inside the left boundary when neither side has enough space', () => {
|
||||
const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
|
||||
|
||||
expect(tooltip.attributes('style')).toContain('left: 8px')
|
||||
expect(tooltip.attributes('style')).not.toContain('right:')
|
||||
})
|
||||
|
||||
it('shows resolved asymmetric errors beside the hovered value', () => {
|
||||
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
props: {
|
||||
visible: true,
|
||||
position: { x: 10, y: 10 },
|
||||
timeUnit: 's',
|
||||
hoveredPoint: pointWithErrors,
|
||||
seriesPoints: [
|
||||
{
|
||||
trackIndex: 0,
|
||||
name: '温度',
|
||||
color: '#f00',
|
||||
unit: 'C',
|
||||
point: pointWithErrors,
|
||||
},
|
||||
],
|
||||
containerWidth: 400,
|
||||
containerHeight: 300,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('.waveform-tooltip__series small').text()).toBe('(+2 / -1)')
|
||||
})
|
||||
|
||||
it('omits the error label when both resolved errors are zero', () => {
|
||||
const point = { x: 1, y: 12 }
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
props: {
|
||||
visible: true,
|
||||
position: { x: 10, y: 10 },
|
||||
timeUnit: 's',
|
||||
hoveredPoint: point,
|
||||
seriesPoints: [{ trackIndex: 0, name: '温度', color: '#f00', point }],
|
||||
containerWidth: 400,
|
||||
containerHeight: 300,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.waveform-tooltip__series small').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { resolveWaveformPointErrors } from '../../core'
|
||||
import { formatTooltipNumber, formatTooltipTime } from '../../utils'
|
||||
import type { WaveformPoint } from '../data/types'
|
||||
|
||||
@@ -30,15 +31,34 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const tooltipGap = 12
|
||||
const containerPadding = 8
|
||||
const tooltipMaxWidth = 238
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
if (!props.visible || !props.hoveredPoint) return { display: 'none' }
|
||||
|
||||
const estimatedHeight = 44 + props.seriesPoints.length * 22
|
||||
const rightPlacement = props.position.x + tooltipGap
|
||||
const leftPlacement = props.position.x - tooltipGap - tooltipMaxWidth
|
||||
const horizontalStyle =
|
||||
rightPlacement + tooltipMaxWidth <= props.containerWidth - containerPadding
|
||||
? { left: `${rightPlacement}px` }
|
||||
: leftPlacement >= containerPadding
|
||||
? { right: `${props.containerWidth - props.position.x + tooltipGap}px` }
|
||||
: { left: `${containerPadding}px` }
|
||||
|
||||
return {
|
||||
left: `${Math.min(props.position.x + 12, Math.max(8, props.containerWidth - 250))}px`,
|
||||
...horizontalStyle,
|
||||
top: `${Math.max(8, Math.min(props.position.y - 18, props.containerHeight - estimatedHeight - 8))}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function formatError(point: WaveformPoint): string | null {
|
||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
||||
if (lower === 0 && upper === 0) return null
|
||||
return `(+${formatTooltipNumber(upper)} / -${formatTooltipNumber(lower)})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -60,6 +80,7 @@ const tooltipStyle = computed(() => {
|
||||
<span>
|
||||
{{ formatTooltipNumber(seriesPoint.point.y)
|
||||
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
|
||||
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -67,6 +88,7 @@ const tooltipStyle = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.waveform-tooltip {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
@@ -111,4 +133,9 @@ const tooltipStyle = computed(() => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.waveform-tooltip__series small {
|
||||
color: #667085;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { WaveformLegendPosition } from '../../types'
|
||||
import type { DisplaySeries } from '../core/types'
|
||||
import {
|
||||
waveformLegendErrorBarPath,
|
||||
waveformLegendLinePath,
|
||||
waveformPointSymbolPath,
|
||||
} from './seriesStyle'
|
||||
|
||||
interface Props {
|
||||
series: DisplaySeries[]
|
||||
@@ -9,9 +16,26 @@ interface Props {
|
||||
backgroundColor: string
|
||||
width: number
|
||||
height: number
|
||||
interactive?: boolean
|
||||
hiddenSeriesIds?: string[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
interactive: false,
|
||||
hiddenSeriesIds: () => [],
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
toggle: [seriesId: string]
|
||||
}>()
|
||||
const hiddenSeriesIdSet = computed(() => new Set(props.hiddenSeriesIds))
|
||||
|
||||
function isHidden(seriesId: string): boolean {
|
||||
return hiddenSeriesIdSet.value.has(seriesId)
|
||||
}
|
||||
|
||||
function toggleSeries(seriesId: string) {
|
||||
if (props.interactive) emit('toggle', seriesId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,19 +56,62 @@ defineProps<Props>()
|
||||
>
|
||||
<div
|
||||
class="waveform-legend__panel"
|
||||
:class="`waveform-legend__panel--${orientation}`"
|
||||
:class="[
|
||||
`waveform-legend__panel--${orientation}`,
|
||||
{ 'waveform-legend__panel--interactive': interactive },
|
||||
]"
|
||||
:style="{ backgroundColor }"
|
||||
role="list"
|
||||
>
|
||||
<div
|
||||
<button
|
||||
v-for="item in series"
|
||||
:key="item.id"
|
||||
class="waveform-legend__item waveform-chart__legend-item"
|
||||
:class="{ 'is-hidden': isHidden(item.id) }"
|
||||
type="button"
|
||||
role="listitem"
|
||||
:disabled="!interactive"
|
||||
:aria-pressed="interactive ? !isHidden(item.id) : undefined"
|
||||
:aria-label="
|
||||
interactive ? `${isHidden(item.id) ? '显示' : '隐藏'}曲线 ${item.name}` : undefined
|
||||
"
|
||||
@click.stop="toggleSeries(item.id)"
|
||||
>
|
||||
<i class="waveform-legend__swatch" :style="{ backgroundColor: item.color }" />
|
||||
<svg
|
||||
class="waveform-legend__swatch"
|
||||
viewBox="0 0 26 16"
|
||||
aria-hidden="true"
|
||||
:data-line-type="item.lineType"
|
||||
:data-point-type="item.pointType"
|
||||
:data-error-bar-visible="item.errorBar.visible || undefined"
|
||||
>
|
||||
<path
|
||||
v-if="waveformLegendLinePath(item.lineType)"
|
||||
class="waveform-legend__line"
|
||||
:d="waveformLegendLinePath(item.lineType) ?? undefined"
|
||||
:stroke="item.color"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
v-if="item.errorBar.visible"
|
||||
class="waveform-legend__error-bar"
|
||||
:d="waveformLegendErrorBarPath(item.errorBar.capWidth)"
|
||||
:stroke="item.errorBar.color || item.color"
|
||||
:stroke-width="item.errorBar.width"
|
||||
stroke-linecap="butt"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
v-if="item.pointType !== 'none'"
|
||||
class="waveform-legend__point"
|
||||
:d="waveformPointSymbolPath(item.pointType, 30) ?? undefined"
|
||||
:fill="item.color"
|
||||
transform="translate(13 8)"
|
||||
/>
|
||||
</svg>
|
||||
<span class="waveform-legend__label" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
@@ -119,6 +186,10 @@ defineProps<Props>()
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.waveform-legend__panel--interactive {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.waveform-legend__panel--horizontal {
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
@@ -135,13 +206,44 @@ defineProps<Props>()
|
||||
max-width: 160px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.waveform-legend__item:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.waveform-legend__panel--interactive .waveform-legend__item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.waveform-legend__panel--interactive .waveform-legend__item:focus-visible {
|
||||
outline: 2px solid #1677ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.waveform-legend__item.is-hidden {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.waveform-legend__item.is-hidden .waveform-legend__label {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.waveform-legend__swatch {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
flex: 0 0 26px;
|
||||
width: 26px;
|
||||
height: 16px;
|
||||
overflow: visible;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.waveform-legend__label {
|
||||
|
||||
117
src/components/rendering/WaveformSeriesLayer.vue
Normal file
117
src/components/rendering/WaveformSeriesLayer.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { resolveWaveformPointErrors } from '../../core'
|
||||
import type { TrackLayout, TrackSeriesPath } from '../core/types'
|
||||
import { waveformPointSeriesPath } from './seriesStyle'
|
||||
|
||||
const props = defineProps<{
|
||||
track: TrackLayout
|
||||
clipPathId: string
|
||||
}>()
|
||||
|
||||
interface RenderedSeriesPath extends TrackSeriesPath {
|
||||
pointPath: string | null
|
||||
errorBarPath: string | null
|
||||
}
|
||||
|
||||
const renderedSeriesPaths = computed<RenderedSeriesPath[]>(() =>
|
||||
props.track.seriesPaths.map((seriesPath) => {
|
||||
const pointPath = waveformPointSeriesPath(
|
||||
seriesPath.series.pointType,
|
||||
seriesPath.pointRenderPoints.map((point) => ({
|
||||
x: props.track.xScale(point.x),
|
||||
y: seriesPath.yScale(point.y),
|
||||
})),
|
||||
)
|
||||
const capHalfWidth = seriesPath.series.errorBar.capWidth / 2
|
||||
const errorBarPath = seriesPath.errorBarRenderPoints
|
||||
.map((point) => {
|
||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
||||
const x = props.track.xScale(point.x)
|
||||
const lowerY = seriesPath.yScale(point.y - lower)
|
||||
const upperY = seriesPath.yScale(point.y + upper)
|
||||
return [
|
||||
`M${x - capHalfWidth},${lowerY}H${x + capHalfWidth}`,
|
||||
`M${x},${lowerY}V${upperY}`,
|
||||
`M${x - capHalfWidth},${upperY}H${x + capHalfWidth}`,
|
||||
].join('')
|
||||
})
|
||||
.join('')
|
||||
return { ...seriesPath, pointPath, errorBarPath: errorBarPath || null }
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<g
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries"
|
||||
class="waveform-track__series"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
>
|
||||
<g
|
||||
v-for="seriesPath in renderedSeriesPaths"
|
||||
:key="seriesPath.series.id"
|
||||
class="waveform-track__series-item waveform-chart__series"
|
||||
:data-series-id="seriesPath.series.id"
|
||||
:data-series-name="seriesPath.series.name || undefined"
|
||||
>
|
||||
<path
|
||||
v-if="seriesPath.path"
|
||||
class="waveform-track__line waveform-chart__line"
|
||||
:data-series-id="seriesPath.series.id"
|
||||
:data-series-name="seriesPath.series.name || undefined"
|
||||
:data-y-axis-index="seriesPath.yAxisIndex"
|
||||
:data-line-type="seriesPath.series.lineType"
|
||||
:d="seriesPath.path"
|
||||
:stroke="seriesPath.series.color"
|
||||
/>
|
||||
|
||||
<g
|
||||
v-if="seriesPath.series.errorBar.visible"
|
||||
class="waveform-track__error-bars waveform-chart__error-bars"
|
||||
:data-series-id="seriesPath.series.id"
|
||||
>
|
||||
<path
|
||||
v-if="seriesPath.errorBarPath"
|
||||
class="waveform-track__error-bar waveform-chart__error-bar"
|
||||
:d="seriesPath.errorBarPath"
|
||||
:stroke="seriesPath.series.errorBar.color || seriesPath.series.color"
|
||||
:stroke-width="seriesPath.series.errorBar.width"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
v-if="seriesPath.series.pointType !== 'none'"
|
||||
class="waveform-track__points waveform-chart__points"
|
||||
:data-series-id="seriesPath.series.id"
|
||||
:data-point-type="seriesPath.series.pointType"
|
||||
>
|
||||
<path
|
||||
v-if="seriesPath.pointPath"
|
||||
class="waveform-track__point waveform-chart__point"
|
||||
:d="seriesPath.pointPath"
|
||||
:fill="seriesPath.series.color"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waveform-track__line {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.waveform-track__error-bar {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.waveform-track__point,
|
||||
.waveform-track__error-bar {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
WaveformYAxisLayout,
|
||||
} from '../core/types'
|
||||
import WaveformLegend from './WaveformLegend.vue'
|
||||
import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
|
||||
|
||||
interface Props {
|
||||
/** 轨道布局信息 */
|
||||
@@ -47,6 +48,10 @@ interface Props {
|
||||
legendOrientation?: 'horizontal' | 'vertical'
|
||||
/** 多曲线图例背景颜色 */
|
||||
legendBackgroundColor?: string
|
||||
/** 图例是否允许切换曲线显隐 */
|
||||
legendInteractive?: boolean
|
||||
/** 当前隐藏的系列 ID */
|
||||
hiddenSeriesIds?: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -54,6 +59,7 @@ interface Emits {
|
||||
(e: 'pointer-leave'): void
|
||||
(e: 'click', event: MouseEvent): void
|
||||
(e: 'contextmenu', event: MouseEvent): void
|
||||
(e: 'series-visibility-toggle', seriesId: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -61,6 +67,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
legendPosition: 'top-right',
|
||||
legendOrientation: 'vertical',
|
||||
legendBackgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
legendInteractive: false,
|
||||
hiddenSeriesIds: () => [],
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -92,14 +100,6 @@ function setYAxisElement(element: unknown, index: number) {
|
||||
if (element) yAxisElements.value[index] = element as SVGGElement
|
||||
}
|
||||
|
||||
function resolveHoveredYScale() {
|
||||
const seriesId = props.hoveredPoint?.id
|
||||
return (
|
||||
props.track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
|
||||
props.track.yScale
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该显示 Y 轴标签
|
||||
* 在紧凑模式下,当轨道高度太小时隐藏标签避免重叠
|
||||
@@ -121,12 +121,6 @@ function crosshairX(): number {
|
||||
: 0
|
||||
}
|
||||
|
||||
function crosshairY(): number {
|
||||
return props.hoveredPoint && props.hoveredPoint.trackIndex === props.track.index
|
||||
? resolveHoveredYScale()(props.hoveredPoint.point.y)
|
||||
: 0
|
||||
}
|
||||
|
||||
function hasCrosshair(): boolean {
|
||||
return (
|
||||
props.showTooltip &&
|
||||
@@ -213,7 +207,11 @@ watch(
|
||||
/>
|
||||
|
||||
<!-- 网格和背景 -->
|
||||
<g v-if="!track.isEmpty" :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true">
|
||||
<g
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries"
|
||||
: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"
|
||||
>
|
||||
@@ -258,7 +256,7 @@ watch(
|
||||
|
||||
<!-- 帧编号水印 -->
|
||||
<text
|
||||
v-if="!track.isEmpty && frameNumber !== undefined"
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined"
|
||||
class="waveform-track__watermark waveform-chart__watermark"
|
||||
:x="(track.width ?? innerWidth) / 2"
|
||||
:y="track.height / 2"
|
||||
@@ -344,6 +342,7 @@ watch(
|
||||
<g
|
||||
v-if="
|
||||
!track.isEmpty &&
|
||||
track.hasVisibleSeries &&
|
||||
track.seriesList.length === 1 &&
|
||||
track.showYAxisLabel &&
|
||||
resolveYAxisLabel(track.series) &&
|
||||
@@ -407,45 +406,21 @@ watch(
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- 波形线 -->
|
||||
<g v-if="!track.isEmpty" class="waveform-track__lines">
|
||||
<path
|
||||
v-for="seriesPath in track.seriesPaths"
|
||||
:key="seriesPath.series.id"
|
||||
class="waveform-track__line waveform-chart__line"
|
||||
:data-series-id="seriesPath.series.id"
|
||||
:data-series-name="seriesPath.series.name || undefined"
|
||||
:data-y-axis-index="seriesPath.yAxisIndex"
|
||||
:d="seriesPath.path ?? undefined"
|
||||
:stroke="seriesPath.series.color"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<WaveformLegend
|
||||
v-if="!track.isEmpty && track.seriesList.length > 1"
|
||||
:series="track.seriesList"
|
||||
:position="legendPosition"
|
||||
:orientation="legendOrientation"
|
||||
:background-color="legendBackgroundColor"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
/>
|
||||
<!-- 波形系列隔离在静态子组件中,避免 hover 更新遍历大量 SVG 节点。 -->
|
||||
<WaveformSeriesLayer :track="track" :clip-path-id="clipPathId" />
|
||||
|
||||
<!-- 十字线 -->
|
||||
<g
|
||||
v-if="!track.isEmpty && hasCrosshair()"
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && hasCrosshair()"
|
||||
class="waveform-track__crosshair waveform-chart__crosshair"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
>
|
||||
<line :x1="crosshairX()" :x2="crosshairX()" y1="0" :y2="track.height" />
|
||||
<line x1="0" :x2="track.width ?? innerWidth" :y1="crosshairY()" :y2="crosshairY()" />
|
||||
<circle :cx="crosshairX()" :cy="crosshairY()" r="4" :fill="track.series.color" />
|
||||
</g>
|
||||
|
||||
<!-- 交互覆盖层(仅在独立模式下) -->
|
||||
<rect
|
||||
v-if="!track.isEmpty && displayMode === 'independent'"
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
|
||||
class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent"
|
||||
:class="{
|
||||
'is-zoomable': zoomable && interactionMode === 'zoom',
|
||||
@@ -459,19 +434,37 @@ watch(
|
||||
@click="emit('click', $event)"
|
||||
@contextmenu="emit('contextmenu', $event)"
|
||||
/>
|
||||
|
||||
<text
|
||||
v-if="!track.isEmpty && !track.hasVisibleSeries"
|
||||
class="waveform-track__no-visible-series"
|
||||
:x="(track.width ?? innerWidth) / 2"
|
||||
:y="track.height / 2"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>
|
||||
暂无可见曲线
|
||||
</text>
|
||||
|
||||
<WaveformLegend
|
||||
v-if="!track.isEmpty && track.legendSeries.length > 1"
|
||||
:series="track.legendSeries"
|
||||
:position="legendPosition"
|
||||
:orientation="legendOrientation"
|
||||
:background-color="legendBackgroundColor"
|
||||
:interactive="legendInteractive"
|
||||
:hidden-series-ids="hiddenSeriesIds"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
@toggle="emit('series-visibility-toggle', $event)"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waveform-track {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.waveform-track__line {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-track__y-axis-label-bg {
|
||||
@@ -517,9 +510,16 @@ watch(
|
||||
.waveform-track__overlay {
|
||||
fill: transparent;
|
||||
cursor: crosshair;
|
||||
pointer-events: all;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.waveform-track__no-visible-series {
|
||||
fill: #8c8c8c;
|
||||
font: 13px sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-track__overlay.is-zoomable {
|
||||
cursor: grab;
|
||||
}
|
||||
@@ -542,11 +542,6 @@ watch(
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.waveform-track__crosshair circle {
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.waveform-track__axis-endpoint {
|
||||
fill: #667085;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as WaveformTrack } from './WaveformTrack.vue'
|
||||
export { default as WaveformLegend } from './WaveformLegend.vue'
|
||||
export { waveformPointSymbolPath } from './seriesStyle'
|
||||
|
||||
98
src/components/rendering/seriesStyle.ts
Normal file
98
src/components/rendering/seriesStyle.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
symbol,
|
||||
symbolCircle,
|
||||
symbolDiamond,
|
||||
symbolSquare,
|
||||
symbolTriangle,
|
||||
type SymbolType,
|
||||
} from 'd3'
|
||||
|
||||
import type { WaveformLineType, WaveformPointType } from '../../types'
|
||||
|
||||
const LEGEND_SWATCH_CENTER_X = 13
|
||||
const LEGEND_ERROR_BAR_TOP = 2
|
||||
const LEGEND_ERROR_BAR_BOTTOM = 14
|
||||
const LEGEND_ERROR_BAR_DEFAULT_CAP_WIDTH = 8
|
||||
const LEGEND_ERROR_BAR_MAX_CAP_WIDTH = 24
|
||||
|
||||
const pointSymbols: Record<Exclude<WaveformPointType, 'none'>, SymbolType> = {
|
||||
circle: symbolCircle,
|
||||
square: symbolSquare,
|
||||
triangle: symbolTriangle,
|
||||
diamond: symbolDiamond,
|
||||
}
|
||||
|
||||
export function waveformPointSymbolPath(pointType: WaveformPointType, size = 48): string | null {
|
||||
if (pointType === 'none') return null
|
||||
return symbol().type(pointSymbols[pointType]).size(size)() ?? null
|
||||
}
|
||||
|
||||
export function waveformPointSeriesPath(
|
||||
pointType: WaveformPointType,
|
||||
points: ReadonlyArray<{ x: number; y: number }>,
|
||||
size = 48,
|
||||
): string | null {
|
||||
if (pointType === 'none' || points.length === 0) return null
|
||||
|
||||
if (pointType === 'circle') {
|
||||
const radius = Math.sqrt(size / Math.PI)
|
||||
return points
|
||||
.map(
|
||||
({ x, y }) =>
|
||||
`M${x + radius},${y}A${radius},${radius},0,1,1,${x - radius},${y}` +
|
||||
`A${radius},${radius},0,1,1,${x + radius},${y}`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
if (pointType === 'square') {
|
||||
const side = Math.sqrt(size)
|
||||
const halfSide = side / 2
|
||||
return points
|
||||
.map(({ x, y }) => `M${x - halfSide},${y - halfSide}h${side}v${side}h${-side}Z`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
if (pointType === 'triangle') {
|
||||
const topOffset = Math.sqrt(size / ((Math.sqrt(3) * 3) / 4))
|
||||
const halfWidth = (topOffset * Math.sqrt(3)) / 2
|
||||
const bottomOffset = topOffset / 2
|
||||
return points
|
||||
.map(
|
||||
({ x, y }) =>
|
||||
`M${x},${y - topOffset}L${x + halfWidth},${y + bottomOffset}` +
|
||||
`L${x - halfWidth},${y + bottomOffset}Z`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const verticalOffset = Math.sqrt(size / (2 * Math.tan(Math.PI / 6)))
|
||||
const horizontalOffset = verticalOffset * Math.tan(Math.PI / 6)
|
||||
return points
|
||||
.map(
|
||||
({ x, y }) =>
|
||||
`M${x},${y - verticalOffset}L${x + horizontalOffset},${y}` +
|
||||
`L${x},${y + verticalOffset}L${x - horizontalOffset},${y}Z`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function waveformLegendLinePath(lineType: WaveformLineType): string | null {
|
||||
if (lineType === 'none') return null
|
||||
return 'M1 8H25'
|
||||
}
|
||||
|
||||
export function waveformLegendErrorBarPath(capWidth: number): string {
|
||||
const resolvedCapWidth =
|
||||
Number.isFinite(capWidth) && capWidth > 0
|
||||
? Math.min(capWidth, LEGEND_ERROR_BAR_MAX_CAP_WIDTH)
|
||||
: LEGEND_ERROR_BAR_DEFAULT_CAP_WIDTH
|
||||
const capHalfWidth = resolvedCapWidth / 2
|
||||
const capStart = LEGEND_SWATCH_CENTER_X - capHalfWidth
|
||||
const capEnd = LEGEND_SWATCH_CENTER_X + capHalfWidth
|
||||
return [
|
||||
`M${capStart} ${LEGEND_ERROR_BAR_TOP}H${capEnd}`,
|
||||
`M${LEGEND_SWATCH_CENTER_X} ${LEGEND_ERROR_BAR_TOP}V${LEGEND_ERROR_BAR_BOTTOM}`,
|
||||
`M${capStart} ${LEGEND_ERROR_BAR_BOTTOM}H${capEnd}`,
|
||||
].join('')
|
||||
}
|
||||
Reference in New Issue
Block a user