Files
mass-driver/frontend/src/lib/render/canvas2d/SolarSystem2D.ts
scott 22dcc5b6ec Add Lambert solver, transfer matrix, Dijkstra routing, and route planner UI
- Lambert's problem solver using universal variable method with bisection
  (handles elliptic, parabolic, hyperbolic transfers + anti-podal cases)
- Transfer matrix: precompute pairwise station transfers over time window
  using Lambert solver with configurable launch velocity
- Dijkstra routing on time-expanded graph (station × week nodes, transfer
  + wait edges) to find minimum-time routes
- Route Planner UI: from/to station dropdowns, search window selector
  (1-10 years), "Find Optimal Route" button with results card
- Route visualization: orange dashed trajectory lines with arrow heads
  and leg numbers on the 2D canvas
- Tested: Mercury L1 → Jupiter L1 computes 6-month direct transfer at
  30 km/s — physically reasonable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:14:37 -07:00

419 lines
12 KiB
TypeScript

import type { BodyInfo } from '$lib/wasm/types';
import { rgbToCss } from '$lib/utils/colors';
const AU_TO_PIXELS_BASE = 80; // pixels per AU at zoom level 1
interface Camera2D {
x: number; // center offset in AU
y: number;
zoom: number;
}
export class SolarSystem2DRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private camera: Camera2D = { x: 0, y: 0, zoom: 1 };
private bodyInfos: BodyInfo[] = [];
private positions: Float64Array = new Float64Array(0);
private velocities: Float64Array = new Float64Array(0);
private orbitPoints: Map<number, Float64Array> = new Map();
private stationPositions: Float64Array = new Float64Array(0);
private stationNames: string[] = [];
private showStations: boolean = true;
private routeLegs: { from: number; to: number }[] = [];
private isDragging = false;
private lastMouse = { x: 0, y: 0 };
private animFrameId: number = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.setupEventListeners();
}
private setupEventListeners() {
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
this.camera.zoom *= zoomFactor;
this.camera.zoom = Math.max(0.05, Math.min(50, this.camera.zoom));
});
this.canvas.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.lastMouse = { x: e.clientX, y: e.clientY };
});
this.canvas.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
const scale = this.getScale();
this.camera.x -= (e.clientX - this.lastMouse.x) / scale;
this.camera.y -= (e.clientY - this.lastMouse.y) / scale;
this.lastMouse = { x: e.clientX, y: e.clientY };
});
this.canvas.addEventListener('mouseup', () => { this.isDragging = false; });
this.canvas.addEventListener('mouseleave', () => { this.isDragging = false; });
}
private getScale(): number {
return AU_TO_PIXELS_BASE * this.camera.zoom;
}
private auToScreen(xAU: number, yAU: number): [number, number] {
const scale = this.getScale();
const rect = this.canvas.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
return [
cx + (xAU - this.camera.x) * scale,
cy - (yAU - this.camera.y) * scale, // flip Y for screen coords
];
}
updateBodies(infos: BodyInfo[], positions: Float64Array, velocities?: Float64Array) {
this.bodyInfos = infos;
this.positions = positions;
if (velocities) this.velocities = velocities;
}
updateOrbit(bodyId: number, points: Float64Array) {
this.orbitPoints.set(bodyId, points);
}
updateStations(positions: Float64Array, names: string[], visible: boolean) {
this.stationPositions = positions;
this.stationNames = names;
this.showStations = visible;
}
updateRoute(legs: { from: number; to: number }[]) {
this.routeLegs = legs;
}
render() {
const { canvas, ctx } = this;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, rect.width, rect.height);
// Draw grid
this.drawGrid(rect.width, rect.height);
// Draw orbit ellipses
for (const [bodyId, points] of this.orbitPoints) {
if (bodyId < this.bodyInfos.length) {
this.drawOrbit(points, this.bodyInfos[bodyId]);
}
}
// Draw bodies on top
if (this.positions.length > 0) {
for (let i = 0; i < this.bodyInfos.length; i++) {
const info = this.bodyInfos[i];
const x = this.positions[i * 3];
const y = this.positions[i * 3 + 1];
if (i === 0) {
this.drawSun(x, y, info);
} else {
this.drawBody(i, x, y, info);
}
}
}
// Draw stations
if (this.showStations && this.stationPositions.length > 0) {
for (let i = 0; i < this.stationNames.length; i++) {
const x = this.stationPositions[i * 3];
const y = this.stationPositions[i * 3 + 1];
this.drawStation(x, y, this.stationNames[i]);
}
}
// Draw route legs
if (this.routeLegs.length > 0 && this.stationPositions.length > 0) {
this.drawRoute();
}
// Scale indicator
this.drawScaleBar(rect.width, rect.height);
}
private drawGrid(w: number, h: number) {
const ctx = this.ctx;
const scale = this.getScale();
// Determine grid spacing in AU
let gridAU = 1;
const pixelsPerGrid = gridAU * scale;
if (pixelsPerGrid < 30) gridAU = 5;
if (pixelsPerGrid > 200) gridAU = 0.5;
if (pixelsPerGrid > 400) gridAU = 0.1;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)';
ctx.lineWidth = 0.5;
// Vertical lines
const startXAU = Math.floor((this.camera.x - w / 2 / scale) / gridAU) * gridAU;
const endXAU = Math.ceil((this.camera.x + w / 2 / scale) / gridAU) * gridAU;
for (let xAU = startXAU; xAU <= endXAU; xAU += gridAU) {
const [sx] = this.auToScreen(xAU, 0);
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx, h);
ctx.stroke();
}
// Horizontal lines
const startYAU = Math.floor((this.camera.y - h / 2 / scale) / gridAU) * gridAU;
const endYAU = Math.ceil((this.camera.y + h / 2 / scale) / gridAU) * gridAU;
for (let yAU = startYAU; yAU <= endYAU; yAU += gridAU) {
const [, sy] = this.auToScreen(0, yAU);
ctx.beginPath();
ctx.moveTo(0, sy);
ctx.lineTo(w, sy);
ctx.stroke();
}
// Concentric orbit reference circles (centered on Sun)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
const [sunX, sunY] = this.auToScreen(0, 0);
for (let r = 1; r <= 40; r++) {
const radiusPx = r * scale;
if (radiusPx < 10 || radiusPx > 5000) continue;
ctx.beginPath();
ctx.arc(sunX, sunY, radiusPx, 0, Math.PI * 2);
ctx.stroke();
}
}
private drawOrbit(points: Float64Array, info: BodyInfo) {
if (points.length < 6) return;
const ctx = this.ctx;
const [r, g, b] = info.color;
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.2)`;
ctx.lineWidth = 0.8;
ctx.beginPath();
const [sx0, sy0] = this.auToScreen(points[0], points[1]);
ctx.moveTo(sx0, sy0);
for (let i = 3; i < points.length; i += 3) {
const [sx, sy] = this.auToScreen(points[i], points[i + 1]);
ctx.lineTo(sx, sy);
}
ctx.closePath();
ctx.stroke();
}
private drawSun(xAU: number, yAU: number, info: BodyInfo) {
const ctx = this.ctx;
const [sx, sy] = this.auToScreen(xAU, yAU);
const [r, g, b] = info.color;
// Glow
const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, 30);
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.8)`);
gradient.addColorStop(0.3, `rgba(${r}, ${g}, ${b}, 0.3)`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(sx, sy, 30, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.fillStyle = rgbToCss(r, g, b);
ctx.beginPath();
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.font = '10px monospace';
ctx.fillText(info.name, sx + 10, sy + 4);
}
private drawBody(bodyIndex: number, xAU: number, yAU: number, info: BodyInfo) {
const ctx = this.ctx;
const [sx, sy] = this.auToScreen(xAU, yAU);
const [r, g, b] = info.color;
// Size based on radius (with minimum for visibility)
let size = Math.max(2, Math.log10(info.radius_km / 1000) * 2);
// Moons are smaller
if (info.radius_km < 3000) size = 1.5;
// Velocity tail
if (this.velocities.length > bodyIndex * 3 + 2) {
const vx = this.velocities[bodyIndex * 3];
const vy = this.velocities[bodyIndex * 3 + 1];
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed > 1e-10) {
// Scale tail length: normalize velocity and multiply by a visual factor
const tailScale = this.getScale() * 0.15; // pixels per (AU/day)
const tx = sx + vx * tailScale;
const ty = sy - vy * tailScale; // flip Y
// Gradient tail from body color to transparent
const gradient = ctx.createLinearGradient(sx, sy, tx, ty);
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.6)`);
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
ctx.strokeStyle = gradient;
ctx.lineWidth = size * 0.8;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(tx, ty);
ctx.stroke();
}
}
// Body dot
ctx.fillStyle = rgbToCss(r, g, b);
ctx.beginPath();
ctx.arc(sx, sy, size, 0, Math.PI * 2);
ctx.fill();
// Subtle glow
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.15)`;
ctx.beginPath();
ctx.arc(sx, sy, size * 3, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '9px monospace';
ctx.fillText(info.name, sx + size + 4, sy + 3);
}
private drawRoute() {
const ctx = this.ctx;
const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33'];
for (let i = 0; i < this.routeLegs.length; i++) {
const leg = this.routeLegs[i];
const fromX = this.stationPositions[leg.from * 3];
const fromY = this.stationPositions[leg.from * 3 + 1];
const toX = this.stationPositions[leg.to * 3];
const toY = this.stationPositions[leg.to * 3 + 1];
const [sx1, sy1] = this.auToScreen(fromX, fromY);
const [sx2, sy2] = this.auToScreen(toX, toY);
const color = colors[i % colors.length];
// Draw line
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
ctx.stroke();
ctx.setLineDash([]);
// Arrow head
const angle = Math.atan2(sy2 - sy1, sx2 - sx1);
const arrowLen = 8;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(sx2, sy2);
ctx.lineTo(
sx2 - arrowLen * Math.cos(angle - 0.3),
sy2 - arrowLen * Math.sin(angle - 0.3),
);
ctx.lineTo(
sx2 - arrowLen * Math.cos(angle + 0.3),
sy2 - arrowLen * Math.sin(angle + 0.3),
);
ctx.closePath();
ctx.fill();
// Leg number
const mx = (sx1 + sx2) / 2;
const my = (sy1 + sy2) / 2;
ctx.fillStyle = color;
ctx.font = 'bold 10px monospace';
ctx.fillText(`${i + 1}`, mx + 5, my - 5);
}
}
private drawStation(xAU: number, yAU: number, name: string) {
const ctx = this.ctx;
const [sx, sy] = this.auToScreen(xAU, yAU);
// Diamond shape
const s = 3;
ctx.fillStyle = 'rgba(74, 158, 255, 0.6)';
ctx.strokeStyle = 'rgba(74, 158, 255, 0.8)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(sx, sy - s);
ctx.lineTo(sx + s, sy);
ctx.lineTo(sx, sy + s);
ctx.lineTo(sx - s, sy);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Label (only show when zoomed in enough)
if (this.getScale() > 50) {
ctx.fillStyle = 'rgba(74, 158, 255, 0.5)';
ctx.font = '8px monospace';
ctx.fillText(name, sx + s + 3, sy + 3);
}
}
private drawScaleBar(w: number, h: number) {
const ctx = this.ctx;
const scale = this.getScale();
// Find a nice round AU value for the bar
let barAU = 1;
let barPx = barAU * scale;
if (barPx > 200) { barAU = 0.5; barPx = barAU * scale; }
if (barPx > 200) { barAU = 0.1; barPx = barAU * scale; }
if (barPx < 30) { barAU = 5; barPx = barAU * scale; }
if (barPx < 30) { barAU = 10; barPx = barAU * scale; }
const x = 20;
const y = h - 20;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + barPx, y);
ctx.stroke();
// End caps
ctx.beginPath();
ctx.moveTo(x, y - 4);
ctx.lineTo(x, y + 4);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + barPx, y - 4);
ctx.lineTo(x + barPx, y + 4);
ctx.stroke();
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.font = '10px monospace';
ctx.fillText(`${barAU} AU`, x + barPx / 2 - 15, y - 8);
}
destroy() {
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
}
}