Add animated package along trajectory with Kepler propagation

- Trajectory sampling: propagate Lambert transfer orbits using universal
  variable Kepler propagation, sample 60 points per leg
- Smooth curved trajectory lines on 2D canvas (real orbital arcs, not
  straight lines)
- Animated green package dot with radial glow traveling along trajectory
- Route progress slider (0-100%) with play/pause animation toggle
- Auto-animating ~20-second cycle through the full route
- WASM API: sample_route_trajectory() with per-leg NaN separators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:21:58 -07:00
parent 22dcc5b6ec
commit 21c842acdc
8 changed files with 329 additions and 38 deletions

View File

@@ -21,6 +21,8 @@ export class SolarSystem2DRenderer {
private stationNames: string[] = [];
private showStations: boolean = true;
private routeLegs: { from: number; to: number }[] = [];
private trajectoryPoints: Float64Array = new Float64Array(0);
private routeProgress: number = 0;
private isDragging = false;
private lastMouse = { x: 0, y: 0 };
private animFrameId: number = 0;
@@ -87,8 +89,14 @@ export class SolarSystem2DRenderer {
this.showStations = visible;
}
updateRoute(legs: { from: number; to: number }[]) {
updateRoute(
legs: { from: number; to: number }[],
trajectoryPoints?: Float64Array,
progress?: number,
) {
this.routeLegs = legs;
if (trajectoryPoints) this.trajectoryPoints = trajectoryPoints;
if (progress !== undefined) this.routeProgress = progress;
}
render() {
@@ -299,10 +307,79 @@ export class SolarSystem2DRenderer {
private drawRoute() {
const ctx = this.ctx;
const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33'];
// Draw smooth trajectory curve from sampled points
if (this.trajectoryPoints.length >= 6) {
ctx.strokeStyle = 'rgba(255, 106, 51, 0.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
let moved = false;
for (let i = 0; i < this.trajectoryPoints.length; i += 3) {
const x = this.trajectoryPoints[i];
const y = this.trajectoryPoints[i + 1];
if (isNaN(x) || isNaN(y)) {
// Leg separator — start new sub-path
moved = false;
continue;
}
const [sx, sy] = this.auToScreen(x, y);
if (!moved) {
ctx.moveTo(sx, sy);
moved = true;
} else {
ctx.lineTo(sx, sy);
}
}
ctx.stroke();
// Draw animated package dot along trajectory
const totalPoints = Math.floor(this.trajectoryPoints.length / 3);
// Filter out NaN separators for interpolation
const validPoints: number[] = [];
for (let i = 0; i < this.trajectoryPoints.length; i += 3) {
if (!isNaN(this.trajectoryPoints[i])) {
validPoints.push(i);
}
}
if (validPoints.length > 1) {
const idx = Math.min(
Math.floor(this.routeProgress * (validPoints.length - 1)),
validPoints.length - 1,
);
const pIdx = validPoints[idx];
const px = this.trajectoryPoints[pIdx];
const py = this.trajectoryPoints[pIdx + 1];
const [sx, sy] = this.auToScreen(px, py);
// Package glow
const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, 12);
gradient.addColorStop(0, 'rgba(51, 255, 136, 0.8)');
gradient.addColorStop(0.5, 'rgba(51, 255, 136, 0.2)');
gradient.addColorStop(1, 'rgba(51, 255, 136, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(sx, sy, 12, 0, Math.PI * 2);
ctx.fill();
// Package core
ctx.fillStyle = '#33ff88';
ctx.beginPath();
ctx.arc(sx, sy, 3, 0, Math.PI * 2);
ctx.fill();
}
}
// Draw station-to-station connection lines (faded, for reference)
const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33'];
for (let i = 0; i < this.routeLegs.length; i++) {
const leg = this.routeLegs[i];
if (leg.from * 3 + 1 >= this.stationPositions.length) continue;
if (leg.to * 3 + 1 >= this.stationPositions.length) continue;
const fromX = this.stationPositions[leg.from * 3];
const fromY = this.stationPositions[leg.from * 3 + 1];
const toX = this.stationPositions[leg.to * 3];
@@ -310,42 +387,17 @@ export class SolarSystem2DRenderer {
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]);
// Faint dashed reference line
ctx.strokeStyle = color + '40';
ctx.lineWidth = 1;
ctx.setLineDash([4, 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);
}
}

View File

@@ -7,6 +7,11 @@ class RoutingState {
isComputing = $state(false);
error = $state('');
weekWindow = $state(260); // ~5 years search window
// Route animation
trajectoryPoints = $state<Float64Array>(new Float64Array(0));
routeProgress = $state(0); // 0..1 along the full route
isAnimating = $state(false);
}
export const routing = new RoutingState();

View File

@@ -70,6 +70,17 @@ export interface RouteResult {
departure_week: number;
}
export function sampleRouteTrajectory(
stationsJson: string,
routeJson: string,
jd: number,
samplesPerLeg: number = 50,
): Float64Array {
const wasm = getWasm();
if (!wasm) return new Float64Array(0);
return wasm.sample_route_trajectory(stationsJson, routeJson, jd, samplesPerLeg);
}
export function computeRoute(
stationsJson: string,
fromStation: number,

View File

@@ -51,10 +51,20 @@
// Update route visualization
if (routing.route) {
renderer.updateRoute(routing.route.legs.map(l => ({
from: l.from_station,
to: l.to_station,
})));
// Advance animation
if (routing.isAnimating) {
routing.routeProgress += dt * 0.05; // ~20 seconds for full route
if (routing.routeProgress > 1) routing.routeProgress = 0;
}
renderer.updateRoute(
routing.route.legs.map(l => ({
from: l.from_station,
to: l.to_station,
})),
routing.trajectoryPoints,
routing.routeProgress,
);
} else {
renderer.updateRoute([]);
}

View File

@@ -2,7 +2,7 @@
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 { computeRoute, sampleRouteTrajectory } from '$lib/wasm/bridge';
import { formatWeeks } from '$lib/utils/format';
function handleCompute() {
@@ -18,8 +18,10 @@
routing.isComputing = true;
routing.error = '';
routing.route = null;
routing.trajectoryPoints = new Float64Array(0);
routing.routeProgress = 0;
routing.isAnimating = false;
// Use setTimeout to let the UI update before blocking computation
setTimeout(() => {
const result = computeRoute(
stations.stationsJson,
@@ -32,6 +34,13 @@
if (result && result.legs.length > 0) {
routing.route = result;
// Sample trajectory for visualization
const routeJson = JSON.stringify(result);
routing.trajectoryPoints = sampleRouteTrajectory(
stations.stationsJson, routeJson, simulation.currentJD, 60,
);
routing.isAnimating = true;
} else {
routing.error = 'No route found. Try increasing velocity or search window.';
}
@@ -153,6 +162,32 @@
</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>
</div>
{/if}
</div>
{/if}
</div>