This commit is contained in:
@@ -4,7 +4,11 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { flushAnimationFrames, pendingAnimationFrameCount, resizeObservers } from '../test/setup'
|
||||
import WaveformChart from './WaveformChart.vue'
|
||||
import { prepareWaveformSeries } from './core/useWaveformData'
|
||||
import { waveformLegendErrorBarPath, waveformLegendLinePath } from './rendering/seriesStyle'
|
||||
import {
|
||||
waveformLegendErrorBarPath,
|
||||
waveformLegendLinePath,
|
||||
waveformLineDasharray,
|
||||
} from './rendering/seriesStyle'
|
||||
import { normalizeWaveformData, normalizeWaveformSeries, type WaveformData } from './waveform'
|
||||
|
||||
async function mountSizedChart(data: WaveformData, extraProps = {}) {
|
||||
@@ -52,6 +56,29 @@ describe('normalizeWaveformData', () => {
|
||||
expect(normalizeWaveformData({ kind: 'samples', values: [1, 2], sampleRate: 0 })).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps large-data error extrema in the prepared Y domain', () => {
|
||||
const points = Array.from({ length: 10_001 }, (_, index) => ({
|
||||
x: index,
|
||||
y: 0,
|
||||
...(index === 5_555 ? { error: 10_000 } : {}),
|
||||
}))
|
||||
const [series] = prepareWaveformSeries({
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
name: 'errors',
|
||||
errorBar: { visible: true },
|
||||
data: { kind: 'points', points },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(series?.points).toHaveLength(points.length)
|
||||
expect(series?.hasErrorPoints).toBe(true)
|
||||
expect(series?.yDomain[0]).toBeLessThanOrEqual(-10_000)
|
||||
expect(series?.yDomain[1]).toBeGreaterThanOrEqual(10_000)
|
||||
})
|
||||
|
||||
it('normalizes errors and preserves a pure error-bar series', () => {
|
||||
const [series] = normalizeWaveformSeries({
|
||||
kind: 'series',
|
||||
@@ -152,6 +179,7 @@ describe('normalizeWaveformData', () => {
|
||||
unit: 'T',
|
||||
color: undefined,
|
||||
lineType: 'linear',
|
||||
lineStyle: 'solid',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [{ x: 1, y: 2 }],
|
||||
@@ -170,6 +198,9 @@ describe('legend series geometry', () => {
|
||||
expect(waveformLegendLinePath('none')).toBeNull()
|
||||
expect(waveformLegendErrorBarPath(10)).toBe('M8 2H18M13 2V14M8 14H18')
|
||||
expect(waveformLegendErrorBarPath(100)).toBe('M1 2H25M13 2V14M1 14H25')
|
||||
expect(waveformLineDasharray('solid')).toBeUndefined()
|
||||
expect(waveformLineDasharray('dashed')).toBe('8 5')
|
||||
expect(waveformLineDasharray('dash-dot')).toBe('8 5 1.5 5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -221,6 +252,26 @@ describe('WaveformChart', () => {
|
||||
second.unmount()
|
||||
})
|
||||
|
||||
it('does not reuse Y scales between chart instances with the same default series ID', async () => {
|
||||
const first = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
})
|
||||
const second = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 10_000 },
|
||||
{ x: 1, y: 20_000 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04')
|
||||
})
|
||||
|
||||
it('renders a configurable zero line only when the Y domain contains zero', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
@@ -478,6 +529,7 @@ describe('WaveformChart', () => {
|
||||
trackId: 'styled-track',
|
||||
name: '纯线',
|
||||
lineType: 'linear',
|
||||
lineStyle: 'dashed',
|
||||
pointType: 'none',
|
||||
data: {
|
||||
kind: 'points',
|
||||
@@ -492,6 +544,7 @@ describe('WaveformChart', () => {
|
||||
trackId: 'styled-track',
|
||||
name: '阶梯误差',
|
||||
lineType: 'step-after',
|
||||
lineStyle: 'dash-dot',
|
||||
pointType: 'circle',
|
||||
errorBar: { visible: true, color: '#222222', width: 2, capWidth: 10 },
|
||||
data: {
|
||||
@@ -525,6 +578,13 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
const stepLine = wrapper.get('.waveform-chart__line[data-series-id="step-errors"]')
|
||||
expect(stepLine.attributes('data-line-type')).toBe('step-after')
|
||||
expect(stepLine.attributes('data-line-style')).toBe('dash-dot')
|
||||
expect(stepLine.attributes('stroke-dasharray')).toBe('8 5 1.5 5')
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__line[data-series-id="line-only"]')
|
||||
.attributes('stroke-dasharray'),
|
||||
).toBe('8 5')
|
||||
expect(stepLine.attributes('d')).toMatch(/^M[\d.-]+,([\d.-]+)L[\d.-]+,\1L/)
|
||||
expect(
|
||||
wrapper
|
||||
@@ -556,6 +616,16 @@ describe('WaveformChart', () => {
|
||||
'none',
|
||||
])
|
||||
expect(swatches[0]?.attributes('data-error-bar-visible')).toBe('true')
|
||||
expect(swatches.map((swatch) => swatch.attributes('data-line-style'))).toEqual([
|
||||
'solid',
|
||||
'dashed',
|
||||
'dash-dot',
|
||||
'solid',
|
||||
])
|
||||
expect(swatches[1]?.get('.waveform-legend__line').attributes('stroke-dasharray')).toBe('8 5')
|
||||
expect(swatches[2]?.get('.waveform-legend__line').attributes('stroke-dasharray')).toBe(
|
||||
'8 5 1.5 5',
|
||||
)
|
||||
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([
|
||||
@@ -2054,6 +2124,38 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ grid: { rowCount: 1, columnCount: 1 }, showTooltip: false },
|
||||
)
|
||||
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 }),
|
||||
})
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ showTooltip: true })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces pointer moves per frame and cancels pending hover work', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
@@ -2517,6 +2619,139 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('enables space-drag panning only when pannable is true and the pointer is inside', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'points',
|
||||
points: Array.from({ length: 5 }, (_, index) => ({ x: index, y: index })),
|
||||
}
|
||||
const disabled = await mountSizedChart(data)
|
||||
const disabledOverlay = disabled.get('.waveform-chart__overlay--independent')
|
||||
const disabledWidth = Number(disabledOverlay.attributes('width'))
|
||||
const disabledHeight = Number(disabledOverlay.attributes('height'))
|
||||
Object.defineProperty(disabledOverlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: disabledWidth, height: disabledHeight }),
|
||||
})
|
||||
await disabled.trigger('pointerenter')
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
|
||||
const disabledDown = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: disabledWidth * 0.25,
|
||||
clientY: disabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(disabledDown, 'pointerId', { value: 31 })
|
||||
disabledOverlay.element.dispatchEvent(disabledDown)
|
||||
const disabledMove = new MouseEvent('pointermove', {
|
||||
clientX: disabledWidth * 0.75,
|
||||
clientY: disabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(disabledMove, 'pointerId', { value: 31 })
|
||||
disabledOverlay.element.dispatchEvent(disabledMove)
|
||||
await flushPromises()
|
||||
expect(disabled.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||
|
||||
const enabled = await mountSizedChart(data, { pannable: true })
|
||||
const enabledOverlay = enabled.get('.waveform-chart__overlay--independent')
|
||||
const enabledWidth = Number(enabledOverlay.attributes('width'))
|
||||
const enabledHeight = Number(enabledOverlay.attributes('height'))
|
||||
Object.defineProperty(enabledOverlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: enabledWidth, height: enabledHeight }),
|
||||
})
|
||||
const boxDown = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: enabledWidth * 0.25,
|
||||
clientY: enabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(boxDown, 'pointerId', { value: 30 })
|
||||
enabledOverlay.element.dispatchEvent(boxDown)
|
||||
const boxMove = new MouseEvent('pointermove', {
|
||||
clientX: enabledWidth * 0.75,
|
||||
clientY: enabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(boxMove, 'pointerId', { value: 30 })
|
||||
enabledOverlay.element.dispatchEvent(boxMove)
|
||||
const boxUp = new MouseEvent('pointerup', {
|
||||
clientX: enabledWidth * 0.75,
|
||||
clientY: enabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(boxUp, 'pointerId', { value: 30 })
|
||||
enabledOverlay.element.dispatchEvent(boxUp)
|
||||
await flushPromises()
|
||||
const startBeforePan = enabled.get('.waveform-chart__axis-endpoint--start').text()
|
||||
|
||||
await enabled.trigger('pointerenter')
|
||||
const spaceDown = new KeyboardEvent('keydown', { code: 'Space', cancelable: true })
|
||||
window.dispatchEvent(spaceDown)
|
||||
const enabledDown = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: enabledWidth / 2,
|
||||
clientY: enabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(enabledDown, 'pointerId', { value: 32 })
|
||||
enabledOverlay.element.dispatchEvent(enabledDown)
|
||||
const enabledMove = new MouseEvent('pointermove', {
|
||||
clientX: enabledWidth / 2 + 20,
|
||||
clientY: enabledHeight / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(enabledMove, 'pointerId', { value: 32 })
|
||||
enabledOverlay.element.dispatchEvent(enabledMove)
|
||||
await flushPromises()
|
||||
|
||||
expect(spaceDown.defaultPrevented).toBe(true)
|
||||
expect(enabled.classes()).toContain('waveform-chart--panning')
|
||||
expect(enabled.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||
expect(enabled.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(startBeforePan)
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
|
||||
})
|
||||
|
||||
it('does not activate pannable on a chart that the pointer is outside', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
}
|
||||
const active = await mountSizedChart(data, { pannable: true })
|
||||
const inactive = await mountSizedChart(data, { pannable: true })
|
||||
await active.trigger('pointerenter')
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
|
||||
|
||||
const inactiveOverlay = inactive.get('.waveform-chart__overlay--independent')
|
||||
const width = Number(inactiveOverlay.attributes('width'))
|
||||
const height = Number(inactiveOverlay.attributes('height'))
|
||||
Object.defineProperty(inactiveOverlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width, height }),
|
||||
})
|
||||
const down = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: width * 0.25,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(down, 'pointerId', { value: 33 })
|
||||
inactiveOverlay.element.dispatchEvent(down)
|
||||
const move = new MouseEvent('pointermove', {
|
||||
clientX: width * 0.75,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(move, 'pointerId', { value: 33 })
|
||||
inactiveOverlay.element.dispatchEvent(move)
|
||||
await flushPromises()
|
||||
|
||||
expect(inactive.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||
expect(inactive.classes()).not.toContain('waveform-chart--panning')
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
|
||||
})
|
||||
|
||||
it('limits box zoom to the configured minimum x span', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
|
||||
@@ -63,6 +63,19 @@ import {
|
||||
channelColors,
|
||||
margin as chartMargin,
|
||||
minimumHeight as chartMinimumHeight,
|
||||
Y_AXIS_CHARACTER_WIDTH,
|
||||
Y_AXIS_TICK_PADDING,
|
||||
Y_AXIS_OUTER_PADDING,
|
||||
Y_AXIS_LABEL_GAP,
|
||||
Y_AXIS_LABEL_BAND_WIDTH,
|
||||
MINIMUM_PLOT_WIDTH,
|
||||
WHEEL_ZOOM_DEBOUNCE_MS,
|
||||
MINIMUM_SELECTION_SIZE,
|
||||
ZOOM_CONSTRAINTS,
|
||||
TITLE_DEFAULT_FONT_SIZE,
|
||||
TITLE_CHAR_WIDTH_RATIO,
|
||||
TITLE_LINE_HEIGHT,
|
||||
ZERO_LINE_DEFAULTS,
|
||||
} from './core/constants'
|
||||
import {
|
||||
getGridGap,
|
||||
@@ -75,12 +88,19 @@ import {
|
||||
type WaveformGridOptions,
|
||||
} from './core/grid'
|
||||
import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
|
||||
import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './core/layout'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
findClosestTrackAtPointer,
|
||||
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'
|
||||
import { useWaveformInstanceId } from '../utils/waveformId'
|
||||
|
||||
const xPointBisector = bisector<WaveformPoint, number>((point) => point.x)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: WaveformData
|
||||
@@ -93,6 +113,7 @@ const props = withDefaults(
|
||||
lineColor?: string
|
||||
showTooltip?: boolean
|
||||
zoomable?: boolean
|
||||
pannable?: boolean
|
||||
minZoomSpan?: number
|
||||
minVisiblePoints?: number
|
||||
initialXDomain?: [number, number]
|
||||
@@ -119,6 +140,7 @@ const props = withDefaults(
|
||||
lineColor: '#0960bd',
|
||||
showTooltip: true,
|
||||
zoomable: true,
|
||||
pannable: false,
|
||||
minVisiblePoints: 0,
|
||||
timeUnit: 'ms',
|
||||
frameNumber: undefined,
|
||||
@@ -191,7 +213,7 @@ const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
|
||||
const lastZoomedTrackIndexes = new Set<number>()
|
||||
const zoomThrottle = useAnimationFrameThrottle()
|
||||
const hoverThrottle = useAnimationFrameThrottle()
|
||||
const wheelZoomDebounceMs = 200
|
||||
const wheelZoomDebounceMs = WHEEL_ZOOM_DEBOUNCE_MS
|
||||
let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
|
||||
|
||||
@@ -211,6 +233,7 @@ interface SelectionState {
|
||||
|
||||
const selection = ref<SelectionState | null>(null)
|
||||
const spacePressed = ref(false)
|
||||
const pointerInsideChart = ref(false)
|
||||
const selectionBox = computed(() => {
|
||||
const active = selection.value
|
||||
if (!active) return null
|
||||
@@ -224,11 +247,22 @@ const selectionBox = computed(() => {
|
||||
})
|
||||
|
||||
function handleInteractionKeyDown(event: KeyboardEvent) {
|
||||
if (event.code === 'Space') spacePressed.value = true
|
||||
if (event.code !== 'Space' || !props.pannable || !pointerInsideChart.value) return
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('button, input, select, textarea, [contenteditable]:not([contenteditable="false"])')
|
||||
) {
|
||||
return
|
||||
}
|
||||
spacePressed.value = true
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handleInteractionKeyUp(event: KeyboardEvent) {
|
||||
if (event.code === 'Space') spacePressed.value = false
|
||||
if (event.code === 'Space') {
|
||||
spacePressed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 用于传递给 WaveformTooltip 的接口
|
||||
@@ -262,9 +296,9 @@ const resolvedZeroLine = computed(() => {
|
||||
const width = props.zeroLine?.width
|
||||
return {
|
||||
visible: props.zeroLine?.visible === true,
|
||||
color: props.zeroLine?.color || '#98a2b3',
|
||||
width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : 1,
|
||||
dash: props.zeroLine?.dash ?? '6 4',
|
||||
color: props.zeroLine?.color || ZERO_LINE_DEFAULTS.COLOR,
|
||||
width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : ZERO_LINE_DEFAULTS.WIDTH,
|
||||
dash: props.zeroLine?.dash ?? ZERO_LINE_DEFAULTS.DASH,
|
||||
}
|
||||
})
|
||||
const legendBackgroundColor = computed(
|
||||
@@ -292,7 +326,7 @@ const titleAreaReserved = computed(
|
||||
const titleVisible = computed(() => titleAreaReserved.value && !isCleanView.value)
|
||||
const titleFontSize = computed(() => {
|
||||
const fontSize = props.title?.textStyle?.fontSize
|
||||
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : 14
|
||||
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : TITLE_DEFAULT_FONT_SIZE
|
||||
})
|
||||
const titleRotation = computed(() => {
|
||||
const rotation = props.title?.textStyle?.rotation
|
||||
@@ -310,14 +344,14 @@ const titlePresentationStyle = computed<CSSProperties>(() => ({
|
||||
fontStyle: props.title?.textStyle?.fontStyle ?? 'normal',
|
||||
textDecoration: props.title?.textStyle?.textDecoration ?? 'none',
|
||||
letterSpacing: props.title?.textStyle?.letterSpacing ?? 'normal',
|
||||
lineHeight: '1.2',
|
||||
lineHeight: String(TITLE_LINE_HEIGHT),
|
||||
}))
|
||||
const estimatedTitleWidth = computed(() => {
|
||||
const letterSpacing = Number.parseFloat(props.title?.textStyle?.letterSpacing ?? '')
|
||||
const spacingWidth = Number.isFinite(letterSpacing)
|
||||
? Math.max(0, resolvedTitleText.value.length - 1) * letterSpacing
|
||||
: 0
|
||||
return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * 0.62 + spacingWidth)
|
||||
return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * TITLE_CHAR_WIDTH_RATIO + spacingWidth)
|
||||
})
|
||||
const titleAvailableWidth = computed(() => {
|
||||
const measuredAvailableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2
|
||||
@@ -333,7 +367,7 @@ const titleMeasureStyle = computed<CSSProperties>(() => ({
|
||||
const titleLayout = computed(() =>
|
||||
calculateRotatedTitleLayout({
|
||||
naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
|
||||
naturalHeight: measuredTitleHeight.value || titleFontSize.value * 1.2,
|
||||
naturalHeight: measuredTitleHeight.value || titleFontSize.value * TITLE_LINE_HEIGHT,
|
||||
availableWidth: titleAvailableWidth.value,
|
||||
rotation: titleRotation.value,
|
||||
}),
|
||||
@@ -381,12 +415,18 @@ const chartTracks = computed<DisplayTrack[]>(() => {
|
||||
})
|
||||
return Array.from(groupedSeries, ([id, series]) => {
|
||||
const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id))
|
||||
const xDomainValues: number[] = []
|
||||
const yDomainValues: number[] = []
|
||||
visibleSeries.forEach((item) => {
|
||||
xDomainValues.push(item.xDomain[0], item.xDomain[1])
|
||||
yDomainValues.push(item.yDomain[0], item.yDomain[1])
|
||||
})
|
||||
return {
|
||||
id,
|
||||
series,
|
||||
visibleSeries,
|
||||
xDomain: paddedDomain(visibleSeries.flatMap((item) => item.xDomain)),
|
||||
yDomain: paddedDomain(visibleSeries.flatMap((item) => item.yDomain)),
|
||||
xDomain: paddedDomain(xDomainValues),
|
||||
yDomain: paddedDomain(yDomainValues),
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -397,12 +437,13 @@ const pagedTracks = computed(() =>
|
||||
paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
|
||||
)
|
||||
|
||||
const yAxisCharacterWidth = 7
|
||||
const yAxisTickPadding = 7
|
||||
const yAxisOuterPadding = 4
|
||||
const yAxisLabelGap = 6
|
||||
const yAxisLabelBandWidth = 24
|
||||
const minimumPlotWidth = 120
|
||||
// 使用从常量文件导入的值
|
||||
const yAxisCharacterWidth = Y_AXIS_CHARACTER_WIDTH
|
||||
const yAxisTickPadding = Y_AXIS_TICK_PADDING
|
||||
const yAxisOuterPadding = Y_AXIS_OUTER_PADDING
|
||||
const yAxisLabelGap = Y_AXIS_LABEL_GAP
|
||||
const yAxisLabelBandWidth = Y_AXIS_LABEL_BAND_WIDTH
|
||||
const minimumPlotWidth = MINIMUM_PLOT_WIDTH
|
||||
|
||||
const yAxisMetrics = computed(() => {
|
||||
const axisText = chartTracks.value
|
||||
@@ -533,11 +574,13 @@ const tooltipSeriesPoints = computed<TooltipSeriesPoint[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const sharedXDomain = computed(() =>
|
||||
paddedDomain(
|
||||
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
|
||||
),
|
||||
)
|
||||
const sharedXDomain = computed(() => {
|
||||
const values: number[] = []
|
||||
chartTracks.value.forEach((track) => {
|
||||
if (track.visibleSeries.length) values.push(track.xDomain[0], track.xDomain[1])
|
||||
})
|
||||
return paddedDomain(values)
|
||||
})
|
||||
const initialXDomain = computed<[number, number]>(() => {
|
||||
const domain = props.initialXDomain
|
||||
if (
|
||||
@@ -820,10 +863,10 @@ function clearZoomBindings() {
|
||||
|
||||
function resolveMaximumZoomScale(domain: [number, number]): number {
|
||||
const minZoomSpan = props.minZoomSpan
|
||||
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return 40
|
||||
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
||||
const domainSpan = Math.abs(domain[1] - domain[0])
|
||||
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return 1
|
||||
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
|
||||
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
||||
return Math.min(ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE, Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)))
|
||||
}
|
||||
|
||||
function canZoomTrack(track: TrackLayout): boolean {
|
||||
@@ -978,7 +1021,7 @@ function consumeHoverSuppression(): boolean {
|
||||
}
|
||||
|
||||
function nearestPoint(series: DisplaySeries, xValue: number): WaveformPoint | undefined {
|
||||
const index = bisector((point: WaveformPoint) => point.x).center(series.points, xValue)
|
||||
const index = xPointBisector.center(series.points, xValue)
|
||||
return series.points[index]
|
||||
}
|
||||
|
||||
@@ -1092,32 +1135,7 @@ function resolveTrackAtPointer(
|
||||
return track?.hasVisibleSeries ? track : undefined
|
||||
}
|
||||
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||
if (!visibleTracks.length) return undefined
|
||||
|
||||
const distanceToTrack = (track: TrackLayout) => {
|
||||
const xDistance =
|
||||
pointerX < track.left
|
||||
? track.left - pointerX
|
||||
: pointerX > track.left + track.width
|
||||
? pointerX - track.left - track.width
|
||||
: 0
|
||||
if (pointerY < track.top) return track.top - pointerY
|
||||
if (pointerY > track.top + track.height) return pointerY - (track.top + track.height)
|
||||
return xDistance
|
||||
}
|
||||
// 修复 O(n²) 问题:缓存距离计算结果
|
||||
const trackDistances = new Map<TrackLayout, number>()
|
||||
visibleTracks.forEach((track) => {
|
||||
trackDistances.set(track, distanceToTrack(track))
|
||||
})
|
||||
return visibleTracks.reduce((closest, candidate) => {
|
||||
const distance = trackDistances.get(candidate)!
|
||||
const closestDistance = trackDistances.get(closest)!
|
||||
if (distance !== closestDistance) return distance < closestDistance ? candidate : closest
|
||||
const centerDistance = Math.abs(pointerY - (candidate.top + candidate.height / 2))
|
||||
const closestCenterDistance = Math.abs(pointerY - (closest.top + closest.height / 2))
|
||||
return centerDistance < closestCenterDistance ? candidate : closest
|
||||
})
|
||||
return findClosestTrackAtPointer(visibleTracks, pointerX, pointerY)
|
||||
}
|
||||
|
||||
function resolveAnnotationCandidates(
|
||||
@@ -1337,7 +1355,7 @@ function handleSharedPointerMove(event: PointerEvent) {
|
||||
})
|
||||
}
|
||||
|
||||
const minimumSelectionSize = 6
|
||||
const minimumSelectionSize = MINIMUM_SELECTION_SIZE
|
||||
|
||||
function transformForDomain(
|
||||
domain: [number, number],
|
||||
@@ -1403,7 +1421,8 @@ function currentYDomains(): Record<string, [number, number]> {
|
||||
}
|
||||
|
||||
function beginViewportDrag(event: PointerEvent, trackIndex: number, independent: boolean) {
|
||||
if (!props.zoomable || !isZoomMode.value || event.button !== 0) return
|
||||
const panRequested = props.pannable && spacePressed.value
|
||||
if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return
|
||||
const overlay = event.currentTarget as SVGRectElement
|
||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||
if (!track) return
|
||||
@@ -1419,7 +1438,7 @@ function beginViewportDrag(event: PointerEvent, trackIndex: number, independent:
|
||||
currentX: x,
|
||||
currentY: y,
|
||||
pointerId: event.pointerId,
|
||||
mode: spacePressed.value ? 'pan' : 'box',
|
||||
mode: panRequested ? 'pan' : 'box',
|
||||
xDomain: track.xScale.domain() as [number, number],
|
||||
yDomains: currentYDomains(),
|
||||
}
|
||||
@@ -1804,6 +1823,8 @@ onBeforeUnmount(() => {
|
||||
:data-overlay-mode="overlayMode"
|
||||
:data-chart-left-margin="resolvedChartLeftMargin"
|
||||
:data-title-area-height="titleAreaHeight"
|
||||
@pointerenter="pointerInsideChart = true"
|
||||
@pointerleave="pointerInsideChart = false"
|
||||
@contextmenu.capture="handleNativeContextMenu"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,129 @@
|
||||
/**
|
||||
* 核心常量定义
|
||||
* 波形图表核心常量配置
|
||||
*/
|
||||
|
||||
/** 通道颜色 */
|
||||
// ==================== 布局常量 ====================
|
||||
|
||||
/** 图表边距 */
|
||||
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
|
||||
|
||||
/**
|
||||
* 图表最小高度(像素)
|
||||
*/
|
||||
export const minimumHeight = 180
|
||||
|
||||
/**
|
||||
* 网格间距配置
|
||||
*/
|
||||
export const gridGap = {
|
||||
independent: 30,
|
||||
separated: 20,
|
||||
compact: 20,
|
||||
}
|
||||
|
||||
// ==================== Y轴常量 ====================
|
||||
|
||||
/**
|
||||
* Y轴字符宽度(像素)
|
||||
*/
|
||||
export const Y_AXIS_CHARACTER_WIDTH = 7
|
||||
|
||||
/**
|
||||
* Y轴刻度内边距(像素)
|
||||
*/
|
||||
export const Y_AXIS_TICK_PADDING = 7
|
||||
|
||||
/**
|
||||
* Y轴外边距(像素)
|
||||
*/
|
||||
export const Y_AXIS_OUTER_PADDING = 4
|
||||
|
||||
/**
|
||||
* Y轴标签间距(像素)
|
||||
*/
|
||||
export const Y_AXIS_LABEL_GAP = 6
|
||||
|
||||
/**
|
||||
* Y轴标签带宽度(像素)
|
||||
*/
|
||||
export const Y_AXIS_LABEL_BAND_WIDTH = 24
|
||||
|
||||
/**
|
||||
* Y轴指数标签间距(像素)
|
||||
*/
|
||||
export const Y_AXIS_EXPONENT_GAP = 8
|
||||
|
||||
/**
|
||||
* 最小绘图宽度(像素)
|
||||
*/
|
||||
export const MINIMUM_PLOT_WIDTH = 120
|
||||
|
||||
// ==================== 交互常量 ====================
|
||||
|
||||
/**
|
||||
* 滚轮缩放防抖时间(毫秒)
|
||||
*/
|
||||
export const WHEEL_ZOOM_DEBOUNCE_MS = 200
|
||||
|
||||
/**
|
||||
* 最小选择框尺寸(像素)
|
||||
*/
|
||||
export const MINIMUM_SELECTION_SIZE = 6
|
||||
|
||||
/**
|
||||
* 缩放限制常量
|
||||
*/
|
||||
export const ZOOM_CONSTRAINTS = {
|
||||
/** 默认最大缩放倍数 */
|
||||
DEFAULT_MAX_SCALE: 40,
|
||||
/** 最小缩放倍数 */
|
||||
MIN_SCALE: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬停检测阈值(像素)
|
||||
* 当指针移动距离小于此值时,使用缓存的悬停结果
|
||||
*/
|
||||
// ==================== 注释常量 ====================
|
||||
|
||||
/**
|
||||
* 注释命中半径(像素)
|
||||
*/
|
||||
export const ANNOTATION_HIT_RADIUS = 8
|
||||
|
||||
/**
|
||||
* 注释歧义距离(像素)
|
||||
* 当多个候选注释点距离差小于此值时,视为歧义
|
||||
*/
|
||||
export const ANNOTATION_AMBIGUITY_DISTANCE = 3
|
||||
|
||||
// ==================== 标题常量 ====================
|
||||
|
||||
/**
|
||||
* 标题区域水平内边距(像素)
|
||||
*/
|
||||
export const TITLE_AREA_HORIZONTAL_PADDING = 24
|
||||
|
||||
/**
|
||||
* 标题默认字体大小(像素)
|
||||
*/
|
||||
export const TITLE_DEFAULT_FONT_SIZE = 14
|
||||
|
||||
/**
|
||||
* 标题默认字符宽度系数
|
||||
*/
|
||||
export const TITLE_CHAR_WIDTH_RATIO = 0.62
|
||||
|
||||
/**
|
||||
* 标题行高
|
||||
*/
|
||||
export const TITLE_LINE_HEIGHT = 1.2
|
||||
|
||||
// ==================== 样式常量 ====================
|
||||
|
||||
/**
|
||||
* 通道默认颜色列表
|
||||
*/
|
||||
export const channelColors = [
|
||||
'#0960bd',
|
||||
'#ff7f0e',
|
||||
@@ -16,8 +137,56 @@ export const channelColors = [
|
||||
'#1d39c4',
|
||||
]
|
||||
|
||||
/** 图表边距 */
|
||||
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
|
||||
/**
|
||||
* 错误条默认配置
|
||||
*/
|
||||
export const ERROR_BAR_DEFAULTS = {
|
||||
/** 线宽(像素) */
|
||||
WIDTH: 1.5,
|
||||
/** 端帽宽度(像素) */
|
||||
CAP_WIDTH: 8,
|
||||
}
|
||||
|
||||
/** 最小高度 */
|
||||
export const minimumHeight = 180
|
||||
/**
|
||||
* 零线默认配置
|
||||
*/
|
||||
export const ZERO_LINE_DEFAULTS = {
|
||||
/** 颜色 */
|
||||
COLOR: '#98a2b3',
|
||||
/** 线宽(像素) */
|
||||
WIDTH: 1,
|
||||
/** 虚线样式 */
|
||||
DASH: '6 4',
|
||||
}
|
||||
|
||||
/**
|
||||
* 图例默认配置
|
||||
*/
|
||||
export const LEGEND_DEFAULTS = {
|
||||
/** 背景色 */
|
||||
BACKGROUND_COLOR: 'rgba(255, 255, 255, 0.7)',
|
||||
/** 位置 */
|
||||
POSITION: 'top-right' as const,
|
||||
/** 方向 */
|
||||
ORIENTATION: 'auto' as const,
|
||||
}
|
||||
|
||||
// ==================== 渲染常量 ====================
|
||||
|
||||
/**
|
||||
* 最大多轴数量
|
||||
*/
|
||||
export const MAX_MULTI_Y_AXIS_COUNT = 4
|
||||
|
||||
/**
|
||||
* 缓存限制
|
||||
*/
|
||||
export const CACHE_LIMITS = {
|
||||
/** Y轴组缓存最大条目数 */
|
||||
Y_AXIS_GROUPS: 100,
|
||||
/** 轨道距离缓存刷新阈值(像素) */
|
||||
TRACK_DISTANCE_REFRESH_THRESHOLD: 5,
|
||||
}
|
||||
|
||||
// 向后兼容性导出
|
||||
export { channelColors as default }
|
||||
|
||||
@@ -39,6 +39,31 @@ describe('waveform grid helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves optional per-direction grid colors and ignores blank values', () => {
|
||||
expect(
|
||||
normalizeGridOptions({
|
||||
trackLines: {
|
||||
voltage: {
|
||||
horizontalColor: '#ef4444',
|
||||
verticalColor: ' #2563eb ',
|
||||
},
|
||||
current: { horizontalColor: ' ' },
|
||||
},
|
||||
}).trackLines,
|
||||
).toEqual({
|
||||
voltage: {
|
||||
horizontal: true,
|
||||
vertical: true,
|
||||
horizontalColor: '#ef4444',
|
||||
verticalColor: ' #2563eb ',
|
||||
},
|
||||
current: {
|
||||
horizontal: true,
|
||||
vertical: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to visible grid lines for invalid runtime values', () => {
|
||||
const options = {
|
||||
trackLines: {
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface WaveformGridOptions {
|
||||
export interface WaveformGridLineOptions {
|
||||
horizontal?: boolean
|
||||
vertical?: boolean
|
||||
/** Optional stroke color for horizontal major and minor grid lines. */
|
||||
horizontalColor?: string
|
||||
/** Optional stroke color for vertical major and minor grid lines. */
|
||||
verticalColor?: string
|
||||
}
|
||||
|
||||
export type WaveformGridTrackLines = Record<string, WaveformGridLineOptions>
|
||||
@@ -21,6 +25,8 @@ export type WaveformGridTrackLines = Record<string, WaveformGridLineOptions>
|
||||
export interface NormalizedWaveformGridLineOptions {
|
||||
horizontal: boolean
|
||||
vertical: boolean
|
||||
horizontalColor?: string
|
||||
verticalColor?: string
|
||||
}
|
||||
|
||||
export interface NormalizedWaveformGridOptions {
|
||||
@@ -57,6 +63,14 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
|
||||
{
|
||||
horizontal: typeof lines?.horizontal === 'boolean' ? lines.horizontal : true,
|
||||
vertical: typeof lines?.vertical === 'boolean' ? lines.vertical : true,
|
||||
horizontalColor:
|
||||
typeof lines?.horizontalColor === 'string' && lines.horizontalColor.trim()
|
||||
? lines.horizontalColor
|
||||
: undefined,
|
||||
verticalColor:
|
||||
typeof lines?.verticalColor === 'string' && lines.verticalColor.trim()
|
||||
? lines.verticalColor
|
||||
: undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './constants'
|
||||
export * from './grid'
|
||||
export * from './layout'
|
||||
export * from './types'
|
||||
export * from './useWaveformData'
|
||||
// 从 layout 选择性导出,避免重复导出 constants
|
||||
export { buildTrackLayouts, measureTrackYAxisClearance, buildYAxisSeriesGroups } from './layout'
|
||||
// 从 constants 统一导出所有常量
|
||||
export * from './constants'
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DisplaySeries, DisplayTrack } from './types'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
buildYAxisSeriesGroups,
|
||||
findClosestTrackAtPointer,
|
||||
MAX_MULTI_Y_AXIS_COUNT,
|
||||
measureYAxisGroupClearance,
|
||||
} from './layout'
|
||||
@@ -16,6 +17,7 @@ function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
||||
name: id,
|
||||
color: '#1677ff',
|
||||
lineType: 'linear',
|
||||
lineStyle: 'solid',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [
|
||||
@@ -77,6 +79,14 @@ function layoutForSeries(
|
||||
}
|
||||
|
||||
describe('multi-value Y-axis grouping', () => {
|
||||
it('rebuilds Y scales when the same track ID receives a different domain', () => {
|
||||
const first = layoutForSeries(series('shared', 0, 1))
|
||||
const second = layoutForSeries(series('shared', 10_000, 20_000))
|
||||
|
||||
expect(first.yScale.domain()).toEqual([0, 1])
|
||||
expect(second.yScale.domain()).toEqual([10_000, 20_000])
|
||||
})
|
||||
|
||||
it('uses a configured visible Y domain for axis and series scales', () => {
|
||||
const source = series('a', 0, 100)
|
||||
const sourceTrack = track([source])
|
||||
@@ -231,6 +241,18 @@ describe('multi-value Y-axis grouping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('track hit testing', () => {
|
||||
const tracks = [
|
||||
{ id: 'first', left: 0, top: 0, width: 100, height: 40 },
|
||||
{ id: 'second', left: 0, top: 50, width: 100, height: 40 },
|
||||
]
|
||||
|
||||
it('switches tracks immediately across a boundary less than five pixels apart', () => {
|
||||
expect(findClosestTrackAtPointer(tracks, 50, 44)?.id).toBe('first')
|
||||
expect(findClosestTrackAtPointer(tracks, 50, 46)?.id).toBe('second')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decoration sampling', () => {
|
||||
const denseSeries = (): DisplaySeries => ({
|
||||
...series('dense', -1, 1),
|
||||
|
||||
@@ -27,14 +27,16 @@ import {
|
||||
type NormalizedWaveformGridOptions,
|
||||
} from './grid'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
||||
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
|
||||
// 导出常量供外部使用
|
||||
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
|
||||
export const MAX_MULTI_Y_AXIS_COUNT = 4
|
||||
const Y_AXIS_CHARACTER_WIDTH = 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
|
||||
export const Y_AXIS_EXPONENT_GAP = 8
|
||||
|
||||
interface YAxisSeriesGroup {
|
||||
index: number
|
||||
@@ -50,39 +52,17 @@ function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
|
||||
return ['left']
|
||||
}
|
||||
|
||||
// Cache across recreated track objects without reusing groups whose axis-relevant data changed.
|
||||
const yAxisGroupsCache = new Map<string, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
|
||||
const MAX_CACHE_SIZE = 100
|
||||
|
||||
function getCacheKey(track: DisplayTrack): string {
|
||||
return JSON.stringify([
|
||||
track.id,
|
||||
track.yDomain,
|
||||
track.visibleSeries.map((series) => [
|
||||
series.id,
|
||||
series.name,
|
||||
series.unit,
|
||||
series.color,
|
||||
series.yDomain,
|
||||
]),
|
||||
])
|
||||
}
|
||||
// 使用 WeakMap 进行缓存优化,避免手动清理
|
||||
const yAxisGroupsCache = new WeakMap<DisplayTrack, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
|
||||
|
||||
export function buildYAxisSeriesGroups(
|
||||
track: DisplayTrack,
|
||||
overlayMode: WaveformOverlayMode,
|
||||
): YAxisSeriesGroup[] {
|
||||
const cacheKey = getCacheKey(track)
|
||||
let trackCache = yAxisGroupsCache.get(cacheKey)
|
||||
let trackCache = yAxisGroupsCache.get(track)
|
||||
if (!trackCache) {
|
||||
trackCache = new Map()
|
||||
yAxisGroupsCache.set(cacheKey, trackCache)
|
||||
if (yAxisGroupsCache.size > MAX_CACHE_SIZE) {
|
||||
const firstKey = yAxisGroupsCache.keys().next().value
|
||||
if (firstKey !== undefined) {
|
||||
yAxisGroupsCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
yAxisGroupsCache.set(track, trackCache)
|
||||
}
|
||||
|
||||
const cached = trackCache.get(overlayMode)
|
||||
@@ -181,6 +161,49 @@ interface SeriesGridCell extends GridCellGeometry {
|
||||
series?: DisplayTrack
|
||||
}
|
||||
|
||||
type PositionedTrack = Pick<TrackLayout, 'left' | 'top' | 'width' | 'height'>
|
||||
|
||||
export function findClosestTrackAtPointer<T extends PositionedTrack>(
|
||||
tracks: readonly T[],
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
): T | undefined {
|
||||
const distanceToTrack = (track: T) => {
|
||||
const xDistance =
|
||||
pointerX < track.left
|
||||
? track.left - pointerX
|
||||
: pointerX > track.left + track.width
|
||||
? pointerX - track.left - track.width
|
||||
: 0
|
||||
return pointerY < track.top
|
||||
? track.top - pointerY
|
||||
: pointerY > track.top + track.height
|
||||
? pointerY - (track.top + track.height)
|
||||
: xDistance
|
||||
}
|
||||
|
||||
let closestTrack = tracks[0]
|
||||
if (!closestTrack) return undefined
|
||||
let closestDistance = distanceToTrack(closestTrack)
|
||||
for (let index = 1; index < tracks.length; index += 1) {
|
||||
const candidate = tracks[index]!
|
||||
const distance = distanceToTrack(candidate)
|
||||
if (distance < closestDistance) {
|
||||
closestTrack = candidate
|
||||
closestDistance = distance
|
||||
continue
|
||||
}
|
||||
if (distance === closestDistance) {
|
||||
const centerDistance = Math.abs(pointerY - (candidate.top + candidate.height / 2))
|
||||
const closestCenterDistance = Math.abs(
|
||||
pointerY - (closestTrack.top + closestTrack.height / 2),
|
||||
)
|
||||
if (centerDistance < closestCenterDistance) closestTrack = candidate
|
||||
}
|
||||
}
|
||||
return closestTrack
|
||||
}
|
||||
|
||||
export interface BuildTrackLayoutsOptions {
|
||||
cells: SeriesGridCell[]
|
||||
grid: NormalizedWaveformGridOptions
|
||||
@@ -210,6 +233,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
name: '',
|
||||
color: 'transparent',
|
||||
lineType: 'linear',
|
||||
lineStyle: 'solid',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ScaleLinear } from 'd3'
|
||||
import type {
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformLineType,
|
||||
WaveformLineStyle,
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
@@ -17,6 +18,7 @@ export interface DisplaySeries {
|
||||
unit?: string
|
||||
color: string
|
||||
lineType: WaveformLineType
|
||||
lineStyle: WaveformLineStyle
|
||||
pointType: WaveformPointType
|
||||
errorBar: ResolvedWaveformErrorBarOptions
|
||||
points: WaveformPoint[]
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformData,
|
||||
WaveformLineType,
|
||||
WaveformLineStyle,
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
@@ -17,6 +18,7 @@ export interface PreparedWaveformSeries {
|
||||
unit?: string
|
||||
color?: string
|
||||
lineType: WaveformLineType
|
||||
lineStyle: WaveformLineStyle
|
||||
pointType: WaveformPointType
|
||||
errorBar: ResolvedWaveformErrorBarOptions
|
||||
points: WaveformPoint[]
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
WaveformZeroLineOptions,
|
||||
SingleWaveformData,
|
||||
WaveformLineType,
|
||||
WaveformLineStyle,
|
||||
WaveformPointType,
|
||||
WaveformErrorBarOptions,
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
|
||||
@@ -21,6 +21,7 @@ export type {
|
||||
WaveformPoint,
|
||||
WaveformSeries,
|
||||
WaveformLineType,
|
||||
WaveformLineStyle,
|
||||
WaveformPointType,
|
||||
WaveformErrorBarOptions,
|
||||
WaveformGridOptions,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DisplaySeries } from '../core/types'
|
||||
import {
|
||||
waveformLegendErrorBarPath,
|
||||
waveformLegendLinePath,
|
||||
waveformLineDasharray,
|
||||
waveformPointSymbolPath,
|
||||
} from './seriesStyle'
|
||||
|
||||
@@ -82,6 +83,7 @@ function toggleSeries(seriesId: string) {
|
||||
viewBox="0 0 26 16"
|
||||
aria-hidden="true"
|
||||
:data-line-type="item.lineType"
|
||||
:data-line-style="item.lineStyle"
|
||||
:data-point-type="item.pointType"
|
||||
:data-error-bar-visible="item.errorBar.visible || undefined"
|
||||
>
|
||||
@@ -90,6 +92,7 @@ function toggleSeries(seriesId: string) {
|
||||
class="waveform-legend__line"
|
||||
:d="waveformLegendLinePath(item.lineType) ?? undefined"
|
||||
:stroke="item.color"
|
||||
:stroke-dasharray="waveformLineDasharray(item.lineStyle)"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
|
||||
import { resolveWaveformPointErrors } from '../../core'
|
||||
import type { TrackLayout, TrackSeriesPath } from '../core/types'
|
||||
import { waveformPointSeriesPath } from './seriesStyle'
|
||||
import { waveformLineDasharray, waveformPointSeriesPath } from './seriesStyle'
|
||||
|
||||
const props = defineProps<{
|
||||
track: TrackLayout
|
||||
@@ -63,8 +63,10 @@ const renderedSeriesPaths = computed<RenderedSeriesPath[]>(() =>
|
||||
:data-series-name="seriesPath.series.name || undefined"
|
||||
:data-y-axis-index="seriesPath.yAxisIndex"
|
||||
:data-line-type="seriesPath.series.lineType"
|
||||
:data-line-style="seriesPath.series.lineStyle"
|
||||
:d="seriesPath.path"
|
||||
:stroke="seriesPath.series.color"
|
||||
:stroke-dasharray="waveformLineDasharray(seriesPath.series.lineStyle)"
|
||||
/>
|
||||
|
||||
<g
|
||||
|
||||
@@ -216,6 +216,7 @@ watch(
|
||||
v-for="tick in track.xMinorTicks"
|
||||
:key="`x-minor-${track.index}-${tick}`"
|
||||
data-grid-direction="vertical"
|
||||
:stroke="track.gridLines.verticalColor"
|
||||
:x1="track.xScale(tick)"
|
||||
:x2="track.xScale(tick)"
|
||||
y1="0"
|
||||
@@ -227,6 +228,7 @@ watch(
|
||||
v-for="tick in track.yMinorTicks"
|
||||
:key="`y-minor-${track.index}-${tick}`"
|
||||
data-grid-direction="horizontal"
|
||||
:stroke="track.gridLines.horizontalColor"
|
||||
x1="0"
|
||||
:x2="track.width ?? innerWidth"
|
||||
:y1="track.yScale(tick)"
|
||||
@@ -242,6 +244,7 @@ watch(
|
||||
v-for="tick in track.xMajorTicks"
|
||||
:key="`x-major-${track.index}-${tick}`"
|
||||
data-grid-direction="vertical"
|
||||
:stroke="track.gridLines.verticalColor"
|
||||
:x1="track.xScale(tick)"
|
||||
:x2="track.xScale(tick)"
|
||||
y1="0"
|
||||
@@ -253,6 +256,7 @@ watch(
|
||||
v-for="tick in track.yMajorTicks"
|
||||
:key="`y-major-${track.index}-${tick}`"
|
||||
data-grid-direction="horizontal"
|
||||
:stroke="track.gridLines.horizontalColor"
|
||||
x1="0"
|
||||
:x2="track.width ?? innerWidth"
|
||||
:y1="track.yScale(tick)"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type SymbolType,
|
||||
} from 'd3'
|
||||
|
||||
import type { WaveformLineType, WaveformPointType } from '../../types'
|
||||
import type { WaveformLineStyle, WaveformLineType, WaveformPointType } from '../../types'
|
||||
|
||||
const LEGEND_SWATCH_CENTER_X = 13
|
||||
const LEGEND_ERROR_BAR_TOP = 2
|
||||
@@ -82,6 +82,12 @@ export function waveformLegendLinePath(lineType: WaveformLineType): string | nul
|
||||
return 'M1 8H25'
|
||||
}
|
||||
|
||||
export function waveformLineDasharray(lineStyle: WaveformLineStyle): string | undefined {
|
||||
if (lineStyle === 'dashed') return '8 5'
|
||||
if (lineStyle === 'dash-dot') return '8 5 1.5 5'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function waveformLegendErrorBarPath(capWidth: number): string {
|
||||
const resolvedCapWidth =
|
||||
Number.isFinite(capWidth) && capWidth > 0
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
WaveformFrameStyle,
|
||||
SingleWaveformData,
|
||||
WaveformSeries,
|
||||
WaveformLineStyle,
|
||||
WaveformData,
|
||||
NormalizedWaveformSeries,
|
||||
} from '../types'
|
||||
|
||||
Reference in New Issue
Block a user