perf: 优化波形核心算法与图框网格控制

This commit is contained in:
李启源
2026-07-22 10:56:25 +08:00
parent 21bf0d803e
commit 356f55c9fd
16 changed files with 613 additions and 191 deletions

View File

@@ -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 },

View File

@@ -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 {

View File

@@ -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,
})
})

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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' ||

View File

@@ -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 方便使用

View File

@@ -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) {

View File

@@ -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'

View File

@@ -24,6 +24,8 @@ export type {
WaveformPointType,
WaveformErrorBarOptions,
WaveformGridOptions,
WaveformGridLineOptions,
WaveformGridTrackLines,
} from './data/types'
export type { WaveformGridOptions as WaveformGridConfig } from './core/grid'

View File

@@ -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
View 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 },
])
})
})

View File

@@ -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
}
/**

View File

@@ -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)
})
})

View File

@@ -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,
)
: [],
}
}

View File

@@ -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'