spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1'use client';2/**3 * Globe orchestrator (client-only; loaded through `LazyGlobe`). Owns filters, selection, focus and the overlay UI;4 * the WebGL scene lives in `globe-scene.tsx`. Two variants: `hero` (compact, homepage) and `full` (/explore).5 */6import { Info, SlidersHorizontal } from 'lucide-react';7import { Component, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';8import { cn } from '@/lib/cn';9import { fmtInt } from '@/lib/format';10import { activeFilterCount, computeVisibility, DEFAULT_FILTERS, toggleIn, type GlobeFilters } from './filters';11import { FocusSearch, type FocusTarget } from './focus-search';12import { hasWebGL } from './geo';13import { GlobeSkeleton, GlobeUnavailable } from './globe-fallback';14import { GlobeFilterChips } from './globe-filters';15import { GlobeLegend } from './globe-legend';16import { GlobeScene } from './globe-scene';17import { SatPanel } from './sat-panel';18import { lerpFactor, type PickResult } from './satellite-points';19import { usePositions } from './use-positions';2021export interface GlobeProps {22 variant?: 'hero' | 'full';23 className?: string;24 /** NORAD to focus once positions are loaded (e.g. `/explore?focus=25544`). */25 initialFocus?: number | null;26}2728class SceneBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, { failed: boolean }> {29 state = { failed: false };30 static getDerivedStateFromError() {31 return { failed: true };32 }33 render() {34 return this.state.failed ? this.props.fallback : this.props.children;35 }36}3738const 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.';3940function utcClock(ms: number): string {41 const d = new Date(ms);42 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')} UTC`;43}4445export default function Globe({ variant = 'full', className, initialFocus = null }: GlobeProps) {46 const [webgl] = useState(() => hasWebGL());47 const { data, error, loading } = usePositions(webgl);48 const [filters, setFilters] = useState<GlobeFilters>(DEFAULT_FILTERS);49 const [selected, setSelected] = useState<number | null>(null); // NORAD50 const [focus, setFocus] = useState<FocusTarget | null>(null);51 const [focusRequest, setFocusRequest] = useState(0);52 const [focusMissing, setFocusMissing] = useState<string | null>(null);53 const [filtersOpen, setFiltersOpen] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024);54 const initialDone = useRef(false);5556 const { flags, visible, flagsVersion } = useMemo(() => {57 if (!data) return { flags: new Float32Array(0), visible: 0, flagsVersion: 0 };58 const f = new Float32Array(data.n);59 const v = computeVisibility(data, filters, f);60 return { flags: f, visible: v, flagsVersion: Date.now() };61 }, [data, filters]);6263 const highlightIndex = useMemo(() => {64 if (!data || selected === null) return null;65 const i = data.norad.indexOf(selected);66 return i >= 0 ? i : null;67 }, [data, selected]);6869 const isSmall = () => typeof window !== 'undefined' && window.innerWidth < 768;7071 // On phones the selection sheet and the filters sheet share the bottom of the screen: keep only one open.72 const select = useCallback((norad: number | null) => {73 setSelected(norad);74 if (norad !== null && isSmall()) setFiltersOpen(false);75 }, []);76 const toggleFilters = () => {77 if (!filtersOpen && isSmall()) setSelected(null);78 setFiltersOpen(!filtersOpen);79 };8081 const onPick = useCallback(82 (r: PickResult | null) => {83 if (r) {84 select(r.norad);85 setFocusMissing(null);86 } else select(null);87 },88 [select],89 );9091 const focusOn = useCallback(92 (t: FocusTarget) => {93 setFocus(t);94 select(t.norad);95 if (data && data.norad.indexOf(t.norad) < 0) {96 setFocusMissing(`${t.name} is not in the live snapshot (no current element set${data.capped ? ' or hidden by the device cap' : ''}).`);97 } else {98 setFocusMissing(null);99 setFocusRequest((n) => n + 1);100 }101 },102 [data, select],103 );104105 useEffect(() => {106 if (!data || initialDone.current || initialFocus === null) return;107 initialDone.current = true;108 focusOn({ norad: initialFocus, name: `NORAD ${initialFocus}`, slug: String(initialFocus) });109 }, [data, initialFocus, focusOn]);110111 const clearFocus = () => {112 setFocus(null);113 setSelected(null);114 setFocusMissing(null);115 };116117 const panelSnapshot = useMemo(() => {118 if (!data || highlightIndex === null) return { altitudeKm: null, velocityKmS: null, orbitClass: null, active: null };119 const f = Math.min(1, lerpFactor(data, Date.now()));120 const alt = (data.alt0[highlightIndex] ?? 0) + ((data.alt1[highlightIndex] ?? 0) - (data.alt0[highlightIndex] ?? 0)) * f;121 return { altitudeKm: alt, velocityKmS: data.vel[highlightIndex] ?? null, orbitClass: data.legend.cls[data.cls[highlightIndex] ?? 0] ?? null, active: data.active[highlightIndex] === 1 };122 }, [data, highlightIndex]);123124 if (!webgl) return <GlobeUnavailable className={className} />;125126 const nFilters = activeFilterCount(filters);127 const isHero = variant === 'hero';128129 return (130 <div className={cn('relative h-full w-full overflow-hidden', className)}>131 <div className="absolute inset-0">132 <SceneBoundary fallback={<GlobeUnavailable reason="The 3D renderer failed to start on this device." />}>133 <GlobeScene data={data} flags={flags} flagsVersion={flagsVersion} highlight={highlightIndex} focusRequest={focusRequest} focusNorad={isHero ? null : focus?.norad ?? selected} onPick={onPick} variant={variant} />134 </SceneBoundary>135 </div>136 {loading && !data && <GlobeSkeleton className="pointer-events-none absolute inset-0 opacity-70" label="Loading live positions…" />}137138 {/* ---- overlays -------------------------------------------------------------------------------------- */}139 {!isHero && (140 <div className="pointer-events-none absolute inset-x-0 top-0 p-3 md:p-4">141 <div className="pointer-events-auto flex flex-wrap items-start gap-2">142 <FocusSearch onPick={focusOn} current={focus} onClear={clearFocus} className="w-full md:w-[380px]" />143 <div className="flex flex-wrap items-center gap-2">144 <button type="button" onClick={toggleFilters} aria-expanded={filtersOpen} aria-controls="globe-filters" className={cn('inline-flex h-11 items-center gap-2 rounded-md border px-3 text-sm backdrop-blur-md', filtersOpen ? 'border-rule-strong bg-plane-3 text-ink' : 'border-rule bg-plane/85 text-ink-2 hover:text-ink')}>145 <SlidersHorizontal className="size-4" aria-hidden /> Filters {nFilters > 0 && <span className="mono rounded bg-accent px-1.5 text-2xs text-accent-ink">{nFilters}</span>}146 </button>147 <div className="mono inline-flex h-11 items-center rounded-md border border-rule bg-plane/85 text-2xs backdrop-blur-md" role="group" aria-label="Time">148 <button type="button" disabled className="h-full px-2.5 text-ink-3 disabled:cursor-not-allowed" title="Time travel is coming soon" aria-label="Minus one hour (soon)">−1h</button>149 <span className="inline-flex h-full items-center gap-1.5 border-x border-rule bg-plane-3 px-3 text-ink"><span className="dot pulse text-active" aria-hidden />Now</span>150 <button type="button" disabled className="h-full px-2.5 text-ink-3 disabled:cursor-not-allowed" title="Time travel is coming soon" aria-label="Plus one hour (soon)">+1h</button>151 <span className="pr-2.5 text-ink-3">soon</span>152 </div>153 </div>154 </div>155 {focusMissing && <p className="pointer-events-auto mt-2 inline-block rounded-md border border-warn/40 bg-warn-soft px-3 py-2 text-xs text-warn">{focusMissing}</p>}156 </div>157 )}158159 {!isHero && filtersOpen && (160 <div id="globe-filters" className="panel absolute inset-x-3 bottom-[58px] z-10 max-h-[55%] overflow-y-auto p-4 md:inset-x-auto md:bottom-auto md:left-4 md:top-[124px] md:w-[360px] md:max-h-[calc(100%-190px)]">161 <div className="mb-2 flex items-center justify-between">162 <p className="text-sm font-medium text-ink">Filters</p>163 <button type="button" onClick={() => setFiltersOpen(false)} className="min-h-[36px] px-2 text-xs text-ink-3 hover:text-ink">Close</button>164 </div>165 <GlobeFilterChips data={data} filters={filters} onChange={setFilters} />166 </div>167 )}168169 {/* status bar (both variants) */}170 <div className={cn('pointer-events-none absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-between gap-x-3 gap-y-1.5', isHero ? 'bg-gradient-to-t from-space via-space/70 to-transparent px-3 pb-3 pt-10' : 'border-t border-rule bg-space/60 px-3 py-2 backdrop-blur-md md:px-4')}>171 <GlobeLegend data={data} filters={filters} onToggle={(c) => setFilters((f) => ({ ...f, cls: toggleIn(f.cls, c) }))} className="pointer-events-auto" dense={isHero} />172 <div className="mono pointer-events-auto min-w-0 text-2xs leading-4 text-ink-3">173 {data ? (174 <>175 <span className="text-ink-2">{fmtInt(data.total)} objects</span>176 {data.capped && <span> · {fmtInt(data.n)} rendered on this device</span>}177 {nFilters > 0 && <span> · {fmtInt(visible)} shown</span>}178 <span> · positions as of {utcClock(data.t0)}</span>179 {!isHero && <span> · SGP4 from public element sets · refresh 30 s</span>}180 <span className="inline-flex items-center align-middle" title={ALT_NOTE}>181 <Info className="ml-1 size-3.5" aria-hidden />182 <span className="sr-only">{ALT_NOTE}</span>183 </span>184 </>185 ) : error ? (186 <span className="text-warn">{error}</span>187 ) : (188 <span>Loading positions…</span>189 )}190 {!isHero && <p className="mt-0.5 max-w-[60ch] text-ink-3/80">Indicative positions from public element sets; not for operational or safety-critical use.</p>}191 </div>192 </div>193194 {selected !== null && (195 <SatPanel norad={selected} snapshot={panelSnapshot} onClose={() => 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]')} />196 )}197 </div>198 );199}200