SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
5.4 KB · 128 lines tsx
Raw Blame History
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