w5isp.com/static/js/moonbounce.js

1167 lines
46 KiB
JavaScript

// 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. dOf = mean - amp*cos(2πφ), so min (perigee) is at φ=0 and φ=1,
// max (apogee) is at φ=0.5.
const periX = xOf(0.25), periY = yOf(dOf(0.25));
const apoX = xOf(0.5), apoY = yOf(dOf(0.5));
// Use phase 0 for perigee marker dot (bottom-left), but put label inside panel at phase 0.25 to keep it readable.
const periMarkX = xOf(0), periMarkY = yOf(dOf(0));
ctx.fillStyle = C.green;
ctx.beginPath(); ctx.arc(periMarkX, periMarkY, 4, 0, Math.PI * 2); ctx.fill();
pill(ctx, 'perigee (closest)', periX, periY + 16, 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, apoY - 14, C.orange, 'center');
// 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();
}
})();