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.
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
// 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<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 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 }
|