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

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