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

View File

@@ -3,7 +3,10 @@ import type { RouteResult } from '$lib/wasm/bridge';
class RoutingState {
fromStation = $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);
error = $state('');
weekWindow = $state(260); // ~5 years search window
@@ -12,6 +15,24 @@ class RoutingState {
trajectoryPoints = $state<Float64Array>(new Float64Array(0));
routeProgress = $state(0); // 0..1 along the full route
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();

View File

@@ -81,21 +81,23 @@ export function sampleRouteTrajectory(
return wasm.sample_route_trajectory(stationsJson, routeJson, jd, samplesPerLeg);
}
export function computeRoute(
export function computeRoutes(
stationsJson: string,
fromStation: number,
toStation: number,
launchVelocityKms: number,
startJd: number,
weekWindow: number,
): RouteResult | null {
): RouteResult[] {
const wasm = getWasm();
if (!wasm) return null;
if (!wasm) return [];
const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow);
if (!json) return null;
if (!json) return [];
try {
return JSON.parse(json);
const result = JSON.parse(json);
// API now returns array of routes
return Array.isArray(result) ? result : [result];
} catch {
return null;
return [];
}
}

View File

@@ -2,9 +2,12 @@
import { simulation } from '$lib/stores/simulation.svelte';
import { stations } from '$lib/stores/stations.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';
const ROUTE_COLORS = ['#ff6a33', '#4a9eff', '#cc44ff'];
const ROUTE_LABELS = ['Fastest', 'Alternative 1', 'Alternative 2'];
function handleCompute() {
if (routing.fromStation === null || routing.toStation === null) {
routing.error = 'Select both start and end stations';
@@ -17,13 +20,14 @@
routing.isComputing = true;
routing.error = '';
routing.route = null;
routing.routes = [];
routing.selectedRouteIndex = 0;
routing.trajectoryPoints = new Float64Array(0);
routing.routeProgress = 0;
routing.isAnimating = false;
setTimeout(() => {
const result = computeRoute(
const results = computeRoutes(
stations.stationsJson,
routing.fromStation!,
routing.toStation!,
@@ -32,11 +36,12 @@
routing.weekWindow,
);
if (result && result.legs.length > 0) {
routing.route = result;
if (results.length > 0) {
routing.routes = results;
routing.selectedRouteIndex = 0;
// Sample trajectory for visualization
const routeJson = JSON.stringify(result);
// Sample trajectory for the first (fastest) route
const routeJson = JSON.stringify(results[0]);
routing.trajectoryPoints = sampleRouteTrajectory(
stations.stationsJson, routeJson, simulation.currentJD, 60,
);
@@ -48,11 +53,23 @@
}, 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 = [
{ label: '1 year', value: 52 },
{ label: '2 years', value: 104 },
{ label: '5 years', value: 260 },
{ label: '10 years', value: 520 },
{ label: '1 yr', value: 52 },
{ label: '2 yr', value: 104 },
{ label: '5 yr', value: 260 },
{ label: '10 yr', value: 520 },
];
</script>
@@ -61,132 +78,149 @@
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}
<!-- From / To -->
<div class="grid grid-cols-2 gap-2">
<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-[10px] text-[var(--text-primary)] border border-white/10 rounded px-1.5 py-1 [color-scheme:dark]"
>
<option value={null}>Station...</option>
{#each stations.stationNames as name, i}
<option value={i}>{name}</option>
{/each}
</select>
</div>
<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-[10px] text-[var(--text-primary)] border border-white/10 rounded px-1.5 py-1 [color-scheme:dark]"
>
<option value={null}>Station...</option>
{#each stations.stationNames as name, i}
<option value={i}>{name}</option>
{/each}
</select>
</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
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
class="w-full py-1.5 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}
{routing.isComputing ? 'Computing...' : 'Find Routes'}
</button>
<!-- Error -->
{#if routing.error}
<p class="text-[10px] text-red-400">{routing.error}</p>
{/if}
<!-- Route result -->
{#if routing.route}
<!-- Route alternatives -->
{#if routing.routes.length > 0}
<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>
<!-- Route tabs -->
{#if routing.routes.length > 1}
<div class="flex gap-1 mb-2">
{#each routing.routes as route, i}
<button
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">
Departs week {routing.route.departure_week} &middot; {routing.route.legs.length} leg{routing.route.legs.length !== 1 ? 's' : ''}
</div>
<!-- Selected route details -->
{#if routing.route}
{@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="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 class="text-[10px] text-[var(--text-secondary)] mb-1">
Departs week {route.departure_week} &middot; {route.legs.length} leg{route.legs.length !== 1 ? 's' : ''}
</div>
<div class="flex flex-col gap-1">
{#each route.legs as leg, i}
<div class="flex items-center gap-1.5 text-[10px]">
<span
class="w-3 h-3 rounded-full flex items-center justify-center text-[8px] text-white font-bold shrink-0"
style="background-color: {color}"
>
{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>
</div>
{/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>
{/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</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}
</div>
{/if}