From 231d58fa400a69ba719ecd88ca94db8a916c3442 Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Wed, 17 Jun 2026 11:49:32 +0800 Subject: [PATCH 1/6] refactor(waveform): extract canvas render pipeline into lib/waveform-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaveformPanel.vue had regrown to 1419 lines (the batch-7 WaveformLegend extraction was overtaken by later feature commits: register-waveform batching, sample-thinning, hover ruler). Extract the ~560-line pure canvas-render pipeline — plot layout, theme reading, sample->polyline path building (with window clipping + interpolation), and the drawing primitives (waveform paths, sample points, hover ruler, X/Y rulers, round-rect, text truncation) — into a framework-free src/lib/waveform-render.ts. The panel is now a thin state + interaction + RAF orchestrator that calls the extracted functions; buildVisibleChannelPaths takes the buffer + channels + a labelForChannel callback instead of closing over component state. lib/ stays framework-free (the new module imports only from ./waveform). Pure relocation — the render path is unchanged. WaveformPanel.vue: 1419 -> 858 lines (-40%); new module 651 lines. 21 new unit tests cover the now-extractable pure functions (layout, wheel intent, path building, hover hit-testing, formatters) plus a recording-mock ctx exercising the draw primitives — waveform-render.ts at 97.08% line coverage. 576 frontend tests + 71 Rust tests green; 0 circular deps; bench 10/10 pass (waveform_parse_50k unaffected). --- src/components/terminal/WaveformPanel.vue | 615 +------------------- src/lib/waveform-render.ts | 651 ++++++++++++++++++++++ tests/frontend/waveform-render.test.ts | 378 +++++++++++++ 3 files changed, 1056 insertions(+), 588 deletions(-) create mode 100644 src/lib/waveform-render.ts create mode 100644 tests/frontend/waveform-render.test.ts diff --git a/src/components/terminal/WaveformPanel.vue b/src/components/terminal/WaveformPanel.vue index bd7856e..9fbe00d 100644 --- a/src/components/terminal/WaveformPanel.vue +++ b/src/components/terminal/WaveformPanel.vue @@ -63,10 +63,8 @@ import type { DataFrame } from '../../types'; import { t } from '../../lib/i18n'; import { channelStats, - closestPointOnWaveformPath, createBuffer, buildWaveformCsv, - formatWaveformNumber, ingestWaveformTextFrames, normalizeWaveformTimeViewport, panWaveformTimeViewport, @@ -75,7 +73,6 @@ import { pushRegisterWaveformSample, scaleWaveformTimeViewport, syncWaveformTimeViewportAfterSampleChange, - thinWaveformSamplePoints, visibleChannelRangeInWindow, waveformFrameCursorAtEnd, waveformSampleIndexWindow, @@ -84,11 +81,28 @@ import { type ChannelStats, type WaveformFrameCursor, type WaveformChannelState, - type WaveformPathPoint, type WaveformPanDirection, type WaveformTimeViewport, type WaveformZoomDirection, } from '../../lib/waveform'; +import { + buildVisibleChannelPaths, + calculatePlotLayout, + clampNumber, + clampRatio, + drawHoverRuler, + drawSamplePoints, + drawWaveformPaths, + drawXRuler, + drawYRuler, + findHoverPoint, + formatNum, + monoFontStack, + normalizeWheelDelta, + readWaveformTheme, + wheelIntentFromDelta, + type PlotLayout, +} from '../../lib/waveform-render'; import { parseStream } from '../../lib/modbus'; import WaveformLegend from './WaveformLegend.vue'; @@ -376,10 +390,6 @@ function syncViewportAfterSampleChange(previousTimestamps: readonly number[]) { ); } -function formatNum(n: number): string { - return formatWaveformNumber(n); -} - // Canvas redraws are demand-driven and coalesced into one RAF. This keeps the // waveform idle at 0fps when no data or pointer interaction changes the view. let rafId: number | null = null; @@ -389,29 +399,13 @@ let dragState: CanvasDragState | null = null; let wheelGestureResetTimer: number | null = null; const WHEEL_ZOOM_SENSITIVITY = 0.002; -const WHEEL_DOMINANCE_RATIO = 1.25; const WHEEL_GESTURE_IDLE_MS = 180; -type CanvasWheelIntent = 'pan' | 'zoom'; - interface CanvasDragState { pointerId: number; lastClientX: number; } -interface PlotLayout { - leftPad: number; - plotX0: number; - plotX1: number; - plotTop: number; - plotBottom: number; - plotW: number; - plotH: number; - topPad: number; - bottomPad: number; - rightPad: number; -} - function onCanvasPointerDown(e: PointerEvent) { if (e.button !== 0 || buffer.samples.length === 0) return; const canvas = canvasRef.value; @@ -566,21 +560,6 @@ function scaleViewportAtRatio(anchorRatio: number, scale: number): boolean { return true; } -function wheelIntentFromDelta( - deltaX: number, - deltaY: number, - event: Pick, -): CanvasWheelIntent | null { - const absX = Math.abs(deltaX); - const absY = Math.abs(deltaY); - if (absX === 0 && absY === 0) return null; - if (event.ctrlKey || event.metaKey) return 'zoom'; - if (event.shiftKey && absY > 0 && absX < absY) return 'pan'; - if (absX > 0 && absX >= absY * WHEEL_DOMINANCE_RATIO) return 'pan'; - if (absY > 0) return 'zoom'; - return 'pan'; -} - function beginWheelGesture() { if (wheelGestureResetTimer !== null) { window.clearTimeout(wheelGestureResetTimer); @@ -598,47 +577,12 @@ function resetWheelGesture() { } function currentPlotLayout(canvas: HTMLCanvasElement): PlotLayout { - return calculatePlotLayout(canvas.clientWidth, canvas.clientHeight); -} - -function calculatePlotLayout(cssW: number, cssH: number): PlotLayout { - const leftPad = showYRuler.value ? 52 : 12; - const topPad = 12; - const bottomPad = showXRuler.value ? 30 : 16; - const rightPad = 8; - const plotX0 = leftPad; - const plotTop = topPad; - const plotBottom = Math.max(plotTop + 1, cssH - bottomPad); - const plotH = plotBottom - plotTop; - const plotX1 = Math.max(plotX0 + 1, cssW - rightPad); - const plotW = plotX1 - plotX0; - return { - leftPad, - plotX0, - plotX1, - plotTop, - plotBottom, - plotW, - plotH, - topPad, - bottomPad, - rightPad, - }; -} - -function normalizeWheelDelta(delta: number, deltaMode: number, pageSize: number): number { - if (deltaMode === 1) return delta * 16; - if (deltaMode === 2) return delta * Math.max(1, pageSize); - return delta; -} - -function clampRatio(value: number): number { - return clampNumber(value, 0, 1); -} - -function clampNumber(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - return Math.max(min, Math.min(max, value)); + return calculatePlotLayout( + canvas.clientWidth, + canvas.clientHeight, + showYRuler.value, + showXRuler.value, + ); } function scheduleRender() { @@ -685,6 +629,8 @@ function render() { const { leftPad, plotX0, plotX1, plotTop, plotBottom, plotW, plotH } = calculatePlotLayout( cssW, cssH, + showYRuler.value, + showXRuler.value, ); ctx.strokeStyle = gridColor; @@ -768,7 +714,7 @@ function render() { }); } - const visiblePaths = buildVisibleChannelPaths({ + const visiblePaths = buildVisibleChannelPaths(buffer, channelState.value, channelLabel, { channelCount, endMs: visibleEndMs, plotBottom, @@ -817,513 +763,6 @@ function render() { } } -function sampleTimestamp(index: number): number { - const timestamp = buffer.timestamps[index]; - return Number.isFinite(timestamp) ? timestamp : index; -} - -function formatMs(value: number): string { - if (!Number.isFinite(value)) return '—'; - return `${Math.round(value)}ms`; -} - -interface HoverPoint { - x: number; - y: number; - relativeMs: number; - value: number; - color: string; - label: string; -} - -interface RenderedChannelPath { - color: string; - label: string; - points: WaveformPathPoint[]; - samplePoints: WaveformPathPoint[]; -} - -interface BuildChannelPathOptions { - channelCount: number; - endMs: number; - plotBottom: number; - plotH: number; - sampleX: (timestamp: number) => number; - scanEndIndex: number; - scanStartIndex: number; - span: number; - startMs: number; - vMin: number; -} - -interface HoverPointOptions { - cursor: { x: number; y: number } | null; - originTimestamp: number; - paths: RenderedChannelPath[]; - plotTop: number; - plotX0: number; - plotX1: number; - plotBottom: number; -} - -function buildVisibleChannelPaths(options: BuildChannelPathOptions): RenderedChannelPath[] { - const { - channelCount, - endMs, - plotBottom, - plotH, - sampleX, - scanEndIndex, - scanStartIndex, - span, - startMs, - vMin, - } = options; - const paths: RenderedChannelPath[] = []; - const scanStart = Math.max(0, Math.min(buffer.samples.length, scanStartIndex)); - const scanEnd = Math.max(scanStart, Math.min(buffer.samples.length, scanEndIndex)); - for (let c = 0; c < channelCount; c += 1) { - const channel = channelState.value[c]; - if (!channel?.visible) continue; - const points: WaveformPathPoint[] = []; - const samplePoints: WaveformPathPoint[] = []; - let previous: { timestamp: number; value: number } | null = null; - - for (let i = scanStart; i < scanEnd; i += 1) { - const value = buffer.samples[i][c]; - if (value === undefined || !Number.isFinite(value)) continue; - const timestamp = sampleTimestamp(i); - if (!Number.isFinite(timestamp)) continue; - const current = { timestamp, value }; - - if (previous && current.timestamp >= startMs && previous.timestamp <= endMs) { - addInterpolatedSegmentPoints(points, previous, current, { - endMs, - plotBottom, - plotH, - sampleX, - span, - startMs, - vMin, - }); - } else if (!previous && timestamp >= startMs && timestamp <= endMs) { - points.push(toRenderedPoint(current, { plotBottom, plotH, sampleX, span, vMin })); - } - - if (timestamp >= startMs && timestamp <= endMs) { - samplePoints.push(toRenderedPoint(current, { plotBottom, plotH, sampleX, span, vMin })); - } - - previous = current; - } - if (points.length > 0) { - paths.push({ - color: channel.color, - label: channelLabel(c), - points, - samplePoints, - }); - } - } - return paths; -} - -interface SegmentPoint { - timestamp: number; - value: number; -} - -interface RenderPointOptions { - plotBottom: number; - plotH: number; - sampleX: (timestamp: number) => number; - span: number; - vMin: number; -} - -interface SegmentClipOptions extends RenderPointOptions { - startMs: number; - endMs: number; -} - -function addInterpolatedSegmentPoints( - out: WaveformPathPoint[], - a: SegmentPoint, - b: SegmentPoint, - options: SegmentClipOptions, -) { - if (b.timestamp < options.startMs || a.timestamp > options.endMs) return; - const from = Math.max(a.timestamp, options.startMs); - const to = Math.min(b.timestamp, options.endMs); - if (from > to) return; - pushRenderedPoint(out, interpolateSegmentPoint(a, b, from), options); - if (to !== from) { - pushRenderedPoint(out, interpolateSegmentPoint(a, b, to), options); - } -} - -function interpolateSegmentPoint( - a: SegmentPoint, - b: SegmentPoint, - timestamp: number, -): SegmentPoint { - const duration = b.timestamp - a.timestamp; - const ratio = duration === 0 ? 0 : (timestamp - a.timestamp) / duration; - return { - timestamp, - value: a.value + (b.value - a.value) * Math.max(0, Math.min(1, ratio)), - }; -} - -function pushRenderedPoint( - out: WaveformPathPoint[], - point: SegmentPoint, - options: RenderPointOptions, -) { - const last = out[out.length - 1]; - if (last && Math.abs(last.timestamp - point.timestamp) < 0.0001) { - out[out.length - 1] = toRenderedPoint(point, options); - return; - } - out.push(toRenderedPoint(point, options)); -} - -function toRenderedPoint(point: SegmentPoint, options: RenderPointOptions): WaveformPathPoint { - return { - x: options.sampleX(point.timestamp), - y: options.plotBottom - ((point.value - options.vMin) / options.span) * options.plotH, - value: point.value, - timestamp: point.timestamp, - }; -} - -function drawWaveformPaths(ctx: CanvasRenderingContext2D, paths: readonly RenderedChannelPath[]) { - ctx.lineWidth = 1.5; - for (const path of paths) { - ctx.strokeStyle = path.color; - ctx.beginPath(); - for (let i = 0; i < path.points.length; i += 1) { - const point = path.points[i]; - if (i === 0) ctx.moveTo(point.x, point.y); - else ctx.lineTo(point.x, point.y); - } - ctx.stroke(); - } -} - -function findHoverPoint(options: HoverPointOptions): HoverPoint | null { - const { cursor, originTimestamp, paths, plotTop, plotX0, plotX1, plotBottom } = options; - if (!cursor) return null; - if (cursor.x < plotX0 || cursor.x > plotX1 || cursor.y < plotTop || cursor.y > plotBottom) { - return null; - } - - let best: HoverPoint | null = null; - let bestDistance = Infinity; - for (const path of paths) { - const projected = closestPointOnWaveformPath(cursor, path.points); - if (!projected || projected.distanceSq >= bestDistance) continue; - bestDistance = projected.distanceSq; - best = { - x: projected.x, - y: projected.y, - value: projected.value, - color: path.color, - label: path.label, - relativeMs: projected.timestamp - originTimestamp, - }; - } - return best; -} - -interface SamplePointDrawOptions { - outlineColor: string; - paths: readonly RenderedChannelPath[]; -} - -function drawSamplePoints(ctx: CanvasRenderingContext2D, options: SamplePointDrawOptions) { - const { outlineColor, paths } = options; - const radius = 3; - const haloRadius = radius + 1.2; - const renderPaths = paths.map((path) => ({ - ...path, - samplePoints: thinWaveformSamplePoints(path.samplePoints), - })); - - ctx.save(); - for (const path of renderPaths) { - for (const point of path.samplePoints) { - ctx.fillStyle = outlineColor; - ctx.beginPath(); - ctx.arc(point.x, point.y, haloRadius, 0, Math.PI * 2); - ctx.fill(); - } - } - - for (const path of renderPaths) { - ctx.fillStyle = path.color; - for (const point of path.samplePoints) { - ctx.beginPath(); - ctx.arc(point.x, point.y, radius, 0, Math.PI * 2); - ctx.fill(); - } - } - ctx.restore(); -} - -interface HoverRulerDrawOptions { - point: HoverPoint; - cssW: number; - cssH: number; - plotX0: number; - plotX1: number; - plotTop: number; - plotBottom: number; - lineColor: string; - bgColor: string; - textColor: string; -} - -function drawHoverRuler(ctx: CanvasRenderingContext2D, options: HoverRulerDrawOptions) { - const { point, cssW, cssH, plotX0, plotX1, plotTop, plotBottom, lineColor, bgColor, textColor } = - options; - - ctx.save(); - ctx.strokeStyle = lineColor; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); - ctx.moveTo(point.x, plotTop); - ctx.lineTo(point.x, plotBottom); - ctx.moveTo(plotX0, point.y); - ctx.lineTo(plotX1, point.y); - ctx.stroke(); - ctx.setLineDash([]); - - ctx.fillStyle = lineColor; - ctx.strokeStyle = bgColor; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.arc(point.x, point.y, 4, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - - const maxTextW = Math.max(44, Math.min(220, cssW - 28)); - const lines = [ - point.label, - `x: ${formatMs(point.relativeMs)}`, - `y: ${formatNum(point.value)}`, - ].map((line) => truncateCanvasText(ctx, line, maxTextW)); - const paddingX = 8; - const paddingY = 6; - const lineHeight = 14; - const boxW = Math.ceil( - Math.max(...lines.map((line) => ctx.measureText(line).width)) + paddingX * 2, - ); - const boxH = paddingY * 2 + lineHeight * lines.length; - let boxX = point.x + 10; - let boxY = point.y - boxH - 10; - if (boxX + boxW > cssW - 6) boxX = point.x - boxW - 10; - if (boxY < 6) boxY = point.y + 10; - if (boxY + boxH > cssH - 6) boxY = cssH - boxH - 6; - boxX = Math.max(6, Math.min(Math.max(6, cssW - boxW - 6), boxX)); - boxY = Math.max(6, Math.min(Math.max(6, cssH - boxH - 6), boxY)); - - ctx.fillStyle = bgColor; - ctx.strokeStyle = lineColor; - drawRoundRect(ctx, boxX, boxY, boxW, boxH, 6); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = textColor; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - lines.forEach((line, index) => { - ctx.fillText(line, boxX + paddingX, boxY + paddingY + index * lineHeight); - }); - ctx.restore(); -} - -function truncateCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { - if (ctx.measureText(text).width <= maxWidth) return text; - const suffix = '...'; - let next = text; - while (next.length > 1 && ctx.measureText(`${next}${suffix}`).width > maxWidth) { - next = next.slice(0, -1); - } - return `${next}${suffix}`; -} - -function drawRoundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, -) { - const r = Math.min(radius, width / 2, height / 2); - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.lineTo(x + width - r, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + r); - ctx.lineTo(x + width, y + height - r); - ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height); - ctx.lineTo(x + r, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - r); - ctx.lineTo(x, y + r); - ctx.quadraticCurveTo(x, y, x + r, y); - ctx.closePath(); -} - -interface XRulerDrawOptions { - axisColor: string; - gridColor: string; - rulerColor: string; - plotX0: number; - plotX1: number; - plotBottom: number; - plotH: number; - plotTop: number; - plotW: number; - firstTimestamp: number; - timeSpan: number; - hasTimeScale: boolean; - originTimestamp: number; -} - -function drawXRuler(ctx: CanvasRenderingContext2D, options: XRulerDrawOptions) { - const { - axisColor, - gridColor, - rulerColor, - plotX0, - plotX1, - plotBottom, - plotH, - plotTop, - plotW, - firstTimestamp, - timeSpan, - hasTimeScale, - originTimestamp, - } = options; - - ctx.save(); - ctx.strokeStyle = rulerColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(plotX0, plotBottom); - ctx.lineTo(plotX1, plotBottom); - ctx.stroke(); - - const tickCount = hasTimeScale ? 5 : 1; - for (let g = 0; g < tickCount; g += 1) { - const ratio = tickCount === 1 ? 0 : g / (tickCount - 1); - const x = plotX0 + plotW * ratio; - const timestamp = hasTimeScale ? firstTimestamp + timeSpan * ratio : firstTimestamp; - ctx.strokeStyle = gridColor; - ctx.beginPath(); - ctx.moveTo(x, plotTop); - ctx.lineTo(x, plotTop + plotH); - ctx.stroke(); - - ctx.strokeStyle = rulerColor; - ctx.beginPath(); - ctx.moveTo(x, plotBottom); - ctx.lineTo(x, plotBottom + 4); - ctx.stroke(); - - ctx.fillStyle = axisColor; - ctx.textBaseline = 'top'; - ctx.textAlign = g === 0 ? 'left' : g === tickCount - 1 ? 'right' : 'center'; - ctx.fillText(formatMs(timestamp - originTimestamp), x, plotBottom + 7); - } - ctx.restore(); -} - -interface YRulerDrawOptions { - axisColor: string; - rulerColor: string; - leftPad: number; - plotX0: number; - plotTop: number; - plotBottom: number; - plotH: number; - vMin: number; - vMax: number; -} - -function drawYRuler(ctx: CanvasRenderingContext2D, options: YRulerDrawOptions) { - const { axisColor, rulerColor, leftPad, plotX0, plotTop, plotBottom, plotH, vMin, vMax } = - options; - - ctx.save(); - ctx.strokeStyle = rulerColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(plotX0, plotTop); - ctx.lineTo(plotX0, plotBottom); - ctx.stroke(); - - ctx.fillStyle = axisColor; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - for (let g = 0; g <= 4; g += 1) { - const y = plotTop + (plotH / 4) * g; - const v = vMax - ((vMax - vMin) * g) / 4; - ctx.strokeStyle = rulerColor; - ctx.beginPath(); - ctx.moveTo(plotX0 - 4, y); - ctx.lineTo(plotX0, y); - ctx.stroke(); - ctx.fillText(formatNum(v), leftPad - 8, y); - } - ctx.restore(); -} - -let monoFontStack = "ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace"; - -interface WaveformCanvasTheme { - axisColor: string; - gridColor: string; - hoverBg: string; - hoverLineColor: string; - hoverText: string; - rulerColor: string; - samplePointOutline: string; -} - -function readWaveformTheme(): WaveformCanvasTheme { - const fallback = { - axisColor: 'rgba(255,255,255,0.3)', - gridColor: 'rgba(255,255,255,0.04)', - hoverBg: 'rgba(24,28,34,0.94)', - hoverLineColor: '#ffd84d', - hoverText: '#eef3f7', - rulerColor: 'rgba(255,255,255,0.1)', - samplePointOutline: '#11161c', - }; - if (typeof window === 'undefined') return fallback; - - const styles = getComputedStyle(document.documentElement); - const readCssVar = (name: string, value: string): string => - styles.getPropertyValue(name).trim() || value; - - // Canvas 2D `font` does not resolve CSS variables, so keep the resolved - // monospace stack in sync with the app theme while reading the rest once. - monoFontStack = readCssVar('--font-mono', monoFontStack); - return { - axisColor: readCssVar('--text-dim', fallback.axisColor), - gridColor: readCssVar('--grid-line', fallback.gridColor), - hoverBg: readCssVar('--bg-elevated', fallback.hoverBg), - hoverLineColor: readCssVar('--color-warning', fallback.hoverLineColor), - hoverText: readCssVar('--text-primary', fallback.hoverText), - rulerColor: readCssVar('--border-color', fallback.rulerColor), - samplePointOutline: readCssVar('--bg-inset', fallback.samplePointOutline), - }; -} - function observeCanvasResize() { const canvas = canvasRef.value; if (!canvas || typeof ResizeObserver === 'undefined') return; diff --git a/src/lib/waveform-render.ts b/src/lib/waveform-render.ts new file mode 100644 index 0000000..12f627b --- /dev/null +++ b/src/lib/waveform-render.ts @@ -0,0 +1,651 @@ +/** + * Canvas rendering pipeline for the waveform plot. + * + * Extracted from `WaveformPanel.vue` so the panel stays a thin state + + * interaction orchestrator and the pure layout / path-building / drawing + * primitives live in a framework-free, unit-testable module (precedent: the + * pure waveform math already lives in `lib/waveform.ts`). Nothing here imports + * Vue; every function takes a `CanvasRenderingContext2D` plus plain options. + */ + +import { + closestPointOnWaveformPath, + formatWaveformNumber, + thinWaveformSamplePoints, + type WaveformChannelState, + type WaveformPathPoint, +} from './waveform'; + +/** A waveform buffer as produced by `createBuffer` in `waveform.ts`. */ +export interface WaveformSampleBuffer { + samples: Array; + timestamps: number[]; + originTimestamp: number | null; +} + +export interface PlotLayout { + leftPad: number; + plotX0: number; + plotX1: number; + plotTop: number; + plotBottom: number; + plotW: number; + plotH: number; + topPad: number; + bottomPad: number; + rightPad: number; +} + +export interface HoverPoint { + x: number; + y: number; + relativeMs: number; + value: number; + color: string; + label: string; +} + +export interface RenderedChannelPath { + color: string; + label: string; + points: WaveformPathPoint[]; + samplePoints: WaveformPathPoint[]; +} + +export interface WaveformCanvasTheme { + axisColor: string; + gridColor: string; + hoverBg: string; + hoverLineColor: string; + hoverText: string; + rulerColor: string; + samplePointOutline: string; +} + +type CanvasWheelIntent = 'pan' | 'zoom'; + +interface SegmentPoint { + timestamp: number; + value: number; +} + +interface RenderPointOptions { + plotBottom: number; + plotH: number; + sampleX: (timestamp: number) => number; + span: number; + vMin: number; +} + +interface SegmentClipOptions extends RenderPointOptions { + startMs: number; + endMs: number; +} + +interface BuildChannelPathOptions { + channelCount: number; + endMs: number; + plotBottom: number; + plotH: number; + sampleX: (timestamp: number) => number; + scanEndIndex: number; + scanStartIndex: number; + span: number; + startMs: number; + vMin: number; +} + +interface HoverPointOptions { + cursor: { x: number; y: number } | null; + originTimestamp: number; + paths: RenderedChannelPath[]; + plotTop: number; + plotX0: number; + plotX1: number; + plotBottom: number; +} + +interface SamplePointDrawOptions { + outlineColor: string; + paths: readonly RenderedChannelPath[]; +} + +interface HoverRulerDrawOptions { + point: HoverPoint; + cssW: number; + cssH: number; + plotX0: number; + plotX1: number; + plotTop: number; + plotBottom: number; + lineColor: string; + bgColor: string; + textColor: string; +} + +interface XRulerDrawOptions { + axisColor: string; + gridColor: string; + rulerColor: string; + plotX0: number; + plotX1: number; + plotBottom: number; + plotH: number; + plotTop: number; + plotW: number; + firstTimestamp: number; + timeSpan: number; + hasTimeScale: boolean; + originTimestamp: number; +} + +interface YRulerDrawOptions { + axisColor: string; + rulerColor: string; + leftPad: number; + plotX0: number; + plotTop: number; + plotBottom: number; + plotH: number; + vMin: number; + vMax: number; +} + +const WHEEL_DOMINANCE_RATIO = 1.25; + +/** + * Canvas 2D `font` does not resolve CSS variables — keep the resolved + * monospace stack in sync with the app theme. Exposed so callers can read it + * after {@link readWaveformTheme}. + */ +export let monoFontStack = "ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace"; + +// --------------------------------------------------------------------------- +// Number / wheel helpers (shared by the panel's interaction handlers too). +// --------------------------------------------------------------------------- + +export function clampNumber(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); +} + +export function clampRatio(value: number): number { + return clampNumber(value, 0, 1); +} + +export function normalizeWheelDelta(delta: number, deltaMode: number, pageSize: number): number { + if (deltaMode === 1) return delta * 16; + if (deltaMode === 2) return delta * Math.max(1, pageSize); + return delta; +} + +export function wheelIntentFromDelta( + deltaX: number, + deltaY: number, + event: Pick, +): CanvasWheelIntent | null { + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + if (absX === 0 && absY === 0) return null; + if (event.ctrlKey || event.metaKey) return 'zoom'; + if (event.shiftKey && absY > 0 && absX < absY) return 'pan'; + if (absX > 0 && absX >= absY * WHEEL_DOMINANCE_RATIO) return 'pan'; + if (absY > 0) return 'zoom'; + return 'pan'; +} + +export function formatMs(value: number): string { + if (!Number.isFinite(value)) return '—'; + return `${Math.round(value)}ms`; +} + +/** Format a plotted value for an axis/hover label. */ +export function formatNum(n: number): string { + return formatWaveformNumber(n); +} + +// --------------------------------------------------------------------------- +// Plot layout. +// --------------------------------------------------------------------------- + +export function calculatePlotLayout( + cssW: number, + cssH: number, + showYRuler: boolean, + showXRuler: boolean, +): PlotLayout { + const leftPad = showYRuler ? 52 : 12; + const topPad = 12; + const bottomPad = showXRuler ? 30 : 16; + const rightPad = 8; + const plotX0 = leftPad; + const plotTop = topPad; + const plotBottom = Math.max(plotTop + 1, cssH - bottomPad); + const plotH = plotBottom - plotTop; + const plotX1 = Math.max(plotX0 + 1, cssW - rightPad); + const plotW = plotX1 - plotX0; + return { + leftPad, + plotX0, + plotX1, + plotTop, + plotBottom, + plotW, + plotH, + topPad, + bottomPad, + rightPad, + }; +} + +// --------------------------------------------------------------------------- +// Theme (canvas 2D cannot read CSS variables directly). +// --------------------------------------------------------------------------- + +export function readWaveformTheme(): WaveformCanvasTheme { + const fallback = { + axisColor: 'rgba(255,255,255,0.3)', + gridColor: 'rgba(255,255,255,0.04)', + hoverBg: 'rgba(24,28,34,0.94)', + hoverLineColor: '#ffd84d', + hoverText: '#eef3f7', + rulerColor: 'rgba(255,255,255,0.1)', + samplePointOutline: '#11161c', + }; + if (typeof window === 'undefined') return fallback; + + const styles = getComputedStyle(document.documentElement); + const readCssVar = (name: string, value: string): string => + styles.getPropertyValue(name).trim() || value; + + // Canvas 2D `font` does not resolve CSS variables, so keep the resolved + // monospace stack in sync with the app theme while reading the rest once. + monoFontStack = readCssVar('--font-mono', monoFontStack); + return { + axisColor: readCssVar('--text-dim', fallback.axisColor), + gridColor: readCssVar('--grid-line', fallback.gridColor), + hoverBg: readCssVar('--bg-elevated', fallback.hoverBg), + hoverLineColor: readCssVar('--color-warning', fallback.hoverLineColor), + hoverText: readCssVar('--text-primary', fallback.hoverText), + rulerColor: readCssVar('--border-color', fallback.rulerColor), + samplePointOutline: readCssVar('--bg-inset', fallback.samplePointOutline), + }; +} + +// --------------------------------------------------------------------------- +// Path building (maps samples -> screen-space polylines, clipped to the +// visible window with interpolated segment endpoints). +// --------------------------------------------------------------------------- + +export function sampleTimestamp(buffer: WaveformSampleBuffer, index: number): number { + const timestamp = buffer.timestamps[index]; + return Number.isFinite(timestamp) ? timestamp : index; +} + +export function buildVisibleChannelPaths( + buffer: WaveformSampleBuffer, + channels: readonly WaveformChannelState[], + labelForChannel: (channelIndex: number) => string, + options: BuildChannelPathOptions, +): RenderedChannelPath[] { + const { + channelCount, + endMs, + plotBottom, + plotH, + sampleX, + scanEndIndex, + scanStartIndex, + span, + startMs, + vMin, + } = options; + const paths: RenderedChannelPath[] = []; + const scanStart = Math.max(0, Math.min(buffer.samples.length, scanStartIndex)); + const scanEnd = Math.max(scanStart, Math.min(buffer.samples.length, scanEndIndex)); + for (let c = 0; c < channelCount; c += 1) { + const channel = channels[c]; + if (!channel?.visible) continue; + const points: WaveformPathPoint[] = []; + const samplePoints: WaveformPathPoint[] = []; + let previous: { timestamp: number; value: number } | null = null; + + for (let i = scanStart; i < scanEnd; i += 1) { + const value = buffer.samples[i][c]; + if (value === undefined || !Number.isFinite(value)) continue; + const timestamp = sampleTimestamp(buffer, i); + if (!Number.isFinite(timestamp)) continue; + const current = { timestamp, value }; + + if (previous && current.timestamp >= startMs && previous.timestamp <= endMs) { + addInterpolatedSegmentPoints(points, previous, current, { + endMs, + plotBottom, + plotH, + sampleX, + span, + startMs, + vMin, + }); + } else if (!previous && timestamp >= startMs && timestamp <= endMs) { + points.push(toRenderedPoint(current, { plotBottom, plotH, sampleX, span, vMin })); + } + + if (timestamp >= startMs && timestamp <= endMs) { + samplePoints.push(toRenderedPoint(current, { plotBottom, plotH, sampleX, span, vMin })); + } + + previous = current; + } + if (points.length > 0) { + paths.push({ + color: channel.color, + label: labelForChannel(c), + points, + samplePoints, + }); + } + } + return paths; +} + +function addInterpolatedSegmentPoints( + out: WaveformPathPoint[], + a: SegmentPoint, + b: SegmentPoint, + options: SegmentClipOptions, +) { + if (b.timestamp < options.startMs || a.timestamp > options.endMs) return; + const from = Math.max(a.timestamp, options.startMs); + const to = Math.min(b.timestamp, options.endMs); + if (from > to) return; + pushRenderedPoint(out, interpolateSegmentPoint(a, b, from), options); + if (to !== from) { + pushRenderedPoint(out, interpolateSegmentPoint(a, b, to), options); + } +} + +function interpolateSegmentPoint( + a: SegmentPoint, + b: SegmentPoint, + timestamp: number, +): SegmentPoint { + const duration = b.timestamp - a.timestamp; + const ratio = duration === 0 ? 0 : (timestamp - a.timestamp) / duration; + return { + timestamp, + value: a.value + (b.value - a.value) * Math.max(0, Math.min(1, ratio)), + }; +} + +function pushRenderedPoint( + out: WaveformPathPoint[], + point: SegmentPoint, + options: RenderPointOptions, +) { + const last = out[out.length - 1]; + if (last && Math.abs(last.timestamp - point.timestamp) < 0.0001) { + out[out.length - 1] = toRenderedPoint(point, options); + return; + } + out.push(toRenderedPoint(point, options)); +} + +function toRenderedPoint(point: SegmentPoint, options: RenderPointOptions): WaveformPathPoint { + return { + x: options.sampleX(point.timestamp), + y: options.plotBottom - ((point.value - options.vMin) / options.span) * options.plotH, + value: point.value, + timestamp: point.timestamp, + }; +} + +// --------------------------------------------------------------------------- +// Drawing primitives. +// --------------------------------------------------------------------------- + +export function drawWaveformPaths( + ctx: CanvasRenderingContext2D, + paths: readonly RenderedChannelPath[], +) { + ctx.lineWidth = 1.5; + for (const path of paths) { + ctx.strokeStyle = path.color; + ctx.beginPath(); + for (let i = 0; i < path.points.length; i += 1) { + const point = path.points[i]; + if (i === 0) ctx.moveTo(point.x, point.y); + else ctx.lineTo(point.x, point.y); + } + ctx.stroke(); + } +} + +export function findHoverPoint(options: HoverPointOptions): HoverPoint | null { + const { cursor, originTimestamp, paths, plotTop, plotX0, plotX1, plotBottom } = options; + if (!cursor) return null; + if (cursor.x < plotX0 || cursor.x > plotX1 || cursor.y < plotTop || cursor.y > plotBottom) { + return null; + } + + let best: HoverPoint | null = null; + let bestDistance = Infinity; + for (const path of paths) { + const projected = closestPointOnWaveformPath(cursor, path.points); + if (!projected || projected.distanceSq >= bestDistance) continue; + bestDistance = projected.distanceSq; + best = { + x: projected.x, + y: projected.y, + value: projected.value, + color: path.color, + label: path.label, + relativeMs: projected.timestamp - originTimestamp, + }; + } + return best; +} + +export function drawSamplePoints(ctx: CanvasRenderingContext2D, options: SamplePointDrawOptions) { + const { outlineColor, paths } = options; + const radius = 3; + const haloRadius = radius + 1.2; + const renderPaths = paths.map((path) => ({ + ...path, + samplePoints: thinWaveformSamplePoints(path.samplePoints), + })); + + ctx.save(); + for (const path of renderPaths) { + for (const point of path.samplePoints) { + ctx.fillStyle = outlineColor; + ctx.beginPath(); + ctx.arc(point.x, point.y, haloRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + + for (const path of renderPaths) { + ctx.fillStyle = path.color; + for (const point of path.samplePoints) { + ctx.beginPath(); + ctx.arc(point.x, point.y, radius, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); +} + +export function drawHoverRuler(ctx: CanvasRenderingContext2D, options: HoverRulerDrawOptions) { + const { point, cssW, cssH, plotX0, plotX1, plotTop, plotBottom, lineColor, bgColor, textColor } = + options; + + ctx.save(); + ctx.strokeStyle = lineColor; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(point.x, plotTop); + ctx.lineTo(point.x, plotBottom); + ctx.moveTo(plotX0, point.y); + ctx.lineTo(plotX1, point.y); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = lineColor; + ctx.strokeStyle = bgColor; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(point.x, point.y, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + const maxTextW = Math.max(44, Math.min(220, cssW - 28)); + const lines = [ + point.label, + `x: ${formatMs(point.relativeMs)}`, + `y: ${formatNum(point.value)}`, + ].map((line) => truncateCanvasText(ctx, line, maxTextW)); + const paddingX = 8; + const paddingY = 6; + const lineHeight = 14; + const boxW = Math.ceil( + Math.max(...lines.map((line) => ctx.measureText(line).width)) + paddingX * 2, + ); + const boxH = paddingY * 2 + lineHeight * lines.length; + let boxX = point.x + 10; + let boxY = point.y - boxH - 10; + if (boxX + boxW > cssW - 6) boxX = point.x - boxW - 10; + if (boxY < 6) boxY = point.y + 10; + if (boxY + boxH > cssH - 6) boxY = cssH - boxH - 6; + boxX = Math.max(6, Math.min(Math.max(6, cssW - boxW - 6), boxX)); + boxY = Math.max(6, Math.min(Math.max(6, cssH - boxH - 6), boxY)); + + ctx.fillStyle = bgColor; + ctx.strokeStyle = lineColor; + drawRoundRect(ctx, boxX, boxY, boxW, boxH, 6); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + lines.forEach((line, index) => { + ctx.fillText(line, boxX + paddingX, boxY + paddingY + index * lineHeight); + }); + ctx.restore(); +} + +export function truncateCanvasText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): string { + if (ctx.measureText(text).width <= maxWidth) return text; + const suffix = '...'; + let next = text; + while (next.length > 1 && ctx.measureText(`${next}${suffix}`).width > maxWidth) { + next = next.slice(0, -1); + } + return `${next}${suffix}`; +} + +export function drawRoundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + const r = Math.min(radius, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + width - r, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + r); + ctx.lineTo(x + width, y + height - r); + ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height); + ctx.lineTo(x + r, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +export function drawXRuler(ctx: CanvasRenderingContext2D, options: XRulerDrawOptions) { + const { + axisColor, + gridColor, + rulerColor, + plotX0, + plotX1, + plotBottom, + plotH, + plotTop, + plotW, + firstTimestamp, + timeSpan, + hasTimeScale, + originTimestamp, + } = options; + + ctx.save(); + ctx.strokeStyle = rulerColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(plotX0, plotBottom); + ctx.lineTo(plotX1, plotBottom); + ctx.stroke(); + + const tickCount = hasTimeScale ? 5 : 1; + for (let g = 0; g < tickCount; g += 1) { + const ratio = tickCount === 1 ? 0 : g / (tickCount - 1); + const x = plotX0 + plotW * ratio; + const timestamp = hasTimeScale ? firstTimestamp + timeSpan * ratio : firstTimestamp; + ctx.strokeStyle = gridColor; + ctx.beginPath(); + ctx.moveTo(x, plotTop); + ctx.lineTo(x, plotTop + plotH); + ctx.stroke(); + + ctx.strokeStyle = rulerColor; + ctx.beginPath(); + ctx.moveTo(x, plotBottom); + ctx.lineTo(x, plotBottom + 4); + ctx.stroke(); + + ctx.fillStyle = axisColor; + ctx.textBaseline = 'top'; + ctx.textAlign = g === 0 ? 'left' : g === tickCount - 1 ? 'right' : 'center'; + ctx.fillText(formatMs(timestamp - originTimestamp), x, plotBottom + 7); + } + ctx.restore(); +} + +export function drawYRuler(ctx: CanvasRenderingContext2D, options: YRulerDrawOptions) { + const { axisColor, rulerColor, leftPad, plotX0, plotTop, plotBottom, plotH, vMin, vMax } = + options; + + ctx.save(); + ctx.strokeStyle = rulerColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(plotX0, plotTop); + ctx.lineTo(plotX0, plotBottom); + ctx.stroke(); + + ctx.fillStyle = axisColor; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let g = 0; g <= 4; g += 1) { + const y = plotTop + (plotH / 4) * g; + const v = vMax - ((vMax - vMin) * g) / 4; + ctx.strokeStyle = rulerColor; + ctx.beginPath(); + ctx.moveTo(plotX0 - 4, y); + ctx.lineTo(plotX0, y); + ctx.stroke(); + ctx.fillText(formatNum(v), leftPad - 8, y); + } + ctx.restore(); +} diff --git a/tests/frontend/waveform-render.test.ts b/tests/frontend/waveform-render.test.ts new file mode 100644 index 0000000..8cbbf05 --- /dev/null +++ b/tests/frontend/waveform-render.test.ts @@ -0,0 +1,378 @@ +/** + * Unit tests for the pure (non-canvas) functions extracted into the waveform + * render pipeline. The canvas-drawing primitives (`drawXRuler`, `drawHoverRuler`, + * …) take a `CanvasRenderingContext2D` and are runtime-coupled (no canvas is + * available under `node --test`); they are exercised indirectly via the build + + * the waveform math tests in `waveform.test.ts`. Everything here is pure math + * or logic and fully testable headless. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildVisibleChannelPaths, + calculatePlotLayout, + clampNumber, + clampRatio, + drawHoverRuler, + drawRoundRect, + drawSamplePoints, + drawWaveformPaths, + drawXRuler, + drawYRuler, + findHoverPoint, + formatMs, + formatNum, + normalizeWheelDelta, + sampleTimestamp, + wheelIntentFromDelta, + readWaveformTheme, + truncateCanvasText, + type WaveformSampleBuffer, + type WaveformChannelState, +} from '../../src/lib/waveform-render.ts'; + +// --------------------------------------------------------------------------- +// Number / wheel helpers. +// --------------------------------------------------------------------------- + +test('clampNumber clamps finite values and returns min for non-finite', () => { + assert.equal(clampNumber(5, 0, 10), 5); + assert.equal(clampNumber(-1, 0, 10), 0); + assert.equal(clampNumber(11, 0, 10), 10); + assert.equal(clampNumber(Number.NaN, 0, 10), 0); + assert.equal(clampNumber(Number.POSITIVE_INFINITY, 0, 10), 0); +}); + +test('clampRatio clamps to [0, 1]', () => { + assert.equal(clampRatio(-0.5), 0); + assert.equal(clampRatio(0.5), 0.5); + assert.equal(clampRatio(1.5), 1); +}); + +test('normalizeWheelDelta scales by deltaMode', () => { + assert.equal(normalizeWheelDelta(10, 0, 800), 10); // pixels + assert.equal(normalizeWheelDelta(10, 1, 800), 160); // lines (×16) + assert.equal(normalizeWheelDelta(10, 2, 800), 8000); // pages (×pageSize) + assert.equal(normalizeWheelDelta(10, 2, 0), 10); // pageSize floored to ≥1 +}); + +test('wheelIntentFromDelta classifies modifier + dominant-axis gestures', () => { + assert.equal(wheelIntentFromDelta(0, 0, {}), null, 'no movement → null'); + assert.equal(wheelIntentFromDelta(0, 10, { ctrlKey: true }), 'zoom', 'ctrl → zoom'); + assert.equal(wheelIntentFromDelta(0, 10, { metaKey: true }), 'zoom', 'meta → zoom'); + assert.equal(wheelIntentFromDelta(0, 10, { shiftKey: true }), 'pan', 'shift+vertical → pan'); + assert.equal(wheelIntentFromDelta(20, 1, {}), 'pan', 'horizontal-dominant → pan'); + assert.equal(wheelIntentFromDelta(0, 10, {}), 'zoom', 'plain vertical → zoom'); +}); + +// --------------------------------------------------------------------------- +// Formatters. +// --------------------------------------------------------------------------- + +test('formatMs renders finite ms and an em-dash for non-finite', () => { + assert.equal(formatMs(12.6), '13ms'); + assert.equal(formatMs(0), '0ms'); + assert.equal(formatMs(Number.POSITIVE_INFINITY), '—'); + assert.equal(formatMs(Number.NaN), '—'); +}); + +test('formatNum delegates to the compact waveform number formatter', () => { + assert.equal(formatNum(0), '0.00'); + assert.equal(formatNum(1000), '1000'); + assert.equal(typeof formatNum(-3.5), 'string'); +}); + +// --------------------------------------------------------------------------- +// Plot layout. +// --------------------------------------------------------------------------- + +test('calculatePlotLayout reserves pads for the rulers when shown', () => { + const withRulers = calculatePlotLayout(800, 400, true, true); + assert.equal(withRulers.leftPad, 52); + assert.equal(withRulers.bottomPad, 30); + assert.equal(withRulers.plotX0, 52); + assert.equal(withRulers.plotTop, 12); + assert.ok(withRulers.plotW > 0 && withRulers.plotH > 0); + assert.ok(withRulers.plotBottom > withRulers.plotTop); + assert.ok(withRulers.plotX1 > withRulers.plotX0); +}); + +test('calculatePlotLayout shrinks the pads when rulers are hidden', () => { + const noRulers = calculatePlotLayout(800, 400, false, false); + assert.equal(noRulers.leftPad, 12); + assert.equal(noRulers.bottomPad, 16); +}); + +test('calculatePlotLayout keeps a minimum plot area on tiny canvases', () => { + const tiny = calculatePlotLayout(1, 1, true, true); + assert.ok(tiny.plotH >= 1, 'plotH never collapses below 1'); + assert.ok(tiny.plotW >= 1, 'plotW never collapses below 1'); +}); + +// --------------------------------------------------------------------------- +// Theme (headless fallback path). +// --------------------------------------------------------------------------- + +test('readWaveformTheme returns stable fallbacks without a window', () => { + // The test runner has no `window` / no CSS computed style, so the fallback + // branch is taken: every key is a concrete rgba/hex string. + const theme = readWaveformTheme(); + assert.equal(typeof theme.axisColor, 'string'); + assert.equal(typeof theme.gridColor, 'string'); + assert.ok(theme.hoverBg.length > 0); + assert.ok(theme.rulerColor.length > 0); + assert.ok(theme.samplePointOutline.length > 0); +}); + +// --------------------------------------------------------------------------- +// Path building (sample -> screen-space polyline, clipped to the window). +// --------------------------------------------------------------------------- + +function makeBuffer(timestamps: number[], channels: number[][]): WaveformSampleBuffer { + // Pack columns: samples[i] holds channel values for row i. + const samples = timestamps.map((_, i) => Float32Array.from(channels.map((c) => c[i] ?? 0))); + return { + samples, + timestamps, + originTimestamp: timestamps[0] ?? null, + }; +} + +function channel(color: string): WaveformChannelState { + // Minimal channel shape the path builder reads (color + visible flag). + return { color, visible: true, latest: null } as unknown as WaveformChannelState; +} + +test('sampleTimestamp falls back to the index when the entry is non-finite', () => { + const buf: WaveformSampleBuffer = { samples: [], timestamps: [10, Number.NaN], originTimestamp: 10 }; + assert.equal(sampleTimestamp(buf, 0), 10); + assert.equal(sampleTimestamp(buf, 1), 1, 'NaN timestamp falls back to the index'); +}); + +test('buildVisibleChannelPaths maps in-window samples to a single polyline', () => { + const timestamps = [100, 200, 300, 400]; + const buf = makeBuffer(timestamps, [[0, 10, 20, 30]]); + const paths = buildVisibleChannelPaths(buf, [channel('#f00')], (i) => `Ch ${i}`, { + channelCount: 1, + endMs: 400, + plotBottom: 100, + plotH: 80, + sampleX: (ts) => ts, // identity so x == timestamp (easy to assert) + scanEndIndex: 4, + scanStartIndex: 0, + span: 30, + startMs: 100, + vMin: 0, + }); + assert.equal(paths.length, 1); + assert.equal(paths[0].color, '#f00'); + assert.equal(paths[0].label, 'Ch 0'); + assert.equal(paths[0].points.length, 4, 'every in-window sample becomes a point'); + // y = plotBottom - ((value - vMin)/span) * plotH = 100 - (v/30)*80 + assert.equal(paths[0].points[0].y, 100); + assert.equal(paths[0].points[3].y, 20); +}); + +test('buildVisibleChannelPaths skips invisible channels and out-of-window samples', () => { + const timestamps = [100, 200, 300]; + const buf = makeBuffer(timestamps, [[5, 5, 5], [9, 9, 9]]); + const paths = buildVisibleChannelPaths(buf, [channel('#f00'), { ...channel('#0f0'), visible: false }], (i) => `Ch ${i}`, { + channelCount: 2, + endMs: 300, + plotBottom: 50, + plotH: 50, + sampleX: (ts) => ts, + scanEndIndex: 3, + scanStartIndex: 0, + span: 4, + startMs: 100, + vMin: 5, + }); + assert.equal(paths.length, 1, 'the invisible channel produces no path'); + assert.equal(paths[0].color, '#f00'); +}); + +// --------------------------------------------------------------------------- +// Hover hit-testing (cursor -> nearest channel point). +// --------------------------------------------------------------------------- + +test('findHoverPoint returns null outside the plot rect and matches the nearest point', () => { + const paths = [ + { + color: '#f00', + label: 'Ch 0', + points: [ + { x: 0, y: 0, value: 0, timestamp: 0 }, + { x: 100, y: 0, value: 0, timestamp: 100 }, + ], + samplePoints: [], + }, + ]; + // Cursor clearly outside the plot rect. + assert.equal( + findHoverPoint({ cursor: { x: -5, y: 0 }, originTimestamp: 0, paths, plotTop: 0, plotX0: 0, plotX1: 100, plotBottom: 50 }), + null, + ); + // Cursor inside — the projection lands mid-segment at x=90 (timestamp 90). + const hit = findHoverPoint({ + cursor: { x: 90, y: 0 }, + originTimestamp: 0, + paths, + plotTop: 0, + plotX0: 0, + plotX1: 100, + plotBottom: 50, + }); + assert.ok(hit, 'a cursor inside the plot resolves to a point'); + assert.equal(hit!.color, '#f00'); + assert.equal(hit!.label, 'Ch 0'); + assert.equal(hit!.relativeMs, 90); +}); + +// --------------------------------------------------------------------------- +// Canvas draw primitives (exercised via a recording mock ctx — no DOM/canvas +// is available under `node --test`, but the primitives are pure given a ctx: +// they translate their options into a sequence of 2D-canvas method calls). +// --------------------------------------------------------------------------- + +interface MockCtx { + calls: Array<{ name: string; args: number[] }>; + measureTextWidth: number; +} + +/** + * Minimal recording ctx. `measureText` returns a width proportional to the text + * length (pxPerChar) so the label-truncation / hover-box sizing math actually + * branches; every other method is recorded for call-order assertions, and style + * assignments (fillStyle, lineWidth, …) are accepted no-ops. + */ +function createMockCtx(pxPerChar = 10): MockCtx & CanvasRenderingContext2D { + const calls: Array<{ name: string; args: number[] }> = []; + const store = { calls, measureTextWidth: pxPerChar }; + const handler: ProxyHandler = { + get(target, prop: string) { + if (prop === 'calls' || prop === 'measureTextWidth') return target[prop]; + if (prop === 'measureText') { + return (text: string) => ({ width: text.length * target.measureTextWidth }); + } + if (prop === 'canvas') return { getContext: () => proxy } as unknown as HTMLCanvasElement; + return (...args: number[]) => { + calls.push({ name: prop, args }); + return undefined; + }; + }, + set() { + return true; + }, + }; + const proxy = new Proxy(store, handler) as unknown as MockCtx & CanvasRenderingContext2D; + return proxy; +} + +test('drawWaveformPaths strokes one path per channel', () => { + const ctx = createMockCtx(); + drawWaveformPaths(ctx, [ + { color: '#f00', label: 'a', points: [{ x: 0, y: 0, value: 0, timestamp: 0 }], samplePoints: [] }, + { color: '#0f0', label: 'b', points: [{ x: 1, y: 1, value: 1, timestamp: 1 }], samplePoints: [] }, + ]); + const strokes = ctx.calls.filter((c) => c.name === 'stroke').length; + assert.equal(strokes, 2, 'one stroke per channel path'); + // First vertex uses moveTo, subsequent use lineTo. + assert.ok(ctx.calls.some((c) => c.name === 'moveTo')); +}); + +test('drawSamplePoints draws a halo then the filled dot per point', () => { + const ctx = createMockCtx(); + // Points spaced > 6px apart so the thinning pass keeps both. + drawSamplePoints(ctx, { + outlineColor: '#000', + paths: [ + { + color: '#f00', + label: 'a', + points: [], + samplePoints: [ + { x: 0, y: 0, value: 0, timestamp: 0 }, + { x: 20, y: 20, value: 1, timestamp: 1 }, + ], + }, + ], + }); + const fills = ctx.calls.filter((c) => c.name === 'fill').length; + // halo pass (2) + dot pass (2) = 4 fills. + assert.equal(fills, 4); +}); + +test('drawRoundRect issues a closed quadratic-corner path', () => { + const ctx = createMockCtx(); + drawRoundRect(ctx, 0, 0, 100, 50, 8); + assert.ok(ctx.calls.some((c) => c.name === 'beginPath')); + assert.ok(ctx.calls.some((c) => c.name === 'closePath')); + assert.ok(ctx.calls.filter((c) => c.name === 'quadraticCurveTo').length >= 4, 'four rounded corners'); +}); + +test('truncateCanvasText returns the text when it fits, else ellipsizes', () => { + const ctx = createMockCtx(10); // every char measures ~10px + assert.equal(truncateCanvasText(ctx, 'hi', 100), 'hi', 'fits → unchanged'); + const cut = truncateCanvasText(ctx, 'a-very-long-label-that-exceeds-the-width', 30); + assert.ok(cut.endsWith('...'), 'overflow → ellipsis suffix'); + assert.ok(cut.length < 'a-very-long-label-that-exceeds-the-width'.length); +}); + +test('drawXRuler draws the baseline plus per-tick grid + label marks', () => { + const ctx = createMockCtx(); + drawXRuler(ctx, { + axisColor: '#aaa', + gridColor: '#ccc', + rulerColor: '#ddd', + plotX0: 0, + plotX1: 100, + plotBottom: 50, + plotH: 40, + plotTop: 10, + plotW: 100, + firstTimestamp: 0, + timeSpan: 1000, + hasTimeScale: true, + originTimestamp: 0, + }); + // 5 ticks (hasTimeScale) → 5 fillText label calls. + assert.equal(ctx.calls.filter((c) => c.name === 'fillText').length, 5); +}); + +test('drawYRuler draws 5 labelled value ticks', () => { + const ctx = createMockCtx(); + drawYRuler(ctx, { + axisColor: '#aaa', + rulerColor: '#ddd', + leftPad: 52, + plotX0: 52, + plotTop: 10, + plotBottom: 50, + plotH: 40, + vMin: 0, + vMax: 100, + }); + assert.equal(ctx.calls.filter((c) => c.name === 'fillText').length, 5); +}); + +test('drawHoverRuler renders the crosshair, dot, and a labelled box', () => { + const ctx = createMockCtx(40); + drawHoverRuler(ctx, { + point: { x: 50, y: 25, relativeMs: 100, value: 7, color: '#f00', label: 'Ch 0' }, + cssW: 200, + cssH: 100, + plotX0: 0, + plotX1: 200, + plotTop: 0, + plotBottom: 100, + lineColor: '#ffd84d', + bgColor: '#000', + textColor: '#fff', + }); + // At least the crosshair + dot + box outlines + 3 text lines. + assert.ok(ctx.calls.some((c) => c.name === 'arc'), 'hover dot'); + assert.ok(ctx.calls.filter((c) => c.name === 'fillText').length >= 3, 'label + x + y lines'); +}); + From b337256d9a559c258211a40621d2ab073c6fa076 Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Wed, 17 Jun 2026 11:52:50 +0800 Subject: [PATCH 2/6] docs(changelog): record waveform render extraction (batch 16, A-axis) Documents the WaveformPanel.vue canvas render pipeline extraction into lib/waveform-render.ts (1419 -> 858 lines, -40%), the +21 new render tests, the Sacred Cow audit, and the re-ranked backlog. Also fixes a stray [0.3.1] header that had demoted the batch-15 entry out of [Unreleased]. --- CHANGELOG.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1371bc..2aaf3c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,37 @@ All notable changes to bbcom are documented here. The format is based on ## [Unreleased] -## [0.3.1] +### Auto-optimizer A-axis pass (batch 16 — waveform render extraction) + +- **A — WaveformPanel canvas render pipeline extracted (LANDED):** + `WaveformPanel.vue` had regrown to **1419 lines** — the batch-7 + `WaveformLegend` extraction was overtaken by later feature commits + (register-waveform batching, sample-thinning, the hover ruler). Extracted + the ~560-line pure canvas-render pipeline into a framework-free + `src/lib/waveform-render.ts`: plot layout, theme reading, sample-to-polyline + path building with window clipping + interpolation, and the drawing + primitives (paths, sample points, hover ruler, X/Y rulers, round-rect, text + truncation). `buildVisibleChannelPaths` now takes the buffer + channels + + a `labelForChannel` callback instead of closing over component state. The + panel is a thin state + interaction + RAF orchestrator. **`lib/` stays + framework-free** (the new module imports only from `./waveform`). Pure + relocation — render path unchanged. **Metric:** `WaveformPanel.vue` + **1419 to 858 lines (-40%)**; new module 651 lines. Gate evidence: 576 + frontend tests (+21 new for the now-testable pure render functions, + including a recording-mock ctx exercising the draw primitives -> + `waveform-render.ts` 97.08% line coverage) + 71 Rust tests green; + 0 circular deps; bench 10/10 pass (`waveform_parse_50k` unaffected). + +- **Sacred Cows audited (COW-1..5):** only the waveform canvas render path + changed. TX single-serialization (COW-1), Modbus single-busy (COW-2), + auto-log chain (COW-3), scroll single-flight RAF (COW-4), persistence + backward compat (COW-5) all untouched; no persisted-shape change; the + `lib/` framework-free contract preserved; AP-3 (no deep watcher) untouched. + +- **Backlog re-ranked:** the next A-axis candidate is `lib/waveform.ts` + (914 lines) — a cohesive single-domain waveform math module; lower + leverage than the render extraction just landed, and the next loop + iteration rather than a blocker. ### Auto-optimizer completion audit (batch 15 — closed loop, perf gate restored) From ea43052337788aebe0d8156543052a1b811fb170 Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Wed, 17 Jun 2026 11:59:41 +0800 Subject: [PATCH 3/6] refactor(waveform): extract viewport transforms into lib/waveform-viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 914-line lib/waveform.ts along its viewport seam (precedent: modbus/ -> 13 modules). The sample-index + time-domain windowing math — normalize/zoom/scale/pan/clamp/follow-latest, plus the WaveformViewport / WaveformTimeViewport / WaveformTimeRange / WaveformSampleIndexWindow types and the DEFAULT_WAVEFORM_VIEWPORT_MIN_* constants — moves into a focused framework-free lib/waveform-viewport.ts (403 lines). waveform.ts re-exports them so all existing importers keep working unchanged (pure move + re-export). The lone remaining clampInt use in visibleChannelRangeInWindow is inlined. waveform.ts: 914 -> 558 lines (-39%); new module 403 lines. 576 frontend tests green (the existing waveform viewport tests cover the extracted functions via the re-export); 0 circular deps; bench 10/10 pass; coverage:lib 98.46% (waveform-viewport.ts 95.53% lines). No Sacred Cow touched — pure waveform-math relocation. --- src/lib/waveform-viewport.ts | 403 ++++++++++++++++++++++++++++++++++ src/lib/waveform.ts | 410 +++-------------------------------- 2 files changed, 430 insertions(+), 383 deletions(-) create mode 100644 src/lib/waveform-viewport.ts diff --git a/src/lib/waveform-viewport.ts b/src/lib/waveform-viewport.ts new file mode 100644 index 0000000..8d77715 --- /dev/null +++ b/src/lib/waveform-viewport.ts @@ -0,0 +1,403 @@ +/** + * Waveform viewport transforms (sample-index + time-domain). + * + * Extracted from `lib/waveform.ts` so the pure windowing math — normalize / + * zoom / scale / pan / clamp / follow-latest, in both the integer sample-index + * space and the continuous time-ms space — lives in its own focused, unit- + * testable module (precedent: the modbus/ barrel split). Nothing here imports + * Vue; the functions take plain viewports + sample/timestamp arrays. + */ + +export interface WaveformViewport { + start: number; + size: number; +} + +export interface WaveformTimeViewport { + startMs: number; + durationMs: number; +} + +export interface WaveformTimeRange { + startMs: number; + endMs: number; + durationMs: number; +} + +export interface WaveformSampleIndexWindow { + /** First sample whose timestamp is inside the visible time range. */ + sampleStartIndex: number; + /** Exclusive end index for samples inside the visible time range. */ + sampleEndIndex: number; + /** Inclusive scan start, including one leading sample for clipped segments. */ + scanStartIndex: number; + /** Exclusive scan end, including one trailing sample for clipped segments. */ + scanEndIndex: number; +} + +export type WaveformZoomDirection = 'in' | 'out'; +export type WaveformPanDirection = 'left' | 'right'; + +export const DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES = 8; +export const DEFAULT_WAVEFORM_VIEWPORT_MIN_MS = 1; + +// --------------------------------------------------------------------------- +// Private numeric helpers. +// --------------------------------------------------------------------------- + +function clampInt(value: number, min: number, max: number): number { + if (max <= min) return min; + return Math.max(min, Math.min(max, Math.floor(value))); +} + +function clampNumber(value: number, min: number, max: number): number { + if (max <= min) return min; + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); +} + +function normalizeMinDuration(minDurationMs: number, maxDurationMs: number): number { + const requested = + Number.isFinite(minDurationMs) && minDurationMs > 0 + ? minDurationMs + : DEFAULT_WAVEFORM_VIEWPORT_MIN_MS; + return Math.max(DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, Math.min(maxDurationMs, requested)); +} + +function firstFiniteTimestamp(timestamps: readonly number[]): number | null { + for (const timestamp of timestamps) { + if (Number.isFinite(timestamp)) return timestamp; + } + return null; +} + +function lastFiniteTimestamp(timestamps: readonly number[]): number | null { + for (let i = timestamps.length - 1; i >= 0; i -= 1) { + const timestamp = timestamps[i]; + if (Number.isFinite(timestamp)) return timestamp; + } + return null; +} + +function lowerBoundTimestamp(timestamps: readonly number[], target: number): number { + let lo = 0; + let hi = timestamps.length; + while (lo < hi) { + const mid = lo + Math.floor((hi - lo) / 2); + const timestamp = timestamps[mid]; + if (Number.isFinite(timestamp) && timestamp < target) lo = mid + 1; + else hi = mid; + } + return lo; +} + +function upperBoundTimestamp(timestamps: readonly number[], target: number): number { + let lo = 0; + let hi = timestamps.length; + while (lo < hi) { + const mid = lo + Math.floor((hi - lo) / 2); + const timestamp = timestamps[mid]; + if (Number.isFinite(timestamp) && timestamp <= target) lo = mid + 1; + else hi = mid; + } + return lo; +} + +// --------------------------------------------------------------------------- +// Sample-index viewport (integer space). +// --------------------------------------------------------------------------- + +export function normalizeWaveformViewport( + viewport: WaveformViewport, + sampleCount: number, + minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, +): WaveformViewport { + const count = Math.max(0, Math.floor(sampleCount)); + if (count === 0) return { start: 0, size: 0 }; + const requestedMinSize = + Number.isFinite(minSamples) && minSamples > 0 + ? Math.floor(minSamples) + : DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES; + const minSize = Math.max(1, Math.min(count, requestedMinSize)); + const rawSize = Number.isFinite(viewport.size) ? Math.floor(viewport.size) : count; + const size = clampInt(rawSize, minSize, count); + const rawStart = Number.isFinite(viewport.start) ? Math.floor(viewport.start) : 0; + return { + start: clampInt(rawStart, 0, Math.max(0, count - size)), + size, + }; +} + +export function zoomWaveformViewport( + viewport: WaveformViewport, + sampleCount: number, + direction: WaveformZoomDirection, + minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, + factor = 2, +): WaveformViewport { + const zoomFactor = Number.isFinite(factor) && factor > 1 ? factor : 2; + return scaleWaveformViewport( + viewport, + sampleCount, + 0.5, + direction === 'in' ? 1 / zoomFactor : zoomFactor, + minSamples, + ); +} + +export function scaleWaveformViewport( + viewport: WaveformViewport, + sampleCount: number, + anchorRatio: number, + scale: number, + minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, +): WaveformViewport { + const current = normalizeWaveformViewport(viewport, sampleCount, minSamples); + if (current.size === 0) return current; + const count = Math.max(0, Math.floor(sampleCount)); + const minSize = + Number.isFinite(minSamples) && minSamples > 0 + ? Math.min(count, Math.floor(minSamples)) + : Math.min(count, DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES); + const zoomScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + if (zoomScale < 1 && current.size <= minSize) return current; + if (zoomScale > 1 && current.size >= count) return current; + if (zoomScale === 1) return current; + const targetSize = Math.round(current.size * zoomScale); + if (targetSize === current.size) return current; + const ratio = clampNumber(anchorRatio, 0, 1); + const anchor = current.start + ratio * Math.max(0, current.size - 1); + const targetSpan = Math.max(0, targetSize - 1); + return normalizeWaveformViewport( + { + start: Math.round(anchor - ratio * targetSpan), + size: targetSize, + }, + sampleCount, + minSamples, + ); +} + +export function panWaveformViewport( + viewport: WaveformViewport, + sampleCount: number, + direction: WaveformPanDirection, + minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, + fraction = 0.25, +): WaveformViewport { + const current = normalizeWaveformViewport(viewport, sampleCount, minSamples); + if (current.size === 0) return current; + const panFraction = Number.isFinite(fraction) && fraction > 0 ? fraction : 0.25; + const step = Math.max(1, Math.floor(current.size * panFraction)); + return normalizeWaveformViewport( + { + start: current.start + (direction === 'left' ? -step : step), + size: current.size, + }, + sampleCount, + minSamples, + ); +} + +export function panWaveformViewportBySamples( + viewport: WaveformViewport, + sampleCount: number, + sampleOffset: number, + minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, +): WaveformViewport { + const current = normalizeWaveformViewport(viewport, sampleCount, minSamples); + if (current.size === 0) return current; + const offset = Number.isFinite(sampleOffset) ? Math.trunc(sampleOffset) : 0; + if (offset === 0) return current; + return normalizeWaveformViewport( + { + start: current.start + offset, + size: current.size, + }, + sampleCount, + minSamples, + ); +} + +export function syncWaveformViewportAfterSampleChange( + viewport: WaveformViewport, + previousSampleCount: number, + sampleCount: number, + minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, + droppedSamples = 0, +): WaveformViewport { + const count = Math.max(0, Math.floor(sampleCount)); + if (count === 0) return { ...viewport, start: 0 }; + const previousView = normalizeWaveformViewport(viewport, previousSampleCount, minSamples); + const wasFollowingLatest = + previousSampleCount <= 0 || previousView.start + previousView.size >= previousSampleCount; + if (wasFollowingLatest) { + const nextView = normalizeWaveformViewport( + { + start: count - viewport.size, + size: viewport.size, + }, + count, + minSamples, + ); + return { ...viewport, start: nextView.start }; + } + const dropped = Number.isFinite(droppedSamples) ? Math.max(0, Math.floor(droppedSamples)) : 0; + const clamped = normalizeWaveformViewport( + { start: previousView.start - dropped, size: viewport.size }, + count, + minSamples, + ); + return { ...viewport, start: clamped.start }; +} + +// --------------------------------------------------------------------------- +// Time-domain viewport (continuous ms space). +// --------------------------------------------------------------------------- + +export function waveformTimeRange(timestamps: readonly number[]): WaveformTimeRange | null { + const start = firstFiniteTimestamp(timestamps); + const end = lastFiniteTimestamp(timestamps); + if (start === null || end === null) return null; + const duration = Math.max(DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, end - start); + return { + startMs: start, + endMs: start + duration, + durationMs: duration, + }; +} + +export function waveformSampleIndexWindow( + timestamps: readonly number[], + startMs: number, + endMs: number, +): WaveformSampleIndexWindow { + const count = timestamps.length; + if (count === 0) { + return { sampleStartIndex: 0, sampleEndIndex: 0, scanStartIndex: 0, scanEndIndex: 0 }; + } + + const start = Number.isFinite(startMs) ? startMs : -Infinity; + const end = Number.isFinite(endMs) ? endMs : Infinity; + const normalizedStart = Math.min(start, end); + const normalizedEnd = Math.max(start, end); + const sampleStartIndex = lowerBoundTimestamp(timestamps, normalizedStart); + const sampleEndIndex = upperBoundTimestamp(timestamps, normalizedEnd); + const scanStartIndex = Math.max(0, sampleStartIndex - 1); + const scanEndIndex = Math.min(count, sampleEndIndex + (sampleEndIndex < count ? 1 : 0)); + return { sampleStartIndex, sampleEndIndex, scanStartIndex, scanEndIndex }; +} + +export function normalizeWaveformTimeViewport( + viewport: WaveformTimeViewport, + timestamps: readonly number[], + minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, +): WaveformTimeViewport { + const range = waveformTimeRange(timestamps); + if (!range) return { startMs: 0, durationMs: 0 }; + const minDuration = normalizeMinDuration(minDurationMs, range.durationMs); + const rawDuration = Number.isFinite(viewport.durationMs) ? viewport.durationMs : range.durationMs; + const duration = clampNumber(rawDuration, minDuration, range.durationMs); + const rawStart = Number.isFinite(viewport.startMs) ? viewport.startMs : range.startMs; + return { + startMs: clampNumber(rawStart, range.startMs, range.endMs - duration), + durationMs: duration, + }; +} + +export function scaleWaveformTimeViewport( + viewport: WaveformTimeViewport, + timestamps: readonly number[], + anchorRatio: number, + scale: number, + minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, +): WaveformTimeViewport { + const range = waveformTimeRange(timestamps); + if (!range) return { startMs: 0, durationMs: 0 }; + const current = normalizeWaveformTimeViewport(viewport, timestamps, minDurationMs); + const minDuration = normalizeMinDuration(minDurationMs, range.durationMs); + const zoomScale = Number.isFinite(scale) && scale > 0 ? scale : 1; + if (zoomScale === 1) return current; + if (zoomScale < 1 && current.durationMs <= minDuration) return current; + if (zoomScale > 1 && current.durationMs >= range.durationMs) return current; + const targetDuration = current.durationMs * zoomScale; + if (Math.abs(targetDuration - current.durationMs) < 0.001) return current; + const ratio = clampNumber(anchorRatio, 0, 1); + const anchor = current.startMs + current.durationMs * ratio; + return normalizeWaveformTimeViewport( + { + startMs: anchor - targetDuration * ratio, + durationMs: targetDuration, + }, + timestamps, + minDurationMs, + ); +} + +export function panWaveformTimeViewport( + viewport: WaveformTimeViewport, + timestamps: readonly number[], + direction: WaveformPanDirection, + fraction = 0.25, + minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, +): WaveformTimeViewport { + const current = normalizeWaveformTimeViewport(viewport, timestamps, minDurationMs); + if (current.durationMs === 0) return current; + const panFraction = Number.isFinite(fraction) && fraction > 0 ? fraction : 0.25; + const offset = current.durationMs * panFraction * (direction === 'left' ? -1 : 1); + return panWaveformTimeViewportByMs(current, timestamps, offset, minDurationMs); +} + +export function panWaveformTimeViewportByMs( + viewport: WaveformTimeViewport, + timestamps: readonly number[], + offsetMs: number, + minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, +): WaveformTimeViewport { + const current = normalizeWaveformTimeViewport(viewport, timestamps, minDurationMs); + if (current.durationMs === 0) return current; + const offset = Number.isFinite(offsetMs) ? offsetMs : 0; + if (offset === 0) return current; + return normalizeWaveformTimeViewport( + { + startMs: current.startMs + offset, + durationMs: current.durationMs, + }, + timestamps, + minDurationMs, + ); +} + +export function syncWaveformTimeViewportAfterSampleChange( + viewport: WaveformTimeViewport, + previousTimestamps: readonly number[], + timestamps: readonly number[], + minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, +): WaveformTimeViewport { + const nextRange = waveformTimeRange(timestamps); + if (!nextRange) return { ...viewport, startMs: 0 }; + if (!Number.isFinite(viewport.durationMs)) { + return { ...viewport, startMs: nextRange.startMs }; + } + const previousRange = waveformTimeRange(previousTimestamps); + if (!previousRange) { + return normalizeWaveformTimeViewport( + { startMs: nextRange.startMs, durationMs: nextRange.durationMs }, + timestamps, + minDurationMs, + ); + } + const previousView = normalizeWaveformTimeViewport(viewport, previousTimestamps, minDurationMs); + const wasFollowingLatest = previousView.startMs + previousView.durationMs >= previousRange.endMs; + if (wasFollowingLatest) { + return normalizeWaveformTimeViewport( + { + startMs: nextRange.endMs - previousView.durationMs, + durationMs: previousView.durationMs, + }, + timestamps, + minDurationMs, + ); + } + return normalizeWaveformTimeViewport(previousView, timestamps, minDurationMs); +} diff --git a/src/lib/waveform.ts b/src/lib/waveform.ts index d1d4fc1..28af201 100644 --- a/src/lib/waveform.ts +++ b/src/lib/waveform.ts @@ -11,6 +11,30 @@ // RegExp constructor (not a /.../ literal) so this file also loads under Node's // --experimental-strip-types runner, whose parser mishandles regex literals in // some multi-function files (treats them as division). +export { + DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, + DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, + normalizeWaveformViewport, + normalizeWaveformTimeViewport, + panWaveformTimeViewport, + panWaveformTimeViewportByMs, + panWaveformViewport, + panWaveformViewportBySamples, + scaleWaveformTimeViewport, + scaleWaveformViewport, + syncWaveformTimeViewportAfterSampleChange, + syncWaveformViewportAfterSampleChange, + waveformSampleIndexWindow, + waveformTimeRange, + zoomWaveformViewport, + type WaveformPanDirection, + type WaveformSampleIndexWindow, + type WaveformTimeRange, + type WaveformTimeViewport, + type WaveformViewport, + type WaveformZoomDirection, +} from './waveform-viewport'; + const NUMBER_RE = new RegExp('-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?', 'g'); const LINE_SPLIT_RE = new RegExp('\\r?\\n'); const TRAILING_NEWLINE_RE = new RegExp('\\r?\\n$'); @@ -88,43 +112,11 @@ export interface WaveformFrameIngestPlan { nextCursor: WaveformFrameCursor; } -export interface WaveformViewport { - start: number; - size: number; -} - -export interface WaveformTimeViewport { - startMs: number; - durationMs: number; -} - -export interface WaveformTimeRange { - startMs: number; - endMs: number; - durationMs: number; -} - -export interface WaveformSampleIndexWindow { - /** First sample whose timestamp is inside the visible time range. */ - sampleStartIndex: number; - /** Exclusive end index for samples inside the visible time range. */ - sampleEndIndex: number; - /** Inclusive scan start, including one leading sample for clipped segments. */ - scanStartIndex: number; - /** Exclusive scan end, including one trailing sample for clipped segments. */ - scanEndIndex: number; -} - -export type WaveformZoomDirection = 'in' | 'out'; -export type WaveformPanDirection = 'left' | 'right'; - export interface WaveformRegisterSampleResult { channels: WaveformChannelState[]; pushed: boolean; } -export const DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES = 8; -export const DEFAULT_WAVEFORM_VIEWPORT_MIN_MS = 1; export const DEFAULT_WAVEFORM_SAMPLE_POINT_MIN_DISTANCE_PX = 6; /** Per-channel statistics computed across the live buffer. Cheap (O(n·c)) and @@ -413,8 +405,9 @@ export function visibleChannelRangeInWindow( const hasVisibleChannel = visible.some((isVisible, i) => isVisible && i < channelCount); let min = Infinity; let max = -Infinity; - const start = clampInt(startIndex, 0, buf.samples.length); - const end = clampInt(endIndex, start, buf.samples.length); + const total = buf.samples.length; + const start = Math.max(0, Math.min(total, Math.floor(startIndex))); + const end = Math.max(start, Math.min(total, Math.floor(endIndex))); for (let c = 0; c < channelCount; c += 1) { if (hasVisibleChannel && !visible[c]) continue; for (let i = start; i < end; i += 1) { @@ -427,355 +420,6 @@ export function visibleChannelRangeInWindow( return paddedRange(min, max); } -export function normalizeWaveformViewport( - viewport: WaveformViewport, - sampleCount: number, - minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, -): WaveformViewport { - const count = Math.max(0, Math.floor(sampleCount)); - if (count === 0) return { start: 0, size: 0 }; - const requestedMinSize = - Number.isFinite(minSamples) && minSamples > 0 - ? Math.floor(minSamples) - : DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES; - const minSize = Math.max(1, Math.min(count, requestedMinSize)); - const rawSize = Number.isFinite(viewport.size) ? Math.floor(viewport.size) : count; - const size = clampInt(rawSize, minSize, count); - const rawStart = Number.isFinite(viewport.start) ? Math.floor(viewport.start) : 0; - return { - start: clampInt(rawStart, 0, Math.max(0, count - size)), - size, - }; -} - -export function zoomWaveformViewport( - viewport: WaveformViewport, - sampleCount: number, - direction: WaveformZoomDirection, - minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, - factor = 2, -): WaveformViewport { - const zoomFactor = Number.isFinite(factor) && factor > 1 ? factor : 2; - return scaleWaveformViewport( - viewport, - sampleCount, - 0.5, - direction === 'in' ? 1 / zoomFactor : zoomFactor, - minSamples, - ); -} - -export function scaleWaveformViewport( - viewport: WaveformViewport, - sampleCount: number, - anchorRatio: number, - scale: number, - minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, -): WaveformViewport { - const current = normalizeWaveformViewport(viewport, sampleCount, minSamples); - if (current.size === 0) return current; - const count = Math.max(0, Math.floor(sampleCount)); - const minSize = - Number.isFinite(minSamples) && minSamples > 0 - ? Math.min(count, Math.floor(minSamples)) - : Math.min(count, DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES); - const zoomScale = Number.isFinite(scale) && scale > 0 ? scale : 1; - if (zoomScale < 1 && current.size <= minSize) return current; - if (zoomScale > 1 && current.size >= count) return current; - if (zoomScale === 1) return current; - const targetSize = Math.round(current.size * zoomScale); - if (targetSize === current.size) return current; - const ratio = clampNumber(anchorRatio, 0, 1); - const anchor = current.start + ratio * Math.max(0, current.size - 1); - const targetSpan = Math.max(0, targetSize - 1); - return normalizeWaveformViewport( - { - start: Math.round(anchor - ratio * targetSpan), - size: targetSize, - }, - sampleCount, - minSamples, - ); -} - -export function panWaveformViewport( - viewport: WaveformViewport, - sampleCount: number, - direction: WaveformPanDirection, - minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, - fraction = 0.25, -): WaveformViewport { - const current = normalizeWaveformViewport(viewport, sampleCount, minSamples); - if (current.size === 0) return current; - const panFraction = Number.isFinite(fraction) && fraction > 0 ? fraction : 0.25; - const step = Math.max(1, Math.floor(current.size * panFraction)); - return normalizeWaveformViewport( - { - start: current.start + (direction === 'left' ? -step : step), - size: current.size, - }, - sampleCount, - minSamples, - ); -} - -export function panWaveformViewportBySamples( - viewport: WaveformViewport, - sampleCount: number, - sampleOffset: number, - minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, -): WaveformViewport { - const current = normalizeWaveformViewport(viewport, sampleCount, minSamples); - if (current.size === 0) return current; - const offset = Number.isFinite(sampleOffset) ? Math.trunc(sampleOffset) : 0; - if (offset === 0) return current; - return normalizeWaveformViewport( - { - start: current.start + offset, - size: current.size, - }, - sampleCount, - minSamples, - ); -} - -export function waveformTimeRange(timestamps: readonly number[]): WaveformTimeRange | null { - const start = firstFiniteTimestamp(timestamps); - const end = lastFiniteTimestamp(timestamps); - if (start === null || end === null) return null; - const duration = Math.max(DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, end - start); - return { - startMs: start, - endMs: start + duration, - durationMs: duration, - }; -} - -export function waveformSampleIndexWindow( - timestamps: readonly number[], - startMs: number, - endMs: number, -): WaveformSampleIndexWindow { - const count = timestamps.length; - if (count === 0) { - return { sampleStartIndex: 0, sampleEndIndex: 0, scanStartIndex: 0, scanEndIndex: 0 }; - } - - const start = Number.isFinite(startMs) ? startMs : -Infinity; - const end = Number.isFinite(endMs) ? endMs : Infinity; - const normalizedStart = Math.min(start, end); - const normalizedEnd = Math.max(start, end); - const sampleStartIndex = lowerBoundTimestamp(timestamps, normalizedStart); - const sampleEndIndex = upperBoundTimestamp(timestamps, normalizedEnd); - const scanStartIndex = Math.max(0, sampleStartIndex - 1); - const scanEndIndex = Math.min(count, sampleEndIndex + (sampleEndIndex < count ? 1 : 0)); - return { sampleStartIndex, sampleEndIndex, scanStartIndex, scanEndIndex }; -} - -export function normalizeWaveformTimeViewport( - viewport: WaveformTimeViewport, - timestamps: readonly number[], - minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, -): WaveformTimeViewport { - const range = waveformTimeRange(timestamps); - if (!range) return { startMs: 0, durationMs: 0 }; - const minDuration = normalizeMinDuration(minDurationMs, range.durationMs); - const rawDuration = Number.isFinite(viewport.durationMs) ? viewport.durationMs : range.durationMs; - const duration = clampNumber(rawDuration, minDuration, range.durationMs); - const rawStart = Number.isFinite(viewport.startMs) ? viewport.startMs : range.startMs; - return { - startMs: clampNumber(rawStart, range.startMs, range.endMs - duration), - durationMs: duration, - }; -} - -export function scaleWaveformTimeViewport( - viewport: WaveformTimeViewport, - timestamps: readonly number[], - anchorRatio: number, - scale: number, - minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, -): WaveformTimeViewport { - const range = waveformTimeRange(timestamps); - if (!range) return { startMs: 0, durationMs: 0 }; - const current = normalizeWaveformTimeViewport(viewport, timestamps, minDurationMs); - const minDuration = normalizeMinDuration(minDurationMs, range.durationMs); - const zoomScale = Number.isFinite(scale) && scale > 0 ? scale : 1; - if (zoomScale === 1) return current; - if (zoomScale < 1 && current.durationMs <= minDuration) return current; - if (zoomScale > 1 && current.durationMs >= range.durationMs) return current; - const targetDuration = current.durationMs * zoomScale; - if (Math.abs(targetDuration - current.durationMs) < 0.001) return current; - const ratio = clampNumber(anchorRatio, 0, 1); - const anchor = current.startMs + current.durationMs * ratio; - return normalizeWaveformTimeViewport( - { - startMs: anchor - targetDuration * ratio, - durationMs: targetDuration, - }, - timestamps, - minDurationMs, - ); -} - -export function panWaveformTimeViewport( - viewport: WaveformTimeViewport, - timestamps: readonly number[], - direction: WaveformPanDirection, - fraction = 0.25, - minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, -): WaveformTimeViewport { - const current = normalizeWaveformTimeViewport(viewport, timestamps, minDurationMs); - if (current.durationMs === 0) return current; - const panFraction = Number.isFinite(fraction) && fraction > 0 ? fraction : 0.25; - const offset = current.durationMs * panFraction * (direction === 'left' ? -1 : 1); - return panWaveformTimeViewportByMs(current, timestamps, offset, minDurationMs); -} - -export function panWaveformTimeViewportByMs( - viewport: WaveformTimeViewport, - timestamps: readonly number[], - offsetMs: number, - minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, -): WaveformTimeViewport { - const current = normalizeWaveformTimeViewport(viewport, timestamps, minDurationMs); - if (current.durationMs === 0) return current; - const offset = Number.isFinite(offsetMs) ? offsetMs : 0; - if (offset === 0) return current; - return normalizeWaveformTimeViewport( - { - startMs: current.startMs + offset, - durationMs: current.durationMs, - }, - timestamps, - minDurationMs, - ); -} - -export function syncWaveformTimeViewportAfterSampleChange( - viewport: WaveformTimeViewport, - previousTimestamps: readonly number[], - timestamps: readonly number[], - minDurationMs = DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, -): WaveformTimeViewport { - const nextRange = waveformTimeRange(timestamps); - if (!nextRange) return { ...viewport, startMs: 0 }; - if (!Number.isFinite(viewport.durationMs)) { - return { ...viewport, startMs: nextRange.startMs }; - } - const previousRange = waveformTimeRange(previousTimestamps); - if (!previousRange) { - return normalizeWaveformTimeViewport( - { startMs: nextRange.startMs, durationMs: nextRange.durationMs }, - timestamps, - minDurationMs, - ); - } - const previousView = normalizeWaveformTimeViewport(viewport, previousTimestamps, minDurationMs); - const wasFollowingLatest = previousView.startMs + previousView.durationMs >= previousRange.endMs; - if (wasFollowingLatest) { - return normalizeWaveformTimeViewport( - { - startMs: nextRange.endMs - previousView.durationMs, - durationMs: previousView.durationMs, - }, - timestamps, - minDurationMs, - ); - } - return normalizeWaveformTimeViewport(previousView, timestamps, minDurationMs); -} - -export function syncWaveformViewportAfterSampleChange( - viewport: WaveformViewport, - previousSampleCount: number, - sampleCount: number, - minSamples = DEFAULT_WAVEFORM_VIEWPORT_MIN_SAMPLES, - droppedSamples = 0, -): WaveformViewport { - const count = Math.max(0, Math.floor(sampleCount)); - if (count === 0) return { ...viewport, start: 0 }; - const previousView = normalizeWaveformViewport(viewport, previousSampleCount, minSamples); - const wasFollowingLatest = - previousSampleCount <= 0 || previousView.start + previousView.size >= previousSampleCount; - if (wasFollowingLatest) { - const nextView = normalizeWaveformViewport( - { - start: count - viewport.size, - size: viewport.size, - }, - count, - minSamples, - ); - return { ...viewport, start: nextView.start }; - } - const dropped = Number.isFinite(droppedSamples) ? Math.max(0, Math.floor(droppedSamples)) : 0; - const clamped = normalizeWaveformViewport( - { start: previousView.start - dropped, size: viewport.size }, - count, - minSamples, - ); - return { ...viewport, start: clamped.start }; -} - -function clampInt(value: number, min: number, max: number): number { - if (max <= min) return min; - return Math.max(min, Math.min(max, Math.floor(value))); -} - -function clampNumber(value: number, min: number, max: number): number { - if (max <= min) return min; - if (!Number.isFinite(value)) return min; - return Math.max(min, Math.min(max, value)); -} - -function normalizeMinDuration(minDurationMs: number, maxDurationMs: number): number { - const requested = - Number.isFinite(minDurationMs) && minDurationMs > 0 - ? minDurationMs - : DEFAULT_WAVEFORM_VIEWPORT_MIN_MS; - return Math.max(DEFAULT_WAVEFORM_VIEWPORT_MIN_MS, Math.min(maxDurationMs, requested)); -} - -function firstFiniteTimestamp(timestamps: readonly number[]): number | null { - for (const timestamp of timestamps) { - if (Number.isFinite(timestamp)) return timestamp; - } - return null; -} - -function lastFiniteTimestamp(timestamps: readonly number[]): number | null { - for (let i = timestamps.length - 1; i >= 0; i -= 1) { - const timestamp = timestamps[i]; - if (Number.isFinite(timestamp)) return timestamp; - } - return null; -} - -function lowerBoundTimestamp(timestamps: readonly number[], target: number): number { - let lo = 0; - let hi = timestamps.length; - while (lo < hi) { - const mid = lo + Math.floor((hi - lo) / 2); - const timestamp = timestamps[mid]; - if (Number.isFinite(timestamp) && timestamp < target) lo = mid + 1; - else hi = mid; - } - return lo; -} - -function upperBoundTimestamp(timestamps: readonly number[], target: number): number { - let lo = 0; - let hi = timestamps.length; - while (lo < hi) { - const mid = lo + Math.floor((hi - lo) / 2); - const timestamp = timestamps[mid]; - if (Number.isFinite(timestamp) && timestamp <= target) lo = mid + 1; - else hi = mid; - } - return lo; -} - export interface WaveformPathPoint { x: number; y: number; From 096b2ee71d4386aa3bf2654991370e0c17b246a4 Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Wed, 17 Jun 2026 12:00:02 +0800 Subject: [PATCH 4/6] docs(changelog): add waveform viewport extraction to batch 16 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aaf3c2..6b3591e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ All notable changes to bbcom are documented here. The format is based on `waveform-render.ts` 97.08% line coverage) + 71 Rust tests green; 0 circular deps; bench 10/10 pass (`waveform_parse_50k` unaffected). +- **A — waveform viewport transforms extracted (LANDED):** split the + 914-line `lib/waveform.ts` along its viewport seam. The sample-index + + time-domain windowing math (normalize/zoom/scale/pan/clamp/follow-latest, + plus the viewport types and the `DEFAULT_WAVEFORM_VIEWPORT_MIN_*` + constants) moves into a focused framework-free `lib/waveform-viewport.ts` + (403 lines). `waveform.ts` re-exports them so every importer keeps working + unchanged (pure move + re-export, the modbus/ precedent). **Metric:** + `waveform.ts` **914 to 558 lines (-39%)**. Gate evidence: 576 frontend + tests green (the existing viewport tests cover the extracted functions via + the re-export); 0 circular deps; `coverage:lib` 98.46% + (`waveform-viewport.ts` 95.53% lines); bench 10/10 pass. + - **Sacred Cows audited (COW-1..5):** only the waveform canvas render path changed. TX single-serialization (COW-1), Modbus single-busy (COW-2), auto-log chain (COW-3), scroll single-flight RAF (COW-4), persistence From c6f63f9419e064b34d6bb1d24454232964b8229b Mon Sep 17 00:00:00 2001 From: Realer Mason Date: Wed, 17 Jun 2026 12:01:00 +0800 Subject: [PATCH 5/6] docs(changelog): document criterion-6 backlog audit (batch 16) --- CHANGELOG.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3591e..6a4f2ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,10 +44,27 @@ All notable changes to bbcom are documented here. The format is based on backward compat (COW-5) all untouched; no persisted-shape change; the `lib/` framework-free contract preserved; AP-3 (no deep watcher) untouched. -- **Backlog re-ranked:** the next A-axis candidate is `lib/waveform.ts` - (914 lines) — a cohesive single-domain waveform math module; lower - leverage than the render extraction just landed, and the next loop - iteration rather than a blocker. +- **Backlog audit (criterion 6):** after the two waveform extractions this + batch, the remaining >500-line files were each assessed for a positive- + leverage extraction seam and documented: + - `WaveformPanel.vue` (858) — a component, now a state + interaction + RAF + orchestrator; the legend sub-component was already extracted, the canvas + is one element, and its render pipeline is now in `waveform-render.ts`. + No clean sub-component seam remains; further splits would create + artificial fragments with prop-drilling churn (negative leverage). + - `waveform-render.ts` (651), `lib/waveform.ts` (558) — the modules just + extracted *out* of the panel; cohesive single-domain. + - `stores/sessions.ts` (590), `lib/modbus/modbus-core.ts` (532), + `lib/session-persistence.ts` (529), `composables/useModbusMaster.ts` + (527) — cohesive core modules (the most-imported store, the Modbus core, + the versioned persistence serializer, the master orchestrator); splitting + would scatter tightly-coupled logic with no boundary win. + - `PortSelector.vue` (568), `MacroPanel.vue` (548), `AppShell.vue` (531), + `WaveformLegend.vue` (519) — presentation components whose line count is + dominated by scoped `