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.
This commit is contained in:
parent
731c095840
commit
109c4e141f
11 changed files with 1976 additions and 6 deletions
|
|
@ -22,6 +22,40 @@ import {RoverMap} from "./rover_map_hook"
|
|||
import {BeaconMap} from "./beacon_map_hook"
|
||||
import {BeaconsListMap} from "./beacons_list_map_hook"
|
||||
|
||||
// /eme is the only page that uses Three.js (~600 kB). Keep that out
|
||||
// of the main app bundle by dynamic-importing on first hook mount:
|
||||
// esbuild's --splitting flag emits a separate chunk that the browser
|
||||
// fetches when (and only when) a user lands on /eme.
|
||||
interface LazyHook {
|
||||
_impl?: any
|
||||
_pendingUpdate?: boolean
|
||||
}
|
||||
|
||||
const EmeGlobe = {
|
||||
mounted(this: ViewHook & LazyHook) {
|
||||
const hook = this
|
||||
import("./eme_globe_hook").then((mod) => {
|
||||
const impl = mod.EmeGlobe
|
||||
hook._impl = impl
|
||||
for (const k of Object.keys(impl)) {
|
||||
if (k !== "mounted" && k !== "updated" && k !== "destroyed") {
|
||||
;(hook as any)[k] = (impl as any)[k]
|
||||
}
|
||||
}
|
||||
impl.mounted.call(hook)
|
||||
if (hook._pendingUpdate && impl.updated) impl.updated.call(hook)
|
||||
hook._pendingUpdate = false
|
||||
})
|
||||
},
|
||||
updated(this: ViewHook & LazyHook) {
|
||||
if (this._impl?.updated) this._impl.updated.call(this)
|
||||
else this._pendingUpdate = true
|
||||
},
|
||||
destroyed(this: ViewHook & LazyHook) {
|
||||
this._impl?.destroyed?.call(this)
|
||||
}
|
||||
}
|
||||
|
||||
interface UtcClockHook extends ViewHook {
|
||||
timer: ReturnType<typeof setInterval>
|
||||
tick(): void
|
||||
|
|
@ -94,7 +128,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']")!.getAttribut
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: initLiveStash({_csrf_token: csrfToken}),
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti},
|
||||
hooks: {...colocatedHooks, PropagationMap, UtcClock, ContactMap, ContactsMap, ElevationProfile, WeatherMap, LocateMe, CopyLink, RoverMap, BeaconMap, BeaconsListMap, CommaNumber, ImportConfetti, EmeGlobe},
|
||||
})
|
||||
|
||||
// live_table rows are dead on their own — wire up whole-row clicks to
|
||||
|
|
|
|||
445
assets/js/eme_globe_hook.ts
Normal file
445
assets/js/eme_globe_hook.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
// 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 }
|
||||
12
assets/js/types/global.d.ts
vendored
12
assets/js/types/global.d.ts
vendored
|
|
@ -108,6 +108,18 @@ declare module "../../deps/live_stash/assets/js/live-stash.js" {
|
|||
): (params: Record<string, unknown>) => Record<string, unknown>
|
||||
}
|
||||
|
||||
// Three.js r160 is vendored locally. We only use a small slice of its
|
||||
// surface, so declare it loose: untyped imports with `any` members keep
|
||||
// the dependency weightless without a 600 kB .d.ts bundle.
|
||||
declare module "../vendor/three/three.module.min.js" {
|
||||
const mod: any
|
||||
export = mod
|
||||
}
|
||||
|
||||
declare module "../vendor/three/OrbitControls.js" {
|
||||
export const OrbitControls: any
|
||||
}
|
||||
|
||||
// Phoenix LiveSocket + LiveReloader are stashed on window for the
|
||||
// DevTools console. Dev-only use; we only need the minimal API shapes
|
||||
// that other code references, not the complete vendor interfaces.
|
||||
|
|
|
|||
1417
assets/vendor/three/OrbitControls.js
vendored
Normal file
1417
assets/vendor/three/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
6
assets/vendor/three/three.module.min.js
vendored
Normal file
6
assets/vendor/three/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -14,7 +14,7 @@ config :esbuild,
|
|||
version: "0.27.4",
|
||||
microwaveprop: [
|
||||
args:
|
||||
~w(js/app.ts --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||
~w(js/app.ts --bundle --target=es2022 --format=esm --splitting --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<link rel="dns-prefetch" href="https://b.tile.openstreetmap.org" />
|
||||
<link rel="dns-prefetch" href="https://c.tile.openstreetmap.org" />
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
<script phx-track-static type="module" src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -171,7 +171,15 @@ defmodule MicrowavepropWeb.EmeLive do
|
|||
doppler_hz: doppler_hz
|
||||
}
|
||||
|
||||
assign(socket, moon: moon, link: link)
|
||||
socket
|
||||
|> assign(moon: moon, link: link)
|
||||
|> push_event("eme:update", %{
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
az: moon.azimuth,
|
||||
el: moon.elevation,
|
||||
moon_visible: moon.elevation >= 0.0
|
||||
})
|
||||
end
|
||||
|
||||
# --- Input parsing --------------------------------------------------
|
||||
|
|
@ -471,7 +479,6 @@ defmodule MicrowavepropWeb.EmeLive do
|
|||
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||||
<dt class="opacity-70 shrink-0">Elevation</dt>
|
||||
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
|
||||
<span>{format_deg(@moon && @moon.elevation)}</span>
|
||||
<span class={"badge badge-sm whitespace-nowrap " <> elevation_badge_class(@moon && @moon.elevation)}>
|
||||
<%= cond do %>
|
||||
<% is_nil(@moon) -> %>
|
||||
|
|
@ -484,6 +491,7 @@ defmodule MicrowavepropWeb.EmeLive do
|
|||
below horizon
|
||||
<% end %>
|
||||
</span>
|
||||
<span>{format_deg(@moon && @moon.elevation)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
|
|
@ -566,7 +574,6 @@ defmodule MicrowavepropWeb.EmeLive do
|
|||
<div class="flex items-baseline justify-between gap-3 flex-wrap border-t border-base-300 pt-1.5">
|
||||
<dt class="opacity-70 shrink-0">SNR</dt>
|
||||
<dd class="font-mono text-right flex items-center gap-2 flex-wrap justify-end">
|
||||
<span class="font-semibold">{format_db(@link && @link.snr_db)}</span>
|
||||
<span class={"badge badge-sm whitespace-nowrap " <> snr_badge_class(@link && @link.snr_db)}>
|
||||
<%= cond do %>
|
||||
<% is_nil(@link) -> %>
|
||||
|
|
@ -579,6 +586,7 @@ defmodule MicrowavepropWeb.EmeLive do
|
|||
below noise
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="font-semibold">{format_db(@link && @link.snr_db)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
@ -591,6 +599,38 @@ defmodule MicrowavepropWeb.EmeLive do
|
|||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= if @moon do %>
|
||||
<section class="bg-base-100 border border-base-300 rounded-box p-4">
|
||||
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||||
<h2 class="text-base font-semibold">Earth–Moon geometry (not to scale)</h2>
|
||||
<span class="text-xs opacity-60">
|
||||
Drag to rotate. Shaded hemisphere is everyone who cannot currently see the Moon — outside your bounce footprint.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
id="eme-globe"
|
||||
phx-hook="EmeGlobe"
|
||||
phx-update="ignore"
|
||||
data-station-lat={Float.round(@source_resolved.lat, 4)}
|
||||
data-station-lon={Float.round(@source_resolved.lon, 4)}
|
||||
data-moon-az={Float.round(@moon.azimuth, 3)}
|
||||
data-moon-el={Float.round(@moon.elevation, 3)}
|
||||
data-moon-visible={to_string(@moon.elevation >= 0.0)}
|
||||
class="mt-3 rounded-box overflow-hidden bg-[#050914]"
|
||||
style="min-height: 360px;"
|
||||
>
|
||||
</div>
|
||||
<%= if @moon.elevation < 0.0 do %>
|
||||
<p class="text-xs text-warning mt-2">
|
||||
Moon is below your horizon right now — no line-of-sight path. Wait for moonrise.
|
||||
</p>
|
||||
<% end %>
|
||||
<p class="text-[10px] opacity-50 mt-2">
|
||||
Earth texture: NASA Blue Marble 2015 (public domain). Moon texture: NASA CGI Moon Kit, LROC colour mosaic (public domain).
|
||||
</p>
|
||||
</section>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-sm opacity-70">
|
||||
Enter your callsign or grid above to start the live Moon aim readout.
|
||||
|
|
|
|||
BIN
priv/static/images/eme/earth.jpg
Normal file
BIN
priv/static/images/eme/earth.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
priv/static/images/eme/moon.jpg
Normal file
BIN
priv/static/images/eme/moon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
|
|
@ -54,5 +54,21 @@ defmodule MicrowavepropWeb.EmeLiveTest do
|
|||
assert html =~ "32.9"
|
||||
assert html =~ "-97.0"
|
||||
end
|
||||
|
||||
test "renders the Earth–Moon geometry WebGL hook with initial station geometry", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=EM13&band=1296")
|
||||
|
||||
assert html =~ "Earth–Moon geometry"
|
||||
# The WebGL globe is rendered by a LiveView hook — assert the
|
||||
# hook element + the initial station/moon data attributes that
|
||||
# feed the Three.js scene on mount.
|
||||
assert html =~ ~s(id="eme-globe")
|
||||
assert html =~ ~s(phx-hook="EmeGlobe")
|
||||
assert html =~ ~s(phx-update="ignore")
|
||||
assert html =~ ~s(data-station-lat=")
|
||||
assert html =~ ~s(data-station-lon=")
|
||||
assert html =~ ~s(data-moon-az=")
|
||||
assert html =~ ~s(data-moon-el=")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue