403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import { bisector } from 'd3'
|
|
|
|
import type { WaveformPoint, WaveformRenderingOptions } from '@/types'
|
|
import { resolveWaveformPointErrors } from './data'
|
|
|
|
export interface ResolvedWaveformRenderingOptions {
|
|
downsample: boolean
|
|
downsampleThreshold: number
|
|
maxPointsPerPixel: number
|
|
pointMinSpacing: number
|
|
errorBarMinSpacing: number
|
|
}
|
|
|
|
export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOptions = {
|
|
downsample: true,
|
|
downsampleThreshold: 2_000,
|
|
maxPointsPerPixel: 4,
|
|
pointMinSpacing: 10,
|
|
errorBarMinSpacing: 12,
|
|
}
|
|
|
|
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,
|
|
): ResolvedWaveformRenderingOptions {
|
|
const threshold = Number(options?.downsampleThreshold)
|
|
const pointsPerPixel = Number(options?.maxPointsPerPixel)
|
|
const pointMinSpacing = Number(options?.pointMinSpacing)
|
|
const errorBarMinSpacing = Number(options?.errorBarMinSpacing)
|
|
return {
|
|
downsample: options?.downsample ?? DEFAULT_WAVEFORM_RENDERING_OPTIONS.downsample,
|
|
downsampleThreshold:
|
|
Number.isFinite(threshold) && threshold >= 2
|
|
? Math.floor(threshold)
|
|
: DEFAULT_WAVEFORM_RENDERING_OPTIONS.downsampleThreshold,
|
|
maxPointsPerPixel:
|
|
Number.isFinite(pointsPerPixel) && pointsPerPixel > 0
|
|
? pointsPerPixel
|
|
: DEFAULT_WAVEFORM_RENDERING_OPTIONS.maxPointsPerPixel,
|
|
pointMinSpacing:
|
|
Number.isFinite(pointMinSpacing) && pointMinSpacing >= 0
|
|
? pointMinSpacing
|
|
: DEFAULT_WAVEFORM_RENDERING_OPTIONS.pointMinSpacing,
|
|
errorBarMinSpacing:
|
|
Number.isFinite(errorBarMinSpacing) && errorBarMinSpacing >= 0
|
|
? errorBarMinSpacing
|
|
: DEFAULT_WAVEFORM_RENDERING_OPTIONS.errorBarMinSpacing,
|
|
}
|
|
}
|
|
|
|
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
|
if (point && target[target.length - 1] !== point) target.push(point)
|
|
}
|
|
|
|
function selectRenderablePointsInRange(
|
|
points: WaveformPoint[],
|
|
range: VisiblePointRange,
|
|
domain: [number, number],
|
|
width: number,
|
|
options: ResolvedWaveformRenderingOptions,
|
|
): WaveformPoint[] {
|
|
const domainStart = Math.min(domain[0], domain[1])
|
|
const domainEnd = Math.max(domain[0], domain[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) {
|
|
return points.slice(start, end)
|
|
}
|
|
|
|
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
|
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
|
if (visibleCount <= maximumPointCount) return points.slice(start, end)
|
|
|
|
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
|
|
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 = range.start; index < range.end; index += 1) {
|
|
const point = points[index]
|
|
const bucket = Math.min(
|
|
bucketCount - 1,
|
|
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
|
)
|
|
if (bucket !== activeBucket) {
|
|
flushBucket()
|
|
activeBucket = bucket
|
|
firstIndex = index
|
|
lastIndex = index
|
|
minimumIndex = index
|
|
maximumIndex = index
|
|
continue
|
|
}
|
|
lastIndex = index
|
|
if (point.y < points[minimumIndex].y) minimumIndex = index
|
|
if (point.y > points[maximumIndex].y) maximumIndex = index
|
|
}
|
|
flushBucket()
|
|
pushUniquePoint(result, points[end - 1])
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
priorityPredicate?: (point: WaveformPoint) => boolean,
|
|
): WaveformPoint[] {
|
|
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 span = domainEnd - domainStart
|
|
if (span <= 0) {
|
|
for (let index = range.start; index < range.end; index += 1) {
|
|
if (predicate(points[index])) return [points[index]]
|
|
}
|
|
return []
|
|
}
|
|
|
|
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
|
|
let last: WaveformPoint | undefined
|
|
let previousPixel = Number.NEGATIVE_INFINITY
|
|
let candidateCount = 0
|
|
|
|
const recordBucketPoint = (point: WaveformPoint, pixel: number) => {
|
|
if (!bucketPoints || !bucketDistances || !priorityBucketPoints || !priorityBucketDistances) {
|
|
return
|
|
}
|
|
const bucket = Math.min(bucketCount - 1, Math.floor(pixel / bucketWidth))
|
|
const center = (bucket + 0.5) * bucketWidth
|
|
const distance = Math.abs(pixel - center)
|
|
if (distance < bucketDistances[bucket]) {
|
|
bucketPoints[bucket] = point
|
|
bucketDistances[bucket] = distance
|
|
}
|
|
if (priorityPredicate?.(point) && distance < priorityBucketDistances[bucket]) {
|
|
priorityBucketPoints[bucket] = point
|
|
priorityBucketDistances[bucket] = distance
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
: [],
|
|
}
|
|
}
|