Files
KSP-MissionControl/apps/live-map/src/hooks/useLiveState.ts
T
Mavis 9e76ec9328
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
Phase 2: 3D live map driven by API WebSocket
- 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.
2026-06-02 19:18:22 +00:00

124 lines
3.8 KiB
TypeScript

/**
* 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 };
}