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:
@@ -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();
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} · {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}
|
||||
· {formatWeeks(leg.wait_weeks)} wait
|
||||
{/if}
|
||||
<div class="text-[10px] text-[var(--text-secondary)] mb-1">
|
||||
Departs week {route.departure_week} · {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}
|
||||
· {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}
|
||||
|
||||
Reference in New Issue
Block a user