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:
2026-04-07 22:06:30 -07:00
commit 5efe0736ac
45 changed files with 4626 additions and 0 deletions

22
frontend/src/app.css Normal file
View File

@@ -0,0 +1,22 @@
@import 'tailwindcss';
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-panel: #1a1a2e;
--text-primary: #e0e0e8;
--text-secondary: #8888a0;
--accent-blue: #4a9eff;
--accent-orange: #ff6a33;
--accent-green: #33ff88;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
frontend/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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);
}
}

View File

@@ -0,0 +1,84 @@
import type { BodyInfo } from '$lib/wasm/types';
/// J2000.0 epoch = 2000-01-01 12:00 TT
const J2000_JD = 2451545.0;
export type ViewMode = '2d' | '3d';
class SimulationState {
// Time
currentJD = $state(J2000_JD);
playbackSpeed = $state(1.0); // weeks per second of real time
isPlaying = $state(false);
// Bodies
bodyInfos = $state<BodyInfo[]>([]);
bodyPositions = $state<Float64Array>(new Float64Array(0));
// View
viewMode = $state<ViewMode>('2d');
// WASM ready
wasmReady = $state(false);
// Derived
get currentDateStr(): string {
return jdToCalendarDate(this.currentJD);
}
get currentWeekIndex(): number {
return Math.floor((this.currentJD - J2000_JD) / 7);
}
advanceTime(dtSeconds: number) {
if (!this.isPlaying) return;
// playbackSpeed is in weeks/second, 1 week = 7 days
this.currentJD += this.playbackSpeed * 7 * dtSeconds;
}
setDate(year: number, month: number, day: number) {
this.currentJD = calendarToJD(year, month, day);
}
togglePlay() {
this.isPlaying = !this.isPlaying;
}
}
export const simulation = new SimulationState();
// Julian Date <-> Calendar conversions
function jdToCalendarDate(jd: number): string {
// Algorithm from Meeus, "Astronomical Algorithms"
const z = Math.floor(jd + 0.5);
const f = jd + 0.5 - z;
let a: number;
if (z < 2299161) {
a = z;
} else {
const alpha = Math.floor((z - 1867216.25) / 36524.25);
a = z + 1 + alpha - Math.floor(alpha / 4);
}
const b = a + 1524;
const c = Math.floor((b - 122.1) / 365.25);
const d = Math.floor(365.25 * c);
const e = Math.floor((b - d) / 30.6001);
const day = b - d - Math.floor(30.6001 * e) + f;
const month = e < 14 ? e - 1 : e - 13;
const year = month > 2 ? c - 4716 : c - 4715;
return `${year}-${String(month).padStart(2, '0')}-${String(Math.floor(day)).padStart(2, '0')}`;
}
function calendarToJD(year: number, month: number, day: number): number {
let y = year;
let m = month;
if (m <= 2) {
y -= 1;
m += 12;
}
const a = Math.floor(y / 100);
const b = 2 - a + Math.floor(a / 4);
return Math.floor(365.25 * (y + 4716)) + Math.floor(30.6001 * (m + 1)) + day + b - 1524.5;
}

View File

@@ -0,0 +1,7 @@
export function rgbToHex(r: number, g: number, b: number): string {
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
}
export function rgbToCss(r: number, g: number, b: number): string {
return `rgb(${r}, ${g}, ${b})`;
}

View File

@@ -0,0 +1,17 @@
export function formatAU(au: number): string {
if (Math.abs(au) < 0.01) {
return `${(au * 149_597_870.7).toFixed(0)} km`;
}
return `${au.toFixed(3)} AU`;
}
export function formatWeeks(weeks: number): string {
if (weeks < 4) return `${weeks.toFixed(1)} weeks`;
if (weeks < 52) return `${(weeks / 4.345).toFixed(1)} months`;
return `${(weeks / 52.177).toFixed(1)} years`;
}
export function formatVelocity(kms: number): string {
if (kms < 1) return `${(kms * 1000).toFixed(0)} m/s`;
return `${kms.toFixed(1)} km/s`;
}

View File

@@ -0,0 +1,35 @@
import { getWasm } from './loader';
import type { BodyInfo } from './types';
export function getBodyPositions(jd: number): Float64Array {
const wasm = getWasm();
if (!wasm) return new Float64Array(0);
return wasm.get_body_positions_at_epoch(jd);
}
export function getBodyCount(): number {
const wasm = getWasm();
if (!wasm) return 0;
return wasm.get_body_count();
}
export function getBodyInfos(): BodyInfo[] {
const wasm = getWasm();
if (!wasm) return [];
const names: string[] = JSON.parse(wasm.get_body_names());
const colors = wasm.get_body_colors();
const radii = wasm.get_body_radii();
return names.map((name, i) => ({
name,
color: [colors[i * 3], colors[i * 3 + 1], colors[i * 3 + 2]] as [number, number, number],
radius_km: radii[i],
}));
}
export function getOrbitPoints(bodyId: number, jd: number, samples: number = 180): Float64Array {
const wasm = getWasm();
if (!wasm) return new Float64Array(0);
return wasm.get_orbit_points(bodyId, jd, samples);
}

View File

@@ -0,0 +1,25 @@
import type * as WasmTypes from 'mass-driver-wasm';
type WasmExports = typeof WasmTypes;
let wasmModule: WasmExports | null = null;
let initPromise: Promise<WasmExports> | null = null;
export async function initWasm(): Promise<WasmExports> {
if (wasmModule) return wasmModule;
if (initPromise) return initPromise;
initPromise = (async () => {
const mod = await import('mass-driver-wasm');
await mod.default();
mod.init();
wasmModule = mod;
return mod;
})();
return initPromise;
}
export function getWasm(): WasmExports | null {
return wasmModule;
}

View File

@@ -0,0 +1,5 @@
export interface BodyInfo {
name: string;
color: [number, number, number];
radius_km: number;
}

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D';
import { simulation } from '$lib/stores/simulation.svelte';
import { getBodyPositions, getBodyInfos, getOrbitPoints } from '$lib/wasm/bridge';
let canvas: HTMLCanvasElement;
let renderer: SolarSystem2DRenderer;
let animFrameId: number;
let orbitsComputed = false;
function computeOrbits() {
const bodyCount = simulation.bodyInfos.length;
for (let i = 1; i < bodyCount; i++) {
const points = getOrbitPoints(i, simulation.currentJD, 360);
if (points.length > 0) {
renderer.updateOrbit(i, points);
}
}
orbitsComputed = true;
}
onMount(() => {
renderer = new SolarSystem2DRenderer(canvas);
if (simulation.wasmReady) {
simulation.bodyInfos = getBodyInfos();
}
let lastTime = performance.now();
let frameCount = 0;
function mainLoop() {
const now = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
simulation.advanceTime(dt);
if (simulation.wasmReady) {
simulation.bodyPositions = getBodyPositions(simulation.currentJD);
renderer.updateBodies(simulation.bodyInfos, simulation.bodyPositions);
// Compute orbits on first frame and refresh every 600 frames (~10s)
if (!orbitsComputed || frameCount % 600 === 0) {
computeOrbits();
}
}
renderer.render();
frameCount++;
animFrameId = requestAnimationFrame(mainLoop);
}
mainLoop();
});
onDestroy(() => {
if (animFrameId) cancelAnimationFrame(animFrameId);
renderer?.destroy();
});
</script>
<canvas
bind:this={canvas}
class="w-full h-full block"
style="cursor: grab;"
></canvas>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { simulation } from '$lib/stores/simulation.svelte';
const speedOptions = [
{ label: '1x', value: 1 },
{ label: '4x', value: 4 },
{ label: '12x', value: 12 },
{ label: '52x', value: 52 },
{ label: '260x', value: 260 },
];
function handleDateInput(e: Event) {
const input = e.target as HTMLInputElement;
const [year, month, day] = input.value.split('-').map(Number);
if (year && month && day) {
simulation.setDate(year, month, day);
}
}
</script>
<div class="flex items-center gap-3 px-4 py-2 bg-[var(--bg-panel)] rounded-lg border border-white/5">
<!-- Play/Pause -->
<button
onclick={() => simulation.togglePlay()}
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10 transition-colors"
title={simulation.isPlaying ? 'Pause' : 'Play'}
>
{#if simulation.isPlaying}
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="2" y="1" width="3.5" height="12" rx="1" />
<rect x="8.5" y="1" width="3.5" height="12" rx="1" />
</svg>
{:else}
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<polygon points="2,1 12,7 2,13" />
</svg>
{/if}
</button>
<!-- Speed selector -->
<div class="flex gap-1">
{#each speedOptions as opt}
<button
onclick={() => { simulation.playbackSpeed = opt.value; }}
class="px-2 py-0.5 text-xs rounded transition-colors {simulation.playbackSpeed === opt.value
? 'bg-[var(--accent-blue)] text-white'
: 'text-[var(--text-secondary)] hover:bg-white/5'}"
>
{opt.label}
</button>
{/each}
</div>
<!-- Separator -->
<div class="w-px h-5 bg-white/10"></div>
<!-- Date display -->
<div class="flex items-center gap-2">
<span class="text-xs text-[var(--text-secondary)]">Date:</span>
<input
type="date"
value={simulation.currentDateStr}
onchange={handleDateInput}
class="bg-transparent text-sm text-[var(--text-primary)] border border-white/10 rounded px-2 py-0.5 [color-scheme:dark]"
/>
</div>
<!-- Week index -->
<span class="text-xs text-[var(--text-secondary)] ml-auto">
Week {simulation.currentWeekIndex.toLocaleString()}
</span>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { simulation, type ViewMode } from '$lib/stores/simulation.svelte';
function setView(mode: ViewMode) {
simulation.viewMode = mode;
}
</script>
<div class="flex bg-[var(--bg-panel)] rounded-lg border border-white/5 overflow-hidden">
<button
onclick={() => setView('2d')}
class="px-3 py-1.5 text-xs font-medium transition-colors {simulation.viewMode === '2d'
? 'bg-[var(--accent-blue)] text-white'
: 'text-[var(--text-secondary)] hover:bg-white/5'}"
>
2D
</button>
<button
onclick={() => setView('3d')}
class="px-3 py-1.5 text-xs font-medium transition-colors {simulation.viewMode === '3d'
? 'bg-[var(--accent-blue)] text-white'
: 'text-[var(--text-secondary)] hover:bg-white/5'}"
>
3D
</button>
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { simulation } from '$lib/stores/simulation.svelte';
import Canvas2DView from './Canvas2DView.svelte';
// 3D view will be added in Phase 3
</script>
<div class="relative w-full h-full">
{#if simulation.viewMode === '2d'}
<Canvas2DView />
{:else}
<div class="w-full h-full flex items-center justify-center text-[var(--text-secondary)]">
<p class="text-sm">3D view coming soon...</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<title>Mass Driver — Interplanetary Relay Network</title>
</svelte:head>
{@render children()}

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { onMount } from 'svelte';
import { simulation } from '$lib/stores/simulation.svelte';
import { initWasm } from '$lib/wasm/loader';
import { getBodyInfos } from '$lib/wasm/bridge';
import Viewport from './(simulator)/components/Viewport.svelte';
import TimeControls from './(simulator)/components/TimeControls.svelte';
import ViewToggle from './(simulator)/components/ViewToggle.svelte';
let wasmError = $state('');
onMount(async () => {
try {
await initWasm();
simulation.bodyInfos = getBodyInfos();
simulation.wasmReady = true;
// Start in Jan 2025 for a recognizable view
simulation.setDate(2025, 1, 1);
simulation.isPlaying = true;
} catch (e) {
wasmError = String(e);
console.error('Failed to load WASM module:', e);
}
});
</script>
<div class="h-screen w-screen flex flex-col bg-[var(--bg-primary)]">
<!-- Top bar -->
<header class="flex items-center justify-between px-4 py-2 bg-[var(--bg-secondary)] border-b border-white/5 shrink-0">
<div class="flex items-center gap-3">
<h1 class="text-sm font-bold tracking-wide text-[var(--text-primary)]">
MASS DRIVER
</h1>
<span class="text-xs text-[var(--text-secondary)]">Interplanetary Relay Network</span>
</div>
<ViewToggle />
</header>
<!-- Main content -->
<main class="flex-1 relative overflow-hidden">
{#if wasmError}
<div class="absolute inset-0 flex items-center justify-center">
<div class="bg-red-900/50 border border-red-500/30 rounded-lg p-6 max-w-md">
<h2 class="text-red-400 font-bold mb-2">Failed to load simulation engine</h2>
<p class="text-sm text-red-300/80">{wasmError}</p>
</div>
</div>
{:else if !simulation.wasmReady}
<div class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<div class="w-8 h-8 border-2 border-[var(--accent-blue)] border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p class="text-sm text-[var(--text-secondary)]">Initializing simulation engine...</p>
</div>
</div>
{:else}
<Viewport />
{/if}
</main>
<!-- Bottom bar: time controls -->
<footer class="px-4 py-2 bg-[var(--bg-secondary)] border-t border-white/5 shrink-0">
<TimeControls />
</footer>
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;