'use client'; import { useEffect, useRef, useState } from 'react'; import { WorldMap, type MapPoint } from '@/components/map/world-map'; import { clientApi } from '@/lib/client-api'; import { fmtDateTime } from '@/lib/format'; import type { LivePosition, TrackPoint } from '@/lib/types'; const POLL_MS = 5000; interface Props { ident: string; name: string; initial: LivePosition | null; sourceEpoch: string | null; } function fmtCoord(v: number, pos: string, neg: string): string { return `${Math.abs(v).toFixed(3)}° ${v >= 0 ? pos : neg}`; } /** * Live ground track + current position. Server passes the first fix (from the detail payload) so the panel is * meaningful before hydration; the client then loads the ±track once and polls `/live` every 5 s while visible. */ export function LiveMap({ ident, name, initial, sourceEpoch }: Props) { const [live, setLive] = useState(initial); const [track, setTrack] = useState(null); const [trackError, setTrackError] = useState(false); const [tick, setTick] = useState(null); const [failures, setFailures] = useState(0); const timer = useRef | null>(null); useEffect(() => { const ctrl = new AbortController(); clientApi .track(ident, ctrl.signal) .then((r) => setTrack(r.data.points)) .catch(() => { if (!ctrl.signal.aborted) setTrackError(true); }); return () => ctrl.abort(); }, [ident]); useEffect(() => { let ctrl: AbortController | null = null; const poll = async () => { ctrl?.abort(); ctrl = new AbortController(); try { const r = await clientApi.live(ident, ctrl.signal); if (r.data && r.data.error == null && Number.isFinite(r.data.lat)) { setLive(r.data); setTick(Date.now()); setFailures(0); } else setFailures((f) => f + 1); } catch { if (!ctrl?.signal.aborted) setFailures((f) => f + 1); } }; const start = () => { if (timer.current) clearInterval(timer.current); void poll(); timer.current = setInterval(poll, POLL_MS); }; const stop = () => { if (timer.current) clearInterval(timer.current); timer.current = null; }; const onVis = () => (document.hidden ? stop() : start()); start(); document.addEventListener('visibilitychange', onVis); return () => { stop(); ctrl?.abort(); document.removeEventListener('visibilitychange', onVis); }; }, [ident]); const past: MapPoint[] = track ? track.filter((p) => !p.future).map((p) => ({ lat: p.lat, lon: p.lon })) : []; const future: MapPoint[] = track ? track.filter((p) => p.future).map((p) => ({ lat: p.lat, lon: p.lon })) : []; // join the two segments at the present so the line is continuous const lastPast = past[past.length - 1]; if (lastPast && future.length) future.unshift(lastPast); const tracks = [ { points: past, color: 'var(--ink-3)', dashed: true, width: 1.2 }, { points: future, color: 'var(--accent)', width: 1.8 }, ]; const markers = live ? [{ lat: live.lat, lon: live.lon, color: 'var(--accent)', size: 5, pulse: true, label: name }] : []; return (
{failures >= 3 ? 'LIVE FEED INTERRUPTED' : 'LIVE · SGP4'}
{trackError &&

Ground track unavailable

} {!track && !trackError &&

Loading ground track…

}
  • past 45 min
  • next 90 min
  • {tick ? `fix ${new Date(tick).toISOString().slice(11, 19)} UTC` : 'first fix from server render'} · element epoch {fmtDateTime(sourceEpoch ?? live?.source_epoch ?? null)}
); } function Tele({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); }