// WebGL Earth-Moon geometry viewer for /eme. // // Reads the station lat/lon and the current Moon az/el from the hook // element's data-* attributes (pushed by EmeLive each tick). Draws a // textured Earth and Moon, a beam line between them, and highlights the // moon-facing hemisphere — the global footprint of the EME return bounce. // // Textures: // /images/eme/earth.jpg — NASA Blue Marble 2015 equirectangular (public domain) // /images/eme/moon.jpg — NASA CGI Moon Kit LROC colour mosaic (public domain) import * as THREE from "../vendor/three/three.module.min.js" import { OrbitControls } from "../vendor/three/OrbitControls.js" // Moon is drawn at its true size ratio to Earth (~0.273 Earth radii) // but the Moon–Earth distance is compressed so both bodies frame // together on screen. Real ratio is ~60 Earth radii — at that scale // the Moon would be off-frame unless the camera pulls way back. const EARTH_R = 1.0 const MOON_R = 0.273 const MOON_DISTANCE = 3.5 interface EmeGlobeHook extends ViewHook { scene: any camera: any renderer: any controls: any earth: any moon: any moonLight: any stationMarker: any beamLine: any returnLine: any returnLines: any[] coverage: any coverageRing: any coveragePlane: any raf: number resizeObserver: ResizeObserver firstUpdate: boolean updateGeometry(): void onResize(): void computeSize(): { w: number; h: number } addStarfield(): void } function parseData(el: HTMLElement) { const d = el.dataset return { lat: parseFloat(d.stationLat || "0"), lon: parseFloat(d.stationLon || "0"), az: parseFloat(d.moonAz || "0"), el: parseFloat(d.moonEl || "0"), moonVisible: (d.moonVisible || "true") === "true" } } // Earth-fixed Cartesian unit vector from geographic lat/lon (degrees). // Texture convention: 0°E maps to +X, 90°N → +Y, longitude increases // to the east (sign flipped on Z so the texture reads left-to-right). function latLonToXYZ(latDeg: number, lonDeg: number): [number, number, number] { const lat = (latDeg * Math.PI) / 180 const lon = (lonDeg * Math.PI) / 180 return [Math.cos(lat) * Math.cos(lon), Math.sin(lat), -Math.cos(lat) * Math.sin(lon)] } const EmeGlobe = { mounted(this: EmeGlobeHook) { const size = this.computeSize() this.scene = new THREE.Scene() this.scene.background = new THREE.Color(0x050914) this.camera = new THREE.PerspectiveCamera(60, size.w / size.h, 0.1, 400) this.camera.position.set(3.4, 1.8, 4.0) this.renderer = new THREE.WebGLRenderer({ antialias: true }) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) this.renderer.setSize(size.w, size.h) this.renderer.outputColorSpace = THREE.SRGBColorSpace this.renderer.localClippingEnabled = true this.el.appendChild(this.renderer.domElement) // Starfield this.addStarfield() // Sun-ish key light roughly from upper-right; ambient keeps the // night side of Earth and the far side of the Moon visible. const key = new THREE.DirectionalLight(0xffffff, 1.3) key.position.set(6, 4, 5) this.scene.add(key) this.scene.add(new THREE.AmbientLight(0x3a4a6a, 0.55)) // Earth sphere. const loader = new THREE.TextureLoader() const earthTex = loader.load("/images/eme/earth.jpg") earthTex.colorSpace = THREE.SRGBColorSpace this.earth = new THREE.Mesh( new THREE.SphereGeometry(EARTH_R, 96, 48), new THREE.MeshPhongMaterial({ map: earthTex, shininess: 6, specular: 0x223344 }) ) this.scene.add(this.earth) // Thin blue atmosphere halo rendered from the inside so it reads // as a rim glow, not a solid shell. const halo = new THREE.Mesh( new THREE.SphereGeometry(EARTH_R * 1.025, 64, 32), new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.09, side: THREE.BackSide }) ) this.scene.add(halo) // Moon sphere. Use MeshBasicMaterial so the Moon reads bright // against the black sky regardless of scene lighting — real-world // lunar radiance dominates EME diagrams, so fudging it here is // authentic enough. const moonTex = loader.load("/images/eme/moon.jpg") moonTex.colorSpace = THREE.SRGBColorSpace this.moon = new THREE.Mesh( new THREE.SphereGeometry(MOON_R, 64, 32), new THREE.MeshBasicMaterial({ map: moonTex }) ) this.scene.add(this.moon) // Station marker: small bright sphere on Earth's surface. this.stationMarker = new THREE.Mesh( new THREE.SphereGeometry(0.022, 20, 20), new THREE.MeshBasicMaterial({ color: 0xef4444 }) ) this.scene.add(this.stationMarker) // Outbound beam: station → Moon. Solid bright yellow. const beamGeom = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(), new THREE.Vector3() ]) this.beamLine = new THREE.Line( beamGeom, new THREE.LineBasicMaterial({ color: 0xfde047, transparent: true, opacity: 1.0 }) ) this.scene.add(this.beamLine) // Return ray: Moon → a representative point in the bounce // hemisphere (the sub-lunar point on Earth's surface). The Moon // is a diffuse scatterer, so the return isn't specularly aimed // at the transmitting station — the coverage hemisphere below // carries the "everywhere in the footprint gets some" story, and // this single dashed ray makes it visually clear the echo doesn't // bounce straight back. const returnGeom = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(), new THREE.Vector3() ]) this.returnLine = new THREE.Line( returnGeom, new THREE.LineDashedMaterial({ color: 0xfcd34d, transparent: true, opacity: 0.85, dashSize: 0.12, gapSize: 0.08 }) ) this.scene.add(this.returnLine) this.returnLines = [] // Anti-coverage hemisphere: the half of Earth that CANNOT see the // Moon right now. Rendered as a translucent red dome clipped so // only the far-side (from the Moon) fragments survive. this.coveragePlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0) this.coverage = new THREE.Mesh( new THREE.SphereGeometry(EARTH_R * 1.015, 64, 32), new THREE.MeshBasicMaterial({ color: 0xef4444, transparent: true, opacity: 0.38, side: THREE.FrontSide, depthWrite: false, clippingPlanes: [this.coveragePlane] }) ) this.coverage.renderOrder = 2 this.scene.add(this.coverage) // Terminator ring — crisp outline between moon-facing and shadowed // hemispheres. Kept neutral so it reads as a boundary, not a label. this.coverageRing = new THREE.Mesh( new THREE.TorusGeometry(EARTH_R * 1.018, 0.012, 12, 128), new THREE.MeshBasicMaterial({ color: 0xfca5a5, transparent: true, opacity: 0.85, depthWrite: false }) ) this.coverageRing.renderOrder = 3 this.scene.add(this.coverageRing) this.controls = new OrbitControls(this.camera, this.renderer.domElement) this.controls.enableDamping = true this.controls.dampingFactor = 0.07 this.controls.enablePan = false this.controls.minDistance = 1.6 this.controls.maxDistance = 40 this.controls.target.set(0, 0, 0) this.firstUpdate = true this.updateGeometry() // EmeLive pushes "eme:update" on every tick and URL patch — the div // itself is phx-update=ignore, so this is the channel for new data. this.handleEvent("eme:update", (payload: Record) => { if (typeof payload.lat === "number") this.el.dataset.stationLat = String(payload.lat) if (typeof payload.lon === "number") this.el.dataset.stationLon = String(payload.lon) if (typeof payload.az === "number") this.el.dataset.moonAz = String(payload.az) if (typeof payload.el === "number") this.el.dataset.moonEl = String(payload.el) if (typeof payload.moon_visible === "boolean") { this.el.dataset.moonVisible = String(payload.moon_visible) } this.updateGeometry() }) const animate = () => { this.raf = requestAnimationFrame(animate) this.controls.update() this.renderer.render(this.scene, this.camera) } animate() this.resizeObserver = new ResizeObserver(() => this.onResize()) this.resizeObserver.observe(this.el) }, computeSize(this: EmeGlobeHook) { const w = Math.max(this.el.clientWidth || 640, 320) const h = Math.max(Math.round(w * 0.5), 300) return { w, h } }, addStarfield(this: EmeGlobeHook) { const n = 900 const positions = new Float32Array(n * 3) for (let i = 0; i < n; i++) { const r = 80 + Math.random() * 80 const theta = Math.random() * 2 * Math.PI const phi = Math.acos(1 - 2 * Math.random()) positions[i * 3] = r * Math.sin(phi) * Math.cos(theta) positions[i * 3 + 1] = r * Math.cos(phi) positions[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta) } const geo = new THREE.BufferGeometry() geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)) const mat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.6, sizeAttenuation: true, transparent: true, opacity: 0.75 }) this.scene.add(new THREE.Points(geo, mat)) }, updateGeometry(this: EmeGlobeHook) { const d = parseData(this.el) // Station position on Earth's surface. const [sx, sy, sz] = latLonToXYZ(d.lat, d.lon) const stationPos = new THREE.Vector3(sx, sy, sz) this.stationMarker.position.copy(stationPos.clone().multiplyScalar(EARTH_R * 1.005)) // Local tangent basis (East/North/Up) at the station. const latR = (d.lat * Math.PI) / 180 const lonR = (d.lon * Math.PI) / 180 const up = stationPos.clone().normalize() const north = new THREE.Vector3( -Math.sin(latR) * Math.cos(lonR), Math.cos(latR), Math.sin(latR) * Math.sin(lonR) ) const east = new THREE.Vector3(-Math.sin(lonR), 0, -Math.cos(lonR)) // Moon direction (az from N, CW; el above horizon) → world frame. const azR = (d.az * Math.PI) / 180 const elR = (d.el * Math.PI) / 180 const cosEl = Math.cos(elR) const moonDir = new THREE.Vector3() .addScaledVector(east, cosEl * Math.sin(azR)) .addScaledVector(north, cosEl * Math.cos(azR)) .addScaledVector(up, Math.sin(elR)) .normalize() const moonPos = moonDir.clone().multiplyScalar(MOON_DISTANCE) this.moon.position.copy(moonPos) // Orient the Moon so its near face points at Earth (roughly — keeps // a familiar disc facing the viewer rather than a random longitude). this.moon.lookAt(0, 0, 0) // Outbound beam endpoints — station to Moon centre. const beamPos = this.beamLine.geometry.attributes.position beamPos.setXYZ( 0, this.stationMarker.position.x, this.stationMarker.position.y, this.stationMarker.position.z ) beamPos.setXYZ(1, moonPos.x, moonPos.y, moonPos.z) beamPos.needsUpdate = true this.beamLine.geometry.computeBoundingSphere() // Return ray: Moon → sub-lunar point on Earth's surface // (moonDir × earth-radius). Sits on the bounce hemisphere — a // representative landing spot, not the station itself. const subLunar = moonDir.clone().multiplyScalar(EARTH_R * 1.005) const returnPos = this.returnLine.geometry.attributes.position returnPos.setXYZ(0, moonPos.x, moonPos.y, moonPos.z) returnPos.setXYZ(1, subLunar.x, subLunar.y, subLunar.z) returnPos.needsUpdate = true this.returnLine.geometry.computeBoundingSphere() // LineDashedMaterial needs per-vertex line-distance attributes. this.returnLine.computeLineDistances() // Build an orthonormal basis (em, side, top) with em = Earth→Moon, // so we can place the terminator great-circle points around the // moon-facing hemisphere of Earth. const em = moonDir.clone() const worldUp = new THREE.Vector3(0, 1, 0) let side = new THREE.Vector3().crossVectors(worldUp, em) if (side.lengthSq() < 1e-4) { side = new THREE.Vector3().crossVectors(new THREE.Vector3(1, 0, 0), em) } side.normalize() const topVec = new THREE.Vector3().crossVectors(em, side).normalize() // Return broadcast rays: from the Moon back to N evenly-spaced // points on the terminator great circle. const ringR = EARTH_R * 1.012 for (let i = 0; i < this.returnLines.length; i++) { const phi = (i / this.returnLines.length) * Math.PI * 2 const target = side.clone() .multiplyScalar(ringR * Math.cos(phi)) .addScaledVector(topVec, ringR * Math.sin(phi)) const pos = this.returnLines[i].geometry.attributes.position pos.setXYZ(0, moonPos.x, moonPos.y, moonPos.z) pos.setXYZ(1, target.x, target.y, target.z) pos.needsUpdate = true this.returnLines[i].geometry.computeBoundingSphere() } // Orient the terminator ring so its plane matches the terminator // great circle (the torus default is in the XY plane with normal // +Z, so rotate +Z → em). const ringQuat = new THREE.Quaternion().setFromUnitVectors( new THREE.Vector3(0, 0, 1), em ) this.coverageRing.quaternion.copy(ringQuat) // Three.js keeps fragments whose signed distance to the plane is // positive. To KEEP the anti-moon (shadow) hemisphere we set the // plane's normal to -moonDir: anti-moon points then satisfy // (-moonDir)·p > 0 and survive, while moon-facing points are // clipped. this.coveragePlane.normal.copy(moonDir).negate() this.coveragePlane.constant = 0 // Dim the beam, return ray & coverage when the Moon is below // the horizon (no line-of-sight path exists). const beamMat = this.beamLine.material const returnMat = this.returnLine.material const coverageMat = this.coverage.material const ringMat = this.coverageRing.material if (d.moonVisible) { beamMat.color.setHex(0xfde047) beamMat.opacity = 1.0 returnMat.opacity = 0.85 coverageMat.opacity = 0.38 ringMat.opacity = 0.85 } else { beamMat.color.setHex(0x64748b) beamMat.opacity = 0.35 returnMat.opacity = 0.2 coverageMat.opacity = 0.08 ringMat.opacity = 0.25 } beamMat.needsUpdate = true returnMat.needsUpdate = true coverageMat.needsUpdate = true ringMat.needsUpdate = true // On first render, frame the scene so the moon-facing "bounce" // hemisphere of Earth is prominent while the Moon still reads in // the distance. Camera sits beyond the Moon on the Earth–Moon // axis with a perpendicular tilt for a 3D feel, and the orbit // target is weighted toward the Earth so Earth dominates the frame. if (this.firstUpdate) { this.firstUpdate = false // Look target biased toward Earth → Earth fills more of the view. const target = moonPos.clone().multiplyScalar(0.12) // Camera: beyond the Moon on +em, plus perpendicular offsets. // Distance from Earth ~ 2.6 × EARTH_R, giving Earth a ~20° angular // radius — the moon-facing coverage hemisphere reads clearly. const camPos = em .clone() .multiplyScalar(MOON_DISTANCE * 1.25) .addScaledVector(side, 1.5) .addScaledVector(topVec, 0.9) this.camera.position.copy(camPos) this.controls.target.copy(target) this.controls.update() } }, updated(this: EmeGlobeHook) { this.updateGeometry() }, onResize(this: EmeGlobeHook) { const { w, h } = this.computeSize() this.camera.aspect = w / h this.camera.updateProjectionMatrix() this.renderer.setSize(w, h) }, destroyed(this: EmeGlobeHook) { if (this.raf) cancelAnimationFrame(this.raf) if (this.resizeObserver) this.resizeObserver.disconnect() this.controls?.dispose?.() this.renderer?.dispose?.() while (this.el.firstChild) this.el.removeChild(this.el.firstChild) } } export { EmeGlobe }