Spaces:
Sleeping
Sleeping
| /** 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<HTMLCanvasElement>(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 ( | |
| <canvas | |
| ref={canvasRef} | |
| className={className ?? "h-[260px] w-full rounded-lg bg-surfaceMuted"} | |
| role="img" | |
| aria-label={`${variableLabel} vs time. Observed in cyan; predicted hypothesis in coral.`} | |
| /> | |
| ); | |
| } | |
| 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(); | |
| } | |