From 356f55c9fdb5dc56b247dbd82391b353703f08a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Wed, 22 Jul 2026 10:56:25 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E6=B3=A2=E5=BD=A2?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E7=AE=97=E6=B3=95=E4=B8=8E=E5=9B=BE=E6=A1=86?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/WaveformChart.test.ts | 44 +++ src/components/WaveformChart.vue | 18 +- src/components/core/grid.test.ts | 35 ++- src/components/core/grid.ts | 24 ++ src/components/core/layout.test.ts | 9 +- src/components/core/layout.ts | 64 ++-- src/components/core/types.ts | 3 + src/components/core/useWaveformData.ts | 46 +-- src/components/data/types.ts | 6 +- src/components/index.ts | 2 + src/components/rendering/WaveformTrack.vue | 76 +++-- src/core/data.test.ts | 58 ++++ src/core/data.ts | 26 +- src/core/rendering.test.ts | 64 ++++ src/core/rendering.ts | 323 ++++++++++++++++----- src/index.ts | 6 +- 16 files changed, 613 insertions(+), 191 deletions(-) create mode 100644 src/core/data.test.ts diff --git a/src/components/WaveformChart.test.ts b/src/components/WaveformChart.test.ts index 1a2a2ed..830ed51 100644 --- a/src/components/WaveformChart.test.ts +++ b/src/components/WaveformChart.test.ts @@ -124,6 +124,7 @@ describe('normalizeWaveformData', () => { expect(series?.yDomain[0]).toBeLessThanOrEqual(-1) expect(series?.yDomain[1]).toBeGreaterThanOrEqual(6) + expect(series?.hasErrorPoints).toBe(true) }) 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') }) + 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 () => { const wrapper = await mountSizedChart(gridSeries(2), { grid: { rowCount: 2, columnCount: 1 }, diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index 3fe90e4..295a76a 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -12,6 +12,7 @@ import { type ZoomTransform, } from 'd3' import { resolveWaveformRenderingOptions } from '../core' +import { hasMinimumVisibleXValues } from '../core/rendering' import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils' import { useAnimationFrameThrottle } from './utils/useAnimationFrameThrottle' import { @@ -825,20 +826,13 @@ function resolveMaximumZoomScale(domain: [number, number]): number { 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() - 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 { 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 { diff --git a/src/components/core/grid.test.ts b/src/components/core/grid.test.ts index 8839799..3c71ebb 100644 --- a/src/components/core/grid.test.ts +++ b/src/components/core/grid.test.ts @@ -11,11 +11,44 @@ import { describe('waveform grid helpers', () => { 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({ rowCount: 1, columnCount: 10, 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[0] + + expect(normalizeGridOptions(options).trackLines.voltage).toEqual({ + horizontal: true, + vertical: true, }) }) diff --git a/src/components/core/grid.ts b/src/components/core/grid.ts index 270bd22..4a4b952 100644 --- a/src/components/core/grid.ts +++ b/src/components/core/grid.ts @@ -8,12 +8,26 @@ export interface WaveformGridOptions { rowCount?: number columnCount?: number showPagination?: boolean + trackLines?: WaveformGridTrackLines +} + +export interface WaveformGridLineOptions { + horizontal?: boolean + vertical?: boolean +} + +export type WaveformGridTrackLines = Record + +export interface NormalizedWaveformGridLineOptions { + horizontal: boolean + vertical: boolean } export interface NormalizedWaveformGridOptions { rowCount: number columnCount: number showPagination: boolean + trackLines: Record } export interface GridCellGeometry { @@ -37,10 +51,20 @@ const normalizeCount = (value: unknown, fallback: number) => { } 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 { rowCount: normalizeCount(options?.rowCount, 2), columnCount: normalizeCount(options?.columnCount, 1), showPagination: options?.showPagination ?? true, + trackLines, } } diff --git a/src/components/core/layout.test.ts b/src/components/core/layout.test.ts index b5274f7..c5f9ccf 100644 --- a/src/components/core/layout.test.ts +++ b/src/components/core/layout.test.ts @@ -24,6 +24,7 @@ function series(id: string, minimum: number, maximum: number): DisplaySeries { ], xDomain: [0, 1], yDomain: [minimum, maximum], + hasErrorPoints: false, } } @@ -61,7 +62,7 @@ function layoutForSeries( series: sourceTrack, }, ], - grid: { rowCount: 1, columnCount: 1, showPagination: false }, + grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, displayMode: 'independent', overlayMode: 'single-axis', independentTransforms: [transform], @@ -95,7 +96,7 @@ describe('multi-value Y-axis grouping', () => { series: sourceTrack, }, ], - grid: { rowCount: 1, columnCount: 1, showPagination: false }, + grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, displayMode: 'independent', overlayMode: 'single-axis', independentTransforms: [zoomIdentity], @@ -198,7 +199,7 @@ describe('multi-value Y-axis grouping', () => { 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', overlayMode: 'multi-axis', independentTransforms: [zoomIdentity], @@ -241,6 +242,7 @@ describe('decoration sampling', () => { error: index % 200 === 1 ? 0.1 : 0, })), xDomain: [0, 999], + hasErrorPoints: true, }) 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', () => { const noErrors = denseSeries() noErrors.points = noErrors.points.map((point) => ({ x: point.x, y: point.y })) + noErrors.hasErrorPoints = false const zeroErrorPath = layoutForSeries(noErrors) expect(zeroErrorPath.errorBarRenderPoints).toEqual([]) expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2) diff --git a/src/components/core/layout.ts b/src/components/core/layout.ts index 44d3b50..5a39e35 100644 --- a/src/components/core/layout.ts +++ b/src/components/core/layout.ts @@ -9,11 +9,9 @@ import { } from 'd3' import { - selectDecorationPoints, - selectRenderablePoints, - resolveWaveformPointErrors, + selectSeriesRenderPoints, type ResolvedWaveformRenderingOptions, -} from '../../core' +} from '../../core/rendering' import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types' import { buildMinorTicks, @@ -217,6 +215,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou points: [], xDomain: [0, 1], yDomain: [0, 1], + hasErrorPoints: false, } const displayTrack: DisplayTrack = cell.series ?? { id: emptySeries.id, @@ -317,51 +316,18 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou axis.seriesList.some((series) => series.id === trackSeries.id), ) const seriesYScale = yAxis?.scale ?? yScale - const pathPoints = selectRenderablePoints( + const renderPoints = selectSeriesRenderPoints( trackSeries.points, domain, cell.width, 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() .x((point) => xScale(point.x)) .y((point) => seriesYScale(point.y)) @@ -372,9 +338,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou } return { series: trackSeries, - path: isEmpty || trackSeries.lineType === 'none' ? null : pathGenerator(pathPoints), - pointRenderPoints, - errorBarRenderPoints, + path: renderPoints.linePoints.length ? pathGenerator(renderPoints.linePoints) : null, + pointRenderPoints: renderPoints.pointRenderPoints, + errorBarRenderPoints: renderPoints.errorBarRenderPoints, yScale: seriesYScale, yAxisIndex: yAxis?.index ?? 0, } @@ -407,6 +373,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou xAxisExponent, path: seriesPaths[0]?.path ?? null, seriesPaths, + gridLines: options.grid.trackLines[displayTrack.id] ?? { + horizontal: true, + vertical: true, + }, showXAxis: (isEmpty || hasVisibleSeries) && (options.displayMode === 'independent' || diff --git a/src/components/core/types.ts b/src/components/core/types.ts index 2942b5a..e72f5eb 100644 --- a/src/components/core/types.ts +++ b/src/components/core/types.ts @@ -5,6 +5,7 @@ import type { WaveformPoint, WaveformPointType, } from '../../types' +import type { NormalizedWaveformGridLineOptions } from './grid' /** * 显示系列 @@ -21,6 +22,7 @@ export interface DisplaySeries { points: WaveformPoint[] xDomain: [number, number] yDomain: [number, number] + hasErrorPoints: boolean } export interface DisplayTrack { @@ -97,6 +99,7 @@ export interface TrackLayout { path: string | null seriesPaths: TrackSeriesPath[] showXAxis: boolean + gridLines: NormalizedWaveformGridLineOptions } // 重新导出 WaveformPoint 方便使用 diff --git a/src/components/core/useWaveformData.ts b/src/components/core/useWaveformData.ts index ef7f0d6..eedfb41 100644 --- a/src/components/core/useWaveformData.ts +++ b/src/components/core/useWaveformData.ts @@ -22,34 +22,42 @@ export interface PreparedWaveformSeries { points: WaveformPoint[] xDomain: [number, number] yDomain: [number, number] + hasErrorPoints: boolean } -function pointDomain( +function pointMetrics( points: WaveformPoint[], - key: 'x' | 'y', includeErrors = false, -): [number, number] { - let minimum = Number.POSITIVE_INFINITY - let maximum = Number.NEGATIVE_INFINITY - points.forEach((point) => { - const value = point[key] - if (value < minimum) minimum = value - if (value > maximum) maximum = value - if (key === 'y' && includeErrors) { +): { xDomain: [number, number]; yDomain: [number, number]; hasErrorPoints: boolean } { + let xMinimum = Number.POSITIVE_INFINITY + let xMaximum = Number.NEGATIVE_INFINITY + let yMinimum = Number.POSITIVE_INFINITY + let yMaximum = Number.NEGATIVE_INFINITY + let hasErrorPoints = false + for (const point of points) { + 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) - minimum = Math.min(minimum, point.y - errors.lower) - maximum = Math.max(maximum, point.y + errors.upper) + if (errors.lower !== 0 || errors.upper !== 0) hasErrorPoints = true + 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[] { - return normalizeWaveformSeries(data).map((series) => ({ - ...series, - xDomain: pointDomain(series.points, 'x'), - yDomain: pointDomain(series.points, 'y', series.errorBar.visible), - })) + return normalizeWaveformSeries(data).map((series) => { + const metrics = pointMetrics(series.points, series.errorBar.visible) + return { ...series, ...metrics } + }) } export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) { diff --git a/src/components/data/types.ts b/src/components/data/types.ts index 46c4aee..0e2a21a 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -30,7 +30,11 @@ export type { NormalizedWaveformSeries, } from '../../types' -export type { WaveformGridOptions } from '../core/grid' +export type { + WaveformGridOptions, + WaveformGridLineOptions, + WaveformGridTrackLines, +} from '../core/grid' // 重新导出数据处理函数 export { normalizeWaveformData, normalizeWaveformSeries } from '../../core' diff --git a/src/components/index.ts b/src/components/index.ts index a3f1431..34c0ea9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -24,6 +24,8 @@ export type { WaveformPointType, WaveformErrorBarOptions, WaveformGridOptions, + WaveformGridLineOptions, + WaveformGridTrackLines, } from './data/types' export type { WaveformGridOptions as WaveformGridConfig } from './core/grid' diff --git a/src/components/rendering/WaveformTrack.vue b/src/components/rendering/WaveformTrack.vue index 39219f9..914396b 100644 --- a/src/components/rendering/WaveformTrack.vue +++ b/src/components/rendering/WaveformTrack.vue @@ -211,42 +211,54 @@ watch( - - + + - - + + diff --git a/src/core/data.test.ts b/src/core/data.test.ts new file mode 100644 index 0000000..d25ad42 --- /dev/null +++ b/src/core/data.test.ts @@ -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 }, + ]) + }) +}) diff --git a/src/core/data.ts b/src/core/data.ts index 5b7924d..817687e 100644 --- a/src/core/data.ts +++ b/src/core/data.ts @@ -46,15 +46,27 @@ export function normalizeWaveformData(data: SingleWaveformData): WaveformPoint[] 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 }] : [], - ) + const points: WaveformPoint[] = [] + 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 - .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)) - .map(normalizeWaveformPoint) - .sort((left, right) => left.x - right.x) + const points: WaveformPoint[] = [] + let previousX = Number.NEGATIVE_INFINITY + let sorted = true + 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 } /** diff --git a/src/core/rendering.test.ts b/src/core/rendering.test.ts index 2963fe0..db88b60 100644 --- a/src/core/rendering.test.ts +++ b/src/core/rendering.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import type { WaveformPoint } from '../types' import { + hasMinimumVisibleXValues, resolveWaveformRenderingOptions, selectDecorationPoints, selectRenderablePoints, + selectSeriesRenderPoints, } from './rendering' describe('waveform rendering selection', () => { @@ -122,4 +124,66 @@ describe('waveform rendering selection', () => { expect(selected.filter(hasError)).toEqual(priorityPoints.filter(hasError)) 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) + }) }) diff --git a/src/core/rendering.ts b/src/core/rendering.ts index a51446a..b036c57 100644 --- a/src/core/rendering.ts +++ b/src/core/rendering.ts @@ -1,6 +1,7 @@ import { bisector } from 'd3' import type { WaveformPoint, WaveformRenderingOptions } from '@/types' +import { resolveWaveformPointErrors } from './data' export interface ResolvedWaveformRenderingOptions { downsample: boolean @@ -19,6 +20,59 @@ export const DEFAULT_WAVEFORM_RENDERING_OPTIONS: ResolvedWaveformRenderingOption } 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() + 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, @@ -52,24 +106,17 @@ function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefin 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( +function selectRenderablePointsInRange( points: WaveformPoint[], + range: VisiblePointRange, 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 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) { @@ -82,22 +129,45 @@ export function selectRenderablePoints( 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 - 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])) + 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 = 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 bucket = Math.min( bucketCount - 1, @@ -121,33 +191,61 @@ export function selectRenderablePoints( 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[], 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 = () => true, + predicate: (point: WaveformPoint) => boolean, priorityPredicate?: (point: WaveformPoint) => boolean, ): 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 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] : [] + for (let index = range.start; index < range.end; index += 1) { + 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 | undefined + let bucketDistances: number[] | undefined + let priorityBucketPoints: Array | undefined + let priorityBucketDistances: number[] | undefined const sparsePoints: WaveformPoint[] = [] let alreadySparse = true let first: WaveformPoint | undefined @@ -155,44 +253,10 @@ export function selectDecorationPoints( 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 + const recordBucketPoint = (point: WaveformPoint, pixel: number) => { + if (!bucketPoints || !bucketDistances || !priorityBucketPoints || !priorityBucketDistances) { + return } - 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 = Array.from({ length: bucketCount }) - const bucketDistances = Array.from({ length: bucketCount }, () => Number.POSITIVE_INFINITY) - const priorityBucketPoints: Array = 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) @@ -206,10 +270,133 @@ export function selectDecorationPoints( } } - const selected = bucketPoints - .map((point, index) => priorityBucketPoints[index] ?? point) + 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, + ) + : [], + } +} diff --git a/src/index.ts b/src/index.ts index 92fb13a..10a7660 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,11 @@ export type { NormalizedWaveformSeries, } from './types' -export type { WaveformGridOptions } from './components/core/grid' +export type { + WaveformGridOptions, + WaveformGridLineOptions, + WaveformGridTrackLines, +} from './components/core/grid' // 核心功能 export { normalizeWaveformData, normalizeWaveformSeries } from './core'