Spaces:
Sleeping
Sleeping
| /** | |
| * 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; | |
| } | |