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%
10.4 KB · 200 lines tsx
Raw Blame History
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