feat: 优化波形渲染性能与交互
All checks were successful
Package component / package (push) Successful in 5m56s

This commit is contained in:
李启源
2026-07-22 16:36:48 +08:00
parent 356f55c9fd
commit 5b0ba7413e
35 changed files with 8208 additions and 246 deletions

View File

@@ -29,3 +29,11 @@ export {
// 几何计算工具
export { resolveTrackGeometry, clamp, type TrackGeometry } from './geometry'
// 数据抽样工具
export {
downsampleLTTB,
downsampleMinMax,
adaptiveSampling,
calculateSamplingThreshold,
} from './sampling'

226
src/utils/sampling.test.ts Normal file
View File

@@ -0,0 +1,226 @@
/**
* 数据抽样算法测试
*/
import { describe, it, expect } from 'vitest'
import {
downsampleLTTB,
downsampleMinMax,
adaptiveSampling,
calculateSamplingThreshold,
} from './sampling'
import type { WaveformPoint } from '../types'
describe('downsampleLTTB', () => {
it('returns empty array for empty input', () => {
expect(downsampleLTTB([], 100)).toEqual([])
})
it('returns original data when threshold >= data length', () => {
const data: WaveformPoint[] = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 },
]
expect(downsampleLTTB(data, 5)).toEqual(data)
expect(downsampleLTTB(data, 3)).toEqual(data)
})
it('preserves first and last points', () => {
const data: WaveformPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: Math.sin(i / 100),
}))
const sampled = downsampleLTTB(data, 50)
expect(sampled[0]).toEqual(data[0])
expect(sampled[sampled.length - 1]).toEqual(data[data.length - 1])
})
it('reduces data to approximately threshold length', () => {
const data: WaveformPoint[] = Array.from({ length: 10000 }, (_, i) => ({
x: i,
y: Math.sin(i / 100),
}))
const threshold = 500
const sampled = downsampleLTTB(data, threshold)
expect(sampled.length).toBe(threshold)
})
it('maintains sorted order', () => {
const data: WaveformPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: Math.random(),
}))
const sampled = downsampleLTTB(data, 100)
for (let i = 1; i < sampled.length; i++) {
expect(sampled[i]!.x).toBeGreaterThan(sampled[i - 1]!.x)
}
})
it('handles minimum threshold of 3', () => {
const data: WaveformPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: i,
}))
const sampled = downsampleLTTB(data, 2)
expect(sampled.length).toBeGreaterThanOrEqual(2)
})
it('preserves peaks in sine wave', () => {
// 生成包含明确峰值的正弦波
const data: WaveformPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: Math.sin((i / 1000) * Math.PI * 4), // 4个周期
}))
const sampled = downsampleLTTB(data, 100)
// 检查是否保留了接近峰值的点
const maxY = Math.max(...sampled.map((p) => p.y))
const minY = Math.min(...sampled.map((p) => p.y))
expect(maxY).toBeGreaterThan(0.9) // 接近1
expect(minY).toBeLessThan(-0.9) // 接近-1
})
})
describe('downsampleMinMax', () => {
it('returns empty array for empty input', () => {
expect(downsampleMinMax([], 100)).toEqual([])
})
it('returns original data when threshold >= data length', () => {
const data: WaveformPoint[] = [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 },
]
expect(downsampleMinMax(data, 5)).toEqual(data)
})
it('captures min and max values in each bucket', () => {
const data: WaveformPoint[] = [
{ x: 0, y: 5 },
{ x: 1, y: 1 }, // min
{ x: 2, y: 10 }, // max
{ x: 3, y: 3 },
{ x: 4, y: 7 },
]
const sampled = downsampleMinMax(data, 2)
// 应该包含最小值和最大值
const yValues = sampled.map((p) => p.y)
expect(yValues).toContain(1)
expect(yValues).toContain(10)
})
it('maintains sorted order', () => {
const data: WaveformPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: Math.random(),
}))
const sampled = downsampleMinMax(data, 100)
for (let i = 1; i < sampled.length; i++) {
expect(sampled[i]!.x).toBeGreaterThanOrEqual(sampled[i - 1]!.x)
}
})
it('preserves overall range of data', () => {
const data: WaveformPoint[] = Array.from({ length: 1000 }, (_, i) => ({
x: i,
y: Math.sin(i / 100) * 100,
}))
const sampled = downsampleMinMax(data, 50)
const originalMax = Math.max(...data.map((p) => p.y))
const originalMin = Math.min(...data.map((p) => p.y))
const sampledMax = Math.max(...sampled.map((p) => p.y))
const sampledMin = Math.min(...sampled.map((p) => p.y))
expect(Math.abs(sampledMax - originalMax)).toBeLessThan(1)
expect(Math.abs(sampledMin - originalMin)).toBeLessThan(1)
})
})
describe('adaptiveSampling', () => {
it('returns original data when below threshold', () => {
const data: WaveformPoint[] = Array.from({ length: 100 }, (_, i) => ({
x: i,
y: i,
}))
const result = adaptiveSampling(data, 500)
expect(result.points).toEqual(data)
expect(result.algorithm).toBe('none')
expect(result.originalCount).toBe(100)
})
it('uses LTTB for moderate data sets', () => {
const data: WaveformPoint[] = Array.from({ length: 10000 }, (_, i) => ({
x: i,
y: Math.sin(i / 100),
}))
const result = adaptiveSampling(data, 1000)
expect(result.points.length).toBeLessThanOrEqual(1000)
expect(result.algorithm).toBe('lttb')
expect(result.originalCount).toBe(10000)
})
it('uses MinMax for very large data sets', () => {
const data: WaveformPoint[] = Array.from({ length: 100000 }, (_, i) => ({
x: i,
y: Math.sin(i / 100),
}))
const result = adaptiveSampling(data, 1000)
expect(result.points.length).toBeGreaterThan(0)
expect(result.algorithm).toBe('minmax')
expect(result.originalCount).toBe(100000)
})
it('respects custom maxPoints parameter', () => {
const data: WaveformPoint[] = Array.from({ length: 10000 }, (_, i) => ({
x: i,
y: i,
}))
const result = adaptiveSampling(data, 200)
expect(result.points.length).toBeLessThanOrEqual(200)
})
})
describe('calculateSamplingThreshold', () => {
it('returns reasonable threshold for typical viewport', () => {
const threshold = calculateSamplingThreshold(1000, 1, 2)
expect(threshold).toBe(2000)
})
it('scales with pixel ratio', () => {
const threshold1x = calculateSamplingThreshold(1000, 1, 2)
const threshold2x = calculateSamplingThreshold(1000, 2, 2)
expect(threshold2x).toBe(threshold1x * 2)
})
it('scales with points per pixel', () => {
const threshold2pp = calculateSamplingThreshold(1000, 1, 2)
const threshold4pp = calculateSamplingThreshold(1000, 1, 4)
expect(threshold4pp).toBe(threshold2pp * 2)
})
it('returns minimum of 100 points', () => {
const threshold = calculateSamplingThreshold(10, 1, 1)
expect(threshold).toBeGreaterThanOrEqual(100)
})
it('handles high DPI displays', () => {
const threshold = calculateSamplingThreshold(1920, 2, 2)
expect(threshold).toBe(7680)
})
})

229
src/utils/sampling.ts Normal file
View File

@@ -0,0 +1,229 @@
/**
* 数据抽样算法
* 用于在保持视觉保真度的同时减少渲染点数
*/
import type { WaveformPoint } from '../types'
/**
* Largest Triangle Three Buckets (LTTB) 抽样算法
*
* 这是一种高效的降采样算法,能够在减少数据点的同时保持波形的视觉特征。
* 算法通过计算三角形面积来选择最具代表性的点。
*
* 参考文献: Sveinn Steinarsson. 2013.
* "Downsampling Time Series for Visual Representation"
*
* @param data 原始数据点数组
* @param threshold 目标点数(必须 >= 3
* @returns 抽样后的数据点数组
*
* @example
* const original = Array.from({ length: 10000 }, (_, i) => ({ x: i, y: Math.sin(i / 100) }))
* const sampled = downsampleLTTB(original, 500) // 从 10000 点降至 500 点
*/
export function downsampleLTTB(data: WaveformPoint[], threshold: number): WaveformPoint[] {
// 边界检查
if (!Array.isArray(data) || data.length === 0) {
return []
}
const dataLength = data.length
// 如果数据点数少于或等于阈值,直接返回
if (threshold >= dataLength || threshold <= 2) {
return data
}
// 确保阈值至少为 3
const sampledLength = Math.max(3, Math.floor(threshold))
const sampled: WaveformPoint[] = new Array(sampledLength)
// 始终保留第一个和最后一个点
sampled[0] = data[0]!
sampled[sampledLength - 1] = data[dataLength - 1]!
// 计算每个桶的大小(除了第一个和最后一个点)
const bucketSize = (dataLength - 2) / (sampledLength - 2)
// 用于计算三角形面积的辅助变量
let sampledIndex = 1
for (let i = 0; i < sampledLength - 2; i++) {
// 当前桶的范围
const avgRangeStart = Math.floor((i + 1) * bucketSize) + 1
const avgRangeEnd = Math.floor((i + 2) * bucketSize) + 1
const avgRangeLength = Math.min(avgRangeEnd, dataLength) - avgRangeStart
// 计算下一个桶的平均点(用于三角形计算)
let avgX = 0
let avgY = 0
for (let j = avgRangeStart; j < Math.min(avgRangeEnd, dataLength); j++) {
const point = data[j]!
avgX += point.x
avgY += point.y
}
if (avgRangeLength > 0) {
avgX /= avgRangeLength
avgY /= avgRangeLength
}
// 当前桶的范围
const rangeStart = Math.floor(i * bucketSize) + 1
const rangeEnd = Math.floor((i + 1) * bucketSize) + 1
// 上一个选中的点
const prevPoint = sampled[sampledIndex - 1]!
// 在当前桶中找到形成最大三角形面积的点
let maxArea = -1
let maxAreaIndex = rangeStart
for (let j = rangeStart; j < Math.min(rangeEnd, dataLength); j++) {
const point = data[j]!
// 计算三角形面积(使用叉积公式的绝对值)
// Area = |((x1 - x3)(y2 - y1) - (x1 - x2)(y3 - y1))| / 2
// 为了性能我们省略除以2因为只需要比较相对大小
const area = Math.abs(
(prevPoint.x - avgX) * (point.y - prevPoint.y) -
(prevPoint.x - point.x) * (avgY - prevPoint.y),
)
if (area > maxArea) {
maxArea = area
maxAreaIndex = j
}
}
// 选择形成最大面积的点
sampled[sampledIndex] = data[maxAreaIndex]!
sampledIndex++
}
return sampled
}
/**
* 最小-最大抽样算法
*
* 这是一种简单但有效的抽样方法,将数据分成桶,每个桶选择最小值和最大值。
* 适合展示数据的整体范围和波动,但可能会丢失一些细节特征。
*
* @param data 原始数据点数组
* @param threshold 目标点数(必须 >= 2最终点数可能略多于阈值
* @returns 抽样后的数据点数组
*
* @example
* const original = Array.from({ length: 10000 }, (_, i) => ({ x: i, y: Math.sin(i / 100) }))
* const sampled = downsampleMinMax(original, 500)
*/
export function downsampleMinMax(data: WaveformPoint[], threshold: number): WaveformPoint[] {
if (!Array.isArray(data) || data.length === 0) {
return []
}
const dataLength = data.length
// 如果数据点数少于阈值,直接返回
if (threshold >= dataLength || threshold <= 1) {
return data
}
const sampled: WaveformPoint[] = []
// 计算每个桶的大小
const bucketSize = Math.max(1, Math.floor(dataLength / Math.floor(threshold / 2)))
for (let i = 0; i < dataLength; i += bucketSize) {
const bucketEnd = Math.min(i + bucketSize, dataLength)
let minPoint = data[i]!
let maxPoint = data[i]!
// 在当前桶中找到最小和最大的Y值
for (let j = i + 1; j < bucketEnd; j++) {
const point = data[j]!
if (point.y < minPoint.y) {
minPoint = point
}
if (point.y > maxPoint.y) {
maxPoint = point
}
}
// 按X坐标顺序添加最小值和最大值
if (minPoint.x < maxPoint.x) {
sampled.push(minPoint)
if (minPoint !== maxPoint) {
sampled.push(maxPoint)
}
} else {
sampled.push(maxPoint)
if (minPoint !== maxPoint) {
sampled.push(minPoint)
}
}
}
return sampled
}
/**
* 自适应抽样策略
*
* 根据数据量自动选择最合适的抽样算法和阈值
*
* @param data 原始数据点数组
* @param maxPoints 最大显示点数可选默认为5000
* @returns 抽样后的数据点数组和使用的算法信息
*/
export function adaptiveSampling(
data: WaveformPoint[],
maxPoints: number = 5000,
): { points: WaveformPoint[]; algorithm: 'none' | 'lttb' | 'minmax'; originalCount: number } {
const dataLength = data.length
// 不需要抽样
if (dataLength <= maxPoints) {
return { points: data, algorithm: 'none', originalCount: dataLength }
}
// 根据数据量选择算法
// LTTB 适合保持波形形状,但对极大数据集可能较慢
// MinMax 适合快速预览大数据集的范围
if (dataLength > maxPoints * 10) {
// 超大数据集,使用更快的 MinMax
return {
points: downsampleMinMax(data, maxPoints),
algorithm: 'minmax',
originalCount: dataLength,
}
} else {
// 使用 LTTB 以获得更好的视觉质量
return {
points: downsampleLTTB(data, maxPoints),
algorithm: 'lttb',
originalCount: dataLength,
}
}
}
/**
* 计算推荐的抽样阈值
*
* 基于视口宽度和像素密度计算合理的抽样点数
*
* @param viewportWidth 视口宽度(像素)
* @param pixelRatio 设备像素比(默认为 window.devicePixelRatio 或 1
* @param pointsPerPixel 每像素点数(默认为 2意味着每像素最多2个数据点
* @returns 推荐的抽样点数
*/
export function calculateSamplingThreshold(
viewportWidth: number,
pixelRatio: number = typeof window !== 'undefined' ? window.devicePixelRatio : 1,
pointsPerPixel: number = 2,
): number {
return Math.max(100, Math.floor(viewportWidth * pixelRatio * pointsPerPixel))
}