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:
Graham McIntire 2026-04-23 17:00:33 -05:00
parent 731c095840
commit 109c4e141f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
11 changed files with 1976 additions and 6 deletions

View file

@ -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
View 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 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 }

View file

@ -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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -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()]}
]

View file

@ -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>
(() => {

View file

@ -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">EarthMoon 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

View file

@ -54,5 +54,21 @@ defmodule MicrowavepropWeb.EmeLiveTest do
assert html =~ "32.9"
assert html =~ "-97.0"
end
test "renders the EarthMoon geometry WebGL hook with initial station geometry", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/eme?source=EM13&band=1296")
assert html =~ "EarthMoon 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