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,109 @@
|
||||
/**
|
||||
* VesselList — sidebar of currently-known vessels.
|
||||
*
|
||||
* Click a vessel to track it (the camera follows + the orbit is
|
||||
* highlighted). Click again to untrack.
|
||||
*/
|
||||
import type { Vessel } from '@kerbal-rt/shared-types';
|
||||
import { vesselColor } from '../scene/color.js';
|
||||
|
||||
export interface VesselListProps {
|
||||
vessels: Vessel[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const PANEL_STYLE: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 110,
|
||||
left: 12,
|
||||
width: 280,
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflow: 'auto',
|
||||
padding: '0.5rem',
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
color: 'white',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
};
|
||||
|
||||
const ROW_STYLE: React.CSSProperties = {
|
||||
padding: '0.4rem 0.5rem',
|
||||
marginBottom: 2,
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
const ROW_ACTIVE: React.CSSProperties = {
|
||||
...ROW_STYLE,
|
||||
background: 'rgba(255,255,255,0.12)',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
};
|
||||
|
||||
const DOT_STYLE: (color: number) => React.CSSProperties = (color) => ({
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: `#${color.toString(16).padStart(6, '0')}`,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export function VesselList({ vessels, selectedId, onSelect }: VesselListProps) {
|
||||
const sorted = [...vessels].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return (
|
||||
<div style={PANEL_STYLE}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
opacity: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Vessels ({vessels.length})
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<div style={{ opacity: 0.4, padding: 4 }}>No vessels yet.</div>
|
||||
) : (
|
||||
sorted.map((v) => {
|
||||
const color = vesselColor(v.owner);
|
||||
const isActive = selectedId === v.id;
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
style={isActive ? ROW_ACTIVE : ROW_STYLE}
|
||||
onClick={() => onSelect(isActive ? null : v.id)}
|
||||
title={v.id}
|
||||
>
|
||||
<span style={DOT_STYLE(color)} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
}}
|
||||
>
|
||||
{v.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.6, fontSize: 10 }}>
|
||||
{v.owner ?? '—'} · {v.situation.toLowerCase()} · {v.referenceBodyId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user