spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1'use client';2import { useEffect, useRef, useState } from 'react';3import { WorldMap, type MapPoint } from '@/components/map/world-map';4import { clientApi } from '@/lib/client-api';5import { fmtDateTime } from '@/lib/format';6import type { LivePosition, TrackPoint } from '@/lib/types';78const POLL_MS = 5000;910interface Props {11 ident: string;12 name: string;13 initial: LivePosition | null;14 sourceEpoch: string | null;15}1617function fmtCoord(v: number, pos: string, neg: string): string {18 return `${Math.abs(v).toFixed(3)}° ${v >= 0 ? pos : neg}`;19}2021/**22 * Live ground track + current position. Server passes the first fix (from the detail payload) so the panel is23 * meaningful before hydration; the client then loads the ±track once and polls `/live` every 5 s while visible.24 */25export function LiveMap({ ident, name, initial, sourceEpoch }: Props) {26 const [live, setLive] = useState<LivePosition | null>(initial);27 const [track, setTrack] = useState<TrackPoint[] | null>(null);28 const [trackError, setTrackError] = useState(false);29 const [tick, setTick] = useState<number | null>(null);30 const [failures, setFailures] = useState(0);31 const timer = useRef<ReturnType<typeof setInterval> | null>(null);3233 useEffect(() => {34 const ctrl = new AbortController();35 clientApi36 .track(ident, ctrl.signal)37 .then((r) => setTrack(r.data.points))38 .catch(() => {39 if (!ctrl.signal.aborted) setTrackError(true);40 });41 return () => ctrl.abort();42 }, [ident]);4344 useEffect(() => {45 let ctrl: AbortController | null = null;46 const poll = async () => {47 ctrl?.abort();48 ctrl = new AbortController();49 try {50 const r = await clientApi.live(ident, ctrl.signal);51 if (r.data && r.data.error == null && Number.isFinite(r.data.lat)) {52 setLive(r.data);53 setTick(Date.now());54 setFailures(0);55 } else setFailures((f) => f + 1);56 } catch {57 if (!ctrl?.signal.aborted) setFailures((f) => f + 1);58 }59 };60 const start = () => {61 if (timer.current) clearInterval(timer.current);62 void poll();63 timer.current = setInterval(poll, POLL_MS);64 };65 const stop = () => {66 if (timer.current) clearInterval(timer.current);67 timer.current = null;68 };69 const onVis = () => (document.hidden ? stop() : start());70 start();71 document.addEventListener('visibilitychange', onVis);72 return () => {73 stop();74 ctrl?.abort();75 document.removeEventListener('visibilitychange', onVis);76 };77 }, [ident]);7879 const past: MapPoint[] = track ? track.filter((p) => !p.future).map((p) => ({ lat: p.lat, lon: p.lon })) : [];80 const future: MapPoint[] = track ? track.filter((p) => p.future).map((p) => ({ lat: p.lat, lon: p.lon })) : [];81 // join the two segments at the present so the line is continuous82 const lastPast = past[past.length - 1];83 if (lastPast && future.length) future.unshift(lastPast);8485 const tracks = [86 { points: past, color: 'var(--ink-3)', dashed: true, width: 1.2 },87 { points: future, color: 'var(--accent)', width: 1.8 },88 ];89 const markers = live ? [{ lat: live.lat, lon: live.lon, color: 'var(--accent)', size: 5, pulse: true, label: name }] : [];9091 return (92 <div>93 <div className="relative overflow-hidden rounded-lg border border-rule">94 <WorldMap tracks={tracks} markers={markers} title={`Ground track of ${name}`} />95 <div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2 rounded-md bg-space/70 px-2 py-1 text-2xs backdrop-blur">96 <span className={live && failures < 3 ? 'dot pulse text-active' : 'dot text-warn'} aria-hidden />97 <span className="mono text-ink-2">{failures >= 3 ? 'LIVE FEED INTERRUPTED' : 'LIVE · SGP4'}</span>98 </div>99 {trackError && <p className="absolute bottom-3 left-3 rounded-md bg-space/70 px-2 py-1 text-2xs text-warn backdrop-blur">Ground track unavailable</p>}100 {!track && !trackError && <p className="absolute bottom-3 left-3 rounded-md bg-space/70 px-2 py-1 text-2xs text-ink-3 backdrop-blur">Loading ground track…</p>}101 </div>102103 <dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm md:grid-cols-4">104 <Tele label="Latitude" value={live ? fmtCoord(live.lat, 'N', 'S') : '—'} />105 <Tele label="Longitude" value={live ? fmtCoord(live.lon, 'E', 'W') : '—'} />106 <Tele label="Altitude" value={live ? `${live.altitude_km.toFixed(1)} km` : '—'} />107 <Tele label="Velocity" value={live ? `${live.velocity_km_s.toFixed(3)} km/s` : '—'} />108 </dl>109 <ul className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-2xs text-ink-3">110 <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0 w-5 border-t border-dashed border-ink-3" aria-hidden /> past 45 min</li>111 <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0 w-5 border-t-2 border-accent" aria-hidden /> next 90 min</li>112 <li className="mono">113 {tick ? `fix ${new Date(tick).toISOString().slice(11, 19)} UTC` : 'first fix from server render'} · element epoch {fmtDateTime(sourceEpoch ?? live?.source_epoch ?? null)}114 </li>115 </ul>116 </div>117 );118}119120function Tele({ label, value }: { label: string; value: string }) {121 return (122 <div className="min-w-0">123 <dt className="eyebrow">{label}</dt>124 <dd className="mono tnum mt-0.5 truncate text-base text-ink md:text-lg">{value}</dd>125 </div>126 );127}128