Add Lambert solver, transfer matrix, Dijkstra routing, and route planner UI

- Lambert's problem solver using universal variable method with bisection
  (handles elliptic, parabolic, hyperbolic transfers + anti-podal cases)
- Transfer matrix: precompute pairwise station transfers over time window
  using Lambert solver with configurable launch velocity
- Dijkstra routing on time-expanded graph (station × week nodes, transfer
  + wait edges) to find minimum-time routes
- Route Planner UI: from/to station dropdowns, search window selector
  (1-10 years), "Find Optimal Route" button with results card
- Route visualization: orange dashed trajectory lines with arrow heads
  and leg numbers on the 2D canvas
- Tested: Mercury L1 → Jupiter L1 computes 6-month direct transfer at
  30 km/s — physically reasonable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:14:37 -07:00
parent a2daa2d617
commit 22dcc5b6ec
12 changed files with 977 additions and 2 deletions

View File

@@ -1,2 +1,4 @@
pub mod station;
pub mod route; pub mod route;
pub mod router;
pub mod station;
pub mod transfer_matrix;

View File

@@ -0,0 +1,187 @@
use crate::route::{RouteLeg, RouteResult};
use crate::transfer_matrix::TransferMatrix;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
/// Find the optimal route between two stations using Dijkstra on
/// the time-expanded graph.
///
/// Nodes: (station_id, week_index)
/// Transfer edges: based on precomputed transfer matrix
/// Wait edges: (station, t) → (station, t+1) with cost 1 week
///
/// # Arguments
/// * `matrix` - Precomputed transfer matrix
/// * `from` - Source station index
/// * `to` - Destination station index
/// * `earliest_week` - Earliest allowed departure week
/// * `latest_week` - Latest allowed departure week
///
/// # Returns
/// The optimal route, or None if no route exists.
pub fn find_optimal_route(
matrix: &TransferMatrix,
from: usize,
to: usize,
earliest_week: usize,
latest_week: usize,
) -> Option<RouteResult> {
let n = matrix.station_count;
let t_max = matrix.week_count;
if from >= n || to >= n || from == to {
return None;
}
let latest_week = latest_week.min(t_max - 1);
if earliest_week > latest_week {
return None;
}
// Node encoding: node_id = station * t_max + week
let node_count = n * t_max;
let mut dist = vec![u32::MAX; node_count];
let mut prev: Vec<Option<usize>> = vec![None; node_count];
// Min-heap: (cost, node_id)
let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::new();
// Seed: all departure times from the source station
for week in earliest_week..=latest_week {
let node = from * t_max + week;
let cost = (week - earliest_week) as u32; // Waiting at origin costs time
dist[node] = cost;
heap.push(Reverse((cost, node)));
}
// Build a lookup for transfer matrix entries by (from, departure_week)
// for faster access during Dijkstra
let mut transfer_lookup: Vec<Vec<(u16, u16)>> = vec![Vec::new(); n * t_max];
for entry in &matrix.entries {
let key = entry.from as usize * t_max + entry.departure_week as usize;
if key < transfer_lookup.len() {
transfer_lookup[key].push((entry.to, entry.travel_weeks));
}
}
while let Some(Reverse((cost, u))) = heap.pop() {
let u_station = u / t_max;
let u_week = u % t_max;
// Found destination — reconstruct path
if u_station == to {
return Some(reconstruct_route(matrix, &prev, u, earliest_week, t_max));
}
// Skip stale entries
if cost > dist[u] {
continue;
}
// Wait edge: stay at this station for one more week
if u_week + 1 < t_max {
let v = u + 1; // Same station, next week
let new_cost = cost + 1;
if new_cost < dist[v] {
dist[v] = new_cost;
prev[v] = Some(u);
heap.push(Reverse((new_cost, v)));
}
}
// Transfer edges from this station at this week
let key = u_station * t_max + u_week;
if key < transfer_lookup.len() {
for &(to_station, travel_weeks) in &transfer_lookup[key] {
let arrival_week = u_week + travel_weeks as usize;
if arrival_week < t_max {
let v = to_station as usize * t_max + arrival_week;
let new_cost = cost + travel_weeks as u32;
if new_cost < dist[v] {
dist[v] = new_cost;
prev[v] = Some(u);
heap.push(Reverse((new_cost, v)));
}
}
}
}
}
None // No route found
}
fn reconstruct_route(
_matrix: &TransferMatrix,
prev: &[Option<usize>],
end_node: usize,
earliest_week: usize,
t_max: usize,
) -> RouteResult {
let mut path = Vec::new();
let mut node = end_node;
// Trace back through prev pointers
loop {
path.push(node);
match prev[node] {
Some(p) => node = p,
None => break,
}
}
path.reverse();
// Convert path to route legs
let mut legs = Vec::new();
let mut i = 0;
while i < path.len() {
let station = path[i] / t_max;
let _week = path[i] % t_max;
// Find next different station in path
let mut j = i + 1;
while j < path.len() && path[j] / t_max == station {
j += 1;
}
if j < path.len() {
let next_station = path[j] / t_max;
let departure_week = path[j - 1] % t_max; // Last week at current station
let arrival_week = path[j] % t_max;
let wait_weeks = if i == 0 {
departure_week - (path[0] % t_max)
} else {
(path[j - 1] % t_max) - (path[i] % t_max)
};
legs.push(RouteLeg {
from_station: station,
to_station: next_station,
departure_week: departure_week as u32,
arrival_week: arrival_week as u32,
wait_weeks: wait_weeks as u32,
});
}
i = j;
}
let departure_week = if !path.is_empty() {
path[0] % t_max
} else {
earliest_week
};
let total_time = if !path.is_empty() {
(path.last().unwrap() % t_max) - departure_week
} else {
0
};
RouteResult {
legs,
total_time_weeks: total_time as u32,
departure_week: departure_week as u32,
}
}

View File

@@ -0,0 +1,150 @@
use crate::station::{Station, station_position};
use orbital_mechanics::bodies;
use orbital_mechanics::constants::*;
use orbital_mechanics::lambert;
use nalgebra::Vector3;
use serde::{Deserialize, Serialize};
/// Precomputed transfer costs between station pairs over time.
///
/// Stored as a sparse list of feasible transfers to save memory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferMatrix {
pub station_count: usize,
pub week_count: usize,
/// Start Julian Date
pub start_jd: f64,
/// Sparse entries: (from, to, departure_week, travel_weeks)
pub entries: Vec<TransferEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferEntry {
pub from: u16,
pub to: u16,
pub departure_week: u16,
pub travel_weeks: u16,
}
/// Compute the transfer matrix for a set of stations.
///
/// For each station pair and each weekly departure time, solve Lambert's problem
/// to find the minimum transfer time achievable at the given launch velocity.
///
/// # Arguments
/// * `stations` - The station network
/// * `max_launch_v_kms` - Maximum launch velocity in km/s
/// * `start_jd` - Start of the time window (Julian Date)
/// * `week_count` - Number of weeks to compute
/// * `progress_callback` - Called with (completed, total) for progress reporting
pub fn compute_transfer_matrix(
stations: &[Station],
max_launch_v_kms: f64,
start_jd: f64,
week_count: usize,
mut progress_callback: impl FnMut(usize, usize),
) -> TransferMatrix {
let all_bodies = bodies::all_bodies();
let n = stations.len();
let total_work = n * (n - 1) * week_count;
let mut completed = 0;
let mut entries = Vec::new();
// Candidate TOFs to try (in days) - logarithmic spacing from weeks to years
let candidate_tofs_days: Vec<f64> = {
let mut tofs = Vec::new();
// From 1 week to 5 years, ~20 samples
let min_days: f64 = 7.0;
let max_days: f64 = 5.0 * 365.25;
for i in 0..20 {
let t: f64 = i as f64 / 19.0;
let days = min_days * (max_days / min_days).powf(t);
tofs.push(days);
}
tofs
};
for week in 0..week_count {
let jd = start_jd + (week as f64) * DAYS_PER_WEEK;
// Precompute all station positions at this epoch
let station_positions: Vec<Vector3<f64>> = stations
.iter()
.map(|s| station_position(s, &all_bodies, jd))
.collect();
for from in 0..n {
for to in 0..n {
if from == to {
continue;
}
let r1 = station_positions[from] * AU_KM; // Convert AU to km
let r2 = station_positions[to] * AU_KM;
// Try different TOFs and find the minimum that works
let mut best_travel_weeks: Option<u16> = None;
for &tof_days in &candidate_tofs_days {
let tof_seconds = tof_days * SECONDS_PER_DAY;
if let Some(sol) = lambert::solve_lambert(r1, r2, tof_seconds, MU_SUN, true) {
// Check if departure delta-v is within launch capability
// For simplicity, we check if the departure velocity magnitude
// is achievable (station is co-moving with its parent body)
let v1_mag = sol.v1.norm();
// Station orbital velocity (approximate)
let r1_au = station_positions[from].norm();
let v_station = if r1_au > 0.01 {
(MU_SUN / (r1_au * AU_KM)).sqrt() // km/s
} else {
0.0
};
// The departure delta-v is roughly v1 - v_station
// This is simplified — in reality we'd need the vector difference
let dv_approx = (v1_mag - v_station).abs();
if dv_approx <= max_launch_v_kms {
let travel_weeks = (tof_days / 7.0).ceil() as u16;
if travel_weeks > 0 && travel_weeks < 5200 {
match best_travel_weeks {
None => best_travel_weeks = Some(travel_weeks),
Some(prev) if travel_weeks < prev => {
best_travel_weeks = Some(travel_weeks);
}
_ => {}
}
}
}
}
}
if let Some(travel_weeks) = best_travel_weeks {
entries.push(TransferEntry {
from: from as u16,
to: to as u16,
departure_week: week as u16,
travel_weeks,
});
}
completed += 1;
if completed % 1000 == 0 {
progress_callback(completed, total_work);
}
}
}
}
progress_callback(total_work, total_work);
TransferMatrix {
station_count: n,
week_count,
start_jd,
entries,
}
}

View File

@@ -1,5 +1,8 @@
use mass_driver_core::router;
use mass_driver_core::station; use mass_driver_core::station;
use mass_driver_core::transfer_matrix;
use orbital_mechanics::bodies; use orbital_mechanics::bodies;
use orbital_mechanics::orbits; use orbital_mechanics::orbits;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@@ -98,3 +101,59 @@ pub fn get_station_names(stations_json: &str) -> String {
let names: Vec<&str> = stations.iter().map(|s| s.name.as_str()).collect(); let names: Vec<&str> = stations.iter().map(|s| s.name.as_str()).collect();
serde_json::to_string(&names).unwrap() serde_json::to_string(&names).unwrap()
} }
/// Compute transfer matrix and find optimal route between two stations.
///
/// This is the main computation function. It:
/// 1. Computes the transfer matrix for the given stations over a time window
/// 2. Runs Dijkstra to find the optimal route
///
/// Returns JSON-serialized RouteResult, or empty string if no route found.
///
/// # Arguments (all as JSON)
/// * `stations_json` - Station configuration
/// * `from_station` - Source station index
/// * `to_station` - Destination station index
/// * `launch_velocity_kms` - Max launch velocity in km/s
/// * `start_jd` - Start of search window (Julian Date)
/// * `week_window` - Number of weeks to search
#[wasm_bindgen]
pub fn compute_route(
stations_json: &str,
from_station: usize,
to_station: usize,
launch_velocity_kms: f64,
start_jd: f64,
week_window: usize,
) -> String {
let stations: Vec<station::Station> = match serde_json::from_str(stations_json) {
Ok(s) => s,
Err(_) => return String::new(),
};
// Compute transfer matrix (the heavy part)
let matrix = transfer_matrix::compute_transfer_matrix(
&stations,
launch_velocity_kms,
start_jd,
week_window,
|_completed, _total| {
// Progress callback — in WASM we can't easily report progress
// to the main thread without Web Workers. For now, just run.
},
);
// Find optimal route
let route = router::find_optimal_route(
&matrix,
from_station,
to_station,
0,
week_window.saturating_sub(1),
);
match route {
Some(r) => serde_json::to_string(&r).unwrap_or_default(),
None => String::new(),
}
}

View File

@@ -0,0 +1,300 @@
//! Lambert's problem solver using universal variable method.
//!
//! Given two position vectors r1 and r2, and a time of flight tof,
//! find the orbit connecting them. Returns departure and arrival velocities.
//!
//! Uses the algorithm from Curtis, "Orbital Mechanics for Engineering Students",
//! with bisection for robust convergence.
use nalgebra::Vector3;
/// Solution to Lambert's problem.
#[derive(Debug, Clone)]
pub struct LambertSolution {
/// Departure velocity (km/s)
pub v1: Vector3<f64>,
/// Arrival velocity (km/s)
pub v2: Vector3<f64>,
}
/// Solve Lambert's problem.
///
/// # Arguments
/// * `r1` - Initial position vector (km)
/// * `r2` - Final position vector (km)
/// * `tof` - Time of flight (seconds)
/// * `mu` - Gravitational parameter (km³/s²)
/// * `prograde` - If true, use prograde (short-way) transfer
///
/// # Returns
/// `Some(LambertSolution)` if a solution exists, `None` otherwise.
pub fn solve_lambert(
r1: Vector3<f64>,
r2: Vector3<f64>,
tof: f64,
mu: f64,
prograde: bool,
) -> Option<LambertSolution> {
if tof <= 0.0 || mu <= 0.0 {
return None;
}
let r1_norm = r1.norm();
let r2_norm = r2.norm();
if r1_norm < 1e-6 || r2_norm < 1e-6 {
return None;
}
// Handle near-180° transfers by adding a tiny out-of-plane perturbation
let cos_angle = r1.dot(&r2) / (r1_norm * r2_norm);
let r2 = if cos_angle < -0.99999 {
// Near anti-podal: perturb slightly to break degeneracy
Vector3::new(r2.x, r2.y, r2.z + r2_norm * 1e-6)
} else {
r2
};
let r2_norm = r2.norm();
// Change in true anomaly
let cos_dnu = r1.dot(&r2) / (r1_norm * r2_norm);
let cos_dnu = cos_dnu.clamp(-1.0, 1.0);
let cross = r1.cross(&r2);
let sin_dnu = if prograde {
if cross.z >= 0.0 { (1.0 - cos_dnu * cos_dnu).sqrt() }
else { -(1.0 - cos_dnu * cos_dnu).sqrt() }
} else {
if cross.z < 0.0 { (1.0 - cos_dnu * cos_dnu).sqrt() }
else { -(1.0 - cos_dnu * cos_dnu).sqrt() }
};
// Parameter A (from Curtis Eq. 5.35)
let a = sin_dnu * (r1_norm * r2_norm / (1.0 - cos_dnu)).sqrt();
if a.abs() < 1e-10 {
return None;
}
// Function to compute TOF for a given z using Stumpff functions
let tof_from_z = |z: f64| -> Option<f64> {
let (c2, c3) = stumpff(z);
if c2.abs() < 1e-20 {
return None;
}
let y = r1_norm + r2_norm + a * (z * c3 - 1.0) / c2.sqrt();
if y < 0.0 {
return None;
}
let chi = (y / c2).sqrt();
let t = (chi * chi * chi * c3 + a * y.sqrt()) / mu.sqrt();
Some(t)
};
// Find z using bisection + Newton hybrid
// For elliptic orbits: z > 0
// For hyperbolic orbits: z < 0
// Find a bracket [z_low, z_high] containing the solution.
// z_low should give TOF < target, z_high should give TOF > target.
let mut z_low = -4.0 * std::f64::consts::PI * std::f64::consts::PI;
let mut z_high = 4.0 * std::f64::consts::PI * std::f64::consts::PI;
// Ensure z_high gives TOF >= target (keep doubling if needed)
for _ in 0..50 {
match tof_from_z(z_high) {
Some(t) if t >= tof => break,
_ => z_high *= 2.0,
}
if z_high > 1e10 { return None; }
}
// Ensure z_low gives TOF < target (keep decreasing if needed)
for _ in 0..50 {
match tof_from_z(z_low) {
Some(t) if t < tof => break,
None => break, // y < 0 means TOF undefined → z is too low, which is fine
_ => z_low -= 10.0,
}
if z_low < -1e10 { return None; }
}
// Bisection iteration
// Higher z → larger semi-major axis → longer TOF
let mut z = 0.0;
for _ in 0..200 {
z = (z_low + z_high) / 2.0;
let t = match tof_from_z(z) {
Some(t) => t,
None => {
// y < 0 means z is too low; increase lower bound
z_low = z;
continue;
}
};
if (t - tof).abs() < 1e-6 {
break; // Converged
}
if t < tof {
z_low = z; // Need more time → increase z
} else {
z_high = z; // Need less time → decrease z
}
if (z_high - z_low).abs() < 1e-12 {
break;
}
}
// Compute velocities from the converged z
let (c2, c3) = stumpff(z);
if c2.abs() < 1e-20 {
return None;
}
let y = r1_norm + r2_norm + a * (z * c3 - 1.0) / c2.sqrt();
if y < 0.0 {
return None;
}
// Lagrange coefficients
let f = 1.0 - y / r1_norm;
let g = a * (y / mu).sqrt();
let g_dot = 1.0 - y / r2_norm;
if g.abs() < 1e-20 {
return None;
}
let v1 = (r2 - f * r1) / g;
let v2 = (g_dot * r2 - r1) / g;
Some(LambertSolution { v1, v2 })
}
/// Stumpff functions c2(z) and c3(z).
fn stumpff(z: f64) -> (f64, f64) {
if z > 1e-6 {
let sz = z.sqrt();
let c2 = (1.0 - sz.cos()) / z;
let c3 = (sz - sz.sin()) / (z * sz);
(c2, c3)
} else if z < -1e-6 {
let sz = (-z).sqrt();
let c2 = (sz.cosh() - 1.0) / (-z);
let c3 = (sz.sinh() - sz) / ((-z) * sz);
(c2, c3)
} else {
// Taylor series near z = 0
(1.0 / 2.0 - z / 24.0 + z * z / 720.0,
1.0 / 6.0 - z / 120.0 + z * z / 5040.0)
}
}
/// Compute the delta-v required for a Lambert transfer.
/// Returns (dv_departure, dv_arrival) in km/s, where dv is relative to
/// the velocity at each position.
pub fn transfer_delta_v(
r1: Vector3<f64>,
v1_body: Vector3<f64>,
r2: Vector3<f64>,
v2_body: Vector3<f64>,
tof: f64,
mu: f64,
) -> Option<(f64, f64)> {
let sol = solve_lambert(r1, r2, tof, mu, true)?;
let dv1 = (sol.v1 - v1_body).norm();
let dv2 = (sol.v2 - v2_body).norm();
Some((dv1, dv2))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::*;
#[test]
fn test_hohmann_earth_mars() {
// Earth at 1 AU, Mars at 1.524 AU (opposite side)
let r1 = Vector3::new(AU_KM, 0.0, 0.0);
let r2 = Vector3::new(-1.524 * AU_KM, 0.0, 0.0);
// Hohmann transfer time ≈ 259 days
let tof = 259.0 * SECONDS_PER_DAY;
let result = solve_lambert(r1, r2, tof, MU_SUN, true);
assert!(result.is_some(), "Lambert solver should find a solution");
let sol = result.unwrap();
let v1_mag = sol.v1.norm();
// Departure velocity should be ~30-35 km/s (Earth orbital velocity + transfer excess)
assert!(
v1_mag > 20.0 && v1_mag < 45.0,
"Departure velocity {} km/s should be reasonable",
v1_mag
);
}
#[test]
fn test_short_transfer() {
// 30-degree transfer at 1 AU
let r1 = Vector3::new(AU_KM, 0.0, 0.0);
let angle = std::f64::consts::FRAC_PI_6;
let r2 = Vector3::new(AU_KM * angle.cos(), AU_KM * angle.sin(), 0.0);
let tof = 50.0 * SECONDS_PER_DAY;
let result = solve_lambert(r1, r2, tof, MU_SUN, true);
assert!(result.is_some(), "Should solve short transfer");
let sol = result.unwrap();
let v1_mag = sol.v1.norm();
assert!(
v1_mag > 10.0 && v1_mag < 60.0,
"Velocity {} km/s should be reasonable",
v1_mag
);
}
#[test]
fn test_circular_orbit_consistency() {
// If we solve Lambert for two points on a circular orbit with
// the correct TOF, the departure velocity should match orbital velocity
let r = AU_KM; // 1 AU
let v_circ = (MU_SUN / r).sqrt(); // ~29.8 km/s
let angle = std::f64::consts::FRAC_PI_4; // 45 degrees
let r1 = Vector3::new(r, 0.0, 0.0);
let r2 = Vector3::new(r * angle.cos(), r * angle.sin(), 0.0);
// Time for 45° of circular orbit
let period = 2.0 * std::f64::consts::PI * (r.powi(3) / MU_SUN).sqrt();
let tof = period * 45.0 / 360.0;
let result = solve_lambert(r1, r2, tof, MU_SUN, true);
assert!(result.is_some(), "Should solve circular orbit transfer");
let sol = result.unwrap();
let v1_mag = sol.v1.norm();
// Should be close to circular velocity
assert!(
(v1_mag - v_circ).abs() / v_circ < 0.05,
"Departure velocity {} km/s should match circular velocity {} km/s",
v1_mag, v_circ
);
}
#[test]
fn test_degenerate_zero_tof() {
let r1 = Vector3::new(AU_KM, 0.0, 0.0);
let r2 = Vector3::new(0.0, AU_KM, 0.0);
let result = solve_lambert(r1, r2, 0.0, MU_SUN, true);
assert!(result.is_none(), "Zero TOF should return None");
}
}

View File

@@ -2,6 +2,7 @@ pub mod bodies;
pub mod constants; pub mod constants;
pub mod kepler; pub mod kepler;
pub mod lagrange; pub mod lagrange;
pub mod lambert;
pub mod orbits; pub mod orbits;
pub use nalgebra::Vector3; pub use nalgebra::Vector3;

View File

@@ -20,6 +20,7 @@ export class SolarSystem2DRenderer {
private stationPositions: Float64Array = new Float64Array(0); private stationPositions: Float64Array = new Float64Array(0);
private stationNames: string[] = []; private stationNames: string[] = [];
private showStations: boolean = true; private showStations: boolean = true;
private routeLegs: { from: number; to: number }[] = [];
private isDragging = false; private isDragging = false;
private lastMouse = { x: 0, y: 0 }; private lastMouse = { x: 0, y: 0 };
private animFrameId: number = 0; private animFrameId: number = 0;
@@ -86,6 +87,10 @@ export class SolarSystem2DRenderer {
this.showStations = visible; this.showStations = visible;
} }
updateRoute(legs: { from: number; to: number }[]) {
this.routeLegs = legs;
}
render() { render() {
const { canvas, ctx } = this; const { canvas, ctx } = this;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -133,6 +138,11 @@ export class SolarSystem2DRenderer {
} }
} }
// Draw route legs
if (this.routeLegs.length > 0 && this.stationPositions.length > 0) {
this.drawRoute();
}
// Scale indicator // Scale indicator
this.drawScaleBar(rect.width, rect.height); this.drawScaleBar(rect.width, rect.height);
} }
@@ -287,6 +297,58 @@ export class SolarSystem2DRenderer {
ctx.fillText(info.name, sx + size + 4, sy + 3); ctx.fillText(info.name, sx + size + 4, sy + 3);
} }
private drawRoute() {
const ctx = this.ctx;
const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33'];
for (let i = 0; i < this.routeLegs.length; i++) {
const leg = this.routeLegs[i];
const fromX = this.stationPositions[leg.from * 3];
const fromY = this.stationPositions[leg.from * 3 + 1];
const toX = this.stationPositions[leg.to * 3];
const toY = this.stationPositions[leg.to * 3 + 1];
const [sx1, sy1] = this.auToScreen(fromX, fromY);
const [sx2, sy2] = this.auToScreen(toX, toY);
const color = colors[i % colors.length];
// Draw line
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(sx1, sy1);
ctx.lineTo(sx2, sy2);
ctx.stroke();
ctx.setLineDash([]);
// Arrow head
const angle = Math.atan2(sy2 - sy1, sx2 - sx1);
const arrowLen = 8;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(sx2, sy2);
ctx.lineTo(
sx2 - arrowLen * Math.cos(angle - 0.3),
sy2 - arrowLen * Math.sin(angle - 0.3),
);
ctx.lineTo(
sx2 - arrowLen * Math.cos(angle + 0.3),
sy2 - arrowLen * Math.sin(angle + 0.3),
);
ctx.closePath();
ctx.fill();
// Leg number
const mx = (sx1 + sx2) / 2;
const my = (sy1 + sy2) / 2;
ctx.fillStyle = color;
ctx.font = 'bold 10px monospace';
ctx.fillText(`${i + 1}`, mx + 5, my - 5);
}
}
private drawStation(xAU: number, yAU: number, name: string) { private drawStation(xAU: number, yAU: number, name: string) {
const ctx = this.ctx; const ctx = this.ctx;
const [sx, sy] = this.auToScreen(xAU, yAU); const [sx, sy] = this.auToScreen(xAU, yAU);

View File

@@ -0,0 +1,12 @@
import type { RouteResult } from '$lib/wasm/bridge';
class RoutingState {
fromStation = $state<number | null>(null);
toStation = $state<number | null>(null);
route = $state<RouteResult | null>(null);
isComputing = $state(false);
error = $state('');
weekWindow = $state(260); // ~5 years search window
}
export const routing = new RoutingState();

View File

@@ -57,3 +57,34 @@ export function getStationNames(stationsJson: string): string[] {
if (!wasm) return []; if (!wasm) return [];
return JSON.parse(wasm.get_station_names(stationsJson)); return JSON.parse(wasm.get_station_names(stationsJson));
} }
export interface RouteResult {
legs: {
from_station: number;
to_station: number;
departure_week: number;
arrival_week: number;
wait_weeks: number;
}[];
total_time_weeks: number;
departure_week: number;
}
export function computeRoute(
stationsJson: string,
fromStation: number,
toStation: number,
launchVelocityKms: number,
startJd: number,
weekWindow: number,
): RouteResult | null {
const wasm = getWasm();
if (!wasm) return null;
const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow);
if (!json) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}

View File

@@ -3,6 +3,7 @@
import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D'; import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D';
import { simulation } from '$lib/stores/simulation.svelte'; import { simulation } from '$lib/stores/simulation.svelte';
import { stations } from '$lib/stores/stations.svelte'; import { stations } from '$lib/stores/stations.svelte';
import { routing } from '$lib/stores/routing.svelte';
import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints, getStationPositions } from '$lib/wasm/bridge'; import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints, getStationPositions } from '$lib/wasm/bridge';
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
@@ -48,6 +49,16 @@
renderer.updateStations(stations.stationPositions, stations.stationNames, stations.visible); renderer.updateStations(stations.stationPositions, stations.stationNames, stations.visible);
} }
// Update route visualization
if (routing.route) {
renderer.updateRoute(routing.route.legs.map(l => ({
from: l.from_station,
to: l.to_station,
})));
} else {
renderer.updateRoute([]);
}
if (!orbitsComputed || frameCount % 600 === 0) { if (!orbitsComputed || frameCount % 600 === 0) {
computeOrbits(); computeOrbits();
} }

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { simulation } from '$lib/stores/simulation.svelte';
import { stations } from '$lib/stores/stations.svelte';
import { routing } from '$lib/stores/routing.svelte';
import { computeRoute } from '$lib/wasm/bridge';
import { formatWeeks } from '$lib/utils/format';
function handleCompute() {
if (routing.fromStation === null || routing.toStation === null) {
routing.error = 'Select both start and end stations';
return;
}
if (routing.fromStation === routing.toStation) {
routing.error = 'Start and end must differ';
return;
}
routing.isComputing = true;
routing.error = '';
routing.route = null;
// Use setTimeout to let the UI update before blocking computation
setTimeout(() => {
const result = computeRoute(
stations.stationsJson,
routing.fromStation!,
routing.toStation!,
stations.launchVelocityKms,
simulation.currentJD,
routing.weekWindow,
);
if (result && result.legs.length > 0) {
routing.route = result;
} else {
routing.error = 'No route found. Try increasing velocity or search window.';
}
routing.isComputing = false;
}, 50);
}
const windowOptions = [
{ label: '1 year', value: 52 },
{ label: '2 years', value: 104 },
{ label: '5 years', value: 260 },
{ label: '10 years', value: 520 },
];
</script>
<div class="flex flex-col gap-3 p-3 bg-[var(--bg-panel)] rounded-lg border border-white/5">
<h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
Route Planner
</h3>
<!-- From station -->
<div>
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">From</label>
<select
bind:value={routing.fromStation}
class="w-full bg-[var(--bg-primary)] text-xs text-[var(--text-primary)] border border-white/10 rounded px-2 py-1.5 [color-scheme:dark]"
>
<option value={null}>Select station...</option>
{#each stations.stationNames as name, i}
<option value={i}>{name}</option>
{/each}
</select>
</div>
<!-- To station -->
<div>
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">To</label>
<select
bind:value={routing.toStation}
class="w-full bg-[var(--bg-primary)] text-xs text-[var(--text-primary)] border border-white/10 rounded px-2 py-1.5 [color-scheme:dark]"
>
<option value={null}>Select station...</option>
{#each stations.stationNames as name, i}
<option value={i}>{name}</option>
{/each}
</select>
</div>
<!-- Search window -->
<div>
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">Search Window</label>
<div class="flex gap-1">
{#each windowOptions as opt}
<button
onclick={() => { routing.weekWindow = opt.value; }}
class="flex-1 px-1 py-0.5 text-[10px] rounded transition-colors {routing.weekWindow === opt.value
? 'bg-[var(--accent-blue)] text-white'
: 'bg-white/5 text-[var(--text-secondary)] hover:bg-white/10'}"
>
{opt.label}
</button>
{/each}
</div>
</div>
<!-- Compute button -->
<button
onclick={handleCompute}
disabled={routing.isComputing || routing.fromStation === null || routing.toStation === null}
class="w-full py-2 px-3 text-xs font-semibold rounded transition-colors
{routing.isComputing
? 'bg-[var(--accent-blue)]/50 text-white/50 cursor-wait'
: 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/80 disabled:opacity-30 disabled:cursor-not-allowed'}"
>
{#if routing.isComputing}
Computing route...
{:else}
Find Optimal Route
{/if}
</button>
<!-- Error -->
{#if routing.error}
<p class="text-[10px] text-red-400">{routing.error}</p>
{/if}
<!-- Route result -->
{#if routing.route}
<div class="border-t border-white/5 pt-2 mt-1">
<div class="flex justify-between items-baseline mb-2">
<span class="text-xs font-semibold text-[var(--accent-green)]">Route Found</span>
<span class="text-sm font-bold text-[var(--text-primary)]">
{formatWeeks(routing.route.total_time_weeks)}
</span>
</div>
<div class="text-[10px] text-[var(--text-secondary)] mb-1">
Departs week {routing.route.departure_week} &middot; {routing.route.legs.length} leg{routing.route.legs.length !== 1 ? 's' : ''}
</div>
<!-- Leg details -->
<div class="flex flex-col gap-1">
{#each routing.route.legs as leg, i}
<div class="flex items-center gap-1.5 text-[10px]">
<span class="w-3 h-3 rounded-full bg-[var(--accent-blue)] flex items-center justify-center text-[8px] text-white font-bold shrink-0">
{i + 1}
</span>
<div class="flex-1 min-w-0">
<div class="text-[var(--text-primary)] truncate">
{stations.stationNames[leg.from_station]}{stations.stationNames[leg.to_station]}
</div>
<div class="text-[var(--text-secondary)]">
{formatWeeks(leg.arrival_week - leg.departure_week)} transit
{#if leg.wait_weeks > 0}
&middot; {formatWeeks(leg.wait_weeks)} wait
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -7,6 +7,7 @@
import TimeControls from './(simulator)/components/TimeControls.svelte'; import TimeControls from './(simulator)/components/TimeControls.svelte';
import ViewToggle from './(simulator)/components/ViewToggle.svelte'; import ViewToggle from './(simulator)/components/ViewToggle.svelte';
import StationPanel from './(simulator)/components/StationPanel.svelte'; import StationPanel from './(simulator)/components/StationPanel.svelte';
import RoutingPanel from './(simulator)/components/RoutingPanel.svelte';
let wasmError = $state(''); let wasmError = $state('');
@@ -58,8 +59,9 @@
<Viewport /> <Viewport />
</div> </div>
<!-- Right sidebar --> <!-- Right sidebar -->
<aside class="w-64 shrink-0 p-2 bg-[var(--bg-secondary)] border-l border-white/5 overflow-y-auto"> <aside class="w-64 shrink-0 p-2 bg-[var(--bg-secondary)] border-l border-white/5 overflow-y-auto flex flex-col gap-2">
<StationPanel /> <StationPanel />
<RoutingPanel />
</aside> </aside>
{/if} {/if}
</main> </main>