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>
This commit is contained in:
284
frontend/src/lib/render/canvas2d/SolarSystem2D.ts
Normal file
284
frontend/src/lib/render/canvas2d/SolarSystem2D.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user