diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5813268 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,36 @@ +# Architecture + +Waveform Analysis uses a hybrid architecture. Vue Composition API remains the orchestration +boundary, while classes are reserved for domain objects with lifecycle or algorithm-selection +invariants. + +## Boundaries + +- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the + existing reactive controller surface; it owns no independent copy of component props. +- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates, + and emitted events. `ViewportInteractionStateMachine` is its domain collaborator: it has no Vue + or DOM dependency and accepts only legal `begin`, `move`, `finish`, `cancel`, and `reset` + transitions. Its state is a `box`/`pan` discriminated union, with `null` representing idle. +- `RenderablePointSelectionStrategy` defines the replaceable rendering algorithm boundary. + `CompletePointSelectionStrategy` preserves the complete visible source range and + `PeakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per bucket. + `resolveRenderablePointSelectionStrategy` resolves and reuses the strategy from rendering options. The + existing `selectRenderablePoints` function remains the compatibility facade used by rendering. +- `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data + shapes to the internal series model. `buildTrackLayouts` remains a functional builder because + layout construction is a stateless calculation, not a long-lived object. + +## Vue Integration + +The state-machine instance is stored in `shallowRef(markRaw(...))`. Vue receives defensive state +snapshots through a shallow ref, while SVG overlay elements remain in the composable as DOM +resources. Presentation mode, annotation editing, scales, domains, ticks, formatting, and other +stateless calculations stay in their existing computed/composable or function boundaries. + +## Constraints + +Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or +pure mathematical helpers. Do not add inheritance trees, global event buses, service locators, or +duplicate prop state. A new class must own a real invariant or replaceable algorithm and must be +used by a production path with isolated tests. diff --git a/src/components/WaveformChartView.vue b/src/components/WaveformChartView.vue index 116e089..0008247 100644 --- a/src/components/WaveformChartView.vue +++ b/src/components/WaveformChartView.vue @@ -110,7 +110,7 @@ const { { 'waveform-chart--clean': isCleanView, 'waveform-chart--presentation': isPresentationMode, - 'waveform-chart--panning': selection?.mode === 'pan', + 'waveform-chart--panning': selection?.kind === 'pan', }, ]" :style="containerStyle" @@ -247,7 +247,7 @@ const { /> (null) + const selection = shallowRef(null) + const viewportInteraction = shallowRef(markRaw(new ViewportInteractionStateMachine())) const spacePressed = ref(false) const pointerInsideChart = ref(false) let handleDataReferenceChange: () => void = () => undefined @@ -193,6 +195,7 @@ export function useWaveformChartController( props, emit, selection, + viewportInteraction, spacePressed, trackLayouts, chartTracks, diff --git a/src/components/core/waveformChartTypes.ts b/src/components/core/waveformChartTypes.ts index 6582322..b434cfa 100644 --- a/src/components/core/waveformChartTypes.ts +++ b/src/components/core/waveformChartTypes.ts @@ -95,16 +95,17 @@ export interface WaveformChartEmit { (event: 'page-change', page: number, pageCount: number): void } -export interface ViewportSelectionState { +interface ViewportSelectionBase { trackIndex: number independent: boolean - overlay: SVGRectElement startX: number startY: number currentX: number currentY: number pointerId: number - mode: 'box' | 'pan' xDomain: [number, number] yDomains: Record } + +export type ViewportSelectionState = + (ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' }) diff --git a/src/components/interaction/useWaveformHover.ts b/src/components/interaction/useWaveformHover.ts index 423f0d6..30324ff 100644 --- a/src/components/interaction/useWaveformHover.ts +++ b/src/components/interaction/useWaveformHover.ts @@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) { } const handleSharedPointerMove = (event: PointerEvent) => { if (isPresentationMode.value) return - if (selection.value?.overlay === event.currentTarget) { + if (selection.value && !selection.value.independent) { updateViewportDrag(event) return } diff --git a/src/components/interaction/useWaveformViewport.ts b/src/components/interaction/useWaveformViewport.ts index 741299e..0226b4c 100644 --- a/src/components/interaction/useWaveformViewport.ts +++ b/src/components/interaction/useWaveformViewport.ts @@ -1,5 +1,5 @@ import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3' -import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue' +import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { MINIMUM_SELECTION_SIZE } from '../core/constants' import type { DisplayTrack, TrackLayout } from '../core/types' @@ -10,11 +10,13 @@ import type { WaveformChartEmit, } from '../core/waveformChartTypes' import type { AnnotationSeriesCandidate } from '../annotation' +import type { ViewportInteractionStateMachine } from './viewportInteractionState' interface ViewportContext { props: ResolvedWaveformChartProps emit: WaveformChartEmit selection: Ref + viewportInteraction: ShallowRef spacePressed: Ref trackLayouts: ComputedRef chartTracks: ComputedRef @@ -43,6 +45,7 @@ export function useWaveformViewport(context: ViewportContext) { props, emit, selection, + viewportInteraction, spacePressed, trackLayouts, chartTracks, @@ -65,6 +68,10 @@ export function useWaveformViewport(context: ViewportContext) { clearHover, resolveTrackAtPointer, } = context + const activeOverlay = shallowRef() + const syncSelection = () => { + selection.value = viewportInteraction.value.state + } const selectionBox = computed(() => { const active = selection.value if (!active) return null @@ -143,19 +150,19 @@ export function useWaveformViewport(context: ViewportContext) { const [rawX, rawY] = pointer(event, overlay) const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX)) const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY)) - selection.value = { + const started = viewportInteraction.value.begin({ trackIndex, independent, - overlay, startX: x, startY: y, - currentX: x, - currentY: y, pointerId: event.pointerId, - mode: panRequested ? 'pan' : 'box', + kind: panRequested ? 'pan' : 'box', xDomain: track.xScale.domain() as [number, number], yDomains: currentYDomains(), - } + }) + if (!started) return + activeOverlay.value = overlay + syncSelection() overlay.setPointerCapture?.(event.pointerId) clearHover() event.preventDefault() @@ -213,27 +220,33 @@ export function useWaveformViewport(context: ViewportContext) { const updateViewportDrag = (event: PointerEvent) => { if (isPresentationMode.value) return const active = selection.value - if (!active || event.pointerId !== active.pointerId) return + const overlay = activeOverlay.value + if (!active || !overlay || event.pointerId !== active.pointerId) return const track = trackLayouts.value.find((item) => item.index === active.trackIndex) if (!track) return - const [rawX, rawY] = pointer(event, active.overlay) - active.currentX = Math.max( + const [rawX, rawY] = pointer(event, overlay) + const currentX = Math.max( 0, Math.min(active.independent ? track.width : innerWidth.value, rawX), ) - active.currentY = Math.max( + const currentY = Math.max( 0, Math.min(active.independent ? track.height : innerHeight.value, rawY), ) - selection.value = { ...active } - if (active.mode === 'pan') applyPan(active, track) + const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY }) + if (!next) return + selection.value = next + if (next.kind === 'pan') applyPan(next, track) event.preventDefault() } const cancelViewportDrag = (event?: PointerEvent) => { const active = selection.value if (!active || (event && event.pointerId !== active.pointerId)) return - active.overlay.releasePointerCapture?.(active.pointerId) - selection.value = null + activeOverlay.value?.releasePointerCapture?.(active.pointerId) + if (viewportInteraction.value.cancel(event?.pointerId)) { + activeOverlay.value = undefined + syncSelection() + } } const applyBoxZoom = (active: ViewportSelectionState) => { const track = trackLayouts.value.find((item) => item.index === active.trackIndex) @@ -300,15 +313,32 @@ export function useWaveformViewport(context: ViewportContext) { } const active = selection.value if (!active || event.pointerId !== active.pointerId) return - updateViewportDrag(event) - active.overlay.releasePointerCapture?.(active.pointerId) - selection.value = null - if (active.mode === 'pan') { + const track = trackLayouts.value.find((item) => item.index === active.trackIndex) + if (!track) return + const overlay = activeOverlay.value + if (!overlay) return + const [rawX, rawY] = pointer(event, overlay) + const currentX = Math.max( + 0, + Math.min(active.independent ? track.width : innerWidth.value, rawX), + ) + const currentY = Math.max( + 0, + Math.min(active.independent ? track.height : innerHeight.value, rawY), + ) + const completed = viewportInteraction.value.finish(event.pointerId, { currentX, currentY }) + if (!completed) return + overlay.releasePointerCapture?.(completed.pointerId) + activeOverlay.value = undefined + syncSelection() + event.preventDefault() + if (completed.kind === 'pan') { + applyPan(completed, track) void nextTick(configureZoom) return } - if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) { - applyBoxZoom(active) + if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) { + applyBoxZoom(completed) } } const resetViewport = (trackIndex?: number) => { diff --git a/src/components/interaction/viewportInteractionState.test.ts b/src/components/interaction/viewportInteractionState.test.ts new file mode 100644 index 0000000..36fdccb --- /dev/null +++ b/src/components/interaction/viewportInteractionState.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { ViewportInteractionStateMachine } from './viewportInteractionState' + +const gesture = { + trackIndex: 2, + independent: true, + startX: 10, + startY: 20, + pointerId: 7, + kind: 'box' as const, + xDomain: [0, 100] as [number, number], + yDomains: { track: [-1, 1] as [number, number] }, +} + +describe('ViewportInteractionStateMachine', () => { + it('accepts one gesture and rejects a conflicting start', () => { + const machine = new ViewportInteractionStateMachine() + + expect(machine.begin(gesture)).toBe(true) + expect(machine.begin({ ...gesture, pointerId: 8 })).toBe(false) + expect(machine.state).toMatchObject({ kind: 'box', pointerId: 7 }) + }) + + it('rejects moves and completion from a different pointer', () => { + const machine = new ViewportInteractionStateMachine() + machine.begin(gesture) + + expect(machine.move(8, { currentX: 30, currentY: 40 })).toBeNull() + expect(machine.finish(8, { currentX: 30, currentY: 40 })).toBeNull() + expect(machine.state?.currentX).toBe(10) + }) + + it('updates a valid pointer, completes it, and returns to idle', () => { + const machine = new ViewportInteractionStateMachine() + machine.begin({ ...gesture, kind: 'pan' }) + + expect(machine.move(7, { currentX: 30, currentY: 40 })).toMatchObject({ + kind: 'pan', + currentX: 30, + currentY: 40, + }) + expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({ + kind: 'pan', + currentX: 50, + currentY: 60, + }) + expect(machine.state).toBeNull() + }) + + it('only cancels the owning pointer and supports explicit reset', () => { + const machine = new ViewportInteractionStateMachine() + machine.begin(gesture) + + expect(machine.cancel(8)).toBe(false) + expect(machine.state).not.toBeNull() + expect(machine.cancel(7)).toBe(true) + expect(machine.state).toBeNull() + + machine.begin(gesture) + machine.reset() + expect(machine.state).toBeNull() + }) + + it('returns defensive copies from its state getter', () => { + const machine = new ViewportInteractionStateMachine() + machine.begin(gesture) + const snapshot = machine.state! + snapshot.currentX = 99 + snapshot.xDomain[0] = 50 + snapshot.yDomains.track![0] = 50 + + expect(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] }) + expect(machine.state?.yDomains.track).toEqual([-1, 1]) + }) +}) diff --git a/src/components/interaction/viewportInteractionState.ts b/src/components/interaction/viewportInteractionState.ts new file mode 100644 index 0000000..e7ba1cd --- /dev/null +++ b/src/components/interaction/viewportInteractionState.ts @@ -0,0 +1,80 @@ +import type { ViewportSelectionState } from '../core/waveformChartTypes' + +export interface ViewportGestureStart { + trackIndex: number + independent: boolean + startX: number + startY: number + pointerId: number + kind: 'box' | 'pan' + xDomain: [number, number] + yDomains: Record +} + +export interface ViewportGesturePosition { + currentX: number + currentY: number +} + +function cloneState(state: ViewportSelectionState | null): ViewportSelectionState | null { + if (!state) return null + return { + ...state, + xDomain: [...state.xDomain], + yDomains: Object.fromEntries( + Object.entries(state.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]), + ), + } +} + +/** Owns the legal lifecycle of one active viewport pointer gesture. */ +export class ViewportInteractionStateMachine { + private current: ViewportSelectionState | null = null + + get state(): ViewportSelectionState | null { + return cloneState(this.current) + } + + begin(input: ViewportGestureStart): boolean { + if (this.current) return false + this.current = { + ...input, + currentX: input.startX, + currentY: input.startY, + xDomain: [...input.xDomain], + yDomains: Object.fromEntries( + Object.entries(input.yDomains).map(([key, domain]) => [ + key, + [...domain] as [number, number], + ]), + ), + } + return true + } + + move(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null { + if (!this.current || this.current.pointerId !== pointerId) return null + this.current = { ...this.current, ...position } + return this.state + } + + finish(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null { + if (!this.current || this.current.pointerId !== pointerId) return null + this.current = { ...this.current, ...position } + const completed = this.state + this.current = null + return completed + } + + cancel(pointerId?: number): boolean { + if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) { + return false + } + this.current = null + return true + } + + reset(): void { + this.current = null + } +} diff --git a/src/core/rendering.ts b/src/core/rendering.ts index fd92d9c..bb323ff 100644 --- a/src/core/rendering.ts +++ b/src/core/rendering.ts @@ -3,6 +3,10 @@ import { bisector } from 'd3' import type { WaveformPoint } from '@/types' import { resolveWaveformPointErrors } from './data' import type { ResolvedWaveformRenderingOptions } from './renderingOptions' +import { + resolveRenderablePointSelectionStrategy, + type VisiblePointRange, +} from './renderingStrategies' export { DEFAULT_WAVEFORM_RENDERING_OPTIONS, @@ -13,11 +17,6 @@ export { const pointBisector = bisector((point: WaveformPoint) => point.x) const acceptAllPoints = () => true -interface VisiblePointRange { - start: number - end: number -} - interface PointSeriesSource { points: WaveformPoint[] } @@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues( return false } -function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) { - if (point && target[target.length - 1] !== point) target.push(point) -} - function selectRenderablePointsInRange( points: WaveformPoint[], range: VisiblePointRange, @@ -76,82 +71,17 @@ function selectRenderablePointsInRange( width: number, options: ResolvedWaveformRenderingOptions, ): WaveformPoint[] { - const domainStart = Math.min(domain[0], domain[1]) - const domainEnd = Math.max(domain[0], domain[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) { - return points.slice(start, end) - } - - const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) - const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) - if (visibleCount <= maximumPointCount) return points.slice(start, end) - - const result: WaveformPoint[] = [] - const span = domainEnd - domainStart || 1 - 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 - 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 = range.start; index < range.end; index += 1) { - const point = points[index] - const bucket = Math.min( - bucketCount - 1, - Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)), - ) - if (bucket !== activeBucket) { - flushBucket() - activeBucket = bucket - firstIndex = index - lastIndex = index - minimumIndex = index - maximumIndex = index - continue - } - lastIndex = index - if (point.y < points[minimumIndex].y) minimumIndex = index - if (point.y > points[maximumIndex].y) maximumIndex = index - } - flushBucket() - pushUniquePoint(result, points[end - 1]) - return result + return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({ + points, + range, + domain, + width, + options, + }) } /** diff --git a/src/core/renderingStrategies.test.ts b/src/core/renderingStrategies.test.ts new file mode 100644 index 0000000..933cd3d --- /dev/null +++ b/src/core/renderingStrategies.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import type { WaveformPoint } from '../types' +import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions' +import { + CompletePointSelectionStrategy, + PeakPreservingPointSelectionStrategy, + resolveRenderablePointSelectionStrategy, +} from './renderingStrategies' + +const denseOptions = { + ...DEFAULT_WAVEFORM_RENDERING_OPTIONS, + downsampleThreshold: 0, + maxPointsPerPixel: 1, +} + +describe('renderable point selection strategies', () => { + it('resolves complete-point selection at the configured boundaries', () => { + const complete = resolveRenderablePointSelectionStrategy({ + visibleCount: 100, + width: 100, + options: DEFAULT_WAVEFORM_RENDERING_OPTIONS, + }) + const disabled = resolveRenderablePointSelectionStrategy({ + visibleCount: 10_000, + width: 100, + options: { ...denseOptions, downsample: false }, + }) + + expect(complete).toBeInstanceOf(CompletePointSelectionStrategy) + expect(disabled).toBeInstanceOf(CompletePointSelectionStrategy) + }) + + it('resolves peak-preserving selection for dense visible data', () => { + const strategy = resolveRenderablePointSelectionStrategy({ + visibleCount: 1_000, + width: 100, + options: denseOptions, + }) + + expect(strategy).toBeInstanceOf(PeakPreservingPointSelectionStrategy) + expect(strategy.name).toBe('peak-preserving') + }) + + it('reuses the resolved strategy instance across selections', () => { + const request = { visibleCount: 100, width: 100, options: DEFAULT_WAVEFORM_RENDERING_OPTIONS } + const peakRequest = { visibleCount: 1_000, width: 100, options: denseOptions } + + expect(resolveRenderablePointSelectionStrategy(request)).toBe( + resolveRenderablePointSelectionStrategy(request), + ) + expect(resolveRenderablePointSelectionStrategy(peakRequest)).toBe( + resolveRenderablePointSelectionStrategy(peakRequest), + ) + }) + + it('retains first, last, minimum, and maximum points in a peak bucket', () => { + const points: WaveformPoint[] = [ + { x: 0, y: 5 }, + { x: 1, y: 1 }, + { x: 2, y: 10 }, + { x: 3, y: 3 }, + { x: 4, y: 7 }, + ] + const strategy = new PeakPreservingPointSelectionStrategy() + const selected = strategy.select({ + points, + range: { start: 0, end: points.length }, + domain: [0, 4], + width: 4, + options: denseOptions, + }) + + expect(selected[0]).toBe(points[0]) + expect(selected.at(-1)).toBe(points.at(-1)) + expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10])) + }) +}) diff --git a/src/core/renderingStrategies.ts b/src/core/renderingStrategies.ts new file mode 100644 index 0000000..7e71ee1 --- /dev/null +++ b/src/core/renderingStrategies.ts @@ -0,0 +1,149 @@ +import type { WaveformPoint } from '../types' +import type { ResolvedWaveformRenderingOptions } from './renderingOptions' + +export interface VisiblePointRange { + start: number + end: number +} + +export interface RenderablePointSelectionContext { + points: WaveformPoint[] + range: VisiblePointRange + domain: [number, number] + width: number + options: ResolvedWaveformRenderingOptions +} + +export interface RenderablePointSelectionStrategy { + readonly name: 'complete' | 'peak-preserving' + select(context: RenderablePointSelectionContext): WaveformPoint[] +} + +function selectionBounds(range: VisiblePointRange, pointCount: number) { + return { + start: Math.max(0, range.start - 1), + end: Math.min(pointCount, range.end + 1), + } +} + +function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) { + if (point && target[target.length - 1] !== point) target.push(point) +} + +export class CompletePointSelectionStrategy implements RenderablePointSelectionStrategy { + readonly name = 'complete' as const + + select(context: RenderablePointSelectionContext): WaveformPoint[] { + const { start, end } = selectionBounds(context.range, context.points.length) + return context.points.slice(start, end) + } +} + +export class PeakPreservingPointSelectionStrategy implements RenderablePointSelectionStrategy { + readonly name = 'peak-preserving' as const + + select(context: RenderablePointSelectionContext): WaveformPoint[] { + const { points, range, domain, width, options } = context + const { start, end } = selectionBounds(range, points.length) + const visibleCount = end - start + if (visibleCount <= 0) return [] + + const domainStart = Math.min(domain[0], domain[1]) + const domainEnd = Math.max(domain[0], domain[1]) + const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) + const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) + 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 + 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 = range.start; index < range.end; index += 1) { + const point = points[index] + const bucket = Math.min( + bucketCount - 1, + Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)), + ) + if (bucket !== activeBucket) { + flushBucket() + activeBucket = bucket + firstIndex = index + lastIndex = index + minimumIndex = index + maximumIndex = index + continue + } + lastIndex = index + if (point.y < points[minimumIndex].y) minimumIndex = index + if (point.y > points[maximumIndex].y) maximumIndex = index + } + flushBucket() + pushUniquePoint(result, points[end - 1]) + return result + } +} + +export interface RenderablePointSelectionStrategyRequest { + visibleCount: number + width: number + options: ResolvedWaveformRenderingOptions +} + +export class RenderablePointSelectionStrategyFactory { + private readonly complete = new CompletePointSelectionStrategy() + private readonly peakPreserving = new PeakPreservingPointSelectionStrategy() + + resolve(request: RenderablePointSelectionStrategyRequest): RenderablePointSelectionStrategy { + const maximumPointCount = Math.max( + 4, + Math.floor(request.width * request.options.maxPointsPerPixel), + ) + const shouldUseCompletePoints = + !request.options.downsample || + request.visibleCount <= request.options.downsampleThreshold || + request.visibleCount <= maximumPointCount + return shouldUseCompletePoints ? this.complete : this.peakPreserving + } +} + +const defaultRenderablePointSelectionStrategyFactory = new RenderablePointSelectionStrategyFactory() + +export function resolveRenderablePointSelectionStrategy( + request: RenderablePointSelectionStrategyRequest, +): RenderablePointSelectionStrategy { + return defaultRenderablePointSelectionStrategyFactory.resolve(request) +}