'use client'; /** * Vector Earth: dark sphere shaded by the real sun direction (day/night terminator), fresnel atmosphere, and * Natural Earth 110 m land + country outlines drawn as GPU line segments (no raster texture — a premium, data-first look). */ import { useFrame } from '@react-three/fiber'; import { useMemo, useRef } from 'react'; import * as THREE from 'three'; import { mesh as topoMesh } from 'topojson-client'; import type { Topology } from 'topojson-specification'; import countries110 from 'world-atlas/countries-110m.json'; import land110 from 'world-atlas/land-110m.json'; import { llaToXyz, prefersReducedMotion, sunDirection, token } from './geo'; const R_LAND = 1.003; type MultiLine = { type: string; coordinates: number[][][] }; function linesToSegments(ml: MultiLine, r: number): Float32Array { let segs = 0; for (const line of ml.coordinates) segs += Math.max(0, line.length - 1); const out = new Float32Array(segs * 6); let o = 0; const a = new Float32Array(3); const b = new Float32Array(3); for (const line of ml.coordinates) { for (let i = 0; i < line.length - 1; i++) { const p = line[i]!; const q = line[i + 1]!; llaToXyz(p[1] ?? 0, p[0] ?? 0, r, a); llaToXyz(q[1] ?? 0, q[0] ?? 0, r, b); out.set(a, o); out.set(b, o + 3); o += 6; } } return out; } function graticule(stepDeg: number, r: number): Float32Array { const pts: number[] = []; const v = new Float32Array(3); const push = (lat: number, lon: number) => { llaToXyz(lat, lon, r, v); pts.push(v[0]!, v[1]!, v[2]!); }; for (let lon = -180; lon < 180; lon += stepDeg) { for (let lat = -90; lat < 90; lat += 3) { push(lat, lon); push(lat + 3, lon); } } for (let lat = -60; lat <= 60; lat += stepDeg) { for (let lon = -180; lon < 180; lon += 3) { push(lat, lon); push(lat, lon + 3); } } return new Float32Array(pts); } function lineGeometry(arr: Float32Array): THREE.BufferGeometry { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(arr, 3)); return g; } const EARTH_VERT = /* glsl */ ` varying vec3 vNormalW; varying vec3 vPosW; void main() { vNormalW = normalize(mat3(modelMatrix) * normal); vPosW = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; const EARTH_FRAG = /* glsl */ ` uniform vec3 uSun; uniform vec3 uNight; uniform vec3 uDay; uniform vec3 uRim; varying vec3 vNormalW; varying vec3 vPosW; void main() { vec3 n = normalize(vNormalW); float l = dot(n, uSun); float day = smoothstep(-0.12, 0.28, l); vec3 col = mix(uNight, uDay, day); vec3 viewDir = normalize(cameraPosition - vPosW); float rim = pow(1.0 - max(dot(n, viewDir), 0.0), 3.0); col += uRim * rim * 0.35; gl_FragColor = vec4(col, 1.0); } `; const ATMO_VERT = /* glsl */ ` varying vec3 vNormalW; varying vec3 vPosW; void main() { vNormalW = normalize(mat3(modelMatrix) * normal); vPosW = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; const ATMO_FRAG = /* glsl */ ` uniform vec3 uColor; varying vec3 vNormalW; varying vec3 vPosW; void main() { vec3 viewDir = normalize(cameraPosition - vPosW); float f = pow(max(dot(normalize(vNormalW), viewDir), 0.0), 2.6); gl_FragColor = vec4(uColor, f * 0.55); } `; export function Earth({ quality = 'high' }: { quality?: 'high' | 'low' }) { const sunRef = useRef(new THREE.Vector3(1, 0, 0)); const lastSun = useRef(0); const reduced = prefersReducedMotion(); const { landGeo, borderGeo, gratGeo, earthMat, atmoMat, landColor, borderColor, gratColor } = useMemo(() => { const landTopo = land110 as unknown as Topology; const cTopo = countries110 as unknown as Topology; const land = topoMesh(landTopo, landTopo.objects.land as never) as unknown as MultiLine; const borders = topoMesh(cTopo, cTopo.objects.countries as never, ((a: { id: string }, b: { id: string }) => a !== b) as never) as unknown as MultiLine; const accent = new THREE.Color(token('--accent')); const night = new THREE.Color(token('--plane')).multiplyScalar(0.55); const day = new THREE.Color(token('--plane-3')).lerp(new THREE.Color(token('--accent')), 0.05); const rim = new THREE.Color(token('--accent')).multiplyScalar(0.6); return { landGeo: lineGeometry(linesToSegments(land, R_LAND)), borderGeo: lineGeometry(linesToSegments(borders, R_LAND)), gratGeo: lineGeometry(graticule(30, 1.001)), earthMat: new THREE.ShaderMaterial({ vertexShader: EARTH_VERT, fragmentShader: EARTH_FRAG, uniforms: { uSun: { value: sunRef.current }, uNight: { value: night }, uDay: { value: day }, uRim: { value: rim } }, }), atmoMat: new THREE.ShaderMaterial({ vertexShader: ATMO_VERT, fragmentShader: ATMO_FRAG, uniforms: { uColor: { value: accent } }, transparent: true, side: THREE.BackSide, depthWrite: false, blending: THREE.AdditiveBlending, }), landColor: new THREE.Color(token('--accent')).lerp(new THREE.Color('#ffffff'), 0.25), borderColor: new THREE.Color(token('--ink-3')), gratColor: new THREE.Color(token('--ink-3')), }; }, []); useFrame(() => { const now = Date.now(); if (now - lastSun.current > (reduced ? 60_000 : 1_000)) { lastSun.current = now; sunDirection(new Date(now), sunRef.current); } }); const seg = quality === 'high' ? 96 : 48; return ( {/* Equator hairline helps read the GEO ring geometry */} ); }