/** * Pure helpers for plotting trajectory data on a 2D canvas. * * We deliberately avoid pulling in a charting library: the demo only needs * one variable plotted vs time, and a hand-rolled linear-axis projector * keeps the bundle tiny. */ import type { TrajectorySample } from "@/types/physix"; export interface PlotPadding { top: number; right: number; bottom: number; left: number; } export interface PlotBounds { /** X-axis: time. */ tMin: number; tMax: number; /** Y-axis: variable being plotted. */ yMin: number; yMax: number; } export const DEFAULT_PADDING: PlotPadding = { top: 24, right: 24, bottom: 36, left: 48, }; /** * Compute axis bounds from one or more series, with a small symmetric pad * around the y-range. */ export function computeBounds( series: TrajectorySample[][], variable: string, padding = 0.08, ): PlotBounds { let tMin = Number.POSITIVE_INFINITY; let tMax = Number.NEGATIVE_INFINITY; let yMin = Number.POSITIVE_INFINITY; let yMax = Number.NEGATIVE_INFINITY; for (const samples of series) { for (const sample of samples) { const yValue = sample[variable]; if (typeof yValue !== "number" || !Number.isFinite(yValue)) continue; if (sample.t < tMin) tMin = sample.t; if (sample.t > tMax) tMax = sample.t; if (yValue < yMin) yMin = yValue; if (yValue > yMax) yMax = yValue; } } if (!Number.isFinite(tMin) || !Number.isFinite(yMin)) { return { tMin: 0, tMax: 1, yMin: 0, yMax: 1 }; } if (yMin === yMax) { yMin -= 1; yMax += 1; } const yRange = yMax - yMin; return { tMin, tMax: tMax === tMin ? tMin + 1 : tMax, yMin: yMin - yRange * padding, yMax: yMax + yRange * padding, }; } export interface AxesProjection { toX: (t: number) => number; toY: (y: number) => number; } export function buildProjection( width: number, height: number, padding: PlotPadding, bounds: PlotBounds, ): AxesProjection { const plotWidth = Math.max(1, width - padding.left - padding.right); const plotHeight = Math.max(1, height - padding.top - padding.bottom); const tSpan = bounds.tMax - bounds.tMin || 1; const ySpan = bounds.yMax - bounds.yMin || 1; return { toX: (t: number) => padding.left + ((t - bounds.tMin) / tSpan) * plotWidth, toY: (y: number) => padding.top + (1 - (y - bounds.yMin) / ySpan) * plotHeight, }; } export function drawAxes( ctx: CanvasRenderingContext2D, width: number, height: number, padding: PlotPadding, bounds: PlotBounds, variableLabel: string, ): void { ctx.save(); ctx.strokeStyle = "rgba(255,255,255,0.12)"; ctx.lineWidth = 1; // Frame lines (left + bottom) ctx.beginPath(); ctx.moveTo(padding.left, padding.top); ctx.lineTo(padding.left, height - padding.bottom); ctx.lineTo(width - padding.right, height - padding.bottom); ctx.stroke(); // Light grid const gridLines = 4; for (let i = 1; i <= gridLines; i += 1) { const yPos = padding.top + (i / gridLines) * (height - padding.top - padding.bottom); ctx.beginPath(); ctx.moveTo(padding.left, yPos); ctx.lineTo(width - padding.right, yPos); ctx.strokeStyle = "rgba(255,255,255,0.05)"; ctx.stroke(); } ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.font = "11px 'JetBrains Mono', monospace"; ctx.textBaseline = "middle"; // Y labels: min / max / midpoint const yLabels = [bounds.yMax, (bounds.yMax + bounds.yMin) / 2, bounds.yMin]; for (const [i, value] of yLabels.entries()) { const yPos = padding.top + (i / 2) * (height - padding.top - padding.bottom); ctx.textAlign = "right"; ctx.fillText(value.toFixed(2), padding.left - 8, yPos); } // X labels: tMin and tMax ctx.textAlign = "center"; ctx.fillText( bounds.tMin.toFixed(2), padding.left, height - padding.bottom + 14, ); ctx.fillText( bounds.tMax.toFixed(2), width - padding.right, height - padding.bottom + 14, ); // Axis title ctx.textAlign = "left"; ctx.fillStyle = "rgba(255,255,255,0.7)"; ctx.font = "11px 'Inter', sans-serif"; ctx.fillText(`${variableLabel} vs t`, padding.left, padding.top - 8); ctx.restore(); } export function drawSeries( ctx: CanvasRenderingContext2D, samples: TrajectorySample[], variable: string, projection: AxesProjection, options: { color: string; lineWidth?: number; progress?: number; // 0..1; only this fraction of the series is drawn dashed?: boolean; }, ): void { if (samples.length === 0) return; const progress = options.progress ?? 1; const sliceEnd = Math.max(2, Math.floor(samples.length * progress)); const slice = samples.slice(0, sliceEnd); ctx.save(); ctx.strokeStyle = options.color; ctx.lineWidth = options.lineWidth ?? 2; if (options.dashed) ctx.setLineDash([4, 4]); ctx.beginPath(); for (const [i, sample] of slice.entries()) { const x = projection.toX(sample.t); const y = projection.toY(sample[variable]); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // Endpoint dot for visual emphasis. const last = slice[slice.length - 1]; if (last) { ctx.fillStyle = options.color; ctx.beginPath(); ctx.arc(projection.toX(last.t), projection.toY(last[variable]), 3.5, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } export function pickPrimaryVariable(stateVariables: string[]): string { // Prefer position-like variables over their derivatives for the headline plot. const ranked = [...stateVariables].sort((a, b) => positionPriority(b) - positionPriority(a)); return ranked[0] ?? stateVariables[0] ?? "y"; } function positionPriority(name: string): number { if (name.startsWith("v") || name.startsWith("d")) return 0; return 1; }