59
src/core/data.ts
Normal file
59
src/core/data.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { SingleWaveformData, WaveformData, WaveformPoint, NormalizedWaveformSeries } from '../types'
|
||||
|
||||
/**
|
||||
* 规范化单波形数据
|
||||
* @param data 输入数据(samples 或 points 格式)
|
||||
* @returns 规范化后的点数组
|
||||
*/
|
||||
export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[] {
|
||||
if (data.kind === 'samples') {
|
||||
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 }] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return data.points
|
||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||
.map((point) => ({ ...point }))
|
||||
.sort((left, right) => left.x - right.x)
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化波形系列数据
|
||||
* @param data 输入数据(单波形或多通道)
|
||||
* @returns 规范化后的系列数组
|
||||
*/
|
||||
export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformSeries[] {
|
||||
if (data.kind !== 'series') {
|
||||
const points = normalizeWaveformData(data)
|
||||
return points.length > 0 ? [{ id: 'series-0', name: '', points }] : []
|
||||
}
|
||||
|
||||
const usedIds = new Set<string>()
|
||||
|
||||
return data.series
|
||||
.map((series, index) => {
|
||||
const id = series.id?.trim() || `series-${index}`
|
||||
|
||||
// 确保 ID 唯一,如果重复则添加后缀
|
||||
let uniqueId = id
|
||||
let suffix = 1
|
||||
while (usedIds.has(uniqueId)) {
|
||||
uniqueId = `${id}-${suffix}`
|
||||
suffix++
|
||||
}
|
||||
usedIds.add(uniqueId)
|
||||
|
||||
return {
|
||||
id: uniqueId,
|
||||
name: series.name,
|
||||
unit: series.unit,
|
||||
color: series.color,
|
||||
points: normalizeWaveformData(series.data),
|
||||
}
|
||||
})
|
||||
.filter((series) => series.points.length > 0)
|
||||
}
|
||||
12
src/core/index.ts
Normal file
12
src/core/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 核心引擎模块统一导出
|
||||
*/
|
||||
|
||||
// 数据处理
|
||||
export { normalizeWaveformData, normalizeWaveformSeries } from './data'
|
||||
export {
|
||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
resolveWaveformRenderingOptions,
|
||||
selectRenderablePoints,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from './rendering'
|
||||
43
src/core/rendering.test.ts
Normal file
43
src/core/rendering.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WaveformPoint } from '../types'
|
||||
import { resolveWaveformRenderingOptions, selectRenderablePoints } from './rendering'
|
||||
|
||||
describe('waveform rendering selection', () => {
|
||||
const points = Array.from({ length: 10_000 }, (_, index): WaveformPoint => ({
|
||||
x: index,
|
||||
y: index === 5_001 ? 10_000 : Math.sin(index / 20),
|
||||
}))
|
||||
|
||||
it('clips to the visible domain and retains one continuity point on each side', () => {
|
||||
const selected = selectRenderablePoints(
|
||||
points,
|
||||
[4_000, 4_100],
|
||||
500,
|
||||
resolveWaveformRenderingOptions({ downsample: false }),
|
||||
)
|
||||
|
||||
expect(selected[0].x).toBe(3_999)
|
||||
expect(selected.at(-1)?.x).toBe(4_101)
|
||||
})
|
||||
|
||||
it('bounds path density while preserving narrow extrema', () => {
|
||||
const selected = selectRenderablePoints(
|
||||
points,
|
||||
[0, 9_999],
|
||||
100,
|
||||
resolveWaveformRenderingOptions({ downsampleThreshold: 100, maxPointsPerPixel: 4 }),
|
||||
)
|
||||
|
||||
expect(selected.length).toBeLessThanOrEqual(402)
|
||||
expect(selected).toContain(points[5_001])
|
||||
expect(selected[0]).toBe(points[0])
|
||||
expect(selected.at(-1)).toBe(points.at(-1))
|
||||
})
|
||||
|
||||
it('normalizes invalid rendering options to stable defaults', () => {
|
||||
expect(
|
||||
resolveWaveformRenderingOptions({ downsampleThreshold: -1, maxPointsPerPixel: 0 }),
|
||||
).toEqual({ downsample: true, downsampleThreshold: 2_000, maxPointsPerPixel: 4 })
|
||||
})
|
||||
})
|
||||
108
src/core/rendering.ts
Normal file
108
src/core/rendering.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { bisector } from 'd3'
|
||||
|
||||
import type { WaveformPoint, WaveformRenderingOptions } from '../types'
|
||||
|
||||
export interface ResolvedWaveformRenderingOptions {
|
||||
downsample: boolean
|
||||
downsampleThreshold: number
|
||||
maxPointsPerPixel: number
|
||||
}
|
||||
|
||||
export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOptions = {
|
||||
downsample: true,
|
||||
downsampleThreshold: 2_000,
|
||||
maxPointsPerPixel: 4,
|
||||
}
|
||||
|
||||
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
||||
|
||||
export function resolveWaveformRenderingOptions(
|
||||
options?: WaveformRenderingOptions,
|
||||
): ResolvedWaveformRenderingOptions {
|
||||
const threshold = Number(options?.downsampleThreshold)
|
||||
const pointsPerPixel = Number(options?.maxPointsPerPixel)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
||||
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(
|
||||
points: WaveformPoint[],
|
||||
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 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
|
||||
let activeBucket = -1
|
||||
let firstIndex = -1
|
||||
let lastIndex = -1
|
||||
let minimumIndex = -1
|
||||
let maximumIndex = -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]))
|
||||
}
|
||||
|
||||
pushUniquePoint(result, points[start])
|
||||
for (let index = Math.max(start, visibleStart); index < Math.min(end, visibleEnd); 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
|
||||
}
|
||||
Reference in New Issue
Block a user