feat(chart): add series styling and visibility controls
This commit is contained in:
@@ -5,6 +5,37 @@ import type {
|
||||
NormalizedWaveformSeries,
|
||||
} from '../types'
|
||||
|
||||
const DEFAULT_ERROR_BAR_WIDTH = 1.5
|
||||
const DEFAULT_ERROR_BAR_CAP_WIDTH = 8
|
||||
|
||||
function normalizeError(value: number | undefined): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
}
|
||||
|
||||
function normalizeWaveformPoint(point: WaveformPoint): WaveformPoint {
|
||||
const error = normalizeError(point.error)
|
||||
const lowerError = normalizeError(point.lowerError)
|
||||
const upperError = normalizeError(point.upperError)
|
||||
return {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
...(error === undefined ? {} : { error }),
|
||||
...(lowerError === undefined ? {} : { lowerError }),
|
||||
...(upperError === undefined ? {} : { upperError }),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWaveformPointErrors(point: WaveformPoint): {
|
||||
lower: number
|
||||
upper: number
|
||||
} {
|
||||
const symmetric = normalizeError(point.error) ?? 0
|
||||
return {
|
||||
lower: normalizeError(point.lowerError) ?? symmetric,
|
||||
upper: normalizeError(point.upperError) ?? symmetric,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化单波形数据
|
||||
* @param data 输入数据(samples 或 points 格式)
|
||||
@@ -22,7 +53,7 @@ export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[]
|
||||
|
||||
return data.points
|
||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
||||
.map((point) => ({ ...point }))
|
||||
.map(normalizeWaveformPoint)
|
||||
.sort((left, right) => left.x - right.x)
|
||||
}
|
||||
|
||||
@@ -34,7 +65,22 @@ export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[]
|
||||
export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformSeries[] {
|
||||
if (data.kind !== 'series') {
|
||||
const points = normalizeWaveformData(data)
|
||||
return points.length > 0 ? [{ id: 'series-0', name: '', points }] : []
|
||||
return points.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'series-0',
|
||||
name: '',
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: {
|
||||
visible: false,
|
||||
width: DEFAULT_ERROR_BAR_WIDTH,
|
||||
capWidth: DEFAULT_ERROR_BAR_CAP_WIDTH,
|
||||
},
|
||||
points,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
||||
const usedIds = new Set<string>()
|
||||
@@ -52,12 +98,31 @@ export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformS
|
||||
}
|
||||
usedIds.add(uniqueId)
|
||||
|
||||
const requestedLineType = series.lineType ?? 'linear'
|
||||
const requestedPointType = series.pointType ?? 'none'
|
||||
const errorBarVisible = series.errorBar?.visible === true
|
||||
const lineType =
|
||||
requestedLineType === 'none' && requestedPointType === 'none' && !errorBarVisible
|
||||
? 'linear'
|
||||
: requestedLineType
|
||||
const width = Number(series.errorBar?.width)
|
||||
const capWidth = Number(series.errorBar?.capWidth)
|
||||
|
||||
return {
|
||||
id: uniqueId,
|
||||
trackId: series.trackId?.trim() || undefined,
|
||||
name: series.name,
|
||||
unit: series.unit,
|
||||
color: series.color,
|
||||
lineType,
|
||||
pointType: requestedPointType,
|
||||
errorBar: {
|
||||
visible: errorBarVisible,
|
||||
color: series.errorBar?.color,
|
||||
width: Number.isFinite(width) && width > 0 ? width : DEFAULT_ERROR_BAR_WIDTH,
|
||||
capWidth:
|
||||
Number.isFinite(capWidth) && capWidth > 0 ? capWidth : DEFAULT_ERROR_BAR_CAP_WIDTH,
|
||||
},
|
||||
points: normalizeWaveformData(series.data),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
|
||||
// 数据处理
|
||||
export { normalizeWaveformData, normalizeWaveformSeries } from './data'
|
||||
export { normalizeWaveformData, normalizeWaveformSeries, resolveWaveformPointErrors } from './data'
|
||||
export {
|
||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
resolveWaveformRenderingOptions,
|
||||
selectDecorationPoints,
|
||||
selectRenderablePoints,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from './rendering'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WaveformPoint } from '../types'
|
||||
import { resolveWaveformRenderingOptions, selectRenderablePoints } from './rendering'
|
||||
import {
|
||||
resolveWaveformRenderingOptions,
|
||||
selectDecorationPoints,
|
||||
selectRenderablePoints,
|
||||
} from './rendering'
|
||||
|
||||
describe('waveform rendering selection', () => {
|
||||
const points = Array.from({ length: 10_000 }, (_, index): WaveformPoint => ({
|
||||
@@ -37,7 +41,85 @@ describe('waveform rendering selection', () => {
|
||||
|
||||
it('normalizes invalid rendering options to stable defaults', () => {
|
||||
expect(
|
||||
resolveWaveformRenderingOptions({ downsampleThreshold: -1, maxPointsPerPixel: 0 }),
|
||||
).toEqual({ downsample: true, downsampleThreshold: 2_000, maxPointsPerPixel: 4 })
|
||||
resolveWaveformRenderingOptions({
|
||||
downsampleThreshold: -1,
|
||||
maxPointsPerPixel: 0,
|
||||
pointMinSpacing: -1,
|
||||
errorBarMinSpacing: Number.POSITIVE_INFINITY,
|
||||
}),
|
||||
).toEqual({
|
||||
downsample: true,
|
||||
downsampleThreshold: 2_000,
|
||||
maxPointsPerPixel: 4,
|
||||
pointMinSpacing: 10,
|
||||
errorBarMinSpacing: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts custom decoration spacing and uses zero to disable it', () => {
|
||||
expect(resolveWaveformRenderingOptions({ pointMinSpacing: 6, errorBarMinSpacing: 0 })).toEqual({
|
||||
downsample: true,
|
||||
downsampleThreshold: 2_000,
|
||||
maxPointsPerPixel: 4,
|
||||
pointMinSpacing: 6,
|
||||
errorBarMinSpacing: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('selects evenly distributed source points for dense decorations', () => {
|
||||
const selected = selectDecorationPoints(points, [0, 9_999], 100, 10, true)
|
||||
|
||||
expect(selected.length).toBeLessThanOrEqual(12)
|
||||
expect(selected[0]).toBe(points[0])
|
||||
expect(selected.at(-1)).toBe(points.at(-1))
|
||||
expect(selected.every((point) => points.includes(point))).toBe(true)
|
||||
})
|
||||
|
||||
it('clips decorations exactly to the visible domain and preserves sparse points', () => {
|
||||
const sparsePoints = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 1 },
|
||||
{ x: 80, y: 2 },
|
||||
{ x: 120, y: 3 },
|
||||
]
|
||||
|
||||
expect(selectDecorationPoints(sparsePoints, [40, 80], 100, 10, true)).toEqual([
|
||||
sparsePoints[1],
|
||||
sparsePoints[2],
|
||||
])
|
||||
})
|
||||
|
||||
it('supports filtering decoration candidates and disabling sampling', () => {
|
||||
const errorPoints = Array.from({ length: 100 }, (_, index) => ({
|
||||
x: index,
|
||||
y: index,
|
||||
error: index % 10 === 0 ? 1 : 0,
|
||||
}))
|
||||
const hasError = (point: WaveformPoint) => (point.error ?? 0) > 0
|
||||
|
||||
expect(selectDecorationPoints(errorPoints, [0, 99], 100, 12, true, hasError)).toHaveLength(10)
|
||||
expect(selectDecorationPoints(errorPoints, [0, 99], 100, 12, false)).toHaveLength(100)
|
||||
expect(selectDecorationPoints(errorPoints, [0, 99], 100, 0, true)).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('prefers priority candidates within dense decoration buckets', () => {
|
||||
const priorityPoints = Array.from({ length: 100 }, (_, index) => ({
|
||||
x: index,
|
||||
y: index,
|
||||
error: index % 20 === 1 ? 1 : 0,
|
||||
}))
|
||||
const hasError = (point: WaveformPoint) => (point.error ?? 0) > 0
|
||||
const selected = selectDecorationPoints(
|
||||
priorityPoints,
|
||||
[0, 99],
|
||||
100,
|
||||
20,
|
||||
true,
|
||||
undefined,
|
||||
hasError,
|
||||
)
|
||||
|
||||
expect(selected.filter(hasError)).toEqual(priorityPoints.filter(hasError))
|
||||
expect(selected.length).toBeLessThanOrEqual(Math.ceil(100 / 20) + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,16 @@ 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)
|
||||
@@ -21,6 +25,8 @@ export function resolveWaveformRenderingOptions(
|
||||
): 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:
|
||||
@@ -31,6 +37,14 @@ export function resolveWaveformRenderingOptions(
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,3 +120,96 @@ export function selectRenderablePoints(
|
||||
pushUniquePoint(result, points[end - 1])
|
||||
return result
|
||||
}
|
||||
|
||||
/** 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 = () => true,
|
||||
priorityPredicate?: (point: WaveformPoint) => boolean,
|
||||
): 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)
|
||||
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] : []
|
||||
}
|
||||
|
||||
const toPixel = (point: WaveformPoint) => ((point.x - domainStart) / span) * width
|
||||
const sparsePoints: WaveformPoint[] = []
|
||||
let alreadySparse = true
|
||||
let first: WaveformPoint | undefined
|
||||
let last: WaveformPoint | undefined
|
||||
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
|
||||
}
|
||||
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)
|
||||
if (distance < bucketDistances[bucket]) {
|
||||
bucketPoints[bucket] = point
|
||||
bucketDistances[bucket] = distance
|
||||
}
|
||||
if (priorityPredicate?.(point) && distance < priorityBucketDistances[bucket]) {
|
||||
priorityBucketPoints[bucket] = point
|
||||
priorityBucketDistances[bucket] = distance
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user