/** * Geometry helpers shared by the globe: geodetic → unit-sphere coordinates (Three.js is Y-up), the compressed * altitude scale used for legibility, sun direction for the day/night terminator and CSS-token colour lookup. * * Altitude scale (documented in the UI tooltip): r = 1 + 0.06 + ln(1 + alt/400) × 0.12 with alt in km. * LEO (400 km) → 1.143 · 1 000 km → 1.21 · MEO (20 000 km) → 1.53 · GEO (35 786 km) → 1.60. It exaggerates low * shells so that the LEO swarm does not sit on the surface, and compresses MEO/GEO so the GEO ring stays on screen. */ import type { Vector3 } from 'three'; export const EARTH_RADIUS_KM = 6371; export const DEG = Math.PI / 180; export function altToRadius(altKm: number): number { const a = Math.max(0, altKm); return 1 + 0.06 + Math.log1p(a / 400) * 0.12; } /** lon/lat (deg) at radius r → xyz (Y-up, prime meridian at +X, east positive). Writes into `out` (Float32Array|number[]). */ export function llaToXyz(latDeg: number, lonDeg: number, r: number, out: Float32Array | number[], off = 0): void { const lat = latDeg * DEG; const lon = lonDeg * DEG; const c = Math.cos(lat); out[off] = r * c * Math.cos(lon); out[off + 1] = r * Math.sin(lat); out[off + 2] = -r * c * Math.sin(lon); } export function llaToVec(latDeg: number, lonDeg: number, r: number, v: Vector3): Vector3 { const lat = latDeg * DEG; const lon = lonDeg * DEG; const c = Math.cos(lat); return v.set(r * c * Math.cos(lon), r * Math.sin(lat), -r * c * Math.sin(lon)); } /** * Sub-solar point (approximate, ±0.5°): low-precision solar coordinates (Astronomical Almanac) + GMST. * Returns a unit vector in the same Y-up frame as `llaToXyz` — cheap enough to recompute every frame. */ export function sunDirection(date: Date, out: Vector3): Vector3 { const jd = date.getTime() / 86400000 + 2440587.5; const d = jd - 2451545.0; const g = ((357.529 + 0.98560028 * d) % 360) * DEG; const q = (280.459 + 0.98564736 * d) % 360; const L = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * DEG; const e = (23.439 - 0.00000036 * d) * DEG; const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L)); const dec = Math.asin(Math.sin(e) * Math.sin(L)); const gmst = ((280.46061837 + 360.98564736629 * d) % 360) * DEG; const lon = ra - gmst; // sub-solar longitude (rad) const c = Math.cos(dec); return out.set(c * Math.cos(lon), Math.sin(dec), -c * Math.sin(lon)).normalize(); } /** Resolve a CSS custom property (design token) to a colour string usable by Three. */ export function cssVar(name: string, fallback: string): string { if (typeof window === 'undefined') return fallback; const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } export const TOKEN_FALLBACKS: Record = { '--leo': '#38d3ff', '--meo': '#8f7dff', '--geo': '#f5b544', '--heo': '#ff7ab6', '--other': '#7c869e', '--accent': '#38d3ff', '--accent-2': '#8f7dff', '--ink-3': '#6b7694', '--rule-strong': 'rgba(160, 180, 230, 0.28)', '--plane': '#0b1020', '--plane-2': '#111830', '--space': '#060912', }; export function token(name: keyof typeof TOKEN_FALLBACKS): string { return cssVar(name, TOKEN_FALLBACKS[name] ?? '#ffffff'); } /** Detect WebGL support without creating a persistent context (headless / old GPUs / blocked GL). */ export function hasWebGL(): boolean { if (typeof window === 'undefined') return false; try { const c = document.createElement('canvas'); const gl = c.getContext('webgl2') ?? c.getContext('webgl'); if (!gl) return false; const ext = gl.getExtension('WEBGL_lose_context'); ext?.loseContext(); return true; } catch { return false; } } export function prefersReducedMotion(): boolean { return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } /** Low-power heuristic: cap rendered points on small screens / few cores. */ export function isLowPower(): boolean { if (typeof window === 'undefined') return false; const cores = navigator.hardwareConcurrency ?? 8; return cores <= 4 || window.innerWidth < 768; }