physix / frontend /src /components /TrajectoryCanvas.tsx
Pratyush-01's picture
Upload folder using huggingface_hub
0e24aff verified
/** 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();
}