/** Single-variable trajectory plot: observed (cyan) overlaid with predicted * (coral, optionally clipped to a `predictedProgress` fraction). */ import { useEffect, useRef } from "react"; import type { TrajectorySample } from "@/types/physix"; import { DEFAULT_PADDING, buildProjection, computeBounds, drawAxes, drawSeries, } from "@/lib/trajectory"; const OBSERVED_COLOR = "#5cc8f9"; const PREDICTED_COLOR = "#f97a5c"; const LEGEND_OBSERVED_LABEL = "Observed"; const LEGEND_PREDICTED_LABEL = "Predicted"; interface TrajectoryCanvasProps { observed: TrajectorySample[]; predicted: TrajectorySample[]; variable: string; variableLabel: string; /** 0..1, fraction of the predicted series to draw this frame. */ predictedProgress: number; className?: string; /** Whether to show the legend (default true). */ showLegend?: boolean; } export function TrajectoryCanvas({ observed, predicted, variable, variableLabel, predictedProgress, className, showLegend = true, }: TrajectoryCanvasProps): JSX.Element { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const cssWidth = Math.max(280, rect.width); const cssHeight = Math.max(220, rect.height); canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssWidth, cssHeight); const bounds = computeBounds([observed, predicted], variable); const projection = buildProjection(cssWidth, cssHeight, DEFAULT_PADDING, bounds); drawAxes(ctx, cssWidth, cssHeight, DEFAULT_PADDING, bounds, variableLabel); drawSeries(ctx, observed, variable, projection, { color: OBSERVED_COLOR, lineWidth: 2, }); drawSeries(ctx, predicted, variable, projection, { color: PREDICTED_COLOR, lineWidth: 2.5, progress: predictedProgress, }); if (showLegend) { drawLegend(ctx, cssWidth, cssHeight, predicted.length === 0); } }, [observed, predicted, predictedProgress, variable, variableLabel, showLegend]); return ( ); } function drawLegend( ctx: CanvasRenderingContext2D, width: number, _height: number, predictedEmpty: boolean, ): void { const padding = 10; const swatchW = 14; const gap = 8; const lineGap = 6; ctx.save(); ctx.font = "11px 'Inter', sans-serif"; const observedText = LEGEND_OBSERVED_LABEL; const predictedText = predictedEmpty ? `${LEGEND_PREDICTED_LABEL} (none yet)` : LEGEND_PREDICTED_LABEL; const observedWidth = ctx.measureText(observedText).width; const predictedWidth = ctx.measureText(predictedText).width; const boxW = swatchW + gap + Math.max(observedWidth, predictedWidth) + padding * 2; const boxH = 11 * 2 + lineGap + padding * 2; const x = width - boxW - 14; const y = 14; ctx.fillStyle = "rgba(11,13,16,0.85)"; ctx.strokeStyle = "rgba(255,255,255,0.08)"; roundedRect(ctx, x, y, boxW, boxH, 6); ctx.fill(); ctx.stroke(); ctx.textBaseline = "middle"; ctx.textAlign = "left"; // Observed swatch + label ctx.fillStyle = OBSERVED_COLOR; ctx.fillRect(x + padding, y + padding + 5, swatchW, 2.5); ctx.fillStyle = "rgba(229,231,235,0.92)"; ctx.fillText(observedText, x + padding + swatchW + gap, y + padding + 6); // Predicted swatch + label ctx.fillStyle = PREDICTED_COLOR; ctx.fillRect( x + padding, y + padding + 11 + lineGap + 5, swatchW, 2.5, ); ctx.fillStyle = predictedEmpty ? "rgba(229,231,235,0.55)" : "rgba(229,231,235,0.92)"; ctx.fillText( predictedText, x + padding + swatchW + gap, y + padding + 11 + lineGap + 6, ); ctx.restore(); } function roundedRect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number, ): void { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); }