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::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
}

View File

@@ -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()
} }
} }

View File

@@ -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();

View File

@@ -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 [];
} }
} }

View File

@@ -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} &middot; {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} &middot; {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">
&middot; {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}
&middot; {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}