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:
2026-04-08 12:48:39 -07:00
parent 2ebe0e90d0
commit 3099b58ea0
5 changed files with 420 additions and 219 deletions

View File

@@ -1,145 +1,118 @@
use crate::route::{RouteLeg, RouteResult};
use crate::transfer_matrix::TransferMatrix;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::collections::{BinaryHeap, HashSet};
/// 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(
/// Build the transfer lookup table for fast Dijkstra edge iteration.
fn build_transfer_lookup(matrix: &TransferMatrix) -> Vec<Vec<(u16, u16)>> {
let n = matrix.station_count;
let t_max = matrix.week_count;
let mut 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 < lookup.len() {
lookup[key].push((entry.to, entry.travel_weeks));
}
}
lookup
}
/// Run Dijkstra with optional node exclusions.
/// Returns (path_as_node_ids, total_cost) or None.
fn dijkstra_with_exclusions(
matrix: &TransferMatrix,
transfer_lookup: &[Vec<(u16, u16)>],
from: usize,
to: usize,
earliest_week: usize,
latest_week: usize,
) -> Option<RouteResult> {
excluded_nodes: &HashSet<usize>,
) -> Option<(Vec<usize>, u32)> {
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
if excluded_nodes.contains(&node) {
continue;
}
let cost = (week - earliest_week) as u32;
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));
// 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] {
continue;
}
// Wait edge: stay at this station for one more week
// Wait edge
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)));
let v = u + 1;
if !excluded_nodes.contains(&v) {
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
// Transfer edges
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)));
if !excluded_nodes.contains(&v) {
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
None
}
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
/// Convert a raw node path into a RouteResult.
fn path_to_route(path: &[usize], t_max: usize) -> RouteResult {
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;
@@ -147,7 +120,7 @@ fn reconstruct_route(
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 departure_week = path[j - 1] % t_max;
let arrival_week = path[j] % t_max;
let wait_weeks = if i == 0 {
departure_week - (path[0] % t_max)
@@ -167,12 +140,7 @@ fn reconstruct_route(
i = j;
}
let departure_week = if !path.is_empty() {
path[0] % t_max
} else {
earliest_week
};
let departure_week = if !path.is_empty() { path[0] % t_max } else { 0 };
let total_time = if !path.is_empty() {
(path.last().unwrap() % t_max) - departure_week
} else {
@@ -185,3 +153,177 @@ fn reconstruct_route(
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
}

View File

@@ -213,17 +213,19 @@ pub fn compute_route(
},
);
// Find optimal route
let route = router::find_optimal_route(
// Find up to 3 shortest routes using Yen's algorithm
let routes = router::find_k_shortest_routes(
&matrix,
from_station,
to_station,
0,
week_window.saturating_sub(1),
3,
);
match route {
Some(r) => serde_json::to_string(&r).unwrap_or_default(),
None => String::new(),
if routes.is_empty() {
String::new()
} else {
serde_json::to_string(&routes).unwrap_or_default()
}
}