Add multi-route comparison with Yen's k-shortest-paths algorithm
- Yen's algorithm in Rust: finds up to 3 distinct shortest routes by iteratively deviating from previously found paths with node exclusions - Routes are deduplicated by station sequence (same stations = same route even if timing differs slightly) - Dijkstra refactored to support node exclusion sets for Yen's spur paths - WASM API returns JSON array of RouteResult (was single object) - Frontend routing store holds multiple routes with selectedRouteIndex - Route tabs: "Fastest", "Alternative 1", "Alternative 2" with distinct colors (orange, blue, purple), click to switch and resample trajectory - Compact route planner layout: 2-column From/To, shorter window buttons - Tested: Earth L1 → Jupiter L1 finds 2-leg relay via Mercury L1 (6.9mo) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,145 +1,118 @@
|
|||||||
use crate::route::{RouteLeg, RouteResult};
|
use crate::route::{RouteLeg, RouteResult};
|
||||||
use crate::transfer_matrix::TransferMatrix;
|
use crate::transfer_matrix::TransferMatrix;
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
use std::collections::BinaryHeap;
|
use std::collections::{BinaryHeap, HashSet};
|
||||||
|
|
||||||
/// Find the optimal route between two stations using Dijkstra on
|
/// Build the transfer lookup table for fast Dijkstra edge iteration.
|
||||||
/// the time-expanded graph.
|
fn build_transfer_lookup(matrix: &TransferMatrix) -> Vec<Vec<(u16, u16)>> {
|
||||||
///
|
let n = matrix.station_count;
|
||||||
/// Nodes: (station_id, week_index)
|
let t_max = matrix.week_count;
|
||||||
/// Transfer edges: based on precomputed transfer matrix
|
let mut lookup: Vec<Vec<(u16, u16)>> = vec![Vec::new(); n * t_max];
|
||||||
/// Wait edges: (station, t) → (station, t+1) with cost 1 week
|
for entry in &matrix.entries {
|
||||||
///
|
let key = entry.from as usize * t_max + entry.departure_week as usize;
|
||||||
/// # Arguments
|
if key < lookup.len() {
|
||||||
/// * `matrix` - Precomputed transfer matrix
|
lookup[key].push((entry.to, entry.travel_weeks));
|
||||||
/// * `from` - Source station index
|
}
|
||||||
/// * `to` - Destination station index
|
}
|
||||||
/// * `earliest_week` - Earliest allowed departure week
|
lookup
|
||||||
/// * `latest_week` - Latest allowed departure week
|
}
|
||||||
///
|
|
||||||
/// # Returns
|
/// Run Dijkstra with optional node exclusions.
|
||||||
/// The optimal route, or None if no route exists.
|
/// Returns (path_as_node_ids, total_cost) or None.
|
||||||
pub fn find_optimal_route(
|
fn dijkstra_with_exclusions(
|
||||||
matrix: &TransferMatrix,
|
matrix: &TransferMatrix,
|
||||||
|
transfer_lookup: &[Vec<(u16, u16)>],
|
||||||
from: usize,
|
from: usize,
|
||||||
to: usize,
|
to: usize,
|
||||||
earliest_week: usize,
|
earliest_week: usize,
|
||||||
latest_week: usize,
|
latest_week: usize,
|
||||||
) -> Option<RouteResult> {
|
excluded_nodes: &HashSet<usize>,
|
||||||
|
) -> Option<(Vec<usize>, u32)> {
|
||||||
let n = matrix.station_count;
|
let n = matrix.station_count;
|
||||||
let t_max = matrix.week_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 node_count = n * t_max;
|
||||||
|
|
||||||
let mut dist = vec![u32::MAX; node_count];
|
let mut dist = vec![u32::MAX; node_count];
|
||||||
let mut prev: Vec<Option<usize>> = vec![None; 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();
|
let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::new();
|
||||||
|
|
||||||
// Seed: all departure times from the source station
|
|
||||||
for week in earliest_week..=latest_week {
|
for week in earliest_week..=latest_week {
|
||||||
let node = from * t_max + week;
|
let node = from * t_max + week;
|
||||||
let cost = (week - earliest_week) as u32; // Waiting at origin costs time
|
if excluded_nodes.contains(&node) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cost = (week - earliest_week) as u32;
|
||||||
dist[node] = cost;
|
dist[node] = cost;
|
||||||
heap.push(Reverse((cost, node)));
|
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() {
|
while let Some(Reverse((cost, u))) = heap.pop() {
|
||||||
let u_station = u / t_max;
|
let u_station = u / t_max;
|
||||||
let u_week = u % t_max;
|
let u_week = u % t_max;
|
||||||
|
|
||||||
// Found destination — reconstruct path
|
|
||||||
if u_station == to {
|
if u_station == to {
|
||||||
return Some(reconstruct_route(matrix, &prev, u, earliest_week, t_max));
|
// Reconstruct path
|
||||||
|
let mut path = Vec::new();
|
||||||
|
let mut node = u;
|
||||||
|
loop {
|
||||||
|
path.push(node);
|
||||||
|
match prev[node] {
|
||||||
|
Some(p) => node = p,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.reverse();
|
||||||
|
return Some((path, cost));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip stale entries
|
|
||||||
if cost > dist[u] {
|
if cost > dist[u] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait edge: stay at this station for one more week
|
// Wait edge
|
||||||
if u_week + 1 < t_max {
|
if u_week + 1 < t_max {
|
||||||
let v = u + 1; // Same station, next week
|
let v = u + 1;
|
||||||
let new_cost = cost + 1;
|
if !excluded_nodes.contains(&v) {
|
||||||
if new_cost < dist[v] {
|
let new_cost = cost + 1;
|
||||||
dist[v] = new_cost;
|
if new_cost < dist[v] {
|
||||||
prev[v] = Some(u);
|
dist[v] = new_cost;
|
||||||
heap.push(Reverse((new_cost, v)));
|
prev[v] = Some(u);
|
||||||
|
heap.push(Reverse((new_cost, v)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer edges from this station at this week
|
// Transfer edges
|
||||||
let key = u_station * t_max + u_week;
|
let key = u_station * t_max + u_week;
|
||||||
if key < transfer_lookup.len() {
|
if key < transfer_lookup.len() {
|
||||||
for &(to_station, travel_weeks) in &transfer_lookup[key] {
|
for &(to_station, travel_weeks) in &transfer_lookup[key] {
|
||||||
let arrival_week = u_week + travel_weeks as usize;
|
let arrival_week = u_week + travel_weeks as usize;
|
||||||
if arrival_week < t_max {
|
if arrival_week < t_max {
|
||||||
let v = to_station as usize * t_max + arrival_week;
|
let v = to_station as usize * t_max + arrival_week;
|
||||||
let new_cost = cost + travel_weeks as u32;
|
if !excluded_nodes.contains(&v) {
|
||||||
if new_cost < dist[v] {
|
let new_cost = cost + travel_weeks as u32;
|
||||||
dist[v] = new_cost;
|
if new_cost < dist[v] {
|
||||||
prev[v] = Some(u);
|
dist[v] = new_cost;
|
||||||
heap.push(Reverse((new_cost, v)));
|
prev[v] = Some(u);
|
||||||
|
heap.push(Reverse((new_cost, v)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None // No route found
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reconstruct_route(
|
/// Convert a raw node path into a RouteResult.
|
||||||
_matrix: &TransferMatrix,
|
fn path_to_route(path: &[usize], t_max: usize) -> RouteResult {
|
||||||
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 legs = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
||||||
while i < path.len() {
|
while i < path.len() {
|
||||||
let station = path[i] / t_max;
|
let station = path[i] / t_max;
|
||||||
let _week = path[i] % t_max;
|
|
||||||
|
|
||||||
// Find next different station in path
|
|
||||||
let mut j = i + 1;
|
let mut j = i + 1;
|
||||||
while j < path.len() && path[j] / t_max == station {
|
while j < path.len() && path[j] / t_max == station {
|
||||||
j += 1;
|
j += 1;
|
||||||
@@ -147,7 +120,7 @@ fn reconstruct_route(
|
|||||||
|
|
||||||
if j < path.len() {
|
if j < path.len() {
|
||||||
let next_station = path[j] / t_max;
|
let next_station = path[j] / t_max;
|
||||||
let departure_week = path[j - 1] % t_max; // Last week at current station
|
let departure_week = path[j - 1] % t_max;
|
||||||
let arrival_week = path[j] % t_max;
|
let arrival_week = path[j] % t_max;
|
||||||
let wait_weeks = if i == 0 {
|
let wait_weeks = if i == 0 {
|
||||||
departure_week - (path[0] % t_max)
|
departure_week - (path[0] % t_max)
|
||||||
@@ -167,12 +140,7 @@ fn reconstruct_route(
|
|||||||
i = j;
|
i = j;
|
||||||
}
|
}
|
||||||
|
|
||||||
let departure_week = if !path.is_empty() {
|
let departure_week = if !path.is_empty() { path[0] % t_max } else { 0 };
|
||||||
path[0] % t_max
|
|
||||||
} else {
|
|
||||||
earliest_week
|
|
||||||
};
|
|
||||||
|
|
||||||
let total_time = if !path.is_empty() {
|
let total_time = if !path.is_empty() {
|
||||||
(path.last().unwrap() % t_max) - departure_week
|
(path.last().unwrap() % t_max) - departure_week
|
||||||
} else {
|
} else {
|
||||||
@@ -185,3 +153,177 @@ fn reconstruct_route(
|
|||||||
departure_week: departure_week as u32,
|
departure_week: departure_week as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the single optimal route.
|
||||||
|
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; }
|
||||||
|
|
||||||
|
let lookup = build_transfer_lookup(matrix);
|
||||||
|
let empty = HashSet::new();
|
||||||
|
|
||||||
|
let (path, _cost) = dijkstra_with_exclusions(
|
||||||
|
matrix, &lookup, from, to, earliest_week, latest_week, &empty,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Some(path_to_route(&path, t_max))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find k-shortest paths using Yen's algorithm.
|
||||||
|
///
|
||||||
|
/// Returns up to `k` routes sorted by total time (shortest first).
|
||||||
|
/// Routes are guaranteed to be distinct (different station sequences).
|
||||||
|
pub fn find_k_shortest_routes(
|
||||||
|
matrix: &TransferMatrix,
|
||||||
|
from: usize,
|
||||||
|
to: usize,
|
||||||
|
earliest_week: usize,
|
||||||
|
latest_week: usize,
|
||||||
|
k: usize,
|
||||||
|
) -> Vec<RouteResult> {
|
||||||
|
let n = matrix.station_count;
|
||||||
|
let t_max = matrix.week_count;
|
||||||
|
if from >= n || to >= n || from == to || k == 0 { return vec![]; }
|
||||||
|
let latest_week = latest_week.min(t_max - 1);
|
||||||
|
if earliest_week > latest_week { return vec![]; }
|
||||||
|
|
||||||
|
let lookup = build_transfer_lookup(matrix);
|
||||||
|
let empty = HashSet::new();
|
||||||
|
|
||||||
|
// Step 1: Find the shortest path
|
||||||
|
let (first_path, first_cost) = match dijkstra_with_exclusions(
|
||||||
|
matrix, &lookup, from, to, earliest_week, latest_week, &empty,
|
||||||
|
) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut a_paths: Vec<(Vec<usize>, u32)> = vec![(first_path, first_cost)];
|
||||||
|
let mut b_candidates: Vec<(Vec<usize>, u32)> = Vec::new();
|
||||||
|
|
||||||
|
// Track which station sequences we've already found to ensure diversity
|
||||||
|
let mut seen_sequences: HashSet<Vec<usize>> = HashSet::new();
|
||||||
|
seen_sequences.insert(
|
||||||
|
station_sequence(&a_paths[0].0, t_max),
|
||||||
|
);
|
||||||
|
|
||||||
|
for ki in 1..k {
|
||||||
|
let prev_path = &a_paths[ki - 1].0;
|
||||||
|
|
||||||
|
// For each spur node in the previous path, find a deviation
|
||||||
|
// We operate on "station transition" points in the path
|
||||||
|
let transition_indices = find_transition_indices(prev_path, t_max);
|
||||||
|
|
||||||
|
for &spur_idx in &transition_indices {
|
||||||
|
let spur_node = prev_path[spur_idx];
|
||||||
|
|
||||||
|
// Exclude nodes used by the root path (before spur point)
|
||||||
|
// and nodes from previously found paths that share the same root
|
||||||
|
let mut excluded = HashSet::new();
|
||||||
|
|
||||||
|
// Exclude the internal nodes of the root path up to spur
|
||||||
|
for &node in &prev_path[..spur_idx] {
|
||||||
|
excluded.insert(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude edges/nodes from all existing A-paths that share
|
||||||
|
// the same root (same path up to spur_idx)
|
||||||
|
for (existing_path, _) in &a_paths {
|
||||||
|
if existing_path.len() > spur_idx
|
||||||
|
&& existing_path[..=spur_idx] == prev_path[..=spur_idx]
|
||||||
|
{
|
||||||
|
// Exclude the next node after spur in this existing path
|
||||||
|
if spur_idx + 1 < existing_path.len() {
|
||||||
|
excluded.insert(existing_path[spur_idx + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find shortest path from spur to destination
|
||||||
|
let spur_station = spur_node / t_max;
|
||||||
|
let spur_week = spur_node % t_max;
|
||||||
|
|
||||||
|
if let Some((spur_path, spur_cost)) = dijkstra_with_exclusions(
|
||||||
|
matrix, &lookup, spur_station, to,
|
||||||
|
spur_week, latest_week, &excluded,
|
||||||
|
) {
|
||||||
|
// Combine root + spur path
|
||||||
|
let mut full_path = prev_path[..spur_idx].to_vec();
|
||||||
|
full_path.extend_from_slice(&spur_path);
|
||||||
|
|
||||||
|
let root_cost = if spur_idx > 0 {
|
||||||
|
// Cost of root portion
|
||||||
|
(prev_path[spur_idx] % t_max - prev_path[0] % t_max) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let total_cost = root_cost + spur_cost;
|
||||||
|
|
||||||
|
let seq = station_sequence(&full_path, t_max);
|
||||||
|
if !seen_sequences.contains(&seq) {
|
||||||
|
b_candidates.push((full_path, total_cost));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if b_candidates.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the best candidate
|
||||||
|
b_candidates.sort_by_key(|(_, cost)| *cost);
|
||||||
|
|
||||||
|
// Find the first candidate with a unique station sequence
|
||||||
|
let mut found = false;
|
||||||
|
while !b_candidates.is_empty() {
|
||||||
|
let (path, cost) = b_candidates.remove(0);
|
||||||
|
let seq = station_sequence(&path, t_max);
|
||||||
|
if !seen_sequences.contains(&seq) {
|
||||||
|
seen_sequences.insert(seq);
|
||||||
|
a_paths.push((path, cost));
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a_paths.into_iter().map(|(path, _)| path_to_route(&path, t_max)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the station-level sequence from a node path (ignoring time).
|
||||||
|
fn station_sequence(path: &[usize], t_max: usize) -> Vec<usize> {
|
||||||
|
let mut seq = Vec::new();
|
||||||
|
let mut last_station = usize::MAX;
|
||||||
|
for &node in path {
|
||||||
|
let station = node / t_max;
|
||||||
|
if station != last_station {
|
||||||
|
seq.push(station);
|
||||||
|
last_station = station;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seq
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find indices in the path where the station changes (transition points).
|
||||||
|
fn find_transition_indices(path: &[usize], t_max: usize) -> Vec<usize> {
|
||||||
|
let mut indices = vec![0]; // Always include the start
|
||||||
|
for i in 1..path.len() {
|
||||||
|
if path[i] / t_max != path[i - 1] / t_max {
|
||||||
|
indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
indices
|
||||||
|
}
|
||||||
|
|||||||
@@ -213,17 +213,19 @@ pub fn compute_route(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find optimal route
|
// Find up to 3 shortest routes using Yen's algorithm
|
||||||
let route = router::find_optimal_route(
|
let routes = router::find_k_shortest_routes(
|
||||||
&matrix,
|
&matrix,
|
||||||
from_station,
|
from_station,
|
||||||
to_station,
|
to_station,
|
||||||
0,
|
0,
|
||||||
week_window.saturating_sub(1),
|
week_window.saturating_sub(1),
|
||||||
|
3,
|
||||||
);
|
);
|
||||||
|
|
||||||
match route {
|
if routes.is_empty() {
|
||||||
Some(r) => serde_json::to_string(&r).unwrap_or_default(),
|
String::new()
|
||||||
None => String::new(),
|
} else {
|
||||||
|
serde_json::to_string(&routes).unwrap_or_default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { RouteResult } from '$lib/wasm/bridge';
|
|||||||
class RoutingState {
|
class RoutingState {
|
||||||
fromStation = $state<number | null>(null);
|
fromStation = $state<number | null>(null);
|
||||||
toStation = $state<number | null>(null);
|
toStation = $state<number | null>(null);
|
||||||
route = $state<RouteResult | null>(null);
|
|
||||||
|
// Multiple route alternatives
|
||||||
|
routes = $state<RouteResult[]>([]);
|
||||||
|
selectedRouteIndex = $state(0);
|
||||||
isComputing = $state(false);
|
isComputing = $state(false);
|
||||||
error = $state('');
|
error = $state('');
|
||||||
weekWindow = $state(260); // ~5 years search window
|
weekWindow = $state(260); // ~5 years search window
|
||||||
@@ -12,6 +15,24 @@ class RoutingState {
|
|||||||
trajectoryPoints = $state<Float64Array>(new Float64Array(0));
|
trajectoryPoints = $state<Float64Array>(new Float64Array(0));
|
||||||
routeProgress = $state(0); // 0..1 along the full route
|
routeProgress = $state(0); // 0..1 along the full route
|
||||||
isAnimating = $state(false);
|
isAnimating = $state(false);
|
||||||
|
|
||||||
|
// Derived: currently selected route
|
||||||
|
get route(): RouteResult | null {
|
||||||
|
return this.routes[this.selectedRouteIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get routeCount(): number {
|
||||||
|
return this.routes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRoute(index: number) {
|
||||||
|
if (index >= 0 && index < this.routes.length) {
|
||||||
|
this.selectedRouteIndex = index;
|
||||||
|
// Reset animation when switching routes
|
||||||
|
this.routeProgress = 0;
|
||||||
|
// Trajectory will be resampled in the next frame by the renderer
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routing = new RoutingState();
|
export const routing = new RoutingState();
|
||||||
|
|||||||
@@ -81,21 +81,23 @@ export function sampleRouteTrajectory(
|
|||||||
return wasm.sample_route_trajectory(stationsJson, routeJson, jd, samplesPerLeg);
|
return wasm.sample_route_trajectory(stationsJson, routeJson, jd, samplesPerLeg);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeRoute(
|
export function computeRoutes(
|
||||||
stationsJson: string,
|
stationsJson: string,
|
||||||
fromStation: number,
|
fromStation: number,
|
||||||
toStation: number,
|
toStation: number,
|
||||||
launchVelocityKms: number,
|
launchVelocityKms: number,
|
||||||
startJd: number,
|
startJd: number,
|
||||||
weekWindow: number,
|
weekWindow: number,
|
||||||
): RouteResult | null {
|
): RouteResult[] {
|
||||||
const wasm = getWasm();
|
const wasm = getWasm();
|
||||||
if (!wasm) return null;
|
if (!wasm) return [];
|
||||||
const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow);
|
const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow);
|
||||||
if (!json) return null;
|
if (!json) return [];
|
||||||
try {
|
try {
|
||||||
return JSON.parse(json);
|
const result = JSON.parse(json);
|
||||||
|
// API now returns array of routes
|
||||||
|
return Array.isArray(result) ? result : [result];
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
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 { routing } from '$lib/stores/routing.svelte';
|
||||||
import { computeRoute, sampleRouteTrajectory } from '$lib/wasm/bridge';
|
import { computeRoutes, sampleRouteTrajectory } from '$lib/wasm/bridge';
|
||||||
import { formatWeeks } from '$lib/utils/format';
|
import { formatWeeks } from '$lib/utils/format';
|
||||||
|
|
||||||
|
const ROUTE_COLORS = ['#ff6a33', '#4a9eff', '#cc44ff'];
|
||||||
|
const ROUTE_LABELS = ['Fastest', 'Alternative 1', 'Alternative 2'];
|
||||||
|
|
||||||
function handleCompute() {
|
function handleCompute() {
|
||||||
if (routing.fromStation === null || routing.toStation === null) {
|
if (routing.fromStation === null || routing.toStation === null) {
|
||||||
routing.error = 'Select both start and end stations';
|
routing.error = 'Select both start and end stations';
|
||||||
@@ -17,13 +20,14 @@
|
|||||||
|
|
||||||
routing.isComputing = true;
|
routing.isComputing = true;
|
||||||
routing.error = '';
|
routing.error = '';
|
||||||
routing.route = null;
|
routing.routes = [];
|
||||||
|
routing.selectedRouteIndex = 0;
|
||||||
routing.trajectoryPoints = new Float64Array(0);
|
routing.trajectoryPoints = new Float64Array(0);
|
||||||
routing.routeProgress = 0;
|
routing.routeProgress = 0;
|
||||||
routing.isAnimating = false;
|
routing.isAnimating = false;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const result = computeRoute(
|
const results = computeRoutes(
|
||||||
stations.stationsJson,
|
stations.stationsJson,
|
||||||
routing.fromStation!,
|
routing.fromStation!,
|
||||||
routing.toStation!,
|
routing.toStation!,
|
||||||
@@ -32,11 +36,12 @@
|
|||||||
routing.weekWindow,
|
routing.weekWindow,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result && result.legs.length > 0) {
|
if (results.length > 0) {
|
||||||
routing.route = result;
|
routing.routes = results;
|
||||||
|
routing.selectedRouteIndex = 0;
|
||||||
|
|
||||||
// Sample trajectory for visualization
|
// Sample trajectory for the first (fastest) route
|
||||||
const routeJson = JSON.stringify(result);
|
const routeJson = JSON.stringify(results[0]);
|
||||||
routing.trajectoryPoints = sampleRouteTrajectory(
|
routing.trajectoryPoints = sampleRouteTrajectory(
|
||||||
stations.stationsJson, routeJson, simulation.currentJD, 60,
|
stations.stationsJson, routeJson, simulation.currentJD, 60,
|
||||||
);
|
);
|
||||||
@@ -48,11 +53,23 @@
|
|||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectRoute(index: number) {
|
||||||
|
routing.selectRoute(index);
|
||||||
|
// Resample trajectory for the newly selected route
|
||||||
|
const route = routing.routes[index];
|
||||||
|
if (route) {
|
||||||
|
const routeJson = JSON.stringify(route);
|
||||||
|
routing.trajectoryPoints = sampleRouteTrajectory(
|
||||||
|
stations.stationsJson, routeJson, simulation.currentJD, 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const windowOptions = [
|
const windowOptions = [
|
||||||
{ label: '1 year', value: 52 },
|
{ label: '1 yr', value: 52 },
|
||||||
{ label: '2 years', value: 104 },
|
{ label: '2 yr', value: 104 },
|
||||||
{ label: '5 years', value: 260 },
|
{ label: '5 yr', value: 260 },
|
||||||
{ label: '10 years', value: 520 },
|
{ label: '10 yr', value: 520 },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,132 +78,149 @@
|
|||||||
Route Planner
|
Route Planner
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- From station -->
|
<!-- From / To -->
|
||||||
<div>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">From</label>
|
<div>
|
||||||
<select
|
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">From</label>
|
||||||
bind:value={routing.fromStation}
|
<select
|
||||||
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]"
|
bind:value={routing.fromStation}
|
||||||
>
|
class="w-full bg-[var(--bg-primary)] text-[10px] text-[var(--text-primary)] border border-white/10 rounded px-1.5 py-1 [color-scheme:dark]"
|
||||||
<option value={null}>Select station...</option>
|
>
|
||||||
{#each stations.stationNames as name, i}
|
<option value={null}>Station...</option>
|
||||||
<option value={i}>{name}</option>
|
{#each stations.stationNames as name, i}
|
||||||
{/each}
|
<option value={i}>{name}</option>
|
||||||
</select>
|
{/each}
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
<!-- To station -->
|
<div>
|
||||||
<div>
|
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">To</label>
|
||||||
<label class="text-[10px] text-[var(--text-secondary)] mb-0.5 block">To</label>
|
<select
|
||||||
<select
|
bind:value={routing.toStation}
|
||||||
bind:value={routing.toStation}
|
class="w-full bg-[var(--bg-primary)] text-[10px] text-[var(--text-primary)] border border-white/10 rounded px-1.5 py-1 [color-scheme:dark]"
|
||||||
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}>Station...</option>
|
||||||
<option value={null}>Select station...</option>
|
{#each stations.stationNames as name, i}
|
||||||
{#each stations.stationNames as name, i}
|
<option value={i}>{name}</option>
|
||||||
<option value={i}>{name}</option>
|
{/each}
|
||||||
{/each}
|
</select>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compute button -->
|
<!-- Search window + Compute -->
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
{#each windowOptions as opt}
|
||||||
|
<button
|
||||||
|
onclick={() => { routing.weekWindow = opt.value; }}
|
||||||
|
class="flex-1 px-1 py-0.5 text-[9px] 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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={handleCompute}
|
onclick={handleCompute}
|
||||||
disabled={routing.isComputing || routing.fromStation === null || routing.toStation === null}
|
disabled={routing.isComputing || routing.fromStation === null || routing.toStation === null}
|
||||||
class="w-full py-2 px-3 text-xs font-semibold rounded transition-colors
|
class="w-full py-1.5 px-3 text-xs font-semibold rounded transition-colors
|
||||||
{routing.isComputing
|
{routing.isComputing
|
||||||
? 'bg-[var(--accent-blue)]/50 text-white/50 cursor-wait'
|
? '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'}"
|
: 'bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/80 disabled:opacity-30 disabled:cursor-not-allowed'}"
|
||||||
>
|
>
|
||||||
{#if routing.isComputing}
|
{routing.isComputing ? 'Computing...' : 'Find Routes'}
|
||||||
Computing route...
|
|
||||||
{:else}
|
|
||||||
Find Optimal Route
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
{#if routing.error}
|
{#if routing.error}
|
||||||
<p class="text-[10px] text-red-400">{routing.error}</p>
|
<p class="text-[10px] text-red-400">{routing.error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Route result -->
|
<!-- Route alternatives -->
|
||||||
{#if routing.route}
|
{#if routing.routes.length > 0}
|
||||||
<div class="border-t border-white/5 pt-2 mt-1">
|
<div class="border-t border-white/5 pt-2 mt-1">
|
||||||
<div class="flex justify-between items-baseline mb-2">
|
<!-- Route tabs -->
|
||||||
<span class="text-xs font-semibold text-[var(--accent-green)]">Route Found</span>
|
{#if routing.routes.length > 1}
|
||||||
<span class="text-sm font-bold text-[var(--text-primary)]">
|
<div class="flex gap-1 mb-2">
|
||||||
{formatWeeks(routing.route.total_time_weeks)}
|
{#each routing.routes as route, i}
|
||||||
</span>
|
<button
|
||||||
</div>
|
onclick={() => selectRoute(i)}
|
||||||
|
class="flex-1 px-1.5 py-1 text-[10px] rounded border transition-colors
|
||||||
|
{routing.selectedRouteIndex === i
|
||||||
|
? 'border-current text-white'
|
||||||
|
: 'border-white/10 text-[var(--text-secondary)] hover:border-white/20'}"
|
||||||
|
style={routing.selectedRouteIndex === i ? `color: ${ROUTE_COLORS[i]}; border-color: ${ROUTE_COLORS[i]}` : ''}
|
||||||
|
>
|
||||||
|
<div class="font-semibold">{ROUTE_LABELS[i]}</div>
|
||||||
|
<div class="text-[9px] opacity-70">{formatWeeks(route.total_time_weeks)}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="text-[10px] text-[var(--text-secondary)] mb-1">
|
<!-- Selected route details -->
|
||||||
Departs week {routing.route.departure_week} · {routing.route.legs.length} leg{routing.route.legs.length !== 1 ? 's' : ''}
|
{#if routing.route}
|
||||||
</div>
|
{@const route = routing.route}
|
||||||
|
{@const color = ROUTE_COLORS[routing.selectedRouteIndex]}
|
||||||
|
<div class="flex justify-between items-baseline mb-1">
|
||||||
|
<span class="text-xs font-semibold" style="color: {color}">
|
||||||
|
{routing.routes.length > 1 ? ROUTE_LABELS[routing.selectedRouteIndex] : 'Route Found'}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{formatWeeks(route.total_time_weeks)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Leg details -->
|
<div class="text-[10px] text-[var(--text-secondary)] mb-1">
|
||||||
<div class="flex flex-col gap-1">
|
Departs week {route.departure_week} · {route.legs.length} leg{route.legs.length !== 1 ? 's' : ''}
|
||||||
{#each routing.route.legs as leg, i}
|
</div>
|
||||||
<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">
|
<div class="flex flex-col gap-1">
|
||||||
{i + 1}
|
{#each route.legs as leg, i}
|
||||||
</span>
|
<div class="flex items-center gap-1.5 text-[10px]">
|
||||||
<div class="flex-1 min-w-0">
|
<span
|
||||||
<div class="text-[var(--text-primary)] truncate">
|
class="w-3 h-3 rounded-full flex items-center justify-center text-[8px] text-white font-bold shrink-0"
|
||||||
{stations.stationNames[leg.from_station]} → {stations.stationNames[leg.to_station]}
|
style="background-color: {color}"
|
||||||
</div>
|
>
|
||||||
<div class="text-[var(--text-secondary)]">
|
{i + 1}
|
||||||
{formatWeeks(leg.arrival_week - leg.departure_week)} transit
|
</span>
|
||||||
{#if leg.wait_weeks > 0}
|
<div class="flex-1 min-w-0">
|
||||||
· {formatWeeks(leg.wait_weeks)} wait
|
<div class="text-[var(--text-primary)] truncate">
|
||||||
{/if}
|
{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}
|
||||||
|
· {formatWeeks(leg.wait_weeks)} wait
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Animation controls -->
|
|
||||||
{#if routing.trajectoryPoints.length > 0}
|
|
||||||
<div class="mt-2 pt-2 border-t border-white/5">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<span class="text-[10px] text-[var(--text-secondary)]">Package Progress</span>
|
|
||||||
<button
|
|
||||||
onclick={() => { routing.isAnimating = !routing.isAnimating; }}
|
|
||||||
class="text-[10px] px-1.5 py-0.5 rounded {routing.isAnimating ? 'bg-[var(--accent-green)]/20 text-[var(--accent-green)]' : 'bg-white/5 text-[var(--text-secondary)]'}"
|
|
||||||
>
|
|
||||||
{routing.isAnimating ? 'Animating' : 'Paused'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.002"
|
|
||||||
bind:value={routing.routeProgress}
|
|
||||||
class="w-full h-1 rounded-full appearance-none bg-white/10 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--accent-green)] [&::-webkit-slider-thumb]:cursor-pointer"
|
|
||||||
/>
|
|
||||||
<div class="text-[9px] text-[var(--text-secondary)] text-center mt-0.5">
|
|
||||||
{Math.round(routing.routeProgress * 100)}% complete
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Animation controls -->
|
||||||
|
{#if routing.trajectoryPoints.length > 0}
|
||||||
|
<div class="mt-2 pt-2 border-t border-white/5">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-[10px] text-[var(--text-secondary)]">Package</span>
|
||||||
|
<button
|
||||||
|
onclick={() => { routing.isAnimating = !routing.isAnimating; }}
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded {routing.isAnimating ? 'bg-[var(--accent-green)]/20 text-[var(--accent-green)]' : 'bg-white/5 text-[var(--text-secondary)]'}"
|
||||||
|
>
|
||||||
|
{routing.isAnimating ? 'Playing' : 'Paused'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.002"
|
||||||
|
bind:value={routing.routeProgress}
|
||||||
|
class="w-full h-1 rounded-full appearance-none bg-white/10 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
|
||||||
|
style="--tw-ring-color: {color}; [&::-webkit-slider-thumb]:background-color: {color}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user