From 2bc10fc6056be1c49eec41cd6b50b18904ad74a5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 22 Apr 2026 15:52:02 -0500 Subject: [PATCH] Add moonbounce post with 9 interactive scenes, rename cascaded to stacked --- content/blog/moonbounce.md | 105 ++ ...oise-figure.md => stacked-noise-figure.md} | 2 +- static/js/moonbounce.js | 1164 +++++++++++++++++ 3 files changed, 1270 insertions(+), 1 deletion(-) create mode 100644 content/blog/moonbounce.md rename content/blog/{cascaded-noise-figure.md => stacked-noise-figure.md} (99%) create mode 100644 static/js/moonbounce.js diff --git a/content/blog/moonbounce.md b/content/blog/moonbounce.md new file mode 100644 index 0000000..03d3712 --- /dev/null +++ b/content/blog/moonbounce.md @@ -0,0 +1,105 @@ ++++ +title = "Moonbounce: the absurd thing that works" +date = 2026-05-01 +draft = false ++++ + + + +
+ +Take an antenna. Point it at the Moon. Transmit. A microscopic fraction of what you sent makes it across 384,000 km of empty space, smacks into the pocked gray surface, scatters off in every direction because the Moon is a deeply unimpressive mirror, and a microscopic fraction of that comes back. A few kilowatts leave; a few attowatts return. On the other side of the planet, someone pointed at the same Moon hears it. + +This is moonbounce. As a method of having a radio contact it is absurd in a way that very little else in the hobby manages. You can sit a non-ham at a reasonable HF station and explain how a signal gets from Texas to Argentina, and if they squint they'll accept it. Try to explain moonbounce with a straight face and you look slightly unwell. + +It does, annoyingly, just work. You need respectable antennas, respectable power, and a computer willing to spend about a minute per transmission sifting what came back from what didn't. + +The whole story is really the link budget, so what follows is the budget broken into the scenes that make it what it is. + +## The geometry + +Two stations on opposite sides of a rotating Earth. One Moon, tidally locked so the same face points our way, drifting through a 27-day orbit that is tilted about 5 degrees off the ecliptic for good measure. For a contact to happen, both stations have to have the Moon above the horizon at the same instant. Some days that's a few hours. Some days that's zero. + +
+ +The Moon dot is cheerfully oversized and the orbit is nowhere near to scale, because to scale it would be a single pixel most of the way across the page and you'd learn nothing. The rule is the useful part: mutual visibility exists only when the Moon is above the horizon at both ends at once. For a North American station working Europe that's maybe a couple of hours a day. For stations further apart, there are nights where the window simply isn't there, and you do laundry instead. + +Serious EME operators keep [ephemeris data](https://en.wikipedia.org/wiki/Ephemeris) within arm's reach and check common-window charts before firing the station up, the way pilots check weather. + +## How far the Moon is on any given night + +The Moon's orbit isn't circular. Perigee is around 356,000 km, apogee around 406,000 km, and the eccentricity shoves the distance around by roughly fourteen percent over a month. That's a round-trip path-length difference of about a hundred thousand kilometers between best and worst, and since moonbounce path loss goes as distance to the fourth power (once out, once back), it costs you about 2.3 dB extra at apogee compared to perigee. + +
+ +Serious EME operators watch for perigee nights the way HF operators watch the solar flux. Same antenna, same amp, same point on the sky, but the signal is 2 dB stronger simply because the Moon couldn't be bothered to be as far away that week. + +## The obscene amount of path loss + +Free-space loss at 144 MHz over 384,000 km one way is about 187 dB. Round trip doubles that to 374. The Moon isn't a perfect reflector either: its radar albedo is somewhere around 6 or 7 percent at VHF and UHF, which scoops out another 12 dB. The number you actually put in a link budget lands near 252 dB at 144 MHz, 259 at 432, and 271 at 1296. + +
+ +For comparison, a quiet VHF terrestrial contact might be 130 dB from antenna to antenna. Moonbounce is roughly a hundred and twenty decibels worse than a normal hobby. Every one of those decibels has to come from somewhere: antenna gain, transmit power, receiver noise figure, integration time in the demodulator. There are only so many places to find 120 dB, and after you've looked in all of them you start looking in sillier ones. + +## Why the Moon is not a mirror + +If the Moon were a polished metal sphere it would reflect specularly and moonbounce would be a different sport. The real Moon is rock, dust, craters, and mountains on every scale from millimeters to kilometers. At the wavelengths hams use (2 m, 70 cm, 23 cm, and a few shorter) the surface is optically rough, so the energy scatters off in every direction it feels like. + +What comes back to Earth is the coherent sum of reflections from everywhere on the lit disk, each with its own path length and its own Doppler shift relative to the rest, because the Moon is not quite rotating in lockstep with Earth. The apparent rocking is called [libration](https://en.wikipedia.org/wiki/Libration), and although the optical excursion is less than 8 degrees over a month, it's enough that different surface patches are moving at slightly different radial velocities. The returned signal is the sum of all those patches and the sum drifts in and out of constructive and destructive interference as they slide past each other. + +
+ +That produces a characteristic fading pattern: deep, not quite random, with timescales from a fraction of a second at 10 GHz to tens of seconds at 50 MHz. If you've ever listened to a strong moonbounce signal in the raw, you've heard it: a CW note that ought to be steady instead shimmers and hollows out and comes back, over and over, while the operator just keeps sending. It's also why the transmissions are 30 or 60 seconds long in the first place. You need to average over several fading cycles for anything to decode reliably. + +## Doppler + +The Moon moves. You move. The round-trip path length changes constantly, so the frequency you get back is not the frequency you sent. At 144 MHz the peak offset from Earth rotation alone (the self-Doppler, for your own round-trip) is roughly ± 400 Hz near moonrise and moonset and passes through zero when the Moon is at its highest. At 432 MHz it's three times that. At 1296, nine. By 10 GHz it's several kilohertz, and finding your own echo is a genuine tuning exercise instead of an afterthought. + +
+ +Operators either tune for it in real time or let the computer do it. JT65 and Q65 assume the Doppler will drift during the 47-second transmit window and accommodate a linear drift inside the decoder, which is one of the small but very grown-up things those modes do that nobody notices until they try to roll their own. + +## Polarization is complicated + +At VHF, the [ionosphere](https://en.wikipedia.org/wiki/Ionosphere) rotates the plane of linear polarization ([Faraday rotation](https://en.wikipedia.org/wiki/Faraday_effect)) by anywhere from a few degrees to several full turns, depending on total electron content, path geometry, and frequency. At 144 MHz it can be huge and unpredictable. At 432 MHz it shrinks. By 1.3 GHz and above, it's mostly not a problem. + +On top of that there's a purely geometric offset: the plane that's "vertical" at one station is not the plane that's "vertical" at the other when the Moon is the apex of the triangle. This "spatial polarization offset" is deterministic and can be computed from the pointing angles at both ends, but it isn't zero, and it's exactly the sort of thing that quietly costs you a contact. + +
+ +Drag the receive antenna's rotation angle. When the two agree, mismatch loss is zero. When they're 90 degrees apart, the signal vanishes into the cross-polarized null, which is why any sensible 2 meter EME station either uses circular polarization or puts a rotator on the linear array. Circular costs you a flat 3 dB against a randomly polarized return, and in exchange it doesn't care about rotation at all, which turns out to be the trade you want. + +## Modes and thresholds + +Everything above was about getting the signal to and from the Moon. What happens once the faint echo reaches the receiver is a question for the demodulator. SSB voice needs about +10 dB SNR in a 2.5 kHz bandwidth, which in EME terms is not a mode so much as a dare, and the handful of stations that have ever done it have antennas visible from orbit. CW needs about 0 dB in a few hundred Hz, which a well-equipped single-yagi station can sometimes manage under ideal conditions. JT65 integrates for 47 seconds and decodes down around −24 dB in 2.5 kHz. Q65, the current weak-signal mode of choice for EME, goes another 3 dB lower. + +
+ +The signal in the waterfall above is always the same power. What changes is the mode threshold. Watch SSB disappear into the noise while JT65 and Q65 sit comfortably above where they need to be. This is the whole reason every serious EME operation now lives inside WSJT-X. You trade real-time interactivity for sensitivity, and the sensitivity is the only thing that makes the link close. + +## The link budget, end to end + +Roll it all up. Transmit power plus transmit antenna gain minus path loss plus receive antenna gain gives you the received signal power. Subtract the system noise and you get the SNR. Compare it to the mode threshold and either the link closes or it doesn't. + +
+ +A typical "starter EME" 2 meter station looks something like 1 kW into a 4-yagi array (around 18 dBd each end), a 0.5 dB LNA, and a Q65 decoder. That puts you maybe 5 to 8 dB above threshold on a decent night, which doesn't sound like much and isn't, but it's enough to actually have contacts with other stations that size. A single yagi and a full gallon puts you right at the threshold, where you'll hear plenty but almost nobody will hear you. A big-gun setup with a stacked eight-yagi or a 10-foot dish on a higher band pulls you 15 to 20 dB above threshold and turns the mode from a lottery into something closer to a scheduled hobby. + +## The ground gain trick + +One more thing, because it's free. When the Moon is near the horizon and your antenna is mounted above actual ground, the signal arrives via two paths: direct, and bounced off the ground. At the right elevation angle the two arrive in phase at the antenna and give you up to 6 dB of "ground gain" on top of whatever the antenna alone can do. At other angles they cancel, and you get a deep null that looks like somebody unplugged the coax. + +
+ +Most moonrise and moonset EME sessions ride this lobe on purpose. Which means the operators who can't afford an elevation rotator and have to settle for pointing at the horizon aren't as disadvantaged as they sound. The ground is doing some of the heavy lifting for them. + +## What this is really + +Moonbounce is a hobby that asks amateurs to overcome several hundred decibels of loss using legal power and reasonable antennas. It works because every piece of the link has been optimized to the limit of what amateurs can currently do, from antenna design to noise figure to the signal-processing modes, and then the whole thing has been given thirty or more seconds of integration time to squeeze a decode out of what little returns. + +The first moonbounce contacts happened in the early 1950s with military surplus gear and a lot of patience. Sixty years later, with a laptop running WSJT-X and a reasonably sized yagi array, the same contacts happen routinely on evenings the Moon is up. The absurdity of it hasn't budged. Only the cost of admission has, which has quietly dropped to where a person with a normal yard can reasonably attempt it. Which, if you stop and think about what's actually happening, is not a small thing. + +
+ + diff --git a/content/blog/cascaded-noise-figure.md b/content/blog/stacked-noise-figure.md similarity index 99% rename from content/blog/cascaded-noise-figure.md rename to content/blog/stacked-noise-figure.md index 74989d8..6fa4bd3 100644 --- a/content/blog/cascaded-noise-figure.md +++ b/content/blog/stacked-noise-figure.md @@ -1,5 +1,5 @@ +++ -title = "Noise figure, cascaded: why your LNA goes where it goes" +title = "Noise figure, stacked: why your LNA goes where it goes" date = 2026-04-29 draft = false +++ diff --git a/static/js/moonbounce.js b/static/js/moonbounce.js new file mode 100644 index 0000000..f19f034 --- /dev/null +++ b/static/js/moonbounce.js @@ -0,0 +1,1164 @@ +// Moonbounce explainer. Vanilla JS + 2D canvas. Scoped to one post. +(() => { + 'use strict'; + + const C = { + bg: '#1a1d24', + bg2: '#232831', + panel: '#242932', + fg: '#d8dce4', + dim: '#8189a0', + grid: '#2d323d', + gridStrong: '#3a414e', + blue: '#78b5f3', + cyan: '#6dc5d3', + green: '#9dca83', + orange: '#d6a86a', + red: '#e58089', + yellow: '#e2c37d', + magenta: '#c78de0', + moon: '#c9cfd7', + moonDark: '#5d646f', + }; + + function setupCanvas(canvas) { + const ctx = canvas.getContext('2d'); + const dpr = Math.max(1, window.devicePixelRatio || 1); + const fit = () => { + const rect = canvas.getBoundingClientRect(); + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + }; + fit(); + new ResizeObserver(fit).observe(canvas); + return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) }; + } + + function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) { + const root = document.getElementById(id); + if (!root) return null; + root.classList.add('scene-box'); + + const canvas = document.createElement('canvas'); + canvas.style.width = '100%'; + canvas.style.height = `${height}px`; + root.appendChild(canvas); + + const readoutEl = document.createElement('div'); + readoutEl.className = 'scene-readout'; + const readoutSpans = {}; + readout.forEach(r => { + const item = document.createElement('span'); + item.className = 'scene-readout-item'; + const lbl = document.createElement('span'); + lbl.textContent = r.label + ':'; + const val = document.createElement('span'); + val.textContent = r.init ?? ''; + item.appendChild(lbl); item.appendChild(val); + readoutEl.appendChild(item); + readoutSpans[r.key] = val; + }); + if (readout.length) root.appendChild(readoutEl); + + const controlsEl = document.createElement('div'); + controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : ''); + const values = {}; + controls.forEach(c => { + const wrap = document.createElement('div'); + wrap.className = 'scene-control'; + const lbl = document.createElement('label'); + const name = document.createElement('span'); name.textContent = c.label; + const valSpan = document.createElement('span'); valSpan.className = 'scene-value'; + lbl.appendChild(name); lbl.appendChild(valSpan); + const input = document.createElement('input'); + input.type = 'range'; + input.min = c.min; input.max = c.max; input.step = c.step ?? 'any'; + input.value = c.value; + values[c.key] = parseFloat(input.value); + const fmt = c.format || (v => v.toFixed(2)); + valSpan.textContent = fmt(values[c.key]); + input.addEventListener('input', () => { + values[c.key] = parseFloat(input.value); + valSpan.textContent = fmt(values[c.key]); + }); + wrap.appendChild(lbl); wrap.appendChild(input); + controlsEl.appendChild(wrap); + }); + if (controls.length) root.appendChild(controlsEl); + + if (caption) { + const cap = document.createElement('div'); + cap.className = 'scene-caption'; + cap.textContent = caption; + root.appendChild(cap); + } + + const { ctx, getSize } = setupCanvas(canvas); + const setReadout = (key, t) => { if (readoutSpans[key]) readoutSpans[key].textContent = t; }; + return { canvas, ctx, getSize, values, setReadout }; + } + + function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); } + function text(ctx, s, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12) { + ctx.save(); + ctx.fillStyle = color; + ctx.font = `${size}px system-ui, sans-serif`; + ctx.textAlign = align; + ctx.textBaseline = baseline; + ctx.fillText(s, x, y); + ctx.restore(); + } + function pill(ctx, s, x, y, color, align = 'left', baseline = 'middle', size = 12) { + ctx.save(); + ctx.font = `${size}px system-ui, sans-serif`; + ctx.textAlign = align; + ctx.textBaseline = baseline; + const tw = ctx.measureText(s).width; + const padX = 5, padY = 3, h = size + 4 + padY * 2; + let bx; + if (align === 'right') bx = x - tw - padX; + else if (align === 'center') bx = x - tw / 2 - padX; + else bx = x - padX; + const by = y - h / 2; + ctx.fillStyle = 'rgba(26, 29, 36, 0.82)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(bx, by, tw + padX * 2, h, 3); + else ctx.rect(bx, by, tw + padX * 2, h); + ctx.fill(); + ctx.fillStyle = color; + ctx.fillText(s, x, y); + ctx.restore(); + } + + // ---------- Scene 1: Earth-Moon geometry ---------- + function sceneGeometry() { + const s = scene('scene-geometry', { + height: 420, + controls: [ + { key: 'latA', label: 'Station A latitude', min: -60, max: 60, value: 33, step: 1, format: v => `${v.toFixed(0)}°` }, + { key: 'lonA', label: 'Station A longitude', min: -180, max: 180, value: -96, step: 1, format: v => `${v.toFixed(0)}°` }, + { key: 'latB', label: 'Station B latitude', min: -60, max: 60, value: 48, step: 1, format: v => `${v.toFixed(0)}°` }, + { key: 'lonB', label: 'Station B longitude', min: -180, max: 180, value: 11, step: 1, format: v => `${v.toFixed(0)}°` }, + { key: 'speed', label: 'Speed', min: 0, max: 3, value: 1, step: 0.01, format: v => `${v.toFixed(1)}x` }, + ], + readout: [ + { key: 'hoursA', label: 'Moon up at A' }, + { key: 'hoursB', label: 'Moon up at B' }, + { key: 'common', label: 'Common window' }, + ], + caption: 'A stylised Earth with two stations. The Moon crawls through a 27-day orbit while Earth spins once a day. Both stations see the Moon only when their dot lies on the side facing it. The window of mutual visibility is what EME operators live and die by. Not to scale because to scale nothing would be visible.', + }); + if (!s) return; + + const start = performance.now(); + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + // Layout: Earth on left, Moon orbit fills rest. + const cx = w * 0.3; + const cy = h * 0.55; + const earthR = Math.min(w, h) * 0.11; + const orbitR = Math.min(w - cx - 60, h * 0.42); + + // Background stars + ctx.save(); + for (let i = 0; i < 40; i++) { + const x = (i * 137 + 23) % w; + const y = (i * 41 + 7) % (h - 20); + const a = 0.15 + 0.4 * Math.abs(Math.sin(i * 0.9)); + ctx.fillStyle = `rgba(216, 220, 228, ${a})`; + ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + + const t = (performance.now() - start) / 1000 * s.values.speed; + // Moon orbital period: 27.3 days, compressed to ~12 s + const moonTheta = (t * 2 * Math.PI) / 12 + 0.2; + // Earth rotates once per day, compressed to ~1 s = slightly over one full turn per sec + const earthTheta = (t * 2 * Math.PI) / 1.0; + + // Moon orbit circle + ctx.strokeStyle = C.grid; + ctx.lineWidth = 1; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.arc(cx, cy, orbitR, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + + // Moon + const mx = cx + Math.cos(moonTheta) * orbitR; + const my = cy + Math.sin(moonTheta) * orbitR * 0.85; + const moonR = Math.min(w, h) * 0.04; + ctx.save(); + ctx.fillStyle = C.moon; + ctx.beginPath(); ctx.arc(mx, my, moonR, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = C.moonDark; + ctx.beginPath(); ctx.arc(mx + moonR * 0.3, my - moonR * 0.2, moonR * 0.18, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(mx - moonR * 0.4, my + moonR * 0.3, moonR * 0.12, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(mx + moonR * 0.1, my + moonR * 0.5, moonR * 0.08, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + pill(ctx, 'Moon', mx, my - moonR - 12, C.fg, 'center'); + + // Earth + const gradE = ctx.createRadialGradient(cx - earthR * 0.3, cy - earthR * 0.3, 1, cx, cy, earthR); + gradE.addColorStop(0, '#3a5a8a'); + gradE.addColorStop(1, '#1e2b44'); + ctx.fillStyle = gradE; + ctx.beginPath(); ctx.arc(cx, cy, earthR, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = C.grid; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(cx, cy, earthR, 0, Math.PI * 2); ctx.stroke(); + + // Latitude and longitude grid + ctx.strokeStyle = 'rgba(120, 181, 243, 0.15)'; + for (let i = 1; i < 6; i++) { + const rr = earthR * i / 6; + ctx.beginPath(); + for (let a = 0; a <= Math.PI * 2; a += 0.05) { + const x = cx + Math.cos(a) * rr; + const y = cy + Math.sin(a) * rr * 0.35; + if (a === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + + // Place stations. We treat Earth as 2D viewed from above: project lat+lon+earthTheta onto the disk. + function stationXY(lat, lon) { + const latR = lat * Math.PI / 180; + const lonR = lon * Math.PI / 180 + earthTheta; + // Simple 3D -> 2D projection: rotate around Y axis (earth spin), look from +Z axis + const x3 = Math.cos(latR) * Math.sin(lonR); + const y3 = -Math.sin(latR); + const z3 = Math.cos(latR) * Math.cos(lonR); + return { x: cx + x3 * earthR, y: cy + y3 * earthR, z: z3 }; + } + const sA = stationXY(s.values.latA, s.values.lonA); + const sB = stationXY(s.values.latB, s.values.lonB); + + // Moon-direction unit vector in the station's frame: whether station can see Moon depends on + // whether the station's local vertical has a non-negative dot product with the vector to Moon. + // In our projection Moon is in the screen plane (z = 0 roughly). Use the station z and x,y position. + function moonVisible(st) { + const dx = mx - cx, dy = my - cy; + // Station "up" direction is its position vector from Earth center (in 3D, normalized) + const upX = (st.x - cx) / earthR; + const upY = (st.y - cy) / earthR; + const upZ = st.z; + // Moon direction (in 2D, z ~ 0 in our projection). + const md = Math.hypot(dx, dy); + const mdx = dx / md, mdy = dy / md, mdz = 0; + return upX * mdx + upY * mdy + upZ * mdz > 0.05; + } + + const seesA = moonVisible(sA) && sA.z > -0.1; + const seesB = moonVisible(sB) && sB.z > -0.1; + + // Draw paths + ctx.lineWidth = 1.5; + ctx.strokeStyle = seesA ? C.cyan : 'rgba(109, 197, 211, 0.18)'; + ctx.setLineDash(seesA ? [] : [4, 5]); + ctx.beginPath(); ctx.moveTo(sA.x, sA.y); ctx.lineTo(mx, my); ctx.stroke(); + ctx.strokeStyle = seesB ? C.orange : 'rgba(214, 168, 106, 0.18)'; + ctx.beginPath(); ctx.moveTo(sB.x, sB.y); ctx.lineTo(mx, my); ctx.stroke(); + ctx.setLineDash([]); + + // Stations + function drawStation(p, color, label) { + const alpha = p.z > -0.1 ? 1 : 0.25; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + if (p.z > -0.1) pill(ctx, label, p.x + 8, p.y - 10, color, 'left'); + } + drawStation(sA, C.cyan, 'A'); + drawStation(sB, C.orange, 'B'); + + // Visibility status + const bothSee = seesA && seesB; + pill(ctx, bothSee ? 'common window: OPEN' : 'common window: closed', + w - 14, 24, bothSee ? C.green : C.red, 'right'); + + // Readouts + s.setReadout('hoursA', seesA ? 'yes' : 'no'); + s.setReadout('hoursB', seesB ? 'yes' : 'no'); + s.setReadout('common', bothSee ? 'open now' : 'closed now'); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 2: lunar distance over the month ---------- + function sceneDistance() { + const s = scene('scene-distance', { + height: 320, + readout: [ + { key: 'dist', label: 'Distance', init: '384,000 km' }, + { key: 'extra', label: 'Extra loss vs mean', init: '0.0 dB' }, + ], + caption: 'Moon distance over one synodic month. The bump in the middle is perigee, the dip at the ends is apogee. The number on the right is how much extra round-trip path loss that costs you compared to the mean distance. Perigee is the good night.', + }); + if (!s) return; + + const start = performance.now(); + const periodSec = 18; // one lunar month in 18 seconds + function dOf(phase) { + // phase 0..1. Roughly sinusoidal between 356k (perigee) and 406k (apogee) + const mean = 384400, amp = 25000; + return mean - amp * Math.cos(phase * 2 * Math.PI); + } + function extraLoss(d) { + const mean = 384400; + return 40 * Math.log10(d / mean); // d^4 loss relative to mean + } + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const m = { l: 60, r: 30, t: 30, b: 40 }; + const pW = w - m.l - m.r; + const pH = h - m.t - m.b; + + const dMin = 350000, dMax = 410000; + const xOf = p => m.l + p * pW; // phase 0..1 + const yOf = d => m.t + (1 - (d - dMin) / (dMax - dMin)) * pH; + + // Grid + ctx.strokeStyle = C.grid; + ctx.lineWidth = 1; + for (let d = 350000; d <= 410000; d += 10000) { + const y = yOf(d); + ctx.beginPath(); ctx.moveTo(m.l, y); ctx.lineTo(m.l + pW, y); ctx.stroke(); + text(ctx, `${(d / 1000).toFixed(0)}k km`, m.l - 6, y, C.dim, 'right', 'middle'); + } + for (let day = 0; day <= 28; day += 7) { + const x = xOf(day / 28); + ctx.beginPath(); ctx.moveTo(x, m.t); ctx.lineTo(x, m.t + pH); ctx.stroke(); + text(ctx, `day ${day}`, x, m.t + pH + 14, C.dim, 'center'); + } + ctx.strokeStyle = C.grid; + ctx.strokeRect(m.l, m.t, pW, pH); + + // Curve + ctx.strokeStyle = C.cyan; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let i = 0; i <= 200; i++) { + const phase = i / 200; + const x = xOf(phase), y = yOf(dOf(phase)); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Perigee/apogee annotations + const periX = xOf(0.5), periY = yOf(dOf(0.5)); + const apoX = xOf(0.0), apoY = yOf(dOf(0.0)); + ctx.fillStyle = C.green; + ctx.beginPath(); ctx.arc(periX, periY, 4, 0, Math.PI * 2); ctx.fill(); + pill(ctx, 'perigee (closest)', periX, periY - 14, C.green, 'center'); + ctx.fillStyle = C.orange; + ctx.beginPath(); ctx.arc(apoX, apoY, 4, 0, Math.PI * 2); ctx.fill(); + pill(ctx, 'apogee (farthest)', apoX + 8, apoY + 14, C.orange, 'left'); + + // Animated cursor + const t = (performance.now() - start) / 1000; + const phase = ((t / periodSec) % 1); + const cx = xOf(phase), cy = yOf(dOf(phase)); + ctx.strokeStyle = C.yellow; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(cx, m.t); ctx.lineTo(cx, m.t + pH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = C.yellow; + ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); + + s.setReadout('dist', `${Math.round(dOf(phase)).toLocaleString()} km`); + s.setReadout('extra', `${extraLoss(dOf(phase)).toFixed(1)} dB`); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 3: path loss vs frequency ---------- + function scenePathloss() { + const s = scene('scene-pathloss', { + height: 360, + controls: [ + { key: 'distance', label: 'Moon distance', min: 356000, max: 406000, value: 384400, step: 100, + format: v => `${(v / 1000).toFixed(0)}k km` }, + ], + readout: [ + { key: 'total', label: 'Total loss at cursor' }, + { key: 'band', label: 'Band' }, + ], + caption: 'Free-space round-trip plus Moon albedo loss, plotted from 50 MHz through 10 GHz. Reference markers sit at the common amateur EME bands. Drag the moon-distance slider to watch the whole curve shift up at apogee and down at perigee.', + }); + if (!s) return; + + const bands = [ + { f: 50, label: '6 m' }, + { f: 144, label: '2 m' }, + { f: 432, label: '70 cm' }, + { f: 1296, label: '23 cm' }, + { f: 2304, label: '13 cm' }, + { f: 5760, label: '6 cm' }, + { f: 10368, label: '3 cm' }, + ]; + + function fsplRound(fMHz, dKm) { + // one-way FSPL: 20 log10(4 pi d / lambda) = 32.45 + 20 log10(f) + 20 log10(d) with f in MHz, d in km + const ow = 32.45 + 20 * Math.log10(fMHz) + 20 * Math.log10(dKm); + return 2 * ow; + } + function albedoLoss(fMHz) { + // Radar albedo is roughly 6..7% at VHF/UHF falling to ~5% or so at higher freqs. In dB, ~12 dB. + return 12; + } + function totalLoss(fMHz, dKm) { return fsplRound(fMHz, dKm) + albedoLoss(fMHz); } + + let cursorF = 1296; + s.canvas.addEventListener('pointermove', e => { + const rect = s.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const { w } = s.getSize(); + const m = { l: 60, r: 30 }; + const pW = w - m.l - m.r; + const xF = Math.max(0, Math.min(1, (x - m.l) / pW)); + const fMin = 50, fMax = 12000; + cursorF = fMin * Math.pow(fMax / fMin, xF); + }); + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const m = { l: 60, r: 30, t: 30, b: 40 }; + const pW = w - m.l - m.r; + const pH = h - m.t - m.b; + + const fMin = 50, fMax = 12000; + const yMin = 230, yMax = 300; + const xOf = f => m.l + Math.log10(f / fMin) / Math.log10(fMax / fMin) * pW; + const yOf = y => m.t + (1 - (y - yMin) / (yMax - yMin)) * pH; + + // Grid + ctx.strokeStyle = C.grid; + ctx.lineWidth = 1; + for (let y = 230; y <= 300; y += 10) { + const yy = yOf(y); + ctx.beginPath(); ctx.moveTo(m.l, yy); ctx.lineTo(m.l + pW, yy); ctx.stroke(); + text(ctx, `${y} dB`, m.l - 6, yy, C.dim, 'right', 'middle'); + } + const gridF = [50, 100, 300, 1000, 3000, 10000]; + gridF.forEach(f => { + const x = xOf(f); + ctx.beginPath(); ctx.moveTo(x, m.t); ctx.lineTo(x, m.t + pH); ctx.stroke(); + text(ctx, f >= 1000 ? `${f / 1000} GHz` : `${f} MHz`, x, m.t + pH + 14, C.dim, 'center'); + }); + ctx.strokeRect(m.l, m.t, pW, pH); + + // Curve + ctx.strokeStyle = C.cyan; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let i = 0; i <= 200; i++) { + const f = fMin * Math.pow(fMax / fMin, i / 200); + const x = xOf(f), y = yOf(totalLoss(f, s.values.distance)); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Band markers + bands.forEach(b => { + const x = xOf(b.f); + const y = yOf(totalLoss(b.f, s.values.distance)); + ctx.fillStyle = C.orange; + ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); + pill(ctx, b.label, x, y - 14, C.orange, 'center'); + }); + + // Cursor + const cx = xOf(cursorF); + const cy = yOf(totalLoss(cursorF, s.values.distance)); + ctx.strokeStyle = C.yellow; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(cx, m.t); ctx.lineTo(cx, m.t + pH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = C.yellow; + ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); + + // Readouts + s.setReadout('total', `${totalLoss(cursorF, s.values.distance).toFixed(1)} dB`); + const nearest = bands.reduce((best, b) => Math.abs(Math.log(b.f) - Math.log(cursorF)) < Math.abs(Math.log(best.f) - Math.log(cursorF)) ? b : best, bands[0]); + s.setReadout('band', `${cursorF >= 1000 ? (cursorF / 1000).toFixed(2) + ' GHz' : cursorF.toFixed(0) + ' MHz'} (near ${nearest.label})`); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 4: libration fading ---------- + function sceneLibration() { + const s = scene('scene-libration', { + height: 380, + controls: [ + { key: 'freq', label: 'Frequency', min: 50, max: 10000, value: 1296, step: 1, + format: v => v >= 1000 ? `${(v / 1000).toFixed(2)} GHz` : `${v.toFixed(0)} MHz` }, + { key: 'speed', label: 'Libration speed', min: 0.1, max: 3, value: 1, step: 0.01, format: v => `${v.toFixed(1)}x` }, + ], + readout: [ + { key: 'ts', label: 'Coherence time' }, + { key: 'fade', label: 'Current level' }, + ], + caption: 'A toy model of the Moon\'s scattering disk. Each bright spot is a surface patch reflecting back with its own random phase and tiny Doppler offset. The trace at the bottom is the vector sum. The fade rate rises with frequency because the same physical motion produces more wavelengths of path change per second.', + }); + if (!s) return; + + const start = performance.now(); + const nScat = 36; + const scatterers = Array.from({ length: nScat }, (_, i) => ({ + x: Math.random() * 2 - 1, y: Math.random() * 2 - 1, + phase: Math.random() * Math.PI * 2, + rate: (Math.random() - 0.5) * 2, + amp: 0.3 + Math.random() * 0.7, + })).filter(p => p.x * p.x + p.y * p.y <= 1); + + const history = []; + const maxHist = 400; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + // Left: moon disk with scatterers. Right: signal trace. + const moonR = Math.min(w * 0.32, h * 0.4); + const mcx = w * 0.22; + const mcy = h * 0.5; + + // Moon body + const grad = ctx.createRadialGradient(mcx - moonR * 0.3, mcy - moonR * 0.3, 1, mcx, mcy, moonR); + grad.addColorStop(0, '#3a3f48'); + grad.addColorStop(1, '#1f2329'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(mcx, mcy, moonR, 0, Math.PI * 2); ctx.fill(); + + const t = (performance.now() - start) / 1000 * s.values.speed; + // Doppler factor scales with frequency + const freqScale = s.values.freq / 144; + + let sumR = 0, sumI = 0; + scatterers.forEach(p => { + const ph = p.phase + p.rate * t * 0.6 * freqScale; + sumR += p.amp * Math.cos(ph); + sumI += p.amp * Math.sin(ph); + const bright = 0.4 + 0.6 * (0.5 + 0.5 * Math.cos(ph)); + ctx.fillStyle = `rgba(216, 220, 228, ${bright.toFixed(3)})`; + ctx.beginPath(); ctx.arc(mcx + p.x * moonR * 0.88, mcy + p.y * moonR * 0.88, 3, 0, Math.PI * 2); ctx.fill(); + }); + + // Outline moon + ctx.strokeStyle = C.grid; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(mcx, mcy, moonR, 0, Math.PI * 2); ctx.stroke(); + + pill(ctx, 'lunar scattering disk', mcx, mcy - moonR - 12, C.fg, 'center'); + + // Sum magnitude in dB, normalized. Mean-square = sum(amp^2) so normalize by that. + const meanSq = scatterers.reduce((a, p) => a + p.amp * p.amp, 0); + const mag2 = (sumR * sumR + sumI * sumI) / meanSq; + const levelDb = 10 * Math.log10(Math.max(1e-5, mag2)); + history.push(levelDb); + while (history.length > maxHist) history.shift(); + + // Right: signal trace + const px = w * 0.5; + const py = h * 0.15; + const pW = w - px - 20; + const pH = h * 0.7; + + ctx.strokeStyle = C.grid; + ctx.strokeRect(px, py, pW, pH); + const yMin = -20, yMax = 6; + const yOf = v => py + (1 - (v - yMin) / (yMax - yMin)) * pH; + for (let v = yMax; v >= yMin; v -= 6) { + const y = yOf(v); + ctx.strokeStyle = C.grid; + ctx.beginPath(); ctx.moveTo(px, y); ctx.lineTo(px + pW, y); ctx.stroke(); + text(ctx, `${v > 0 ? '+' : ''}${v} dB`, px + pW + 4, y, C.dim, 'left', 'middle'); + } + + ctx.strokeStyle = C.green; + ctx.lineWidth = 2; + ctx.beginPath(); + history.forEach((v, i) => { + const x = px + (i / maxHist) * pW; + const y = yOf(Math.max(yMin, Math.min(yMax, v))); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + }); + ctx.stroke(); + + pill(ctx, 'returned signal amplitude', px + pW / 2, py - 10, C.fg, 'center'); + + // Approx coherence time in seconds + const coh = 8 / freqScale / s.values.speed; + s.setReadout('ts', coh >= 1 ? `~${coh.toFixed(1)} s` : `~${(coh * 1000).toFixed(0)} ms`); + s.setReadout('fade', `${levelDb.toFixed(1)} dB`); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 5: Doppler over a moon pass ---------- + function sceneDoppler() { + const s = scene('scene-doppler', { + height: 340, + controls: [ + { key: 'freq', label: 'Frequency', min: 50, max: 10368, value: 144, step: 1, + format: v => v >= 1000 ? `${(v / 1000).toFixed(2)} GHz` : `${v.toFixed(0)} MHz` }, + ], + readout: [ + { key: 'current', label: 'Doppler now' }, + { key: 'peak', label: 'Peak' }, + ], + caption: 'Self-Doppler over a moon transit from rise to set. At VHF, small. At microwave, substantial. The shape is roughly sinusoidal because the projection of Earth rotation onto the line-of-sight vector swings through zero at the Moon\'s highest point.', + }); + if (!s) return; + + const start = performance.now(); + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const m = { l: 60, r: 30, t: 30, b: 40 }; + const pW = w - m.l - m.r; + const pH = h - m.t - m.b; + + // At 144 MHz, peak self-doppler is about ±400 Hz. Scales linearly with freq. + const fRef = 144; + const peakHz = 400 * (s.values.freq / fRef); + const maxY = Math.max(peakHz * 1.2, 50); + const xOf = t => m.l + t * pW; // t 0..1 (rise to set) + const yOf = v => m.t + (1 - (v + maxY) / (2 * maxY)) * pH; + + // Grid + ctx.strokeStyle = C.grid; + for (let v = -Math.round(maxY); v <= Math.round(maxY); v += Math.max(50, Math.round(maxY / 4))) { + const y = yOf(v); + ctx.beginPath(); ctx.moveTo(m.l, y); ctx.lineTo(m.l + pW, y); ctx.stroke(); + text(ctx, v >= 1000 ? `${(v / 1000).toFixed(1)} kHz` : `${v} Hz`, m.l - 6, y, C.dim, 'right', 'middle'); + } + for (let t = 0; t <= 1; t += 0.25) { + const x = xOf(t); + ctx.beginPath(); ctx.moveTo(x, m.t); ctx.lineTo(x, m.t + pH); ctx.stroke(); + const labels = ['rise', '+3h', 'zenith', '-3h', 'set']; + text(ctx, labels[Math.round(t * 4)], x, m.t + pH + 14, C.dim, 'center'); + } + ctx.strokeRect(m.l, m.t, pW, pH); + + // Zero line emphasized + ctx.strokeStyle = C.gridStrong; + const y0 = yOf(0); + ctx.beginPath(); ctx.moveTo(m.l, y0); ctx.lineTo(m.l + pW, y0); ctx.stroke(); + + // Curve + ctx.strokeStyle = C.cyan; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let i = 0; i <= 200; i++) { + const t = i / 200; + const dop = peakHz * Math.cos(t * Math.PI); + const x = xOf(t), y = yOf(dop); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Animated cursor + const tnow = ((performance.now() - start) / 1000) * 0.1; + const phase = (tnow % 1); + const dopNow = peakHz * Math.cos(phase * Math.PI); + const cx = xOf(phase), cy = yOf(dopNow); + ctx.strokeStyle = C.yellow; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(cx, m.t); ctx.lineTo(cx, m.t + pH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = C.yellow; + ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); + + // Labels on curve + pill(ctx, `+${Math.round(peakHz)} Hz at rise`, xOf(0) + 10, yOf(peakHz) + 14, C.cyan, 'left'); + pill(ctx, `${Math.round(-peakHz)} Hz at set`, xOf(1) - 10, yOf(-peakHz) - 14, C.cyan, 'right'); + + s.setReadout('current', `${dopNow >= 0 ? '+' : ''}${dopNow.toFixed(0)} Hz`); + s.setReadout('peak', `±${peakHz.toFixed(0)} Hz`); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 6: polarization ---------- + function scenePolarization() { + const s = scene('scene-polarization', { + height: 340, + controls: [ + { key: 'tx', label: 'TX polarization angle', min: 0, max: 180, value: 0, step: 1, format: v => `${v.toFixed(0)}°` }, + { key: 'rx', label: 'RX polarization angle', min: 0, max: 180, value: 0, step: 1, format: v => `${v.toFixed(0)}°` }, + ], + readout: [ + { key: 'delta', label: 'Angle difference' }, + { key: 'loss', label: 'Mismatch loss' }, + ], + caption: 'Two linear antennas looking at each other. The mismatch loss is 20 log10 |cos(Δ)|. At 0° it\'s nothing. At 90° it\'s infinite, which is the whole reason circular polarization exists. The wiggle is what Faraday rotation looks like in the wild: unpredictable, time-varying, frequency-dependent, and occasionally ruinous.', + }); + if (!s) return; + + const start = performance.now(); + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + // Two circles: TX on left, RX on right. + const t = (performance.now() - start) / 1000; + const faraday = 20 * Math.sin(t * 0.3) + 10 * Math.sin(t * 0.9 + 1); + + const r = Math.min(w * 0.18, h * 0.38); + const lcx = w * 0.25, rcx = w * 0.75, cy = h * 0.5; + + function drawAntenna(cx, label, angleDeg, color) { + ctx.save(); + ctx.strokeStyle = C.grid; + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = color; + ctx.lineWidth = 3; + const a = angleDeg * Math.PI / 180; + const dx = Math.cos(a) * r * 0.9; + const dy = Math.sin(a) * r * 0.9; + ctx.beginPath(); ctx.moveTo(cx - dx, cy - dy); ctx.lineTo(cx + dx, cy + dy); ctx.stroke(); + pill(ctx, label, cx, cy - r - 14, color, 'center'); + ctx.restore(); + } + + // TX angle is user + faraday. RX is user. + const txAngle = s.values.tx + faraday; + const rxAngle = s.values.rx; + drawAntenna(lcx, `TX ${s.values.tx.toFixed(0)}° + Faraday ${faraday >= 0 ? '+' : ''}${faraday.toFixed(0)}°`, txAngle, C.cyan); + drawAntenna(rcx, `RX ${s.values.rx.toFixed(0)}°`, rxAngle, C.orange); + + // Moon icon between them + ctx.fillStyle = C.moon; + ctx.beginPath(); ctx.arc(w / 2, cy, 14, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = C.moonDark; + ctx.beginPath(); ctx.arc(w / 2 + 4, cy - 3, 3, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(w / 2 - 3, cy + 4, 2, 0, Math.PI * 2); ctx.fill(); + + // Arrow from TX to moon to RX + ctx.strokeStyle = C.dim; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(lcx + r, cy); ctx.lineTo(w / 2 - 16, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(w / 2 + 16, cy); ctx.lineTo(rcx - r, cy); ctx.stroke(); + ctx.setLineDash([]); + + // Compute mismatch loss + let delta = (txAngle - rxAngle) % 180; + if (delta < -90) delta += 180; + if (delta > 90) delta -= 180; + const cosD = Math.cos(delta * Math.PI / 180); + const lossDb = -20 * Math.log10(Math.max(1e-4, Math.abs(cosD))); + + // Loss bar at bottom + const bx = w * 0.1, by = h - 30, bW = w * 0.8, bH = 10; + ctx.fillStyle = C.grid; + ctx.fillRect(bx, by, bW, bH); + const lossFrac = Math.min(1, lossDb / 40); + const barColor = lossDb < 3 ? C.green : lossDb < 10 ? C.yellow : C.red; + ctx.fillStyle = barColor; + ctx.fillRect(bx, by, bW * lossFrac, bH); + pill(ctx, `mismatch loss: ${lossDb.toFixed(1)} dB`, w / 2, by - 10, barColor, 'center'); + + s.setReadout('delta', `${delta.toFixed(0)}°`); + s.setReadout('loss', `${lossDb.toFixed(1)} dB`); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 7: modes and thresholds ---------- + function sceneModes() { + const s = scene('scene-modes', { + height: 360, + controls: [ + { key: 'sigDb', label: 'Signal power', min: -35, max: 0, value: -15, step: 0.5, format: v => `${v.toFixed(1)} dB` }, + ], + readout: [ + { key: 'ssb', label: 'SSB' }, + { key: 'cw', label: 'CW' }, + { key: 'jt', label: 'JT65' }, + { key: 'q', label: 'Q65' }, + ], + caption: 'A live waterfall. Signal is the bright vertical slit. The four horizontal bands show each mode\'s decode threshold relative to a 2.5 kHz noise reference. Bands colored green are decoding. Slide the signal power to see how far below the noise Q65 and JT65 will still work.', + }); + if (!s) return; + + const thresholds = [ + { key: 'ssb', label: 'SSB', thr: 10, color: C.red }, + { key: 'cw', label: 'CW', thr: 0, color: C.orange }, + { key: 'jt', label: 'JT65', thr: -24, color: C.cyan }, + { key: 'q', label: 'Q65', thr: -27, color: C.green }, + ]; + + const start = performance.now(); + const history = []; + const historyMax = 140; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const m = { l: 80, r: 20, t: 30, b: 40 }; + const pW = w - m.l - m.r; + const pH = h - m.t - m.b; + + // Build one frame of noise with signal + const frame = new Float32Array(80); + for (let i = 0; i < frame.length; i++) { + frame[i] = -30 + (Math.random() * 6 - 3); // noise around -30 dB + } + // Signal at column ~40 + const sigCol = 40; + const sigPx = s.values.sigDb; + for (let i = -2; i <= 2; i++) { + const j = sigCol + i; + const contrib = sigPx - Math.abs(i) * 3; + frame[j] = Math.max(frame[j], contrib); + } + history.push(frame); + while (history.length > historyMax) history.shift(); + + // Draw waterfall + const cellW = pW / frame.length; + const cellH = pH / historyMax; + for (let i = 0; i < history.length; i++) { + for (let j = 0; j < frame.length; j++) { + const v = history[i][j]; + const norm = Math.max(0, Math.min(1, (v + 35) / 50)); + const r = Math.round(26 + 200 * norm * norm); + const g = Math.round(29 + 160 * norm); + const b = Math.round(36 + 80 * norm); + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(m.l + j * cellW, m.t + (historyMax - history.length + i) * cellH, cellW + 1, cellH + 1); + } + } + + ctx.strokeStyle = C.grid; + ctx.strokeRect(m.l, m.t, pW, pH); + text(ctx, 'frequency', m.l + pW / 2, m.t + pH + 14, C.dim, 'center'); + text(ctx, 'time', m.l - 10, m.t + pH / 2, C.dim, 'right', 'middle'); + + // Mode threshold indicators to the left + const listX = 8; + let row = m.t + 10; + thresholds.forEach((t, i) => { + const ok = s.values.sigDb >= t.thr; + const color = ok ? C.green : C.red; + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(listX + 6, row, 5, 0, Math.PI * 2); ctx.fill(); + text(ctx, `${t.label}: ${t.thr >= 0 ? '+' : ''}${t.thr} dB`, listX + 18, row, color, 'left', 'middle', 11); + s.setReadout(t.key, ok ? 'decoding' : 'below threshold'); + row += 18; + }); + + // Signal label + const sigX = m.l + (sigCol + 0.5) / frame.length * pW; + pill(ctx, `signal: ${s.values.sigDb.toFixed(1)} dB`, sigX + 8, m.t + 14, C.yellow, 'left'); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 8: end-to-end link budget ---------- + function sceneBudget() { + const s = scene('scene-budget', { + height: 380, + controls: [ + { key: 'pTx', label: 'TX power', min: 100, max: 1500, value: 1000, step: 10, format: v => `${v.toFixed(0)} W` }, + { key: 'gTx', label: 'TX antenna gain', min: 8, max: 35, value: 18, step: 0.1, format: v => `${v.toFixed(1)} dBi` }, + { key: 'gRx', label: 'RX antenna gain', min: 8, max: 35, value: 18, step: 0.1, format: v => `${v.toFixed(1)} dBi` }, + { key: 'nf', label: 'RX noise figure', min: 0.2, max: 3.0, value: 0.5, step: 0.05, format: v => `${v.toFixed(2)} dB` }, + { key: 'freq', label: 'Frequency', min: 50, max: 10368, value: 144, step: 1, + format: v => v >= 1000 ? `${(v / 1000).toFixed(2)} GHz` : `${v.toFixed(0)} MHz` }, + { key: 'mode', label: 'Mode threshold', min: -30, max: 10, value: -27, step: 0.5, format: v => `${v.toFixed(1)} dB` }, + ], + readout: [ + { key: 'loss', label: 'Path loss' }, + { key: 'sig', label: 'RX signal' }, + { key: 'snr', label: 'SNR in 2.5 kHz' }, + { key: 'margin', label: 'Margin to threshold' }, + ], + caption: 'The whole link in one view. Adjust anything and every box downstream updates. Note how much free margin comes from a bigger antenna and how little comes from the last half watt of transmitter output.', + threeCol: true, + }); + if (!s) return; + + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + + const v = s.values; + // Convert power in W to dBm + const pTxDbm = 10 * Math.log10(v.pTx * 1000); + // FSPL round trip at 384,400 km + const dKm = 384400; + const fspl = 2 * (32.45 + 20 * Math.log10(v.freq) + 20 * Math.log10(dKm)); + const albedo = 12; + const total = fspl + albedo; + // Received power in dBm + const pRx = pTxDbm + v.gTx - total + v.gRx; + // Noise floor at T=290K in 2.5 kHz: -174 + 10 log10(2500) + NF + const noise = -174 + 10 * Math.log10(2500) + v.nf; + const snr = pRx - noise; + const margin = snr - v.mode; + + s.setReadout('loss', `${total.toFixed(1)} dB`); + s.setReadout('sig', `${pRx.toFixed(1)} dBm`); + s.setReadout('snr', `${snr.toFixed(1)} dB`); + s.setReadout('margin', `${margin >= 0 ? '+' : ''}${margin.toFixed(1)} dB`); + + // Visualize as stacked horizontal bar chart from TX to RX + const m = { l: 30, r: 30, t: 30, b: 30 }; + const pW = w - m.l - m.r; + const pH = h - m.t - m.b; + + const stages = [ + { label: 'TX power', value: pTxDbm, color: C.green, signed: false }, + { label: 'TX antenna gain', value: v.gTx, color: C.cyan, signed: true }, + { label: 'free-space loss round trip', value: -fspl, color: C.red, signed: true }, + { label: 'moon albedo loss', value: -albedo, color: C.orange, signed: true }, + { label: 'RX antenna gain', value: v.gRx, color: C.cyan, signed: true }, + ]; + + // Cumulative level graph + let levels = [0]; + // Start level at pTxDbm + let curLevel = 0; + const inputLevel = pTxDbm; + const outputLevel = pRx; + const yMin = Math.min(-220, outputLevel - 20); + const yMax = Math.max(80, inputLevel + 10); + + const xPerStage = pW / stages.length; + const yOf = p => m.t + (1 - (p - yMin) / (yMax - yMin)) * pH; + + // Grid + ctx.strokeStyle = C.grid; + for (let p = Math.ceil(yMin / 50) * 50; p <= yMax; p += 50) { + const y = yOf(p); + ctx.beginPath(); ctx.moveTo(m.l, y); ctx.lineTo(m.l + pW, y); ctx.stroke(); + text(ctx, `${p} dBm`, m.l - 4, y, C.dim, 'right', 'middle', 10); + } + ctx.strokeRect(m.l, m.t, pW, pH); + + // Draw cumulative line + const points = [{ x: m.l, y: yOf(pTxDbm), level: pTxDbm, label: 'TX output' }]; + let cum = pTxDbm; + stages.slice(1).forEach((st, i) => { + cum += st.value; + const x = m.l + (i + 1) * xPerStage; + points.push({ x, y: yOf(cum), level: cum, label: st.label }); + }); + + // Stem lines for each stage + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1], cur = points[i]; + // horizontal at previous level + ctx.strokeStyle = C.grid; + ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(cur.x, prev.y); ctx.stroke(); + // vertical drop colored by gain/loss + const delta = cur.level - prev.level; + ctx.strokeStyle = delta >= 0 ? C.cyan : C.red; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(cur.x, prev.y); ctx.lineTo(cur.x, cur.y); ctx.stroke(); + ctx.lineWidth = 1; + pill(ctx, `${delta >= 0 ? '+' : ''}${delta.toFixed(1)} dB`, cur.x, (prev.y + cur.y) / 2, delta >= 0 ? C.cyan : C.red, 'center'); + } + + // Draw noise floor line + const yN = yOf(noise); + ctx.strokeStyle = C.yellow; + ctx.setLineDash([5, 5]); + ctx.beginPath(); ctx.moveTo(m.l, yN); ctx.lineTo(m.l + pW, yN); ctx.stroke(); + ctx.setLineDash([]); + pill(ctx, `noise floor: ${noise.toFixed(1)} dBm`, m.l + pW - 6, yN - 10, C.yellow, 'right'); + + // Final dot at RX + const last = points[points.length - 1]; + ctx.fillStyle = C.green; + ctx.beginPath(); ctx.arc(last.x, last.y, 5, 0, Math.PI * 2); ctx.fill(); + pill(ctx, `RX: ${pRx.toFixed(1)} dBm`, last.x - 6, last.y - 14, C.green, 'right'); + + // Stage labels along the bottom + stages.forEach((st, i) => { + const x = m.l + (i + 0.5) * xPerStage; + text(ctx, st.label, x, m.t + pH + 14, C.dim, 'center', 'alphabetic', 10); + }); + + // Margin indicator at top right + const marginColor = margin >= 3 ? C.green : margin >= 0 ? C.yellow : C.red; + pill(ctx, `margin: ${margin >= 0 ? '+' : ''}${margin.toFixed(1)} dB`, w - 14, 16, marginColor, 'right'); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // ---------- Scene 9: ground gain at moonrise ---------- + function sceneGroundGain() { + const s = scene('scene-groundgain', { + height: 360, + controls: [ + { key: 'hWl', label: 'Antenna height (wavelengths)', min: 1, max: 20, value: 5, step: 0.1, format: v => `${v.toFixed(1)} λ` }, + { key: 'elev', label: 'Moon elevation', min: 0, max: 30, value: 4, step: 0.1, format: v => `${v.toFixed(1)}°` }, + ], + readout: [ + { key: 'gain', label: 'Ground gain' }, + { key: 'peak', label: 'First peak at' }, + ], + caption: 'Ground below the antenna reflects the signal and it arrives back in phase at some elevation angles. The lobe pattern climbs with antenna height: higher means more lobes, more chance that whatever elevation you want actually lines up with a peak. A single yagi at 5λ above ground picks up 3 to 6 dB on its first lobe, for free.', + }); + if (!s) return; + + const start = performance.now(); + function draw() { + const { w, h } = s.getSize(); + const ctx = s.ctx; + clear(ctx, w, h); + const m = { l: 60, r: 20, t: 30, b: 40 }; + const pW = w - m.l - m.r; + const pH = h - m.t - m.b; + + const eMax = 30; // degrees + const xOf = e => m.l + e / eMax * pW; + // Gain in dB relative to free space; two-ray model: |1 + exp(j * 2pi * 2h sin(elev) / lambda)|^2 + function gainAt(elevDeg, hWl) { + const eR = elevDeg * Math.PI / 180; + const phase = 2 * Math.PI * 2 * hWl * Math.sin(eR); + const mag2 = 2 + 2 * Math.cos(phase); + return 10 * Math.log10(Math.max(1e-6, mag2)); + } + + const yMin = -30, yMax = 7; + const yOf = v => m.t + (1 - (v - yMin) / (yMax - yMin)) * pH; + + ctx.strokeStyle = C.grid; + for (let v = -30; v <= 6; v += 6) { + const y = yOf(v); + ctx.beginPath(); ctx.moveTo(m.l, y); ctx.lineTo(m.l + pW, y); ctx.stroke(); + text(ctx, `${v > 0 ? '+' : ''}${v} dB`, m.l - 4, y, C.dim, 'right', 'middle'); + } + for (let e = 0; e <= 30; e += 5) { + const x = xOf(e); + ctx.beginPath(); ctx.moveTo(x, m.t); ctx.lineTo(x, m.t + pH); ctx.stroke(); + text(ctx, `${e}°`, x, m.t + pH + 14, C.dim, 'center'); + } + ctx.strokeRect(m.l, m.t, pW, pH); + + // Free-space reference + const yFs = yOf(0); + ctx.strokeStyle = C.dim; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(m.l, yFs); ctx.lineTo(m.l + pW, yFs); ctx.stroke(); + ctx.setLineDash([]); + pill(ctx, 'free space', m.l + pW - 6, yFs - 10, C.dim, 'right'); + + // Curve + ctx.strokeStyle = C.cyan; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let i = 0; i <= 400; i++) { + const e = (i / 400) * eMax; + const g = Math.max(yMin, gainAt(e, s.values.hWl)); + const x = xOf(e), y = yOf(g); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Cursor at current elevation + const e0 = s.values.elev; + const g0 = gainAt(e0, s.values.hWl); + const cx = xOf(e0), cy = yOf(Math.max(yMin, g0)); + ctx.strokeStyle = C.yellow; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(cx, m.t); ctx.lineTo(cx, m.t + pH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = C.yellow; + ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); + + // First peak elevation: phase 2pi * 2h sin(e) / lambda = 2pi, so sin(e) = 1/(2h) + const sinFirst = 1 / (2 * s.values.hWl); + const eFirst = sinFirst < 1 ? Math.asin(sinFirst) * 180 / Math.PI : Infinity; + + s.setReadout('gain', `${g0 >= 0 ? '+' : ''}${g0.toFixed(1)} dB`); + s.setReadout('peak', isFinite(eFirst) ? `${eFirst.toFixed(1)}°` : 'above horizon'); + + // Small inset: schematic of two-ray + const ix = m.l + 12, iy = m.t + 12, iw = 130, ih = 70; + ctx.fillStyle = 'rgba(26, 29, 36, 0.82)'; + ctx.strokeStyle = C.grid; + if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(ix, iy, iw, ih, 4); ctx.fill(); ctx.stroke(); } + else { ctx.fillRect(ix, iy, iw, ih); ctx.strokeRect(ix, iy, iw, ih); } + const gx = ix + 10, gy = iy + ih - 12; + const ax = gx, ay = gy - 30; + const mx = ix + iw - 12, my = iy + 8; + ctx.strokeStyle = '#6b7380'; + ctx.beginPath(); ctx.moveTo(ix + 4, gy); ctx.lineTo(ix + iw - 4, gy); ctx.stroke(); + ctx.fillStyle = C.orange; + ctx.beginPath(); ctx.arc(ax, ay, 3, 0, Math.PI * 2); ctx.fill(); + // Direct ray + ctx.strokeStyle = C.cyan; + ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(mx, my); ctx.stroke(); + // Reflected ray + ctx.strokeStyle = C.green; + const gmx = gx + 30; + ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(gmx, gy); ctx.lineTo(mx, my); ctx.stroke(); + text(ctx, 'direct + reflected', ix + iw / 2, iy + ih - 3, C.dim, 'center', 'alphabetic', 10); + + requestAnimationFrame(draw); + } + requestAnimationFrame(draw); + } + + // Init after DOM + function init() { + sceneGeometry(); + sceneDistance(); + scenePathloss(); + sceneLibration(); + sceneDoppler(); + scenePolarization(); + sceneModes(); + sceneBudget(); + sceneGroundGain(); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();