perf: 优化波形核心算法与图框网格控制
This commit is contained in:
@@ -124,6 +124,7 @@ describe('normalizeWaveformData', () => {
|
||||
|
||||
expect(series?.yDomain[0]).toBeLessThanOrEqual(-1)
|
||||
expect(series?.yDomain[1]).toBeGreaterThanOrEqual(6)
|
||||
expect(series?.hasErrorPoints).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes multiple named series and removes empty series', () => {
|
||||
@@ -2136,6 +2137,49 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
|
||||
})
|
||||
|
||||
it('controls horizontal and vertical grid lines independently by track ID', async () => {
|
||||
const data = gridSeries(2)
|
||||
if (data.kind === 'series') {
|
||||
data.series[0].trackId = 'frame-a'
|
||||
data.series[1].trackId = 'frame-b'
|
||||
}
|
||||
const wrapper = await mountSizedChart(data, {
|
||||
grid: {
|
||||
rowCount: 1,
|
||||
columnCount: 2,
|
||||
trackLines: {
|
||||
'frame-a': { horizontal: false },
|
||||
'frame-b': { vertical: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||
expect(tracks).toHaveLength(2)
|
||||
expect(tracks[0].findAll('[data-grid-direction="horizontal"]')).toHaveLength(0)
|
||||
expect(tracks[0].findAll('[data-grid-direction="vertical"]').length).not.toBe(0)
|
||||
expect(tracks[1].findAll('[data-grid-direction="vertical"]')).toHaveLength(0)
|
||||
expect(tracks[1].findAll('[data-grid-direction="horizontal"]').length).not.toBe(0)
|
||||
})
|
||||
|
||||
it('keeps per-track grid line visibility attached across pagination', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(3), {
|
||||
grid: {
|
||||
rowCount: 1,
|
||||
columnCount: 1,
|
||||
trackLines: {
|
||||
'channel-1': { horizontal: false, vertical: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-grid-direction]')).not.toHaveLength(0)
|
||||
await wrapper.get('.ant-pagination-next button').trigger('click')
|
||||
expect(wrapper.findAll('[data-grid-direction]')).toHaveLength(0)
|
||||
await wrapper.get('.ant-pagination-next button').trigger('click')
|
||||
expect(wrapper.findAll('[data-grid-direction]')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('applies one custom frame style to every non-empty track', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||
grid: { rowCount: 2, columnCount: 1 },
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ZoomTransform,
|
||||
} from 'd3'
|
||||
import { resolveWaveformRenderingOptions } from '../core'
|
||||
import { hasMinimumVisibleXValues } from '../core/rendering'
|
||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils'
|
||||
import { useAnimationFrameThrottle } from './utils/useAnimationFrameThrottle'
|
||||
import {
|
||||
@@ -825,20 +826,13 @@ function resolveMaximumZoomScale(domain: [number, number]): number {
|
||||
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
|
||||
}
|
||||
|
||||
function visiblePointCount(track: TrackLayout): number {
|
||||
const [start, end] = track.xScale.domain()
|
||||
const xValues = new Set<number>()
|
||||
track.seriesList.forEach((series) => {
|
||||
series.points.forEach((point) => {
|
||||
if (point.x >= start && point.x <= end) xValues.add(point.x)
|
||||
})
|
||||
})
|
||||
return xValues.size
|
||||
}
|
||||
|
||||
function canZoomTrack(track: TrackLayout): boolean {
|
||||
const minimum = Number(props.minVisiblePoints)
|
||||
return !Number.isFinite(minimum) || minimum <= 0 || visiblePointCount(track) >= minimum
|
||||
return hasMinimumVisibleXValues(
|
||||
track.seriesList,
|
||||
track.xScale.domain() as [number, number],
|
||||
minimum,
|
||||
)
|
||||
}
|
||||
|
||||
function canZoomSharedTracks(): boolean {
|
||||
|
||||
@@ -11,11 +11,44 @@ import {
|
||||
|
||||
describe('waveform grid helpers', () => {
|
||||
it('normalizes grid counts and uses a two by one default', () => {
|
||||
expect(normalizeGridOptions()).toEqual({ rowCount: 2, columnCount: 1, showPagination: true })
|
||||
expect(normalizeGridOptions()).toEqual({
|
||||
rowCount: 2,
|
||||
columnCount: 1,
|
||||
showPagination: true,
|
||||
trackLines: {},
|
||||
})
|
||||
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
|
||||
rowCount: 1,
|
||||
columnCount: 10,
|
||||
showPagination: true,
|
||||
trackLines: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes per-track grid line visibility with visible defaults', () => {
|
||||
expect(
|
||||
normalizeGridOptions({
|
||||
trackLines: {
|
||||
voltage: { horizontal: false },
|
||||
current: { vertical: false },
|
||||
},
|
||||
}).trackLines,
|
||||
).toEqual({
|
||||
voltage: { horizontal: false, vertical: true },
|
||||
current: { horizontal: true, vertical: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to visible grid lines for invalid runtime values', () => {
|
||||
const options = {
|
||||
trackLines: {
|
||||
voltage: { horizontal: 'invalid', vertical: null },
|
||||
},
|
||||
} as unknown as Parameters<typeof normalizeGridOptions>[0]
|
||||
|
||||
expect(normalizeGridOptions(options).trackLines.voltage).toEqual({
|
||||
horizontal: true,
|
||||
vertical: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,12 +8,26 @@ export interface WaveformGridOptions {
|
||||
rowCount?: number
|
||||
columnCount?: number
|
||||
showPagination?: boolean
|
||||
trackLines?: WaveformGridTrackLines
|
||||
}
|
||||
|
||||
export interface WaveformGridLineOptions {
|
||||
horizontal?: boolean
|
||||
vertical?: boolean
|
||||
}
|
||||
|
||||
export type WaveformGridTrackLines = Record<string, WaveformGridLineOptions>
|
||||
|
||||
export interface NormalizedWaveformGridLineOptions {
|
||||
horizontal: boolean
|
||||
vertical: boolean
|
||||
}
|
||||
|
||||
export interface NormalizedWaveformGridOptions {
|
||||
rowCount: number
|
||||
columnCount: number
|
||||
showPagination: boolean
|
||||
trackLines: Record<string, NormalizedWaveformGridLineOptions>
|
||||
}
|
||||
|
||||
export interface GridCellGeometry {
|
||||
@@ -37,10 +51,20 @@ const normalizeCount = (value: unknown, fallback: number) => {
|
||||
}
|
||||
|
||||
export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedWaveformGridOptions {
|
||||
const trackLines = Object.fromEntries(
|
||||
Object.entries(options?.trackLines ?? {}).map(([trackId, lines]) => [
|
||||
trackId,
|
||||
{
|
||||
horizontal: typeof lines?.horizontal === 'boolean' ? lines.horizontal : true,
|
||||
vertical: typeof lines?.vertical === 'boolean' ? lines.vertical : true,
|
||||
},
|
||||
]),
|
||||
)
|
||||
return {
|
||||
rowCount: normalizeCount(options?.rowCount, 2),
|
||||
columnCount: normalizeCount(options?.columnCount, 1),
|
||||
showPagination: options?.showPagination ?? true,
|
||||
trackLines,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
||||
],
|
||||
xDomain: [0, 1],
|
||||
yDomain: [minimum, maximum],
|
||||
hasErrorPoints: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ function layoutForSeries(
|
||||
series: sourceTrack,
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'single-axis',
|
||||
independentTransforms: [transform],
|
||||
@@ -95,7 +96,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
series: sourceTrack,
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'single-axis',
|
||||
independentTransforms: [zoomIdentity],
|
||||
@@ -198,7 +199,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'multi-axis',
|
||||
independentTransforms: [zoomIdentity],
|
||||
@@ -241,6 +242,7 @@ describe('decoration sampling', () => {
|
||||
error: index % 200 === 1 ? 0.1 : 0,
|
||||
})),
|
||||
xDomain: [0, 999],
|
||||
hasErrorPoints: true,
|
||||
})
|
||||
|
||||
it('shares prioritized source points between dense symbols and error bars', () => {
|
||||
@@ -262,6 +264,7 @@ describe('decoration sampling', () => {
|
||||
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 }))
|
||||
noErrors.hasErrorPoints = false
|
||||
const zeroErrorPath = layoutForSeries(noErrors)
|
||||
expect(zeroErrorPath.errorBarRenderPoints).toEqual([])
|
||||
expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
} from 'd3'
|
||||
|
||||
import {
|
||||
selectDecorationPoints,
|
||||
selectRenderablePoints,
|
||||
resolveWaveformPointErrors,
|
||||
selectSeriesRenderPoints,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from '../../core'
|
||||
} from '../../core/rendering'
|
||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||
import {
|
||||
buildMinorTicks,
|
||||
@@ -217,6 +215,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
points: [],
|
||||
xDomain: [0, 1],
|
||||
yDomain: [0, 1],
|
||||
hasErrorPoints: false,
|
||||
}
|
||||
const displayTrack: DisplayTrack = cell.series ?? {
|
||||
id: emptySeries.id,
|
||||
@@ -317,51 +316,18 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
axis.seriesList.some((series) => series.id === trackSeries.id),
|
||||
)
|
||||
const seriesYScale = yAxis?.scale ?? yScale
|
||||
const pathPoints = selectRenderablePoints(
|
||||
const renderPoints = selectSeriesRenderPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering,
|
||||
{
|
||||
lineVisible: !isEmpty && trackSeries.lineType !== 'none',
|
||||
pointVisible: trackSeries.pointType !== 'none',
|
||||
errorBarVisible: trackSeries.errorBar.visible,
|
||||
hasErrorPoints: trackSeries.hasErrorPoints,
|
||||
},
|
||||
)
|
||||
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))
|
||||
@@ -372,9 +338,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
}
|
||||
return {
|
||||
series: trackSeries,
|
||||
path: isEmpty || trackSeries.lineType === 'none' ? null : pathGenerator(pathPoints),
|
||||
pointRenderPoints,
|
||||
errorBarRenderPoints,
|
||||
path: renderPoints.linePoints.length ? pathGenerator(renderPoints.linePoints) : null,
|
||||
pointRenderPoints: renderPoints.pointRenderPoints,
|
||||
errorBarRenderPoints: renderPoints.errorBarRenderPoints,
|
||||
yScale: seriesYScale,
|
||||
yAxisIndex: yAxis?.index ?? 0,
|
||||
}
|
||||
@@ -407,6 +373,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
xAxisExponent,
|
||||
path: seriesPaths[0]?.path ?? null,
|
||||
seriesPaths,
|
||||
gridLines: options.grid.trackLines[displayTrack.id] ?? {
|
||||
horizontal: true,
|
||||
vertical: true,
|
||||
},
|
||||
showXAxis:
|
||||
(isEmpty || hasVisibleSeries) &&
|
||||
(options.displayMode === 'independent' ||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
import type { NormalizedWaveformGridLineOptions } from './grid'
|
||||
|
||||
/**
|
||||
* 显示系列
|
||||
@@ -21,6 +22,7 @@ export interface DisplaySeries {
|
||||
points: WaveformPoint[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
hasErrorPoints: boolean
|
||||
}
|
||||
|
||||
export interface DisplayTrack {
|
||||
@@ -97,6 +99,7 @@ export interface TrackLayout {
|
||||
path: string | null
|
||||
seriesPaths: TrackSeriesPath[]
|
||||
showXAxis: boolean
|
||||
gridLines: NormalizedWaveformGridLineOptions
|
||||
}
|
||||
|
||||
// 重新导出 WaveformPoint 方便使用
|
||||
|
||||
@@ -22,34 +22,42 @@ export interface PreparedWaveformSeries {
|
||||
points: WaveformPoint[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
hasErrorPoints: boolean
|
||||
}
|
||||
|
||||
function pointDomain(
|
||||
function pointMetrics(
|
||||
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) {
|
||||
): { xDomain: [number, number]; yDomain: [number, number]; hasErrorPoints: boolean } {
|
||||
let xMinimum = Number.POSITIVE_INFINITY
|
||||
let xMaximum = Number.NEGATIVE_INFINITY
|
||||
let yMinimum = Number.POSITIVE_INFINITY
|
||||
let yMaximum = Number.NEGATIVE_INFINITY
|
||||
let hasErrorPoints = false
|
||||
for (const point of points) {
|
||||
if (point.x < xMinimum) xMinimum = point.x
|
||||
if (point.x > xMaximum) xMaximum = point.x
|
||||
if (point.y < yMinimum) yMinimum = point.y
|
||||
if (point.y > yMaximum) yMaximum = point.y
|
||||
if (includeErrors) {
|
||||
const errors = resolveWaveformPointErrors(point)
|
||||
minimum = Math.min(minimum, point.y - errors.lower)
|
||||
maximum = Math.max(maximum, point.y + errors.upper)
|
||||
if (errors.lower !== 0 || errors.upper !== 0) hasErrorPoints = true
|
||||
yMinimum = Math.min(yMinimum, point.y - errors.lower)
|
||||
yMaximum = Math.max(yMaximum, point.y + errors.upper)
|
||||
}
|
||||
}
|
||||
return {
|
||||
xDomain: paddedDomain(Number.isFinite(xMinimum) ? [xMinimum, xMaximum] : []),
|
||||
yDomain: paddedDomain(Number.isFinite(yMinimum) ? [yMinimum, yMaximum] : []),
|
||||
hasErrorPoints,
|
||||
}
|
||||
})
|
||||
return paddedDomain(Number.isFinite(minimum) ? [minimum, maximum] : [])
|
||||
}
|
||||
|
||||
export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSeries[] {
|
||||
return normalizeWaveformSeries(data).map((series) => ({
|
||||
...series,
|
||||
xDomain: pointDomain(series.points, 'x'),
|
||||
yDomain: pointDomain(series.points, 'y', series.errorBar.visible),
|
||||
}))
|
||||
return normalizeWaveformSeries(data).map((series) => {
|
||||
const metrics = pointMetrics(series.points, series.errorBar.visible)
|
||||
return { ...series, ...metrics }
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
|
||||
|
||||
@@ -30,7 +30,11 @@ export type {
|
||||
NormalizedWaveformSeries,
|
||||
} from '../../types'
|
||||
|
||||
export type { WaveformGridOptions } from '../core/grid'
|
||||
export type {
|
||||
WaveformGridOptions,
|
||||
WaveformGridLineOptions,
|
||||
WaveformGridTrackLines,
|
||||
} from '../core/grid'
|
||||
|
||||
// 重新导出数据处理函数
|
||||
export { normalizeWaveformData, normalizeWaveformSeries } from '../../core'
|
||||
|
||||
@@ -24,6 +24,8 @@ export type {
|
||||
WaveformPointType,
|
||||
WaveformErrorBarOptions,
|
||||
WaveformGridOptions,
|
||||
WaveformGridLineOptions,
|
||||
WaveformGridTrackLines,
|
||||
} from './data/types'
|
||||
|
||||
export type { WaveformGridOptions as WaveformGridConfig } from './core/grid'
|
||||
|
||||
@@ -211,42 +211,54 @@ watch(
|
||||
<g
|
||||
class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor"
|
||||
>
|
||||
<template v-if="track.gridLines.vertical">
|
||||
<line
|
||||
v-for="tick in track.xMinorTicks"
|
||||
:key="`x-minor-${track.index}-${tick}`"
|
||||
data-grid-direction="vertical"
|
||||
:x1="track.xScale(tick)"
|
||||
:x2="track.xScale(tick)"
|
||||
y1="0"
|
||||
:y2="track.height"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="track.gridLines.horizontal">
|
||||
<line
|
||||
v-for="tick in track.yMinorTicks"
|
||||
:key="`y-minor-${track.index}-${tick}`"
|
||||
data-grid-direction="horizontal"
|
||||
x1="0"
|
||||
:x2="track.width ?? innerWidth"
|
||||
:y1="track.yScale(tick)"
|
||||
:y2="track.yScale(tick)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
<g
|
||||
class="waveform-track__grid waveform-track__grid--major waveform-chart__grid waveform-chart__grid--major"
|
||||
>
|
||||
<template v-if="track.gridLines.vertical">
|
||||
<line
|
||||
v-for="tick in track.xMajorTicks"
|
||||
:key="`x-major-${track.index}-${tick}`"
|
||||
data-grid-direction="vertical"
|
||||
:x1="track.xScale(tick)"
|
||||
:x2="track.xScale(tick)"
|
||||
y1="0"
|
||||
:y2="track.height"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="track.gridLines.horizontal">
|
||||
<line
|
||||
v-for="tick in track.yMajorTicks"
|
||||
:key="`y-major-${track.index}-${tick}`"
|
||||
data-grid-direction="horizontal"
|
||||
x1="0"
|
||||
:x2="track.width ?? innerWidth"
|
||||
:y1="track.yScale(tick)"
|
||||
:y2="track.yScale(tick)"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
58
src/core/data.test.ts
Normal file
58
src/core/data.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { normalizeWaveformData } from './data'
|
||||
|
||||
describe('waveform data normalization', () => {
|
||||
it('builds sample points in one pass while preserving source indexes', () => {
|
||||
expect(
|
||||
normalizeWaveformData({
|
||||
kind: 'samples',
|
||||
values: [1, Number.NaN, 3],
|
||||
sampleRate: 2,
|
||||
startTime: 1,
|
||||
}),
|
||||
).toEqual([
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips sorting already ordered points and normalizes errors', () => {
|
||||
const sortSpy = vi.spyOn(Array.prototype, 'sort')
|
||||
try {
|
||||
const result = normalizeWaveformData({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 1, error: -1, upperError: 2 },
|
||||
{ x: 1, y: 2, lowerError: 3 },
|
||||
],
|
||||
})
|
||||
|
||||
expect(sortSpy).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([
|
||||
{ x: 0, y: 1, upperError: 2 },
|
||||
{ x: 1, y: 2, lowerError: 3 },
|
||||
])
|
||||
} finally {
|
||||
sortSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('sorts only unordered points and preserves duplicate-x order', () => {
|
||||
expect(
|
||||
normalizeWaveformData({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 2, y: 20 },
|
||||
{ x: 1, y: 10 },
|
||||
{ x: 1, y: 11 },
|
||||
{ x: Number.NaN, y: 12 },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ x: 1, y: 10 },
|
||||
{ x: 1, y: 11 },
|
||||
{ x: 2, y: 20 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -46,15 +46,27 @@ export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[]
|
||||
if (!Number.isFinite(data.sampleRate) || data.sampleRate <= 0) return []
|
||||
|
||||
const startTime = Number.isFinite(data.startTime) ? (data.startTime ?? 0) : 0
|
||||
return data.values.flatMap((value, index) =>
|
||||
Number.isFinite(value) ? [{ x: startTime + index / data.sampleRate, y: value }] : [],
|
||||
)
|
||||
const points: WaveformPoint[] = []
|
||||
for (let index = 0; index < data.values.length; index += 1) {
|
||||
const value = data.values[index]
|
||||
if (!Number.isFinite(value)) continue
|
||||
points.push({ x: startTime + index / data.sampleRate, y: value })
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
return data.points
|
||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||
.map(normalizeWaveformPoint)
|
||||
.sort((left, right) => left.x - right.x)
|
||||
const points: WaveformPoint[] = []
|
||||
let previousX = Number.NEGATIVE_INFINITY
|
||||
let sorted = true
|
||||
for (const point of data.points) {
|
||||
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
|
||||
const normalized = normalizeWaveformPoint(point)
|
||||
if (normalized.x < previousX) sorted = false
|
||||
previousX = normalized.x
|
||||
points.push(normalized)
|
||||
}
|
||||
if (!sorted) points.sort((left, right) => left.x - right.x)
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WaveformPoint } from '../types'
|
||||
import {
|
||||
hasMinimumVisibleXValues,
|
||||
resolveWaveformRenderingOptions,
|
||||
selectDecorationPoints,
|
||||
selectRenderablePoints,
|
||||
selectSeriesRenderPoints,
|
||||
} from './rendering'
|
||||
|
||||
describe('waveform rendering selection', () => {
|
||||
@@ -122,4 +124,66 @@ describe('waveform rendering selection', () => {
|
||||
expect(selected.filter(hasError)).toEqual(priorityPoints.filter(hasError))
|
||||
expect(selected.length).toBeLessThanOrEqual(Math.ceil(100 / 20) + 2)
|
||||
})
|
||||
|
||||
it('shares visible-range selection for a dense 100k-point series', () => {
|
||||
const densePoints = Array.from({ length: 100_000 }, (_, index): WaveformPoint => ({
|
||||
x: index,
|
||||
y: index === 50_001 ? 10_000 : Math.sin(index / 20),
|
||||
error: index % 1_000 === 0 ? 1 : undefined,
|
||||
}))
|
||||
const selected = selectSeriesRenderPoints(
|
||||
densePoints,
|
||||
[99_999, 0],
|
||||
500,
|
||||
resolveWaveformRenderingOptions({ downsampleThreshold: 100 }),
|
||||
{
|
||||
lineVisible: true,
|
||||
pointVisible: true,
|
||||
errorBarVisible: true,
|
||||
hasErrorPoints: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(selected.linePoints.length).toBeLessThanOrEqual(2_002)
|
||||
expect(selected.linePoints).toContain(densePoints[50_001])
|
||||
expect(selected.linePoints[0]).toBe(densePoints[0])
|
||||
expect(selected.linePoints.at(-1)).toBe(densePoints.at(-1))
|
||||
expect(selected.pointRenderPoints.length).toBeLessThanOrEqual(52)
|
||||
expect(selected.errorBarRenderPoints.every((point) => (point.error ?? 0) > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('counts unique visible x values across series and reversed domains', () => {
|
||||
const first = {
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
}
|
||||
const second = {
|
||||
points: [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 3 },
|
||||
{ x: 2, y: 4 },
|
||||
],
|
||||
}
|
||||
|
||||
expect(hasMinimumVisibleXValues([first, second], [2, 0], 3)).toBe(true)
|
||||
expect(hasMinimumVisibleXValues([first, second], [0, 2], 4)).toBe(false)
|
||||
expect(hasMinimumVisibleXValues([first, second], [1, 1], 1)).toBe(true)
|
||||
expect(hasMinimumVisibleXValues([first, second], [3, 4], 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('stops scanning a 100k-point series after reaching the minimum', () => {
|
||||
const source = Array.from({ length: 100_000 }, (_, index) => ({ x: index, y: index }))
|
||||
let pointReads = 0
|
||||
const points = new Proxy(source, {
|
||||
get(target, property, receiver) {
|
||||
if (typeof property === 'string' && /^\d+$/.test(property)) pointReads += 1
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
|
||||
expect(hasMinimumVisibleXValues([{ points }], [0, 99_999], 5)).toBe(true)
|
||||
expect(pointReads).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bisector } from 'd3'
|
||||
|
||||
import type { WaveformPoint, WaveformRenderingOptions } from '@/types'
|
||||
import { resolveWaveformPointErrors } from './data'
|
||||
|
||||
export interface ResolvedWaveformRenderingOptions {
|
||||
downsample: boolean
|
||||
@@ -19,6 +20,59 @@ export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOption
|
||||
}
|
||||
|
||||
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
||||
const acceptAllPoints = () => true
|
||||
|
||||
interface VisiblePointRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface PointSeriesSource {
|
||||
points: WaveformPoint[]
|
||||
}
|
||||
|
||||
interface SeriesRenderSelectionOptions {
|
||||
lineVisible: boolean
|
||||
pointVisible: boolean
|
||||
errorBarVisible: boolean
|
||||
hasErrorPoints: boolean
|
||||
}
|
||||
|
||||
export interface SeriesRenderPointSelection {
|
||||
linePoints: WaveformPoint[]
|
||||
pointRenderPoints: WaveformPoint[]
|
||||
errorBarRenderPoints: WaveformPoint[]
|
||||
}
|
||||
|
||||
export function resolveVisiblePointRange(
|
||||
points: WaveformPoint[],
|
||||
domain: [number, number],
|
||||
): VisiblePointRange {
|
||||
const domainStart = Math.min(domain[0], domain[1])
|
||||
const domainEnd = Math.max(domain[0], domain[1])
|
||||
return {
|
||||
start: pointBisector.left(points, domainStart),
|
||||
end: pointBisector.right(points, domainEnd),
|
||||
}
|
||||
}
|
||||
|
||||
export function hasMinimumVisibleXValues(
|
||||
seriesList: readonly PointSeriesSource[],
|
||||
domain: [number, number],
|
||||
minimum: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(minimum) || minimum <= 0) return true
|
||||
const required = Math.ceil(minimum)
|
||||
const xValues = new Set<number>()
|
||||
for (const series of seriesList) {
|
||||
const range = resolveVisiblePointRange(series.points, domain)
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
xValues.add(series.points[index].x)
|
||||
if (xValues.size >= required) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveWaveformRenderingOptions(
|
||||
options?: WaveformRenderingOptions,
|
||||
@@ -52,24 +106,17 @@ function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefin
|
||||
if (point && target[target.length - 1] !== point) target.push(point)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the visible source range and preserve first/min/max/last values in each X bucket.
|
||||
* Source points must be sorted by X.
|
||||
*/
|
||||
export function selectRenderablePoints(
|
||||
function selectRenderablePointsInRange(
|
||||
points: WaveformPoint[],
|
||||
range: VisiblePointRange,
|
||||
domain: [number, number],
|
||||
width: number,
|
||||
options: ResolvedWaveformRenderingOptions,
|
||||
): WaveformPoint[] {
|
||||
if (!points.length || width <= 0) return []
|
||||
|
||||
const domainStart = Math.min(domain[0], domain[1])
|
||||
const domainEnd = Math.max(domain[0], domain[1])
|
||||
const visibleStart = pointBisector.left(points, domainStart)
|
||||
const visibleEnd = pointBisector.right(points, domainEnd)
|
||||
const start = Math.max(0, visibleStart - 1)
|
||||
const end = Math.min(points.length, visibleEnd + 1)
|
||||
const start = Math.max(0, range.start - 1)
|
||||
const end = Math.min(points.length, range.end + 1)
|
||||
const visibleCount = end - start
|
||||
if (visibleCount <= 0) return []
|
||||
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
|
||||
@@ -82,22 +129,45 @@ export function selectRenderablePoints(
|
||||
|
||||
const result: WaveformPoint[] = []
|
||||
const span = domainEnd - domainStart || 1
|
||||
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
||||
let activeBucket = -1
|
||||
let firstIndex = -1
|
||||
let lastIndex = -1
|
||||
let minimumIndex = -1
|
||||
let maximumIndex = -1
|
||||
|
||||
const addBucketIndex = (index: number, count: number) => {
|
||||
if (index < 0) return count
|
||||
for (let position = 0; position < count; position += 1) {
|
||||
if (bucketIndexes[position] === index) return count
|
||||
}
|
||||
bucketIndexes[count] = index
|
||||
return count + 1
|
||||
}
|
||||
|
||||
const flushBucket = () => {
|
||||
if (firstIndex < 0) return
|
||||
const indexes = [firstIndex, minimumIndex, maximumIndex, lastIndex]
|
||||
.filter((index, position, source) => index >= 0 && source.indexOf(index) === position)
|
||||
.sort((left, right) => left - right)
|
||||
indexes.forEach((index) => pushUniquePoint(result, points[index]))
|
||||
let count = 0
|
||||
count = addBucketIndex(firstIndex, count)
|
||||
count = addBucketIndex(minimumIndex, count)
|
||||
count = addBucketIndex(maximumIndex, count)
|
||||
count = addBucketIndex(lastIndex, count)
|
||||
for (let index = 1; index < count; index += 1) {
|
||||
const value = bucketIndexes[index]
|
||||
let position = index - 1
|
||||
while (position >= 0 && bucketIndexes[position] > value) {
|
||||
bucketIndexes[position + 1] = bucketIndexes[position]
|
||||
position -= 1
|
||||
}
|
||||
bucketIndexes[position + 1] = value
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
pushUniquePoint(result, points[bucketIndexes[index]])
|
||||
}
|
||||
}
|
||||
|
||||
pushUniquePoint(result, points[start])
|
||||
for (let index = Math.max(start, visibleStart); index < Math.min(end, visibleEnd); index += 1) {
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const point = points[index]
|
||||
const bucket = Math.min(
|
||||
bucketCount - 1,
|
||||
@@ -121,33 +191,61 @@ export function selectRenderablePoints(
|
||||
return result
|
||||
}
|
||||
|
||||
/** Selects real source points for discrete decorations without using line-extrema sampling. */
|
||||
export function selectDecorationPoints(
|
||||
/**
|
||||
* Select the visible source range and preserve first/min/max/last values in each X bucket.
|
||||
* Source points must be sorted by X.
|
||||
*/
|
||||
export function selectRenderablePoints(
|
||||
points: WaveformPoint[],
|
||||
domain: [number, number],
|
||||
width: number,
|
||||
options: ResolvedWaveformRenderingOptions,
|
||||
): WaveformPoint[] {
|
||||
if (!points.length || width <= 0) return []
|
||||
return selectRenderablePointsInRange(
|
||||
points,
|
||||
resolveVisiblePointRange(points, domain),
|
||||
domain,
|
||||
width,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
function selectDecorationPointsInRange(
|
||||
points: WaveformPoint[],
|
||||
range: VisiblePointRange,
|
||||
domain: [number, number],
|
||||
width: number,
|
||||
minSpacing: number,
|
||||
downsample: boolean,
|
||||
predicate: (point: WaveformPoint) => boolean = () => true,
|
||||
predicate: (point: WaveformPoint) => boolean,
|
||||
priorityPredicate?: (point: WaveformPoint) => boolean,
|
||||
): WaveformPoint[] {
|
||||
if (!points.length || width <= 0) return []
|
||||
if (!downsample || minSpacing === 0) {
|
||||
if (predicate === acceptAllPoints) return points.slice(range.start, range.end)
|
||||
const visiblePoints: WaveformPoint[] = []
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
if (predicate(points[index])) visiblePoints.push(points[index])
|
||||
}
|
||||
return visiblePoints
|
||||
}
|
||||
|
||||
const domainStart = Math.min(domain[0], domain[1])
|
||||
const domainEnd = Math.max(domain[0], domain[1])
|
||||
const visibleStart = pointBisector.left(points, domainStart)
|
||||
const visibleEnd = pointBisector.right(points, domainEnd)
|
||||
if (!downsample || minSpacing === 0) {
|
||||
return points.slice(visibleStart, visibleEnd).filter(predicate)
|
||||
}
|
||||
|
||||
const span = domainEnd - domainStart
|
||||
if (span <= 0) {
|
||||
const point = points.slice(visibleStart, visibleEnd).find(predicate)
|
||||
return point ? [point] : []
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
if (predicate(points[index])) return [points[index]]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const toPixel = (point: WaveformPoint) => ((point.x - domainStart) / span) * width
|
||||
const bucketCount = Math.max(1, Math.ceil(width / minSpacing))
|
||||
const bucketWidth = width / bucketCount
|
||||
let bucketPoints: Array<WaveformPoint | undefined> | undefined
|
||||
let bucketDistances: number[] | undefined
|
||||
let priorityBucketPoints: Array<WaveformPoint | undefined> | undefined
|
||||
let priorityBucketDistances: number[] | undefined
|
||||
const sparsePoints: WaveformPoint[] = []
|
||||
let alreadySparse = true
|
||||
let first: WaveformPoint | undefined
|
||||
@@ -155,44 +253,10 @@ export function selectDecorationPoints(
|
||||
let previousPixel = Number.NEGATIVE_INFINITY
|
||||
let candidateCount = 0
|
||||
|
||||
for (let index = visibleStart; index < visibleEnd; index += 1) {
|
||||
const point = points[index]
|
||||
if (!predicate(point)) continue
|
||||
first ??= point
|
||||
last = point
|
||||
candidateCount += 1
|
||||
if (!alreadySparse) continue
|
||||
const pixel = toPixel(point)
|
||||
if (pixel - previousPixel < minSpacing) {
|
||||
alreadySparse = false
|
||||
sparsePoints.length = 0
|
||||
continue
|
||||
const recordBucketPoint = (point: WaveformPoint, pixel: number) => {
|
||||
if (!bucketPoints || !bucketDistances || !priorityBucketPoints || !priorityBucketDistances) {
|
||||
return
|
||||
}
|
||||
sparsePoints.push(point)
|
||||
previousPixel = pixel
|
||||
}
|
||||
if (candidateCount <= 2) {
|
||||
if (!first) return []
|
||||
return last && last !== first ? [first, last] : [first]
|
||||
}
|
||||
if (alreadySparse) return sparsePoints
|
||||
|
||||
const bucketCount = Math.max(1, Math.ceil(width / minSpacing))
|
||||
const bucketWidth = width / bucketCount
|
||||
const bucketPoints: Array<WaveformPoint | undefined> = Array.from({ length: bucketCount })
|
||||
const bucketDistances = Array.from({ length: bucketCount }, () => Number.POSITIVE_INFINITY)
|
||||
const priorityBucketPoints: Array<WaveformPoint | undefined> = Array.from({
|
||||
length: bucketCount,
|
||||
})
|
||||
const priorityBucketDistances = Array.from(
|
||||
{ length: bucketCount },
|
||||
() => Number.POSITIVE_INFINITY,
|
||||
)
|
||||
|
||||
for (let index = visibleStart; index < visibleEnd; index += 1) {
|
||||
const point = points[index]
|
||||
if (!predicate(point)) continue
|
||||
const pixel = Math.max(0, Math.min(width, toPixel(point)))
|
||||
const bucket = Math.min(bucketCount - 1, Math.floor(pixel / bucketWidth))
|
||||
const center = (bucket + 0.5) * bucketWidth
|
||||
const distance = Math.abs(pixel - center)
|
||||
@@ -206,10 +270,133 @@ export function selectDecorationPoints(
|
||||
}
|
||||
}
|
||||
|
||||
const selected = bucketPoints
|
||||
.map((point, index) => priorityBucketPoints[index] ?? point)
|
||||
const initializeBuckets = () => {
|
||||
bucketPoints = Array.from({ length: bucketCount })
|
||||
bucketDistances = Array.from({ length: bucketCount }, () => Number.POSITIVE_INFINITY)
|
||||
priorityBucketPoints = Array.from({ length: bucketCount })
|
||||
priorityBucketDistances = Array.from({ length: bucketCount }, () => Number.POSITIVE_INFINITY)
|
||||
for (const point of sparsePoints) {
|
||||
const pixel = Math.max(0, Math.min(width, ((point.x - domainStart) / span) * width))
|
||||
recordBucketPoint(point, pixel)
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const point = points[index]
|
||||
if (!predicate(point)) continue
|
||||
first ??= point
|
||||
last = point
|
||||
candidateCount += 1
|
||||
const pixel = Math.max(0, Math.min(width, ((point.x - domainStart) / span) * width))
|
||||
if (alreadySparse) {
|
||||
if (pixel - previousPixel < minSpacing) {
|
||||
alreadySparse = false
|
||||
initializeBuckets()
|
||||
sparsePoints.length = 0
|
||||
} else {
|
||||
sparsePoints.push(point)
|
||||
previousPixel = pixel
|
||||
}
|
||||
}
|
||||
if (!alreadySparse) recordBucketPoint(point, pixel)
|
||||
}
|
||||
if (candidateCount <= 2) {
|
||||
if (!first) return []
|
||||
return last && last !== first ? [first, last] : [first]
|
||||
}
|
||||
if (alreadySparse) return sparsePoints
|
||||
|
||||
const selected = (bucketPoints ?? [])
|
||||
.map((point, index) => priorityBucketPoints?.[index] ?? point)
|
||||
.filter((point): point is WaveformPoint => point !== undefined)
|
||||
if (first && selected[0] !== first) selected.unshift(first)
|
||||
if (last && selected.at(-1) !== last) selected.push(last)
|
||||
return selected
|
||||
}
|
||||
|
||||
/** Selects real source points for discrete decorations without using line-extrema sampling. */
|
||||
export function selectDecorationPoints(
|
||||
points: WaveformPoint[],
|
||||
domain: [number, number],
|
||||
width: number,
|
||||
minSpacing: number,
|
||||
downsample: boolean,
|
||||
predicate: (point: WaveformPoint) => boolean = acceptAllPoints,
|
||||
priorityPredicate?: (point: WaveformPoint) => boolean,
|
||||
): WaveformPoint[] {
|
||||
if (!points.length || width <= 0) return []
|
||||
return selectDecorationPointsInRange(
|
||||
points,
|
||||
resolveVisiblePointRange(points, domain),
|
||||
domain,
|
||||
width,
|
||||
minSpacing,
|
||||
downsample,
|
||||
predicate,
|
||||
priorityPredicate,
|
||||
)
|
||||
}
|
||||
|
||||
function hasPointError(point: WaveformPoint): boolean {
|
||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
||||
return lower !== 0 || upper !== 0
|
||||
}
|
||||
|
||||
export function selectSeriesRenderPoints(
|
||||
points: WaveformPoint[],
|
||||
domain: [number, number],
|
||||
width: number,
|
||||
rendering: ResolvedWaveformRenderingOptions,
|
||||
selection: SeriesRenderSelectionOptions,
|
||||
): SeriesRenderPointSelection {
|
||||
if (!points.length || width <= 0) {
|
||||
return { linePoints: [], pointRenderPoints: [], errorBarRenderPoints: [] }
|
||||
}
|
||||
const range = resolveVisiblePointRange(points, domain)
|
||||
const linePoints = selection.lineVisible
|
||||
? selectRenderablePointsInRange(points, range, domain, width, rendering)
|
||||
: []
|
||||
const errorBarVisible = selection.errorBarVisible && selection.hasErrorPoints
|
||||
if (selection.pointVisible && errorBarVisible) {
|
||||
const sharedPoints = selectDecorationPointsInRange(
|
||||
points,
|
||||
range,
|
||||
domain,
|
||||
width,
|
||||
Math.max(rendering.pointMinSpacing, rendering.errorBarMinSpacing),
|
||||
rendering.downsample,
|
||||
acceptAllPoints,
|
||||
hasPointError,
|
||||
)
|
||||
return {
|
||||
linePoints,
|
||||
pointRenderPoints: sharedPoints,
|
||||
errorBarRenderPoints: sharedPoints.filter(hasPointError),
|
||||
}
|
||||
}
|
||||
return {
|
||||
linePoints,
|
||||
pointRenderPoints: selection.pointVisible
|
||||
? selectDecorationPointsInRange(
|
||||
points,
|
||||
range,
|
||||
domain,
|
||||
width,
|
||||
rendering.pointMinSpacing,
|
||||
rendering.downsample,
|
||||
acceptAllPoints,
|
||||
)
|
||||
: [],
|
||||
errorBarRenderPoints: errorBarVisible
|
||||
? selectDecorationPointsInRange(
|
||||
points,
|
||||
range,
|
||||
domain,
|
||||
width,
|
||||
rendering.errorBarMinSpacing,
|
||||
rendering.downsample,
|
||||
hasPointError,
|
||||
)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ export type {
|
||||
NormalizedWaveformSeries,
|
||||
} from './types'
|
||||
|
||||
export type { WaveformGridOptions } from './components/core/grid'
|
||||
export type {
|
||||
WaveformGridOptions,
|
||||
WaveformGridLineOptions,
|
||||
WaveformGridTrackLines,
|
||||
} from './components/core/grid'
|
||||
|
||||
// 核心功能
|
||||
export { normalizeWaveformData, normalizeWaveformSeries } from './core'
|
||||
|
||||
Reference in New Issue
Block a user