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.
124 lines
3.8 KiB
TypeScript
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 };
|
|
}
|