prop/assets/js/eme_globe_hook.ts
Graham McIntire 109c4e141f
feat(eme): 3D WebGL Earth-Moon globe with lazy-loaded Three.js
Replaces the SVG schematic on /eme with a textured WebGL scene: NASA
Blue Marble Earth, real-size Moon, outbound beam, dashed return ray
to the sub-lunar point, and a red shading on the anti-moon hemisphere
that marks who can't see the Moon (outside the bounce footprint).

Three.js (~680 kB) now lives in its own chunk; esbuild --splitting
keeps it out of the main bundle so it only loads when a user hits
/eme. Main app.js drops from 1.6 MB to 922 kB.

Also swaps the Elevation/SNR rows so the badge comes before the number.
2026-04-23 17:00:53 -05:00

445 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 MoonEarth 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<string, unknown>) => {
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 EarthMoon
// 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 }