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:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Rust
|
||||
target/
|
||||
Cargo.lock
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
|
||||
# WASM build output
|
||||
crates/mass-driver-wasm/pkg/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/orbital-mechanics",
|
||||
"crates/mass-driver-core",
|
||||
"crates/mass-driver-wasm",
|
||||
"crates/mass-driver-backend",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
nalgebra = "0.33"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
16
crates/mass-driver-backend/Cargo.toml
Normal file
16
crates/mass-driver-backend/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "mass-driver-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
orbital-mechanics = { path = "../orbital-mechanics" }
|
||||
mass-driver-core = { path = "../mass-driver-core" }
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tower-http = { version = "0.6", features = ["cors", "compression-gzip"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
3
crates/mass-driver-backend/src/main.rs
Normal file
3
crates/mass-driver-backend/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("mass-driver backend — not yet implemented");
|
||||
}
|
||||
13
crates/mass-driver-core/Cargo.toml
Normal file
13
crates/mass-driver-core/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "mass-driver-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
orbital-mechanics = { path = "../orbital-mechanics" }
|
||||
nalgebra = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
petgraph = "0.8"
|
||||
ordered-float = "5"
|
||||
rmp-serde = "1"
|
||||
2
crates/mass-driver-core/src/lib.rs
Normal file
2
crates/mass-driver-core/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod station;
|
||||
pub mod route;
|
||||
24
crates/mass-driver-core/src/route.rs
Normal file
24
crates/mass-driver-core/src/route.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single leg of a route between two stations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteLeg {
|
||||
pub from_station: usize,
|
||||
pub to_station: usize,
|
||||
/// Departure week index (0 = first week of simulation)
|
||||
pub departure_week: u32,
|
||||
/// Arrival week index
|
||||
pub arrival_week: u32,
|
||||
/// Weeks spent waiting at departure station before this leg
|
||||
pub wait_weeks: u32,
|
||||
}
|
||||
|
||||
/// The result of a route computation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteResult {
|
||||
pub legs: Vec<RouteLeg>,
|
||||
/// Total elapsed time in weeks from departure to final arrival
|
||||
pub total_time_weeks: u32,
|
||||
/// The week index when the package departs the origin
|
||||
pub departure_week: u32,
|
||||
}
|
||||
44
crates/mass-driver-core/src/station.rs
Normal file
44
crates/mass-driver-core/src/station.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a station is placed in the solar system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StationPlacement {
|
||||
/// Station in heliocentric orbit
|
||||
SolarOrbit {
|
||||
a: f64, // Semi-major axis (AU)
|
||||
e: f64, // Eccentricity
|
||||
i: f64, // Inclination (degrees)
|
||||
omega: f64, // Longitude of ascending node (degrees)
|
||||
w: f64, // Argument of perihelion (degrees)
|
||||
m0: f64, // Mean anomaly at epoch (degrees)
|
||||
},
|
||||
/// Station in orbit around a planet
|
||||
PlanetaryOrbit {
|
||||
parent_body_id: usize,
|
||||
altitude_km: f64,
|
||||
inclination: f64,
|
||||
},
|
||||
/// Station at a Lagrange point
|
||||
LagrangePoint {
|
||||
primary_body_id: usize, // e.g., Sun
|
||||
secondary_body_id: usize, // e.g., Earth
|
||||
point: LagrangePointId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum LagrangePointId {
|
||||
L1,
|
||||
L2,
|
||||
L3,
|
||||
L4,
|
||||
L5,
|
||||
}
|
||||
|
||||
/// A mass driver relay station.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Station {
|
||||
pub id: usize,
|
||||
pub name: String,
|
||||
pub placement: StationPlacement,
|
||||
}
|
||||
8
crates/mass-driver-wasm/.vite/deps/_metadata.json
Normal file
8
crates/mass-driver-wasm/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "bef44088",
|
||||
"configHash": "3d8fe0fc",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "7d9faa18",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
crates/mass-driver-wasm/.vite/deps/package.json
Normal file
3
crates/mass-driver-wasm/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
20
crates/mass-driver-wasm/Cargo.toml
Normal file
20
crates/mass-driver-wasm/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "mass-driver-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
orbital-mechanics = { path = "../orbital-mechanics" }
|
||||
mass-driver-core = { path = "../mass-driver-core" }
|
||||
wasm-bindgen = "0.2"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
59
crates/mass-driver-wasm/src/api.rs
Normal file
59
crates/mass-driver-wasm/src/api.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use orbital_mechanics::bodies;
|
||||
use orbital_mechanics::orbits;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Initialize the WASM module. Called once on page load.
|
||||
#[wasm_bindgen]
|
||||
pub fn init() -> Result<(), JsValue> {
|
||||
web_sys::console::log_1(&"mass-driver WASM initialized".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the number of celestial bodies in the simulation.
|
||||
#[wasm_bindgen]
|
||||
pub fn get_body_count() -> usize {
|
||||
bodies::all_bodies().len()
|
||||
}
|
||||
|
||||
/// Get positions of all celestial bodies at a given Julian Date.
|
||||
///
|
||||
/// Returns a Float64Array of [x0, y0, z0, x1, y1, z1, ...] in AU.
|
||||
#[wasm_bindgen]
|
||||
pub fn get_body_positions_at_epoch(jd: f64) -> Vec<f64> {
|
||||
let all = bodies::all_bodies();
|
||||
orbits::all_positions_at_epoch(&all, jd)
|
||||
}
|
||||
|
||||
/// Get body names as a JSON array of strings.
|
||||
#[wasm_bindgen]
|
||||
pub fn get_body_names() -> String {
|
||||
let all = bodies::all_bodies();
|
||||
let names: Vec<&str> = all.iter().map(|b| b.name).collect();
|
||||
serde_json::to_string(&names).unwrap()
|
||||
}
|
||||
|
||||
/// Get body colors as a flat array [r0, g0, b0, r1, g1, b1, ...].
|
||||
#[wasm_bindgen]
|
||||
pub fn get_body_colors() -> Vec<u8> {
|
||||
let all = bodies::all_bodies();
|
||||
all.iter().flat_map(|b| b.color.iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// Get body radii in km as a Float64Array.
|
||||
#[wasm_bindgen]
|
||||
pub fn get_body_radii() -> Vec<f64> {
|
||||
let all = bodies::all_bodies();
|
||||
all.iter().map(|b| b.radius_km).collect()
|
||||
}
|
||||
|
||||
/// Get orbit points for a given body, sampled around its full orbit.
|
||||
/// Returns a Float64Array of [x0, y0, z0, x1, y1, z1, ...] in AU.
|
||||
/// `samples` is the number of points around the orbit.
|
||||
#[wasm_bindgen]
|
||||
pub fn get_orbit_points(body_id: usize, jd: f64, samples: usize) -> Vec<f64> {
|
||||
let all = bodies::all_bodies();
|
||||
if body_id >= all.len() || body_id == bodies::id::SUN {
|
||||
return vec![];
|
||||
}
|
||||
orbits::orbit_points(&all, body_id, jd, samples)
|
||||
}
|
||||
3
crates/mass-driver-wasm/src/lib.rs
Normal file
3
crates/mass-driver-wasm/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod api;
|
||||
|
||||
pub use api::*;
|
||||
8
crates/orbital-mechanics/Cargo.toml
Normal file
8
crates/orbital-mechanics/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "orbital-mechanics"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nalgebra = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
413
crates/orbital-mechanics/src/bodies.rs
Normal file
413
crates/orbital-mechanics/src/bodies.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Keplerian orbital elements at J2000.0 epoch.
|
||||
///
|
||||
/// For planets: heliocentric, ecliptic coordinates.
|
||||
/// Values from JPL "Approximate Positions of the Planets"
|
||||
/// (https://ssd.jpl.nasa.gov/planets/approx_pos.html)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeplerianElements {
|
||||
/// Semi-major axis (AU)
|
||||
pub a: f64,
|
||||
/// Eccentricity
|
||||
pub e: f64,
|
||||
/// Inclination (degrees)
|
||||
pub i: f64,
|
||||
/// Mean longitude (degrees)
|
||||
pub l: f64,
|
||||
/// Longitude of perihelion (degrees)
|
||||
pub w_bar: f64,
|
||||
/// Longitude of ascending node (degrees)
|
||||
pub omega: f64,
|
||||
}
|
||||
|
||||
/// Rates of change of Keplerian elements per Julian century.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeplerianRates {
|
||||
pub a: f64,
|
||||
pub e: f64,
|
||||
pub i: f64,
|
||||
pub l: f64,
|
||||
pub w_bar: f64,
|
||||
pub omega: f64,
|
||||
}
|
||||
|
||||
/// A celestial body with orbital elements and physical properties.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CelestialBody {
|
||||
pub name: &'static str,
|
||||
pub id: usize,
|
||||
/// Gravitational parameter (km³/s²)
|
||||
pub mu: f64,
|
||||
/// Mean radius (km)
|
||||
pub radius_km: f64,
|
||||
/// Keplerian elements at J2000.0
|
||||
pub elements: KeplerianElements,
|
||||
/// Rates of change per century
|
||||
pub rates: KeplerianRates,
|
||||
/// Display color as [r, g, b]
|
||||
pub color: [u8; 3],
|
||||
}
|
||||
|
||||
/// Enumeration of body IDs for quick lookup.
|
||||
pub mod id {
|
||||
pub const SUN: usize = 0;
|
||||
pub const MERCURY: usize = 1;
|
||||
pub const VENUS: usize = 2;
|
||||
pub const EARTH: usize = 3;
|
||||
pub const MARS: usize = 4;
|
||||
pub const JUPITER: usize = 5;
|
||||
pub const SATURN: usize = 6;
|
||||
pub const URANUS: usize = 7;
|
||||
pub const NEPTUNE: usize = 8;
|
||||
pub const PLUTO: usize = 9;
|
||||
pub const CERES: usize = 10;
|
||||
pub const EUROPA: usize = 11;
|
||||
pub const TITAN: usize = 12;
|
||||
pub const GANYMEDE: usize = 13;
|
||||
}
|
||||
|
||||
/// Returns all celestial bodies with JPL Keplerian elements.
|
||||
///
|
||||
/// Planet elements from JPL "Approximate Positions of the Planets"
|
||||
/// valid for 3000 BC to 3000 AD.
|
||||
pub fn all_bodies() -> Vec<CelestialBody> {
|
||||
vec![
|
||||
// Sun (placeholder — no orbit, always at origin)
|
||||
CelestialBody {
|
||||
name: "Sun",
|
||||
id: id::SUN,
|
||||
mu: 1.327_124_400_41e11,
|
||||
radius_km: 695_700.0,
|
||||
elements: KeplerianElements {
|
||||
a: 0.0, e: 0.0, i: 0.0, l: 0.0, w_bar: 0.0, omega: 0.0,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.0, e: 0.0, i: 0.0, l: 0.0, w_bar: 0.0, omega: 0.0,
|
||||
},
|
||||
color: [255, 204, 0],
|
||||
},
|
||||
// Mercury
|
||||
CelestialBody {
|
||||
name: "Mercury",
|
||||
id: id::MERCURY,
|
||||
mu: 2.2032e4,
|
||||
radius_km: 2_439.7,
|
||||
elements: KeplerianElements {
|
||||
a: 0.387_099_27,
|
||||
e: 0.205_301_04,
|
||||
i: 7.004_986_8,
|
||||
l: 252.250_32,
|
||||
w_bar: 77.457_796,
|
||||
omega: 48.330_893,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.000_000_37,
|
||||
e: 0.000_010_02,
|
||||
i: -0.005_946_30,
|
||||
l: 149_472.674_11,
|
||||
w_bar: 0.160_476_24,
|
||||
omega: -0.125_340_2,
|
||||
},
|
||||
color: [183, 168, 153],
|
||||
},
|
||||
// Venus
|
||||
CelestialBody {
|
||||
name: "Venus",
|
||||
id: id::VENUS,
|
||||
mu: 3.24859e5,
|
||||
radius_km: 6_051.8,
|
||||
elements: KeplerianElements {
|
||||
a: 0.723_332_01,
|
||||
e: 0.006_773_23,
|
||||
i: 3.394_662_0,
|
||||
l: 181.979_73,
|
||||
w_bar: 131.602_467,
|
||||
omega: 76.679_920,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.000_000_60,
|
||||
e: -0.000_048_98,
|
||||
i: -0.007_889_0,
|
||||
l: 58_517.815_39,
|
||||
w_bar: 0.002_688_29,
|
||||
omega: -0.278_008_0,
|
||||
},
|
||||
color: [231, 196, 150],
|
||||
},
|
||||
// Earth
|
||||
CelestialBody {
|
||||
name: "Earth",
|
||||
id: id::EARTH,
|
||||
mu: 3.986e5,
|
||||
radius_km: 6_371.0,
|
||||
elements: KeplerianElements {
|
||||
a: 1.000_002_61,
|
||||
e: 0.016_711_23,
|
||||
i: -0.000_015_31,
|
||||
l: 100.464_572,
|
||||
w_bar: 102.937_682,
|
||||
omega: 0.0,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.000_005_62,
|
||||
e: -0.000_043_92,
|
||||
i: -0.012_946_68,
|
||||
l: 35_999.372_47,
|
||||
w_bar: 0.323_281_26,
|
||||
omega: 0.0,
|
||||
},
|
||||
color: [100, 149, 237],
|
||||
},
|
||||
// Mars
|
||||
CelestialBody {
|
||||
name: "Mars",
|
||||
id: id::MARS,
|
||||
mu: 4.2828e4,
|
||||
radius_km: 3_389.5,
|
||||
elements: KeplerianElements {
|
||||
a: 1.523_679_40,
|
||||
e: 0.093_400_62,
|
||||
i: 1.849_726_0,
|
||||
l: -4.553_432,
|
||||
w_bar: -23.943_629_5,
|
||||
omega: 49.559_539,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.000_018_47,
|
||||
e: 0.000_079_48,
|
||||
i: -0.006_813_52,
|
||||
l: 19_140.303_00,
|
||||
w_bar: 0.445_265_40,
|
||||
omega: -0.295_257_0,
|
||||
},
|
||||
color: [193, 68, 14],
|
||||
},
|
||||
// Jupiter
|
||||
CelestialBody {
|
||||
name: "Jupiter",
|
||||
id: id::JUPITER,
|
||||
mu: 1.26687e8,
|
||||
radius_km: 69_911.0,
|
||||
elements: KeplerianElements {
|
||||
a: 5.202_603_0,
|
||||
e: 0.048_498_26,
|
||||
i: 1.303_270,
|
||||
l: 34.396_441,
|
||||
w_bar: 14.728_479,
|
||||
omega: 100.473_909,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.000_016_05,
|
||||
e: 0.000_163_94,
|
||||
i: -0.001_983_72,
|
||||
l: 3_034.746_12,
|
||||
w_bar: 0.216_519_4,
|
||||
omega: 0.205_831_6,
|
||||
},
|
||||
color: [216, 165, 108],
|
||||
},
|
||||
// Saturn
|
||||
CelestialBody {
|
||||
name: "Saturn",
|
||||
id: id::SATURN,
|
||||
mu: 3.7931e7,
|
||||
radius_km: 58_232.0,
|
||||
elements: KeplerianElements {
|
||||
a: 9.554_909_6,
|
||||
e: 0.055_086_22,
|
||||
i: 2.488_878,
|
||||
l: 49.954_244,
|
||||
w_bar: 92.598_78,
|
||||
omega: 113.662_424,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: -0.000_025_06,
|
||||
e: -0.000_050_32,
|
||||
i: 0.004_530_33,
|
||||
l: 1_222.113_94,
|
||||
w_bar: -0.411_897_0,
|
||||
omega: -0.288_668_0,
|
||||
},
|
||||
color: [210, 180, 140],
|
||||
},
|
||||
// Uranus
|
||||
CelestialBody {
|
||||
name: "Uranus",
|
||||
id: id::URANUS,
|
||||
mu: 5.7940e6,
|
||||
radius_km: 25_362.0,
|
||||
elements: KeplerianElements {
|
||||
a: 19.218_446_2,
|
||||
e: 0.047_317_65,
|
||||
i: 0.773_196,
|
||||
l: 313.238_105,
|
||||
w_bar: 170.954_276,
|
||||
omega: 74.016_925,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: -0.000_198_58,
|
||||
e: -0.000_004_07,
|
||||
i: -0.002_446_1,
|
||||
l: 428.481_40,
|
||||
w_bar: 0.408_030_0,
|
||||
omega: 0.046_863_9,
|
||||
},
|
||||
color: [172, 229, 238],
|
||||
},
|
||||
// Neptune
|
||||
CelestialBody {
|
||||
name: "Neptune",
|
||||
id: id::NEPTUNE,
|
||||
mu: 6.8351e6,
|
||||
radius_km: 24_622.0,
|
||||
elements: KeplerianElements {
|
||||
a: 30.110_386_9,
|
||||
e: 0.008_590_48,
|
||||
i: 1.769_952,
|
||||
l: -55.120_029,
|
||||
w_bar: 44.964_762,
|
||||
omega: 131.784_057,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.000_006_08,
|
||||
e: 0.000_006_30,
|
||||
i: 0.000_062_6,
|
||||
l: 218.457_64,
|
||||
w_bar: -0.327_55,
|
||||
omega: -0.006_066_2,
|
||||
},
|
||||
color: [63, 84, 186],
|
||||
},
|
||||
// Pluto (approximate elements)
|
||||
CelestialBody {
|
||||
name: "Pluto",
|
||||
id: id::PLUTO,
|
||||
mu: 8.71e2,
|
||||
radius_km: 1_188.3,
|
||||
elements: KeplerianElements {
|
||||
a: 39.481_686_77,
|
||||
e: 0.248_905_87,
|
||||
i: 17.140_175,
|
||||
l: 238.928_06,
|
||||
w_bar: 224.068_76,
|
||||
omega: 110.303_47,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: -0.007_608_12,
|
||||
e: 0.000_060_65,
|
||||
i: 0.003_681_9,
|
||||
l: 145.205_80,
|
||||
w_bar: -0.045_07,
|
||||
omega: -0.018_16,
|
||||
},
|
||||
color: [190, 180, 170],
|
||||
},
|
||||
// Ceres (approximate)
|
||||
CelestialBody {
|
||||
name: "Ceres",
|
||||
id: id::CERES,
|
||||
mu: 63.13,
|
||||
radius_km: 469.73,
|
||||
elements: KeplerianElements {
|
||||
a: 2.769_1,
|
||||
e: 0.075_8,
|
||||
i: 10.594,
|
||||
l: 153.94,
|
||||
w_bar: 73.597 + 80.329,
|
||||
omega: 80.329,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.0,
|
||||
e: 0.0,
|
||||
i: 0.0,
|
||||
l: 78_362.73, // ~4.6 yr period
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
color: [148, 137, 121],
|
||||
},
|
||||
// Europa (orbits Jupiter — elements are Jovicentric)
|
||||
// For visualization, we compute Jupiter's position and add Europa's offset
|
||||
CelestialBody {
|
||||
name: "Europa",
|
||||
id: id::EUROPA,
|
||||
mu: 3_202.7,
|
||||
radius_km: 1_560.8,
|
||||
elements: KeplerianElements {
|
||||
a: 0.004_486, // ~671,100 km in AU
|
||||
e: 0.009_4,
|
||||
i: 0.47,
|
||||
l: 171.016,
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.0,
|
||||
e: 0.0,
|
||||
i: 0.0,
|
||||
l: 101_375.3, // ~3.55 day period
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
color: [200, 190, 160],
|
||||
},
|
||||
// Titan (orbits Saturn)
|
||||
CelestialBody {
|
||||
name: "Titan",
|
||||
id: id::TITAN,
|
||||
mu: 8_978.1,
|
||||
radius_km: 2_574.7,
|
||||
elements: KeplerianElements {
|
||||
a: 0.008_168, // ~1,221,870 km in AU
|
||||
e: 0.028_8,
|
||||
i: 0.34,
|
||||
l: 120.0,
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.0,
|
||||
e: 0.0,
|
||||
i: 0.0,
|
||||
l: 22_577.0, // ~15.95 day period
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
color: [230, 190, 100],
|
||||
},
|
||||
// Ganymede (orbits Jupiter)
|
||||
CelestialBody {
|
||||
name: "Ganymede",
|
||||
id: id::GANYMEDE,
|
||||
mu: 9_887.8,
|
||||
radius_km: 2_634.1,
|
||||
elements: KeplerianElements {
|
||||
a: 0.007_155, // ~1,070,400 km in AU
|
||||
e: 0.001_3,
|
||||
i: 0.20,
|
||||
l: 317.54,
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
rates: KeplerianRates {
|
||||
a: 0.0,
|
||||
e: 0.0,
|
||||
i: 0.0,
|
||||
l: 50_317.6, // ~7.15 day period
|
||||
w_bar: 0.0,
|
||||
omega: 0.0,
|
||||
},
|
||||
color: [160, 145, 130],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns the parent body ID for moons (None for planets/Sun).
|
||||
pub fn parent_body(body_id: usize) -> Option<usize> {
|
||||
match body_id {
|
||||
id::EUROPA | id::GANYMEDE => Some(id::JUPITER),
|
||||
id::TITAN => Some(id::SATURN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
26
crates/orbital-mechanics/src/constants.rs
Normal file
26
crates/orbital-mechanics/src/constants.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
/// Gravitational parameter of the Sun (km³/s²)
|
||||
pub const MU_SUN: f64 = 1.327_124_400_41e11;
|
||||
|
||||
/// One Astronomical Unit in kilometers
|
||||
pub const AU_KM: f64 = 1.495_978_707e8;
|
||||
|
||||
/// Seconds per day
|
||||
pub const SECONDS_PER_DAY: f64 = 86_400.0;
|
||||
|
||||
/// Days per Julian century
|
||||
pub const DAYS_PER_CENTURY: f64 = 36_525.0;
|
||||
|
||||
/// J2000.0 epoch in Julian Date
|
||||
pub const J2000_JD: f64 = 2_451_545.0;
|
||||
|
||||
/// Days per week
|
||||
pub const DAYS_PER_WEEK: f64 = 7.0;
|
||||
|
||||
/// Pi
|
||||
pub const PI: f64 = std::f64::consts::PI;
|
||||
|
||||
/// Two Pi
|
||||
pub const TAU: f64 = std::f64::consts::TAU;
|
||||
|
||||
/// Degrees to radians
|
||||
pub const DEG_TO_RAD: f64 = PI / 180.0;
|
||||
56
crates/orbital-mechanics/src/kepler.rs
Normal file
56
crates/orbital-mechanics/src/kepler.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::constants::TAU;
|
||||
|
||||
/// Solve Kepler's equation M = E - e*sin(E) for eccentric anomaly E.
|
||||
///
|
||||
/// Uses Newton-Raphson iteration. Converges in 3-6 iterations for
|
||||
/// eccentricities < 0.9. All angles in radians.
|
||||
pub fn solve_kepler(mean_anomaly: f64, eccentricity: f64) -> f64 {
|
||||
let m = mean_anomaly % TAU;
|
||||
let e = eccentricity;
|
||||
|
||||
// Initial guess (Markley's starting value for better convergence)
|
||||
let mut ea = if e < 0.8 {
|
||||
m + e * m.sin()
|
||||
} else {
|
||||
std::f64::consts::PI
|
||||
};
|
||||
|
||||
// Newton-Raphson iteration
|
||||
for _ in 0..50 {
|
||||
let delta = (ea - e * ea.sin() - m) / (1.0 - e * ea.cos());
|
||||
ea -= delta;
|
||||
if delta.abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ea
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_circular_orbit() {
|
||||
// For e=0, E should equal M
|
||||
let m = 1.0;
|
||||
let e = solve_kepler(m, 0.0);
|
||||
assert!((e - m).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_values() {
|
||||
// For M=π, any eccentricity, E should be π
|
||||
let e = solve_kepler(std::f64::consts::PI, 0.5);
|
||||
assert!((e - std::f64::consts::PI).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_high_eccentricity() {
|
||||
let ea = solve_kepler(1.0, 0.9);
|
||||
// Verify it satisfies Kepler's equation
|
||||
let m_check = ea - 0.9 * ea.sin();
|
||||
assert!((m_check - 1.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
6
crates/orbital-mechanics/src/lib.rs
Normal file
6
crates/orbital-mechanics/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod bodies;
|
||||
pub mod constants;
|
||||
pub mod kepler;
|
||||
pub mod orbits;
|
||||
|
||||
pub use nalgebra::Vector3;
|
||||
192
crates/orbital-mechanics/src/orbits.rs
Normal file
192
crates/orbital-mechanics/src/orbits.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use crate::bodies::{self, CelestialBody};
|
||||
use crate::constants::*;
|
||||
use crate::kepler::solve_kepler;
|
||||
use nalgebra::Vector3;
|
||||
|
||||
/// Compute the heliocentric ecliptic position of a body at a given Julian Date.
|
||||
///
|
||||
/// Returns position in AU in the ecliptic coordinate frame:
|
||||
/// - X toward vernal equinox
|
||||
/// - Y in ecliptic plane, 90° from X
|
||||
/// - Z toward ecliptic north pole
|
||||
///
|
||||
/// For moons, returns the heliocentric position (parent position + moon offset).
|
||||
pub fn position_at_epoch(bodies: &[CelestialBody], body_id: usize, jd: f64) -> Vector3<f64> {
|
||||
let body = &bodies[body_id];
|
||||
|
||||
// Sun is at origin
|
||||
if body_id == bodies::id::SUN {
|
||||
return Vector3::zeros();
|
||||
}
|
||||
|
||||
// Centuries since J2000.0
|
||||
let t = (jd - J2000_JD) / DAYS_PER_CENTURY;
|
||||
|
||||
// Compute current elements
|
||||
let a = body.elements.a + body.rates.a * t;
|
||||
let e = body.elements.e + body.rates.e * t;
|
||||
let i = (body.elements.i + body.rates.i * t) * DEG_TO_RAD;
|
||||
let l = (body.elements.l + body.rates.l * t) * DEG_TO_RAD;
|
||||
let w_bar = (body.elements.w_bar + body.rates.w_bar * t) * DEG_TO_RAD;
|
||||
let omega = (body.elements.omega + body.rates.omega * t) * DEG_TO_RAD;
|
||||
|
||||
// Argument of perihelion
|
||||
let w = w_bar - omega;
|
||||
|
||||
// Mean anomaly
|
||||
let m = l - w_bar;
|
||||
|
||||
// Solve Kepler's equation for eccentric anomaly
|
||||
let ea = solve_kepler(m, e);
|
||||
|
||||
// True anomaly
|
||||
let nu = 2.0 * ((1.0 + e).sqrt() * (ea / 2.0).sin())
|
||||
.atan2((1.0 - e).sqrt() * (ea / 2.0).cos());
|
||||
|
||||
// Distance from focus
|
||||
let r = a * (1.0 - e * ea.cos());
|
||||
|
||||
// Position in orbital plane
|
||||
let x_orb = r * nu.cos();
|
||||
let y_orb = r * nu.sin();
|
||||
|
||||
// Rotate to ecliptic coordinates
|
||||
let cos_w = w.cos();
|
||||
let sin_w = w.sin();
|
||||
let cos_o = omega.cos();
|
||||
let sin_o = omega.sin();
|
||||
let cos_i = i.cos();
|
||||
let sin_i = i.sin();
|
||||
|
||||
let x_ecl = (cos_w * cos_o - sin_w * sin_o * cos_i) * x_orb
|
||||
+ (-sin_w * cos_o - cos_w * sin_o * cos_i) * y_orb;
|
||||
let y_ecl = (cos_w * sin_o + sin_w * cos_o * cos_i) * x_orb
|
||||
+ (-sin_w * sin_o + cos_w * cos_o * cos_i) * y_orb;
|
||||
let z_ecl = (sin_w * sin_i) * x_orb + (cos_w * sin_i) * y_orb;
|
||||
|
||||
let pos = Vector3::new(x_ecl, y_ecl, z_ecl);
|
||||
|
||||
// For moons, add parent body position
|
||||
if let Some(parent_id) = bodies::parent_body(body_id) {
|
||||
let parent_pos = position_at_epoch(bodies, parent_id, jd);
|
||||
parent_pos + pos
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute positions of all bodies at a given epoch.
|
||||
/// Returns a flat Vec of [x, y, z, x, y, z, ...] in AU.
|
||||
pub fn all_positions_at_epoch(bodies: &[CelestialBody], jd: f64) -> Vec<f64> {
|
||||
let mut result = Vec::with_capacity(bodies.len() * 3);
|
||||
for i in 0..bodies.len() {
|
||||
let pos = position_at_epoch(bodies, i, jd);
|
||||
result.push(pos.x);
|
||||
result.push(pos.y);
|
||||
result.push(pos.z);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Sample points around a body's orbit for drawing orbit ellipses.
|
||||
/// For planets, samples one full heliocentric orbit.
|
||||
/// For moons, samples one full orbit around the parent and adds parent position.
|
||||
/// Returns flat [x, y, z, x, y, z, ...] in AU.
|
||||
pub fn orbit_points(
|
||||
bodies: &[CelestialBody],
|
||||
body_id: usize,
|
||||
jd: f64,
|
||||
samples: usize,
|
||||
) -> Vec<f64> {
|
||||
let body = &bodies[body_id];
|
||||
let t = (jd - J2000_JD) / DAYS_PER_CENTURY;
|
||||
|
||||
let a = body.elements.a + body.rates.a * t;
|
||||
let e = body.elements.e + body.rates.e * t;
|
||||
let i = (body.elements.i + body.rates.i * t) * DEG_TO_RAD;
|
||||
let w_bar = (body.elements.w_bar + body.rates.w_bar * t) * DEG_TO_RAD;
|
||||
let omega = (body.elements.omega + body.rates.omega * t) * DEG_TO_RAD;
|
||||
let w = w_bar - omega;
|
||||
|
||||
let cos_w = w.cos();
|
||||
let sin_w = w.sin();
|
||||
let cos_o = omega.cos();
|
||||
let sin_o = omega.sin();
|
||||
let cos_i = i.cos();
|
||||
let sin_i = i.sin();
|
||||
|
||||
// Parent offset for moons
|
||||
let parent_pos = if let Some(parent_id) = bodies::parent_body(body_id) {
|
||||
position_at_epoch(bodies, parent_id, jd)
|
||||
} else {
|
||||
Vector3::zeros()
|
||||
};
|
||||
|
||||
let mut result = Vec::with_capacity(samples * 3);
|
||||
for s in 0..samples {
|
||||
let nu = TAU * (s as f64) / (samples as f64);
|
||||
let r = a * (1.0 - e * e) / (1.0 + e * nu.cos());
|
||||
|
||||
let x_orb = r * nu.cos();
|
||||
let y_orb = r * nu.sin();
|
||||
|
||||
let x = (cos_w * cos_o - sin_w * sin_o * cos_i) * x_orb
|
||||
+ (-sin_w * cos_o - cos_w * sin_o * cos_i) * y_orb
|
||||
+ parent_pos.x;
|
||||
let y = (cos_w * sin_o + sin_w * cos_o * cos_i) * x_orb
|
||||
+ (-sin_w * sin_o + cos_w * cos_o * cos_i) * y_orb
|
||||
+ parent_pos.y;
|
||||
let z = (sin_w * sin_i) * x_orb + (cos_w * sin_i) * y_orb + parent_pos.z;
|
||||
|
||||
result.push(x);
|
||||
result.push(y);
|
||||
result.push(z);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bodies::all_bodies;
|
||||
|
||||
#[test]
|
||||
fn test_sun_at_origin() {
|
||||
let bodies = all_bodies();
|
||||
let pos = position_at_epoch(&bodies, bodies::id::SUN, J2000_JD);
|
||||
assert!(pos.norm() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_earth_distance_from_sun() {
|
||||
let bodies = all_bodies();
|
||||
let pos = position_at_epoch(&bodies, bodies::id::EARTH, J2000_JD);
|
||||
let distance_au = pos.norm();
|
||||
// Earth should be ~1 AU from the Sun
|
||||
assert!(
|
||||
(distance_au - 1.0).abs() < 0.02,
|
||||
"Earth distance: {} AU",
|
||||
distance_au
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jupiter_distance() {
|
||||
let bodies = all_bodies();
|
||||
let pos = position_at_epoch(&bodies, bodies::id::JUPITER, J2000_JD);
|
||||
let distance_au = pos.norm();
|
||||
// Jupiter should be ~5.2 AU
|
||||
assert!(
|
||||
(distance_au - 5.2).abs() < 0.3,
|
||||
"Jupiter distance: {} AU",
|
||||
distance_au
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_positions_length() {
|
||||
let bodies = all_bodies();
|
||||
let positions = all_positions_at_epoch(&bodies, J2000_JD);
|
||||
assert_eq!(positions.len(), bodies.len() * 3);
|
||||
}
|
||||
}
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
2826
frontend/package-lock.json
generated
Normal file
2826
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/three": "^0.183.1",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"mass-driver-wasm": "file:../crates/mass-driver-wasm/pkg",
|
||||
"@threlte/core": "^8.5.8",
|
||||
"@threlte/extras": "^9.14.5",
|
||||
"@threlte/flex": "^2.2.2",
|
||||
"astronomy-engine": "^2.1.19",
|
||||
"postprocessing": "^6.39.0",
|
||||
"three": "^0.183.2",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
}
|
||||
}
|
||||
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;
|
||||
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
17
frontend/svelte.config.js
Normal file
17
frontend/svelte.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import wasm from 'vite-plugin-wasm';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
wasm(),
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
],
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['mass-driver-wasm'],
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user