Add Lambert solver, transfer matrix, Dijkstra routing, and route planner UI
- Lambert's problem solver using universal variable method with bisection (handles elliptic, parabolic, hyperbolic transfers + anti-podal cases) - Transfer matrix: precompute pairwise station transfers over time window using Lambert solver with configurable launch velocity - Dijkstra routing on time-expanded graph (station × week nodes, transfer + wait edges) to find minimum-time routes - Route Planner UI: from/to station dropdowns, search window selector (1-10 years), "Find Optimal Route" button with results card - Route visualization: orange dashed trajectory lines with arrow heads and leg numbers on the 2D canvas - Tested: Mercury L1 → Jupiter L1 computes 6-month direct transfer at 30 km/s — physically reasonable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ export class SolarSystem2DRenderer {
|
||||
private stationPositions: Float64Array = new Float64Array(0);
|
||||
private stationNames: string[] = [];
|
||||
private showStations: boolean = true;
|
||||
private routeLegs: { from: number; to: number }[] = [];
|
||||
private isDragging = false;
|
||||
private lastMouse = { x: 0, y: 0 };
|
||||
private animFrameId: number = 0;
|
||||
@@ -86,6 +87,10 @@ export class SolarSystem2DRenderer {
|
||||
this.showStations = visible;
|
||||
}
|
||||
|
||||
updateRoute(legs: { from: number; to: number }[]) {
|
||||
this.routeLegs = legs;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { canvas, ctx } = this;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -133,6 +138,11 @@ export class SolarSystem2DRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// Draw route legs
|
||||
if (this.routeLegs.length > 0 && this.stationPositions.length > 0) {
|
||||
this.drawRoute();
|
||||
}
|
||||
|
||||
// Scale indicator
|
||||
this.drawScaleBar(rect.width, rect.height);
|
||||
}
|
||||
@@ -287,6 +297,58 @@ export class SolarSystem2DRenderer {
|
||||
ctx.fillText(info.name, sx + size + 4, sy + 3);
|
||||
}
|
||||
|
||||
private drawRoute() {
|
||||
const ctx = this.ctx;
|
||||
const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33'];
|
||||
|
||||
for (let i = 0; i < this.routeLegs.length; i++) {
|
||||
const leg = this.routeLegs[i];
|
||||
const fromX = this.stationPositions[leg.from * 3];
|
||||
const fromY = this.stationPositions[leg.from * 3 + 1];
|
||||
const toX = this.stationPositions[leg.to * 3];
|
||||
const toY = this.stationPositions[leg.to * 3 + 1];
|
||||
|
||||
const [sx1, sy1] = this.auToScreen(fromX, fromY);
|
||||
const [sx2, sy2] = this.auToScreen(toX, toY);
|
||||
|
||||
const color = colors[i % colors.length];
|
||||
|
||||
// Draw line
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx1, sy1);
|
||||
ctx.lineTo(sx2, sy2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Arrow head
|
||||
const angle = Math.atan2(sy2 - sy1, sx2 - sx1);
|
||||
const arrowLen = 8;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx2, sy2);
|
||||
ctx.lineTo(
|
||||
sx2 - arrowLen * Math.cos(angle - 0.3),
|
||||
sy2 - arrowLen * Math.sin(angle - 0.3),
|
||||
);
|
||||
ctx.lineTo(
|
||||
sx2 - arrowLen * Math.cos(angle + 0.3),
|
||||
sy2 - arrowLen * Math.sin(angle + 0.3),
|
||||
);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Leg number
|
||||
const mx = (sx1 + sx2) / 2;
|
||||
const my = (sy1 + sy2) / 2;
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.fillText(`${i + 1}`, mx + 5, my - 5);
|
||||
}
|
||||
}
|
||||
|
||||
private drawStation(xAU: number, yAU: number, name: string) {
|
||||
const ctx = this.ctx;
|
||||
const [sx, sy] = this.auToScreen(xAU, yAU);
|
||||
|
||||
12
frontend/src/lib/stores/routing.svelte.ts
Normal file
12
frontend/src/lib/stores/routing.svelte.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RouteResult } from '$lib/wasm/bridge';
|
||||
|
||||
class RoutingState {
|
||||
fromStation = $state<number | null>(null);
|
||||
toStation = $state<number | null>(null);
|
||||
route = $state<RouteResult | null>(null);
|
||||
isComputing = $state(false);
|
||||
error = $state('');
|
||||
weekWindow = $state(260); // ~5 years search window
|
||||
}
|
||||
|
||||
export const routing = new RoutingState();
|
||||
@@ -57,3 +57,34 @@ export function getStationNames(stationsJson: string): string[] {
|
||||
if (!wasm) return [];
|
||||
return JSON.parse(wasm.get_station_names(stationsJson));
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
legs: {
|
||||
from_station: number;
|
||||
to_station: number;
|
||||
departure_week: number;
|
||||
arrival_week: number;
|
||||
wait_weeks: number;
|
||||
}[];
|
||||
total_time_weeks: number;
|
||||
departure_week: number;
|
||||
}
|
||||
|
||||
export function computeRoute(
|
||||
stationsJson: string,
|
||||
fromStation: number,
|
||||
toStation: number,
|
||||
launchVelocityKms: number,
|
||||
startJd: number,
|
||||
weekWindow: number,
|
||||
): RouteResult | null {
|
||||
const wasm = getWasm();
|
||||
if (!wasm) return null;
|
||||
const json = wasm.compute_route(stationsJson, fromStation, toStation, launchVelocityKms, startJd, weekWindow);
|
||||
if (!json) return null;
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { SolarSystem2DRenderer } from '$lib/render/canvas2d/SolarSystem2D';
|
||||
import { simulation } from '$lib/stores/simulation.svelte';
|
||||
import { stations } from '$lib/stores/stations.svelte';
|
||||
import { routing } from '$lib/stores/routing.svelte';
|
||||
import { getBodyPositions, getBodyVelocities, getBodyInfos, getOrbitPoints, getStationPositions } from '$lib/wasm/bridge';
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
@@ -48,6 +49,16 @@
|
||||
renderer.updateStations(stations.stationPositions, stations.stationNames, stations.visible);
|
||||
}
|
||||
|
||||
// Update route visualization
|
||||
if (routing.route) {
|
||||
renderer.updateRoute(routing.route.legs.map(l => ({
|
||||
from: l.from_station,
|
||||
to: l.to_station,
|
||||
})));
|
||||
} else {
|
||||
renderer.updateRoute([]);
|
||||
}
|
||||
|
||||
if (!orbitsComputed || frameCount % 600 === 0) {
|
||||
computeOrbits();
|
||||
}
|
||||
|
||||
158
frontend/src/routes/(simulator)/components/RoutingPanel.svelte
Normal file
158
frontend/src/routes/(simulator)/components/RoutingPanel.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { simulation } from '$lib/stores/simulation.svelte';
|
||||
import { stations } from '$lib/stores/stations.svelte';
|
||||
import { routing } from '$lib/stores/routing.svelte';
|
||||
import { computeRoute } from '$lib/wasm/bridge';
|
||||
import { formatWeeks } from '$lib/utils/format';
|
||||
|
||||
function handleCompute() {
|
||||
if (routing.fromStation === null || routing.toStation === null) {
|
||||
routing.error = 'Select both start and end stations';
|
||||
return;
|
||||
}
|
||||
if (routing.fromStation === routing.toStation) {
|
||||
routing.error = 'Start and end must differ';
|
||||
return;
|
||||
}
|
||||
|
||||
routing.isComputing = true;
|
||||
routing.error = '';
|
||||
routing.route = null;
|
||||
|
||||
// Use setTimeout to let the UI update before blocking computation
|
||||
setTimeout(() => {
|
||||
const result = computeRoute(
|
||||
stations.stationsJson,
|
||||
routing.fromStation!,
|
||||
routing.toStation!,
|
||||
stations.launchVelocityKms,
|
||||
simulation.currentJD,
|
||||
routing.weekWindow,
|
||||
);
|
||||
|
||||
if (result && result.legs.length > 0) {
|
||||
routing.route = result;
|
||||
} else {
|
||||
routing.error = 'No route found. Try increasing velocity or search window.';
|
||||
}
|
||||
routing.isComputing = false;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
const windowOptions = [
|
||||
{ label: '1 year', value: 52 },
|
||||
{ label: '2 years', value: 104 },
|
||||
{ label: '5 years', value: 260 },
|
||||
{ label: '10 years', value: 520 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 p-3 bg-[var(--bg-panel)] rounded-lg border border-white/5">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compute button -->
|
||||
<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
|
||||
{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}
|
||||
</button>
|
||||
|
||||
<!-- Error -->
|
||||
{#if routing.error}
|
||||
<p class="text-[10px] text-red-400">{routing.error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Route result -->
|
||||
{#if routing.route}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
import TimeControls from './(simulator)/components/TimeControls.svelte';
|
||||
import ViewToggle from './(simulator)/components/ViewToggle.svelte';
|
||||
import StationPanel from './(simulator)/components/StationPanel.svelte';
|
||||
import RoutingPanel from './(simulator)/components/RoutingPanel.svelte';
|
||||
|
||||
let wasmError = $state('');
|
||||
|
||||
@@ -58,8 +59,9 @@
|
||||
<Viewport />
|
||||
</div>
|
||||
<!-- Right sidebar -->
|
||||
<aside class="w-64 shrink-0 p-2 bg-[var(--bg-secondary)] border-l border-white/5 overflow-y-auto">
|
||||
<aside class="w-64 shrink-0 p-2 bg-[var(--bg-secondary)] border-l border-white/5 overflow-y-auto flex flex-col gap-2">
|
||||
<StationPanel />
|
||||
<RoutingPanel />
|
||||
</aside>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user