Phase 2: 3D live map driven by API WebSocket
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
- apps/live-map/src/hooks/useLiveState.ts: WebSocket subscription with exponential-backoff reconnect, polling fallback, status tracking - apps/live-map/src/scene/Scene.tsx: refactored Three.js scene with per-frame orbit propagation, vessel marker color-coding by owner, orbit-line visibility tied to focus filters, smooth camera follow on selected vessel/body - apps/live-map/src/scene/layout.ts: bodyPositionAt / vesselPositionAt helpers (heliocentric frame, walk up the parent chain), logScale helpers for the system view - apps/live-map/src/scene/color.ts: per-body and per-owner color maps - apps/live-map/src/panels/TimeControls.tsx: play/pause/reverse/reset buttons, ×1/×10/×100/×1k/×10k/×100k speeds, UT scrub slider, live-edge indicator (LIVE / Nh behind / Nh ahead) - apps/live-map/src/panels/VesselList.tsx: vessel sidebar with click- to-track; color-coded by owner (KASA=blue, SPES=orange) - apps/live-map/src/panels/FocusPanel.tsx: planet/moon/vessel orbit visibility toggles - apps/live-map/src/panels/StatusPill.tsx: WS status (LIVE/POLLING/ OFFLINE/STALE), body + vessel + message counts - tests/scene.test.ts: 10 tests for layout helpers (periodicity, vessel-centered positioning, logScale round-trips) End-to-end verified: mock publisher → API → live-map WebSocket → scene re-renders with the new vessel positions and orbits.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* useLiveState — subscribe to the kerbal-rt API over WebSocket and
|
||||
* expose the latest UniverseSnapshot plus connection status.
|
||||
*
|
||||
* Auto-reconnects with exponential backoff on drop. Falls back to
|
||||
* polling /api/v1/state every 5s if WebSocket fails to connect after
|
||||
* 3 retries (e.g. a proxy doesn't support upgrade).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types';
|
||||
|
||||
export type ConnectionState = 'connecting' | 'open' | 'closed' | 'fallback';
|
||||
|
||||
export interface LiveState {
|
||||
snapshot: UniverseSnapshot | null;
|
||||
status: ConnectionState;
|
||||
lastUpdate: string | null;
|
||||
messageCount: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10_000, 30_000];
|
||||
|
||||
export function useLiveState(apiUrl: string): LiveState {
|
||||
const [snapshot, setSnapshot] = useState<UniverseSnapshot | null>(null);
|
||||
const [status, setStatus] = useState<ConnectionState>('connecting');
|
||||
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
|
||||
const [messageCount, setMessageCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
const stopped = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
stopped.current = false;
|
||||
let pollTimer: number | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
const startPollingFallback = () => {
|
||||
if (pollTimer) return;
|
||||
setStatus('fallback');
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/v1/state`);
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
if (!body.error && body.data) {
|
||||
setSnapshot(body.data as UniverseSnapshot);
|
||||
setLastUpdate(new Date().toISOString());
|
||||
setMessageCount((n) => n + 1);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
pollTimer = window.setInterval(tick, 5000);
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (stopped.current) return;
|
||||
if (reconnectAttempt.current >= RECONNECT_DELAYS_MS.length) {
|
||||
// Gave up on WS, fall back to polling.
|
||||
startPollingFallback();
|
||||
return;
|
||||
}
|
||||
const delay = RECONNECT_DELAYS_MS[reconnectAttempt.current] ?? 30_000;
|
||||
reconnectAttempt.current += 1;
|
||||
setStatus('closed');
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (stopped.current) return;
|
||||
setStatus('connecting');
|
||||
setError(null);
|
||||
const wsUrl = apiUrl.replace(/^http/, 'ws') + '/api/v1/live';
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e));
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
setStatus('open');
|
||||
reconnectAttempt.current = 0; // success — reset backoff
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
setMessageCount((n) => n + 1);
|
||||
setLastUpdate(new Date().toISOString());
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as LiveMessage;
|
||||
if (msg.type === 'snapshot') {
|
||||
setSnapshot(msg.snapshot);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`bad message: ${String((e as Error).message ?? e)}`);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
// close will fire too
|
||||
};
|
||||
ws.onclose = () => {
|
||||
scheduleReconnect();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
stopped.current = true;
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
if (pollTimer) window.clearInterval(pollTimer);
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, [apiUrl]);
|
||||
|
||||
return { snapshot, status, lastUpdate, messageCount, error };
|
||||
}
|
||||
Reference in New Issue
Block a user