physix / frontend /src /lib /trajectory.ts
Pratyush-01's picture
Upload folder using huggingface_hub
0e24aff verified
/**
* 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;
}