'use client'; /** * Globe orchestrator (client-only; loaded through `LazyGlobe`). Owns filters, selection, focus and the overlay UI; * the WebGL scene lives in `globe-scene.tsx`. Two variants: `hero` (compact, homepage) and `full` (/explore). */ import { Info, SlidersHorizontal } from 'lucide-react'; import { Component, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { cn } from '@/lib/cn'; import { fmtInt } from '@/lib/format'; import { activeFilterCount, computeVisibility, DEFAULT_FILTERS, toggleIn, type GlobeFilters } from './filters'; import { FocusSearch, type FocusTarget } from './focus-search'; import { hasWebGL } from './geo'; import { GlobeSkeleton, GlobeUnavailable } from './globe-fallback'; import { GlobeFilterChips } from './globe-filters'; import { GlobeLegend } from './globe-legend'; import { GlobeScene } from './globe-scene'; import { SatPanel } from './sat-panel'; import { lerpFactor, type PickResult } from './satellite-points'; import { usePositions } from './use-positions'; export interface GlobeProps { variant?: 'hero' | 'full'; className?: string; /** NORAD to focus once positions are loaded (e.g. `/explore?focus=25544`). */ initialFocus?: number | null; } class SceneBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } render() { return this.state.failed ? this.props.fallback : this.props.children; } } const ALT_NOTE = 'Altitude scale is compressed for legibility: r = 1 + 0.06 + ln(1 + alt/400 km) × 0.12. Low orbits are exaggerated, MEO/GEO compressed; the GEO ring still reads as a ring.'; function utcClock(ms: number): string { const d = new Date(ms); return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')} UTC`; } export default function Globe({ variant = 'full', className, initialFocus = null }: GlobeProps) { const [webgl] = useState(() => hasWebGL()); const { data, error, loading } = usePositions(webgl); const [filters, setFilters] = useState(DEFAULT_FILTERS); const [selected, setSelected] = useState(null); // NORAD const [focus, setFocus] = useState(null); const [focusRequest, setFocusRequest] = useState(0); const [focusMissing, setFocusMissing] = useState(null); const [filtersOpen, setFiltersOpen] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024); const initialDone = useRef(false); const { flags, visible, flagsVersion } = useMemo(() => { if (!data) return { flags: new Float32Array(0), visible: 0, flagsVersion: 0 }; const f = new Float32Array(data.n); const v = computeVisibility(data, filters, f); return { flags: f, visible: v, flagsVersion: Date.now() }; }, [data, filters]); const highlightIndex = useMemo(() => { if (!data || selected === null) return null; const i = data.norad.indexOf(selected); return i >= 0 ? i : null; }, [data, selected]); const isSmall = () => typeof window !== 'undefined' && window.innerWidth < 768; // On phones the selection sheet and the filters sheet share the bottom of the screen: keep only one open. const select = useCallback((norad: number | null) => { setSelected(norad); if (norad !== null && isSmall()) setFiltersOpen(false); }, []); const toggleFilters = () => { if (!filtersOpen && isSmall()) setSelected(null); setFiltersOpen(!filtersOpen); }; const onPick = useCallback( (r: PickResult | null) => { if (r) { select(r.norad); setFocusMissing(null); } else select(null); }, [select], ); const focusOn = useCallback( (t: FocusTarget) => { setFocus(t); select(t.norad); if (data && data.norad.indexOf(t.norad) < 0) { setFocusMissing(`${t.name} is not in the live snapshot (no current element set${data.capped ? ' or hidden by the device cap' : ''}).`); } else { setFocusMissing(null); setFocusRequest((n) => n + 1); } }, [data, select], ); useEffect(() => { if (!data || initialDone.current || initialFocus === null) return; initialDone.current = true; focusOn({ norad: initialFocus, name: `NORAD ${initialFocus}`, slug: String(initialFocus) }); }, [data, initialFocus, focusOn]); const clearFocus = () => { setFocus(null); setSelected(null); setFocusMissing(null); }; const panelSnapshot = useMemo(() => { if (!data || highlightIndex === null) return { altitudeKm: null, velocityKmS: null, orbitClass: null, active: null }; const f = Math.min(1, lerpFactor(data, Date.now())); const alt = (data.alt0[highlightIndex] ?? 0) + ((data.alt1[highlightIndex] ?? 0) - (data.alt0[highlightIndex] ?? 0)) * f; return { altitudeKm: alt, velocityKmS: data.vel[highlightIndex] ?? null, orbitClass: data.legend.cls[data.cls[highlightIndex] ?? 0] ?? null, active: data.active[highlightIndex] === 1 }; }, [data, highlightIndex]); if (!webgl) return ; const nFilters = activeFilterCount(filters); const isHero = variant === 'hero'; return (
}>
{loading && !data && } {/* ---- overlays -------------------------------------------------------------------------------------- */} {!isHero && (
Now soon
{focusMissing &&

{focusMissing}

}
)} {!isHero && filtersOpen && (

Filters

)} {/* status bar (both variants) */}
setFilters((f) => ({ ...f, cls: toggleIn(f.cls, c) }))} className="pointer-events-auto" dense={isHero} />
{data ? ( <> {fmtInt(data.total)} objects {data.capped && · {fmtInt(data.n)} rendered on this device} {nFilters > 0 && · {fmtInt(visible)} shown} · positions as of {utcClock(data.t0)} {!isHero && · SGP4 from public element sets · refresh 30 s} {ALT_NOTE} ) : error ? ( {error} ) : ( Loading positions… )} {!isHero &&

Indicative positions from public element sets; not for operational or safety-critical use.

}
{selected !== null && ( setSelected(null)} className={cn('absolute inset-x-0 bottom-0 z-20 rounded-b-none md:inset-x-auto md:bottom-auto md:right-3 md:top-3 md:w-[340px] md:rounded-xl', isHero ? 'md:top-3' : 'md:top-[76px]')} /> )}
); }