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:
22
frontend/src/app.css
Normal file
22
frontend/src/app.css
Normal 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
13
frontend/src/app.d.ts
vendored
Normal 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
12
frontend/src/app.html
Normal 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>
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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 |
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
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);
|
||||
}
|
||||
}
|
||||
84
frontend/src/lib/stores/simulation.svelte.ts
Normal file
84
frontend/src/lib/stores/simulation.svelte.ts
Normal 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;
|
||||
}
|
||||
7
frontend/src/lib/utils/colors.ts
Normal file
7
frontend/src/lib/utils/colors.ts
Normal 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})`;
|
||||
}
|
||||
17
frontend/src/lib/utils/format.ts
Normal file
17
frontend/src/lib/utils/format.ts
Normal 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`;
|
||||
}
|
||||
35
frontend/src/lib/wasm/bridge.ts
Normal file
35
frontend/src/lib/wasm/bridge.ts
Normal 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);
|
||||
}
|
||||
25
frontend/src/lib/wasm/loader.ts
Normal file
25
frontend/src/lib/wasm/loader.ts
Normal 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;
|
||||
}
|
||||
5
frontend/src/lib/wasm/types.ts
Normal file
5
frontend/src/lib/wasm/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface BodyInfo {
|
||||
name: string;
|
||||
color: [number, number, number];
|
||||
radius_km: number;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
26
frontend/src/routes/(simulator)/components/ViewToggle.svelte
Normal file
26
frontend/src/routes/(simulator)/components/ViewToggle.svelte
Normal 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>
|
||||
16
frontend/src/routes/(simulator)/components/Viewport.svelte
Normal file
16
frontend/src/routes/(simulator)/components/Viewport.svelte
Normal 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>
|
||||
13
frontend/src/routes/+layout.svelte
Normal file
13
frontend/src/routes/+layout.svelte
Normal 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()}
|
||||
64
frontend/src/routes/+page.svelte
Normal file
64
frontend/src/routes/+page.svelte
Normal 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>
|
||||
1
frontend/src/routes/+page.ts
Normal file
1
frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user