Files
mass-driver/frontend/src/lib/render/canvas2d/SolarSystem2D.ts
scott 5efe0736ac Initial project setup: Rust/WASM solar system simulator with SvelteKit frontend
- Rust workspace with 4 crates: orbital-mechanics, mass-driver-core, mass-driver-wasm, mass-driver-backend
- Keplerian orbital mechanics engine with JPL elements for 14 bodies (Sun, 8 planets, Pluto, Ceres, Europa, Titan, Ganymede)
- Kepler equation solver and orbital position computation compiled to WASM
- SvelteKit frontend with Tailwind CSS, Canvas2D renderer showing animated solar system
- Orbit ellipses, planet dots with labels, Sun glow, grid, scale bar, pan/zoom controls
- Time controls (play/pause, 5 speed levels, date picker) driving live simulation
- 2D/3D view toggle (3D placeholder for Threlte integration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:06:30 -07:00

285 lines
7.7 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 orbitPoints: Map<number, Float64Array> = new Map();
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) {
this.bodyInfos = infos;
this.positions = positions;
}
updateOrbit(bodyId: number, points: Float64Array) {
this.orbitPoints.set(bodyId, points);
}
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(x, y, info);
}
}
}
// 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(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;
// 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 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);
}
}