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

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