- 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>
419 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|