perf: 优化波形核心算法与图框网格控制
This commit is contained in:
@@ -124,6 +124,7 @@ describe('normalizeWaveformData', () => {
|
|||||||
|
|
||||||
expect(series?.yDomain[0]).toBeLessThanOrEqual(-1)
|
expect(series?.yDomain[0]).toBeLessThanOrEqual(-1)
|
||||||
expect(series?.yDomain[1]).toBeGreaterThanOrEqual(6)
|
expect(series?.yDomain[1]).toBeGreaterThanOrEqual(6)
|
||||||
|
expect(series?.hasErrorPoints).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes multiple named series and removes empty series', () => {
|
it('normalizes multiple named series and removes empty series', () => {
|
||||||
@@ -2136,6 +2137,49 @@ describe('WaveformChart', () => {
|
|||||||
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
|
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('controls horizontal and vertical grid lines independently by track ID', async () => {
|
||||||
|
const data = gridSeries(2)
|
||||||
|
if (data.kind === 'series') {
|
||||||
|
data.series[0].trackId = 'frame-a'
|
||||||
|
data.series[1].trackId = 'frame-b'
|
||||||
|
}
|
||||||
|
const wrapper = await mountSizedChart(data, {
|
||||||
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 2,
|
||||||
|
trackLines: {
|
||||||
|
'frame-a': { horizontal: false },
|
||||||
|
'frame-b': { vertical: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||||
|
expect(tracks).toHaveLength(2)
|
||||||
|
expect(tracks[0].findAll('[data-grid-direction="horizontal"]')).toHaveLength(0)
|
||||||
|
expect(tracks[0].findAll('[data-grid-direction="vertical"]').length).not.toBe(0)
|
||||||
|
expect(tracks[1].findAll('[data-grid-direction="vertical"]')).toHaveLength(0)
|
||||||
|
expect(tracks[1].findAll('[data-grid-direction="horizontal"]').length).not.toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps per-track grid line visibility attached across pagination', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(3), {
|
||||||
|
grid: {
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 1,
|
||||||
|
trackLines: {
|
||||||
|
'channel-1': { horizontal: false, vertical: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-grid-direction]')).not.toHaveLength(0)
|
||||||
|
await wrapper.get('.ant-pagination-next button').trigger('click')
|
||||||
|
expect(wrapper.findAll('[data-grid-direction]')).toHaveLength(0)
|
||||||
|
await wrapper.get('.ant-pagination-next button').trigger('click')
|
||||||
|
expect(wrapper.findAll('[data-grid-direction]')).not.toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('applies one custom frame style to every non-empty track', async () => {
|
it('applies one custom frame style to every non-empty track', async () => {
|
||||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
grid: { rowCount: 2, columnCount: 1 },
|
grid: { rowCount: 2, columnCount: 1 },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ZoomTransform,
|
type ZoomTransform,
|
||||||
} from 'd3'
|
} from 'd3'
|
||||||
import { resolveWaveformRenderingOptions } from '../core'
|
import { resolveWaveformRenderingOptions } from '../core'
|
||||||
|
import { hasMinimumVisibleXValues } from '../core/rendering'
|
||||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils'
|
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils'
|
||||||
import { useAnimationFrameThrottle } from './utils/useAnimationFrameThrottle'
|
import { useAnimationFrameThrottle } from './utils/useAnimationFrameThrottle'
|
||||||
import {
|
import {
|
||||||
@@ -825,20 +826,13 @@ function resolveMaximumZoomScale(domain: [number, number]): number {
|
|||||||
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
|
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function visiblePointCount(track: TrackLayout): number {
|
|
||||||
const [start, end] = track.xScale.domain()
|
|
||||||
const xValues = new Set<number>()
|
|
||||||
track.seriesList.forEach((series) => {
|
|
||||||
series.points.forEach((point) => {
|
|
||||||
if (point.x >= start && point.x <= end) xValues.add(point.x)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return xValues.size
|
|
||||||
}
|
|
||||||
|
|
||||||
function canZoomTrack(track: TrackLayout): boolean {
|
function canZoomTrack(track: TrackLayout): boolean {
|
||||||
const minimum = Number(props.minVisiblePoints)
|
const minimum = Number(props.minVisiblePoints)
|
||||||
return !Number.isFinite(minimum) || minimum <= 0 || visiblePointCount(track) >= minimum
|
return hasMinimumVisibleXValues(
|
||||||
|
track.seriesList,
|
||||||
|
track.xScale.domain() as [number, number],
|
||||||
|
minimum,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canZoomSharedTracks(): boolean {
|
function canZoomSharedTracks(): boolean {
|
||||||
|
|||||||
@@ -11,11 +11,44 @@ import {
|
|||||||
|
|
||||||
describe('waveform grid helpers', () => {
|
describe('waveform grid helpers', () => {
|
||||||
it('normalizes grid counts and uses a two by one default', () => {
|
it('normalizes grid counts and uses a two by one default', () => {
|
||||||
expect(normalizeGridOptions()).toEqual({ rowCount: 2, columnCount: 1, showPagination: true })
|
expect(normalizeGridOptions()).toEqual({
|
||||||
|
rowCount: 2,
|
||||||
|
columnCount: 1,
|
||||||
|
showPagination: true,
|
||||||
|
trackLines: {},
|
||||||
|
})
|
||||||
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
|
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
columnCount: 10,
|
columnCount: 10,
|
||||||
showPagination: true,
|
showPagination: true,
|
||||||
|
trackLines: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes per-track grid line visibility with visible defaults', () => {
|
||||||
|
expect(
|
||||||
|
normalizeGridOptions({
|
||||||
|
trackLines: {
|
||||||
|
voltage: { horizontal: false },
|
||||||
|
current: { vertical: false },
|
||||||
|
},
|
||||||
|
}).trackLines,
|
||||||
|
).toEqual({
|
||||||
|
voltage: { horizontal: false, vertical: true },
|
||||||
|
current: { horizontal: true, vertical: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to visible grid lines for invalid runtime values', () => {
|
||||||
|
const options = {
|
||||||
|
trackLines: {
|
||||||
|
voltage: { horizontal: 'invalid', vertical: null },
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof normalizeGridOptions>[0]
|
||||||
|
|
||||||
|
expect(normalizeGridOptions(options).trackLines.voltage).toEqual({
|
||||||
|
horizontal: true,
|
||||||
|
vertical: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,26 @@ export interface WaveformGridOptions {
|
|||||||
rowCount?: number
|
rowCount?: number
|
||||||
columnCount?: number
|
columnCount?: number
|
||||||
showPagination?: boolean
|
showPagination?: boolean
|
||||||
|
trackLines?: WaveformGridTrackLines
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaveformGridLineOptions {
|
||||||
|
horizontal?: boolean
|
||||||
|
vertical?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WaveformGridTrackLines = Record<string, WaveformGridLineOptions>
|
||||||
|
|
||||||
|
export interface NormalizedWaveformGridLineOptions {
|
||||||
|
horizontal: boolean
|
||||||
|
vertical: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedWaveformGridOptions {
|
export interface NormalizedWaveformGridOptions {
|
||||||
rowCount: number
|
rowCount: number
|
||||||
columnCount: number
|
columnCount: number
|
||||||
showPagination: boolean
|
showPagination: boolean
|
||||||
|
trackLines: Record<string, NormalizedWaveformGridLineOptions>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridCellGeometry {
|
export interface GridCellGeometry {
|
||||||
@@ -37,10 +51,20 @@ const normalizeCount = (value: unknown, fallback: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedWaveformGridOptions {
|
export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedWaveformGridOptions {
|
||||||
|
const trackLines = Object.fromEntries(
|
||||||
|
Object.entries(options?.trackLines ?? {}).map(([trackId, lines]) => [
|
||||||
|
trackId,
|
||||||
|
{
|
||||||
|
horizontal: typeof lines?.horizontal === 'boolean' ? lines.horizontal : true,
|
||||||
|
vertical: typeof lines?.vertical === 'boolean' ? lines.vertical : true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
rowCount: normalizeCount(options?.rowCount, 2),
|
rowCount: normalizeCount(options?.rowCount, 2),
|
||||||
columnCount: normalizeCount(options?.columnCount, 1),
|
columnCount: normalizeCount(options?.columnCount, 1),
|
||||||
showPagination: options?.showPagination ?? true,
|
showPagination: options?.showPagination ?? true,
|
||||||
|
trackLines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
|||||||
],
|
],
|
||||||
xDomain: [0, 1],
|
xDomain: [0, 1],
|
||||||
yDomain: [minimum, maximum],
|
yDomain: [minimum, maximum],
|
||||||
|
hasErrorPoints: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ function layoutForSeries(
|
|||||||
series: sourceTrack,
|
series: sourceTrack,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'single-axis',
|
overlayMode: 'single-axis',
|
||||||
independentTransforms: [transform],
|
independentTransforms: [transform],
|
||||||
@@ -95,7 +96,7 @@ describe('multi-value Y-axis grouping', () => {
|
|||||||
series: sourceTrack,
|
series: sourceTrack,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'single-axis',
|
overlayMode: 'single-axis',
|
||||||
independentTransforms: [zoomIdentity],
|
independentTransforms: [zoomIdentity],
|
||||||
@@ -198,7 +199,7 @@ describe('multi-value Y-axis grouping', () => {
|
|||||||
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||||
displayMode: 'independent',
|
displayMode: 'independent',
|
||||||
overlayMode: 'multi-axis',
|
overlayMode: 'multi-axis',
|
||||||
independentTransforms: [zoomIdentity],
|
independentTransforms: [zoomIdentity],
|
||||||
@@ -241,6 +242,7 @@ describe('decoration sampling', () => {
|
|||||||
error: index % 200 === 1 ? 0.1 : 0,
|
error: index % 200 === 1 ? 0.1 : 0,
|
||||||
})),
|
})),
|
||||||
xDomain: [0, 999],
|
xDomain: [0, 999],
|
||||||
|
hasErrorPoints: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shares prioritized source points between dense symbols and error bars', () => {
|
it('shares prioritized source points between dense symbols and error bars', () => {
|
||||||
@@ -262,6 +264,7 @@ describe('decoration sampling', () => {
|
|||||||
it('keeps standalone, zero-error, and non-downsampled decoration behavior', () => {
|
it('keeps standalone, zero-error, and non-downsampled decoration behavior', () => {
|
||||||
const noErrors = denseSeries()
|
const noErrors = denseSeries()
|
||||||
noErrors.points = noErrors.points.map((point) => ({ x: point.x, y: point.y }))
|
noErrors.points = noErrors.points.map((point) => ({ x: point.x, y: point.y }))
|
||||||
|
noErrors.hasErrorPoints = false
|
||||||
const zeroErrorPath = layoutForSeries(noErrors)
|
const zeroErrorPath = layoutForSeries(noErrors)
|
||||||
expect(zeroErrorPath.errorBarRenderPoints).toEqual([])
|
expect(zeroErrorPath.errorBarRenderPoints).toEqual([])
|
||||||
expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import {
|
|||||||
} from 'd3'
|
} from 'd3'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
selectDecorationPoints,
|
selectSeriesRenderPoints,
|
||||||
selectRenderablePoints,
|
|
||||||
resolveWaveformPointErrors,
|
|
||||||
type ResolvedWaveformRenderingOptions,
|
type ResolvedWaveformRenderingOptions,
|
||||||
} from '../../core'
|
} from '../../core/rendering'
|
||||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||||
import {
|
import {
|
||||||
buildMinorTicks,
|
buildMinorTicks,
|
||||||
@@ -217,6 +215,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
points: [],
|
points: [],
|
||||||
xDomain: [0, 1],
|
xDomain: [0, 1],
|
||||||
yDomain: [0, 1],
|
yDomain: [0, 1],
|
||||||
|
hasErrorPoints: false,
|
||||||
}
|
}
|
||||||
const displayTrack: DisplayTrack = cell.series ?? {
|
const displayTrack: DisplayTrack = cell.series ?? {
|
||||||
id: emptySeries.id,
|
id: emptySeries.id,
|
||||||
@@ -317,51 +316,18 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
axis.seriesList.some((series) => series.id === trackSeries.id),
|
axis.seriesList.some((series) => series.id === trackSeries.id),
|
||||||
)
|
)
|
||||||
const seriesYScale = yAxis?.scale ?? yScale
|
const seriesYScale = yAxis?.scale ?? yScale
|
||||||
const pathPoints = selectRenderablePoints(
|
const renderPoints = selectSeriesRenderPoints(
|
||||||
trackSeries.points,
|
trackSeries.points,
|
||||||
domain,
|
domain,
|
||||||
cell.width,
|
cell.width,
|
||||||
options.rendering,
|
options.rendering,
|
||||||
|
{
|
||||||
|
lineVisible: !isEmpty && trackSeries.lineType !== 'none',
|
||||||
|
pointVisible: trackSeries.pointType !== 'none',
|
||||||
|
errorBarVisible: trackSeries.errorBar.visible,
|
||||||
|
hasErrorPoints: trackSeries.hasErrorPoints,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const hasError = (point: WaveformPoint) => {
|
|
||||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
|
||||||
return lower !== 0 || upper !== 0
|
|
||||||
}
|
|
||||||
const hasErrorPoints = trackSeries.errorBar.visible && trackSeries.points.some(hasError)
|
|
||||||
const sharesDecorationPoints = trackSeries.pointType !== 'none' && hasErrorPoints
|
|
||||||
const sharedDecorationPoints = sharesDecorationPoints
|
|
||||||
? selectDecorationPoints(
|
|
||||||
trackSeries.points,
|
|
||||||
domain,
|
|
||||||
cell.width,
|
|
||||||
Math.max(options.rendering.pointMinSpacing, options.rendering.errorBarMinSpacing),
|
|
||||||
options.rendering.downsample,
|
|
||||||
undefined,
|
|
||||||
hasError,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const pointRenderPoints =
|
|
||||||
trackSeries.pointType === 'none'
|
|
||||||
? []
|
|
||||||
: (sharedDecorationPoints ??
|
|
||||||
selectDecorationPoints(
|
|
||||||
trackSeries.points,
|
|
||||||
domain,
|
|
||||||
cell.width,
|
|
||||||
options.rendering.pointMinSpacing,
|
|
||||||
options.rendering.downsample,
|
|
||||||
))
|
|
||||||
const errorBarRenderPoints = trackSeries.errorBar.visible
|
|
||||||
? (sharedDecorationPoints?.filter(hasError) ??
|
|
||||||
selectDecorationPoints(
|
|
||||||
trackSeries.points,
|
|
||||||
domain,
|
|
||||||
cell.width,
|
|
||||||
options.rendering.errorBarMinSpacing,
|
|
||||||
options.rendering.downsample,
|
|
||||||
hasError,
|
|
||||||
))
|
|
||||||
: []
|
|
||||||
const pathGenerator = line<WaveformPoint>()
|
const pathGenerator = line<WaveformPoint>()
|
||||||
.x((point) => xScale(point.x))
|
.x((point) => xScale(point.x))
|
||||||
.y((point) => seriesYScale(point.y))
|
.y((point) => seriesYScale(point.y))
|
||||||
@@ -372,9 +338,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
series: trackSeries,
|
series: trackSeries,
|
||||||
path: isEmpty || trackSeries.lineType === 'none' ? null : pathGenerator(pathPoints),
|
path: renderPoints.linePoints.length ? pathGenerator(renderPoints.linePoints) : null,
|
||||||
pointRenderPoints,
|
pointRenderPoints: renderPoints.pointRenderPoints,
|
||||||
errorBarRenderPoints,
|
errorBarRenderPoints: renderPoints.errorBarRenderPoints,
|
||||||
yScale: seriesYScale,
|
yScale: seriesYScale,
|
||||||
yAxisIndex: yAxis?.index ?? 0,
|
yAxisIndex: yAxis?.index ?? 0,
|
||||||
}
|
}
|
||||||
@@ -407,6 +373,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
xAxisExponent,
|
xAxisExponent,
|
||||||
path: seriesPaths[0]?.path ?? null,
|
path: seriesPaths[0]?.path ?? null,
|
||||||
seriesPaths,
|
seriesPaths,
|
||||||
|
gridLines: options.grid.trackLines[displayTrack.id] ?? {
|
||||||
|
horizontal: true,
|
||||||
|
vertical: true,
|
||||||
|
},
|
||||||
showXAxis:
|
showXAxis:
|
||||||
(isEmpty || hasVisibleSeries) &&
|
(isEmpty || hasVisibleSeries) &&
|
||||||
(options.displayMode === 'independent' ||
|
(options.displayMode === 'independent' ||
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
WaveformPoint,
|
WaveformPoint,
|
||||||
WaveformPointType,
|
WaveformPointType,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
|
import type { NormalizedWaveformGridLineOptions } from './grid'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示系列
|
* 显示系列
|
||||||
@@ -21,6 +22,7 @@ export interface DisplaySeries {
|
|||||||
points: WaveformPoint[]
|
points: WaveformPoint[]
|
||||||
xDomain: [number, number]
|
xDomain: [number, number]
|
||||||
yDomain: [number, number]
|
yDomain: [number, number]
|
||||||
|
hasErrorPoints: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayTrack {
|
export interface DisplayTrack {
|
||||||
@@ -97,6 +99,7 @@ export interface TrackLayout {
|
|||||||
path: string | null
|
path: string | null
|
||||||
seriesPaths: TrackSeriesPath[]
|
seriesPaths: TrackSeriesPath[]
|
||||||
showXAxis: boolean
|
showXAxis: boolean
|
||||||
|
gridLines: NormalizedWaveformGridLineOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新导出 WaveformPoint 方便使用
|
// 重新导出 WaveformPoint 方便使用
|
||||||
|
|||||||
@@ -22,34 +22,42 @@ export interface PreparedWaveformSeries {
|
|||||||
points: WaveformPoint[]
|
points: WaveformPoint[]
|
||||||
xDomain: [number, number]
|
xDomain: [number, number]
|
||||||
yDomain: [number, number]
|
yDomain: [number, number]
|
||||||
|
hasErrorPoints: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointDomain(
|
function pointMetrics(
|
||||||
points: WaveformPoint[],
|
points: WaveformPoint[],
|
||||||
key: 'x' | 'y',
|
|
||||||
includeErrors = false,
|
includeErrors = false,
|
||||||
): [number, number] {
|
): { xDomain: [number, number]; yDomain: [number, number]; hasErrorPoints: boolean } {
|
||||||
let minimum = Number.POSITIVE_INFINITY
|
let xMinimum = Number.POSITIVE_INFINITY
|
||||||
let maximum = Number.NEGATIVE_INFINITY
|
let xMaximum = Number.NEGATIVE_INFINITY
|
||||||
points.forEach((point) => {
|
let yMinimum = Number.POSITIVE_INFINITY
|
||||||
const value = point[key]
|
let yMaximum = Number.NEGATIVE_INFINITY
|
||||||
if (value < minimum) minimum = value
|
let hasErrorPoints = false
|
||||||
if (value > maximum) maximum = value
|
for (const point of points) {
|
||||||
if (key === 'y' && includeErrors) {
|
if (point.x < xMinimum) xMinimum = point.x
|
||||||
|
if (point.x > xMaximum) xMaximum = point.x
|
||||||
|
if (point.y < yMinimum) yMinimum = point.y
|
||||||
|
if (point.y > yMaximum) yMaximum = point.y
|
||||||
|
if (includeErrors) {
|
||||||
const errors = resolveWaveformPointErrors(point)
|
const errors = resolveWaveformPointErrors(point)
|
||||||
minimum = Math.min(minimum, point.y - errors.lower)
|
if (errors.lower !== 0 || errors.upper !== 0) hasErrorPoints = true
|
||||||
maximum = Math.max(maximum, point.y + errors.upper)
|
yMinimum = Math.min(yMinimum, point.y - errors.lower)
|
||||||
|
yMaximum = Math.max(yMaximum, point.y + errors.upper)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
return paddedDomain(Number.isFinite(minimum) ? [minimum, maximum] : [])
|
return {
|
||||||
|
xDomain: paddedDomain(Number.isFinite(xMinimum) ? [xMinimum, xMaximum] : []),
|
||||||
|
yDomain: paddedDomain(Number.isFinite(yMinimum) ? [yMinimum, yMaximum] : []),
|
||||||
|
hasErrorPoints,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSeries[] {
|
export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSeries[] {
|
||||||
return normalizeWaveformSeries(data).map((series) => ({
|
return normalizeWaveformSeries(data).map((series) => {
|
||||||
...series,
|
const metrics = pointMetrics(series.points, series.errorBar.visible)
|
||||||
xDomain: pointDomain(series.points, 'x'),
|
return { ...series, ...metrics }
|
||||||
yDomain: pointDomain(series.points, 'y', series.errorBar.visible),
|
})
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
|
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export type {
|
|||||||
NormalizedWaveformSeries,
|
NormalizedWaveformSeries,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
|
|
||||||
export type { WaveformGridOptions } from '../core/grid'
|
export type {
|
||||||
|
WaveformGridOptions,
|
||||||
|
WaveformGridLineOptions,
|
||||||
|
WaveformGridTrackLines,
|
||||||
|
} from '../core/grid'
|
||||||
|
|
||||||
// 重新导出数据处理函数
|
// 重新导出数据处理函数
|
||||||
export { normalizeWaveformData, normalizeWaveformSeries } from '../../core'
|
export { normalizeWaveformData, normalizeWaveformSeries } from '../../core'
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type {
|
|||||||
WaveformPointType,
|
WaveformPointType,
|
||||||
WaveformErrorBarOptions,
|
WaveformErrorBarOptions,
|
||||||
WaveformGridOptions,
|
WaveformGridOptions,
|
||||||
|
WaveformGridLineOptions,
|
||||||
|
WaveformGridTrackLines,
|
||||||
} from './data/types'
|
} from './data/types'
|
||||||
|
|
||||||
export type { WaveformGridOptions as WaveformGridConfig } from './core/grid'
|
export type { WaveformGridOptions as WaveformGridConfig } from './core/grid'
|
||||||
|
|||||||
@@ -211,42 +211,54 @@ watch(
|
|||||||
<g
|
<g
|
||||||
class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor"
|
class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor"
|
||||||
>
|
>
|
||||||
<line
|
<template v-if="track.gridLines.vertical">
|
||||||
v-for="tick in track.xMinorTicks"
|
<line
|
||||||
:key="`x-minor-${track.index}-${tick}`"
|
v-for="tick in track.xMinorTicks"
|
||||||
:x1="track.xScale(tick)"
|
:key="`x-minor-${track.index}-${tick}`"
|
||||||
:x2="track.xScale(tick)"
|
data-grid-direction="vertical"
|
||||||
y1="0"
|
:x1="track.xScale(tick)"
|
||||||
:y2="track.height"
|
:x2="track.xScale(tick)"
|
||||||
/>
|
y1="0"
|
||||||
<line
|
:y2="track.height"
|
||||||
v-for="tick in track.yMinorTicks"
|
/>
|
||||||
:key="`y-minor-${track.index}-${tick}`"
|
</template>
|
||||||
x1="0"
|
<template v-if="track.gridLines.horizontal">
|
||||||
:x2="track.width ?? innerWidth"
|
<line
|
||||||
:y1="track.yScale(tick)"
|
v-for="tick in track.yMinorTicks"
|
||||||
:y2="track.yScale(tick)"
|
:key="`y-minor-${track.index}-${tick}`"
|
||||||
/>
|
data-grid-direction="horizontal"
|
||||||
|
x1="0"
|
||||||
|
:x2="track.width ?? innerWidth"
|
||||||
|
:y1="track.yScale(tick)"
|
||||||
|
:y2="track.yScale(tick)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</g>
|
</g>
|
||||||
<g
|
<g
|
||||||
class="waveform-track__grid waveform-track__grid--major waveform-chart__grid waveform-chart__grid--major"
|
class="waveform-track__grid waveform-track__grid--major waveform-chart__grid waveform-chart__grid--major"
|
||||||
>
|
>
|
||||||
<line
|
<template v-if="track.gridLines.vertical">
|
||||||
v-for="tick in track.xMajorTicks"
|
<line
|
||||||
:key="`x-major-${track.index}-${tick}`"
|
v-for="tick in track.xMajorTicks"
|
||||||
:x1="track.xScale(tick)"
|
:key="`x-major-${track.index}-${tick}`"
|
||||||
:x2="track.xScale(tick)"
|
data-grid-direction="vertical"
|
||||||
y1="0"
|
:x1="track.xScale(tick)"
|
||||||
:y2="track.height"
|
:x2="track.xScale(tick)"
|
||||||
/>
|
y1="0"
|
||||||
<line
|
:y2="track.height"
|
||||||
v-for="tick in track.yMajorTicks"
|
/>
|
||||||
:key="`y-major-${track.index}-${tick}`"
|
</template>
|
||||||
x1="0"
|
<template v-if="track.gridLines.horizontal">
|
||||||
:x2="track.width ?? innerWidth"
|
<line
|
||||||
:y1="track.yScale(tick)"
|
v-for="tick in track.yMajorTicks"
|
||||||
:y2="track.yScale(tick)"
|
:key="`y-major-${track.index}-${tick}`"
|
||||||
/>
|
data-grid-direction="horizontal"
|
||||||
|
x1="0"
|
||||||
|
:x2="track.width ?? innerWidth"
|
||||||
|
:y1="track.yScale(tick)"
|
||||||
|
:y2="track.yScale(tick)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
|
|||||||
58
src/core/data.test.ts
Normal file
58
src/core/data.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { normalizeWaveformData } from './data'
|
||||||
|
|
||||||
|
describe('waveform data normalization', () => {
|
||||||
|
it('builds sample points in one pass while preserving source indexes', () => {
|
||||||
|
expect(
|
||||||
|
normalizeWaveformData({
|
||||||
|
kind: 'samples',
|
||||||
|
values: [1, Number.NaN, 3],
|
||||||
|
sampleRate: 2,
|
||||||
|
startTime: 1,
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
{ x: 2, y: 3 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips sorting already ordered points and normalizes errors', () => {
|
||||||
|
const sortSpy = vi.spyOn(Array.prototype, 'sort')
|
||||||
|
try {
|
||||||
|
const result = normalizeWaveformData({
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 1, error: -1, upperError: 2 },
|
||||||
|
{ x: 1, y: 2, lowerError: 3 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sortSpy).not.toHaveBeenCalled()
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ x: 0, y: 1, upperError: 2 },
|
||||||
|
{ x: 1, y: 2, lowerError: 3 },
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
sortSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts only unordered points and preserves duplicate-x order', () => {
|
||||||
|
expect(
|
||||||
|
normalizeWaveformData({
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 2, y: 20 },
|
||||||
|
{ x: 1, y: 10 },
|
||||||
|
{ x: 1, y: 11 },
|
||||||
|
{ x: Number.NaN, y: 12 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ x: 1, y: 10 },
|
||||||
|
{ x: 1, y: 11 },
|
||||||
|
{ x: 2, y: 20 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -46,15 +46,27 @@ export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[]
|
|||||||
if (!Number.isFinite(data.sampleRate) || data.sampleRate <= 0) return []
|
if (!Number.isFinite(data.sampleRate) || data.sampleRate <= 0) return []
|
||||||
|
|
||||||
const startTime = Number.isFinite(data.startTime) ? (data.startTime ?? 0) : 0
|
const startTime = Number.isFinite(data.startTime) ? (data.startTime ?? 0) : 0
|
||||||
return data.values.flatMap((value, index) =>
|
const points: WaveformPoint[] = []
|
||||||
Number.isFinite(value) ? [{ x: startTime + index / data.sampleRate, y: value }] : [],
|
for (let index = 0; index < data.values.length; index += 1) {
|
||||||
)
|
const value = data.values[index]
|
||||||
|
if (!Number.isFinite(value)) continue
|
||||||
|
points.push({ x: startTime + index / data.sampleRate, y: value })
|
||||||
|
}
|
||||||
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.points
|
const points: WaveformPoint[] = []
|
||||||
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
|
let previousX = Number.NEGATIVE_INFINITY
|
||||||
.map(normalizeWaveformPoint)
|
let sorted = true
|
||||||
.sort((left, right) => left.x - right.x)
|
for (const point of data.points) {
|
||||||
|
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
|
||||||
|
const normalized = normalizeWaveformPoint(point)
|
||||||
|
if (normalized.x < previousX) sorted = false
|
||||||
|
previousX = normalized.x
|
||||||
|
points.push(normalized)
|
||||||
|
}
|
||||||
|
if (!sorted) points.sort((left, right) => left.x - right.x)
|
||||||
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import type { WaveformPoint } from '../types'
|
import type { WaveformPoint } from '../types'
|
||||||
import {
|
import {
|
||||||
|
hasMinimumVisibleXValues,
|
||||||
resolveWaveformRenderingOptions,
|
resolveWaveformRenderingOptions,
|
||||||
selectDecorationPoints,
|
selectDecorationPoints,
|
||||||
selectRenderablePoints,
|
selectRenderablePoints,
|
||||||
|
selectSeriesRenderPoints,
|
||||||
} from './rendering'
|
} from './rendering'
|
||||||
|
|
||||||
describe('waveform rendering selection', () => {
|
describe('waveform rendering selection', () => {
|
||||||
@@ -122,4 +124,66 @@ describe('waveform rendering selection', () => {
|
|||||||
expect(selected.filter(hasError)).toEqual(priorityPoints.filter(hasError))
|
expect(selected.filter(hasError)).toEqual(priorityPoints.filter(hasError))
|
||||||
expect(selected.length).toBeLessThanOrEqual(Math.ceil(100 / 20) + 2)
|
expect(selected.length).toBeLessThanOrEqual(Math.ceil(100 / 20) + 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shares visible-range selection for a dense 100k-point series', () => {
|
||||||
|
const densePoints = Array.from({ length: 100_000 }, (_, index): WaveformPoint => ({
|
||||||
|
x: index,
|
||||||
|
y: index === 50_001 ? 10_000 : Math.sin(index / 20),
|
||||||
|
error: index % 1_000 === 0 ? 1 : undefined,
|
||||||
|
}))
|
||||||
|
const selected = selectSeriesRenderPoints(
|
||||||
|
densePoints,
|
||||||
|
[99_999, 0],
|
||||||
|
500,
|
||||||
|
resolveWaveformRenderingOptions({ downsampleThreshold: 100 }),
|
||||||
|
{
|
||||||
|
lineVisible: true,
|
||||||
|
pointVisible: true,
|
||||||
|
errorBarVisible: true,
|
||||||
|
hasErrorPoints: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(selected.linePoints.length).toBeLessThanOrEqual(2_002)
|
||||||
|
expect(selected.linePoints).toContain(densePoints[50_001])
|
||||||
|
expect(selected.linePoints[0]).toBe(densePoints[0])
|
||||||
|
expect(selected.linePoints.at(-1)).toBe(densePoints.at(-1))
|
||||||
|
expect(selected.pointRenderPoints.length).toBeLessThanOrEqual(52)
|
||||||
|
expect(selected.errorBarRenderPoints.every((point) => (point.error ?? 0) > 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts unique visible x values across series and reversed domains', () => {
|
||||||
|
const first = {
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const second = {
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 1, y: 3 },
|
||||||
|
{ x: 2, y: 4 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasMinimumVisibleXValues([first, second], [2, 0], 3)).toBe(true)
|
||||||
|
expect(hasMinimumVisibleXValues([first, second], [0, 2], 4)).toBe(false)
|
||||||
|
expect(hasMinimumVisibleXValues([first, second], [1, 1], 1)).toBe(true)
|
||||||
|
expect(hasMinimumVisibleXValues([first, second], [3, 4], 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops scanning a 100k-point series after reaching the minimum', () => {
|
||||||
|
const source = Array.from({ length: 100_000 }, (_, index) => ({ x: index, y: index }))
|
||||||
|
let pointReads = 0
|
||||||
|
const points = new Proxy(source, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (typeof property === 'string' && /^\d+$/.test(property)) pointReads += 1
|
||||||
|
return Reflect.get(target, property, receiver)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(hasMinimumVisibleXValues([{ points }], [0, 99_999], 5)).toBe(true)
|
||||||
|
expect(pointReads).toBeLessThan(100)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { bisector } from 'd3'
|
import { bisector } from 'd3'
|
||||||
|
|
||||||
import type { WaveformPoint, WaveformRenderingOptions } from '@/types'
|
import type { WaveformPoint, WaveformRenderingOptions } from '@/types'
|
||||||
|
import { resolveWaveformPointErrors } from './data'
|
||||||
|
|
||||||
export interface ResolvedWaveformRenderingOptions {
|
export interface ResolvedWaveformRenderingOptions {
|
||||||
downsample: boolean
|
downsample: boolean
|
||||||
@@ -19,6 +20,59 @@ export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOption
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
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(
|
export function resolveWaveformRenderingOptions(
|
||||||
options?: WaveformRenderingOptions,
|
options?: WaveformRenderingOptions,
|
||||||
@@ -52,24 +106,17 @@ function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefin
|
|||||||
if (point && target[target.length - 1] !== point) target.push(point)
|
if (point && target[target.length - 1] !== point) target.push(point)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function selectRenderablePointsInRange(
|
||||||
* 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[],
|
points: WaveformPoint[],
|
||||||
|
range: VisiblePointRange,
|
||||||
domain: [number, number],
|
domain: [number, number],
|
||||||
width: number,
|
width: number,
|
||||||
options: ResolvedWaveformRenderingOptions,
|
options: ResolvedWaveformRenderingOptions,
|
||||||
): WaveformPoint[] {
|
): WaveformPoint[] {
|
||||||
if (!points.length || width <= 0) return []
|
|
||||||
|
|
||||||
const domainStart = Math.min(domain[0], domain[1])
|
const domainStart = Math.min(domain[0], domain[1])
|
||||||
const domainEnd = Math.max(domain[0], domain[1])
|
const domainEnd = Math.max(domain[0], domain[1])
|
||||||
const visibleStart = pointBisector.left(points, domainStart)
|
const start = Math.max(0, range.start - 1)
|
||||||
const visibleEnd = pointBisector.right(points, domainEnd)
|
const end = Math.min(points.length, range.end + 1)
|
||||||
const start = Math.max(0, visibleStart - 1)
|
|
||||||
const end = Math.min(points.length, visibleEnd + 1)
|
|
||||||
const visibleCount = end - start
|
const visibleCount = end - start
|
||||||
if (visibleCount <= 0) return []
|
if (visibleCount <= 0) return []
|
||||||
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
|
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
|
||||||
@@ -82,22 +129,45 @@ export function selectRenderablePoints(
|
|||||||
|
|
||||||
const result: WaveformPoint[] = []
|
const result: WaveformPoint[] = []
|
||||||
const span = domainEnd - domainStart || 1
|
const span = domainEnd - domainStart || 1
|
||||||
|
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
||||||
let activeBucket = -1
|
let activeBucket = -1
|
||||||
let firstIndex = -1
|
let firstIndex = -1
|
||||||
let lastIndex = -1
|
let lastIndex = -1
|
||||||
let minimumIndex = -1
|
let minimumIndex = -1
|
||||||
let maximumIndex = -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 = () => {
|
const flushBucket = () => {
|
||||||
if (firstIndex < 0) return
|
if (firstIndex < 0) return
|
||||||
const indexes = [firstIndex, minimumIndex, maximumIndex, lastIndex]
|
let count = 0
|
||||||
.filter((index, position, source) => index >= 0 && source.indexOf(index) === position)
|
count = addBucketIndex(firstIndex, count)
|
||||||
.sort((left, right) => left - right)
|
count = addBucketIndex(minimumIndex, count)
|
||||||
indexes.forEach((index) => pushUniquePoint(result, points[index]))
|
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])
|
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 point = points[index]
|
||||||
const bucket = Math.min(
|
const bucket = Math.min(
|
||||||
bucketCount - 1,
|
bucketCount - 1,
|
||||||
@@ -121,33 +191,61 @@ export function selectRenderablePoints(
|
|||||||
return result
|
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[],
|
points: WaveformPoint[],
|
||||||
domain: [number, number],
|
domain: [number, number],
|
||||||
width: 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,
|
minSpacing: number,
|
||||||
downsample: boolean,
|
downsample: boolean,
|
||||||
predicate: (point: WaveformPoint) => boolean = () => true,
|
predicate: (point: WaveformPoint) => boolean,
|
||||||
priorityPredicate?: (point: WaveformPoint) => boolean,
|
priorityPredicate?: (point: WaveformPoint) => boolean,
|
||||||
): WaveformPoint[] {
|
): 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 domainStart = Math.min(domain[0], domain[1])
|
||||||
const domainEnd = Math.max(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
|
const span = domainEnd - domainStart
|
||||||
if (span <= 0) {
|
if (span <= 0) {
|
||||||
const point = points.slice(visibleStart, visibleEnd).find(predicate)
|
for (let index = range.start; index < range.end; index += 1) {
|
||||||
return point ? [point] : []
|
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[] = []
|
const sparsePoints: WaveformPoint[] = []
|
||||||
let alreadySparse = true
|
let alreadySparse = true
|
||||||
let first: WaveformPoint | undefined
|
let first: WaveformPoint | undefined
|
||||||
@@ -155,44 +253,10 @@ export function selectDecorationPoints(
|
|||||||
let previousPixel = Number.NEGATIVE_INFINITY
|
let previousPixel = Number.NEGATIVE_INFINITY
|
||||||
let candidateCount = 0
|
let candidateCount = 0
|
||||||
|
|
||||||
for (let index = visibleStart; index < visibleEnd; index += 1) {
|
const recordBucketPoint = (point: WaveformPoint, pixel: number) => {
|
||||||
const point = points[index]
|
if (!bucketPoints || !bucketDistances || !priorityBucketPoints || !priorityBucketDistances) {
|
||||||
if (!predicate(point)) continue
|
return
|
||||||
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 bucket = Math.min(bucketCount - 1, Math.floor(pixel / bucketWidth))
|
||||||
const center = (bucket + 0.5) * bucketWidth
|
const center = (bucket + 0.5) * bucketWidth
|
||||||
const distance = Math.abs(pixel - center)
|
const distance = Math.abs(pixel - center)
|
||||||
@@ -206,10 +270,133 @@ export function selectDecorationPoints(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = bucketPoints
|
const initializeBuckets = () => {
|
||||||
.map((point, index) => priorityBucketPoints[index] ?? point)
|
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)
|
.filter((point): point is WaveformPoint => point !== undefined)
|
||||||
if (first && selected[0] !== first) selected.unshift(first)
|
if (first && selected[0] !== first) selected.unshift(first)
|
||||||
if (last && selected.at(-1) !== last) selected.push(last)
|
if (last && selected.at(-1) !== last) selected.push(last)
|
||||||
return selected
|
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,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export type {
|
|||||||
NormalizedWaveformSeries,
|
NormalizedWaveformSeries,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export type { WaveformGridOptions } from './components/core/grid'
|
export type {
|
||||||
|
WaveformGridOptions,
|
||||||
|
WaveformGridLineOptions,
|
||||||
|
WaveformGridTrackLines,
|
||||||
|
} from './components/core/grid'
|
||||||
|
|
||||||
// 核心功能
|
// 核心功能
|
||||||
export { normalizeWaveformData, normalizeWaveformSeries } from './core'
|
export { normalizeWaveformData, normalizeWaveformSeries } from './core'
|
||||||
|
|||||||
Reference in New Issue
Block a user