w5isp.com/static/js/nlos.js
Graham McIntire 725857594e
Add interactive NLOS propagation post
New post "why-signals-bend" with ten hand-rolled canvas scenes
(LOS, Earth curvature, Fresnel, knife-edge and rounded diffraction,
refractivity profile, ray tracing, duct trapping, duct types,
and the real 65 km QTH-to-W5HN/B path with 4/3-Earth bulge).

Also drop QSO/em-dash conventions from prop1 and the
"written by humans" footer line.
2026-04-22 10:29:20 -05:00

1705 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// NLOS propagation post, all interactive scenes.
// Vanilla JS, 2D canvas. Scoped to this one post only.
(() => {
'use strict';
const COLORS = {
bg: '#1f232b',
grid: '#323844',
dim: '#5c6370',
fg: '#d4d8df',
blue: '#61afef',
cyan: '#56b6c2',
green: '#98c379',
orange: '#d19a66',
red: '#e06c75',
yellow: '#e5c07b',
magenta: '#c678dd',
};
// Real Earth radius in meters.
const R_EARTH = 6_371_000;
// Speed of light, m/s.
const C = 2.998e8;
// ---------- framework ----------
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);
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(canvas);
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
}
// Build a scene: creates canvas, controls, readout. Returns helpers.
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
const root = document.getElementById(id);
if (!root) return null;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.aspectRatio = `${16} / ${(16 * height) / 640}`;
canvas.style.width = '100%';
canvas.style.height = `${height}px`;
root.appendChild(canvas);
const readoutEl = document.createElement('div');
readoutEl.className = 'nlos-readout';
const readoutSpans = {};
readout.forEach(r => {
const item = document.createElement('span');
item.className = 'nlos-readout-item';
const label = document.createElement('span');
label.textContent = r.label + ':';
const val = document.createElement('span');
val.textContent = r.init ?? '';
item.appendChild(label);
item.appendChild(val);
readoutEl.appendChild(item);
readoutSpans[r.key] = val;
});
if (readout.length) root.appendChild(readoutEl);
const controlsEl = document.createElement('div');
controlsEl.className = 'nlos-controls' + (threeCol ? ' nlos-controls--three' : '');
const values = {};
controls.forEach(c => {
const wrap = document.createElement('div');
wrap.className = 'nlos-control';
const label = document.createElement('label');
const name = document.createElement('span');
name.textContent = c.label;
const valSpan = document.createElement('span');
valSpan.className = 'nlos-value';
label.appendChild(name);
label.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(1) + (c.unit || ''));
valSpan.textContent = fmt(values[c.key]);
input.addEventListener('input', () => {
values[c.key] = parseFloat(input.value);
valSpan.textContent = fmt(values[c.key]);
});
wrap.appendChild(label);
wrap.appendChild(input);
controlsEl.appendChild(wrap);
});
if (controls.length) root.appendChild(controlsEl);
if (caption) {
const cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = caption;
root.appendChild(cap);
}
const { ctx, getSize } = setupCanvas(canvas);
const setReadout = (key, text) => {
if (readoutSpans[key]) readoutSpans[key].textContent = text;
};
// Simple drag handling.
const dragHandlers = [];
canvas.addEventListener('pointerdown', e => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
for (const h of dragHandlers) {
if (h.hit(x, y)) {
h.active = true;
canvas.setPointerCapture(e.pointerId);
h.onMove?.(x, y);
return;
}
}
});
canvas.addEventListener('pointermove', e => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
for (const h of dragHandlers) if (h.active) h.onMove?.(x, y);
});
canvas.addEventListener('pointerup', e => {
for (const h of dragHandlers) h.active = false;
});
canvas.addEventListener('pointercancel', e => {
for (const h of dragHandlers) h.active = false;
});
const addDrag = (hit, onMove) => dragHandlers.push({ hit, onMove, active: false });
return { canvas, ctx, getSize, values, setReadout, addDrag };
}
// ---------- shared drawing helpers ----------
function clear(ctx, w, h, bg = 'transparent') {
ctx.clearRect(0, 0, w, h);
if (bg !== 'transparent') {
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
}
}
function grid(ctx, w, h, { stepX = 40, stepY = 40, color = COLORS.grid } = {}) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < w; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, h); }
for (let y = 0; y < h; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(w, y); }
ctx.stroke();
ctx.restore();
}
function drawAntenna(ctx, x, groundY, heightPx, color = COLORS.orange) {
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 2;
// mast
ctx.beginPath();
ctx.moveTo(x, groundY);
ctx.lineTo(x, groundY - heightPx);
ctx.stroke();
// base
ctx.beginPath();
ctx.moveTo(x - 6, groundY);
ctx.lineTo(x + 6, groundY);
ctx.stroke();
// top
ctx.beginPath();
ctx.arc(x, groundY - heightPx, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function labelText(ctx, text, x, y, color = COLORS.fg, align = 'left', baseline = 'alphabetic') {
ctx.save();
ctx.fillStyle = color;
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(text, x, y);
ctx.restore();
}
// ---------- Scene 1: Straight-line LOS ----------
function sceneLOS() {
const s = scene('scene-los', {
height: 320,
controls: [
{ key: 'h1', label: 'Station A height', min: 5, max: 80, value: 20, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'h2', label: 'Station B height', min: 5, max: 80, value: 20, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'obstacle', label: 'Hill height', min: 0, max: 100, value: 45, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'obstaclePos', label: 'Hill position', min: 0.15, max: 0.85, value: 0.5, step: 0.01, format: v => (v * 100).toFixed(0) + '%' },
],
readout: [
{ key: 'status', label: 'Path' },
{ key: 'clearance', label: 'Clearance' },
],
caption: 'Two amateur stations on flat ground. A direct ray connects the antennas. If any terrain pokes above that line, the signal is blocked. This is what "line of sight" means in the simplest case.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = 40;
const groundY = h - 40;
const xA = margin, xB = w - margin;
const maxH = 100; // meters corresponds to pixels
const pxPerM = 2;
// ground
ctx.fillStyle = '#2b3039';
ctx.fillRect(0, groundY, w, h - groundY);
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, groundY); ctx.lineTo(w, groundY); ctx.stroke();
// hill
const hx = margin + (w - 2 * margin) * s.values.obstaclePos;
const hh = s.values.obstacle * pxPerM;
const hw = 80;
ctx.fillStyle = '#3a4150';
ctx.strokeStyle = COLORS.dim;
ctx.beginPath();
ctx.moveTo(hx - hw, groundY);
ctx.quadraticCurveTo(hx, groundY - hh * 2, hx + hw, groundY);
ctx.closePath();
ctx.fill();
ctx.stroke();
const h1px = s.values.h1 * pxPerM;
const h2px = s.values.h2 * pxPerM;
const yA = groundY - h1px, yB = groundY - h2px;
// signal line, compute clearance over hill apex
const t = (hx - xA) / (xB - xA);
const yOnRay = yA + (yB - yA) * t;
const hillTop = groundY - hh * 2;
const clearance = hillTop - yOnRay; // negative = obstructed... actually no: smaller y = higher. yOnRay < hillTop (more negative offset) means ray is ABOVE hill => clearance positive.
// With screen coords (y down), ray above hill means yOnRay < hillTop. Clearance in meters = (hillTop - yOnRay)/pxPerM. Positive when ray above hill.
const clearanceM = (hillTop - yOnRay) / pxPerM;
const blocked = clearanceM < 0;
// beam
ctx.save();
ctx.strokeStyle = blocked ? COLORS.red : COLORS.cyan;
ctx.lineWidth = 2;
ctx.setLineDash(blocked ? [6, 4] : []);
ctx.beginPath();
ctx.moveTo(xA, yA);
ctx.lineTo(xB, yB);
ctx.stroke();
ctx.restore();
drawAntenna(ctx, xA, groundY, h1px, COLORS.orange);
drawAntenna(ctx, xB, groundY, h2px, COLORS.orange);
labelText(ctx, 'A', xA, groundY - h1px - 10, COLORS.orange, 'center');
labelText(ctx, 'B', xB, groundY - h2px - 10, COLORS.orange, 'center');
s.setReadout('status', blocked ? 'BLOCKED' : 'CLEAR');
s.setReadout('clearance', `${clearanceM.toFixed(1)} m`);
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 2: Earth curvature ----------
function sceneEarthCurve() {
const s = scene('scene-curve', {
height: 360,
controls: [
{ key: 'h1', label: 'Station A height', min: 2, max: 300, value: 30, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'h2', label: 'Station B height', min: 2, max: 300, value: 30, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'dist', label: 'Distance', min: 10, max: 200, value: 80, unit: ' km', format: v => v.toFixed(0) + ' km' },
],
readout: [
{ key: 'horizonA', label: 'Horizon A' },
{ key: 'horizonB', label: 'Horizon B' },
{ key: 'bulge', label: 'Earth bulge' },
{ key: 'status', label: 'LOS' },
],
caption: 'Zoom out, and the ground stops being flat. The horizon distance for an antenna of height h is approximately √(2·R·h). If A and B are farther apart than both their horizon distances combined, the Earths bulge rises above the straight line between them.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = 40;
const bottom = h - 30;
const dKm = s.values.dist;
const dM = dKm * 1000;
// compute Earth bulge at midpoint for given distance.
// bulge = R - R*cos(d/(2R)) ≈ d²/(8R). Use exact.
const halfAngle = dM / (2 * R_EARTH);
const bulgeM = R_EARTH * (1 - Math.cos(halfAngle));
// Vertical scale: we want antennas and bulge visible. Use max of bulge and heights for vertical scale.
const maxVertical = Math.max(bulgeM, s.values.h1, s.values.h2) * 1.4;
const pxPerM = (bottom - margin) / maxVertical;
const xA = margin, xB = w - margin;
const cx = (xA + xB) / 2;
// draw curved ground: arc whose chord is xA..xB and rise (bulge) at center.
// Parameterize ground: y(x) = bottom - (bulge - d_from_center_bulge(x))*pxPerM, where d_from_center_bulge(x) = R*(1 - cos(halfAngle*(1 - 2*|x-cx|/(xB-xA))))? easier: use the parabolic approx for rendering. y_above = (bulge - localBulge(x))*pxPerM. localBulge(x) = R*(1 - cos(halfAngle*(x-cx)/(xB-xA)*2*... )) no. Actually bulge at position along arc (fraction f from center, -1..1): R*(1 - cos(halfAngle*(1-|f|))). Hmm actually we want the ground to be the arc: for a chord of length d, the ground surface is the arc. Height above the chord at fraction u from one end: R*(cos(halfAngle - u*2*halfAngle) - cos(halfAngle)). Easier parametric approach using arc segment angle theta from -halfAngle to +halfAngle:
ctx.fillStyle = '#2b3039';
ctx.beginPath();
ctx.moveTo(xA, bottom);
const N = 60;
for (let i = 0; i <= N; i++) {
const u = i / N; // 0..1
const theta = -halfAngle + u * 2 * halfAngle;
const local = R_EARTH * (Math.cos(theta) - Math.cos(halfAngle));
const x = xA + u * (xB - xA);
const y = bottom - local * pxPerM;
ctx.lineTo(x, y);
}
ctx.lineTo(xB, h);
ctx.lineTo(xA, h);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const u = i / N;
const theta = -halfAngle + u * 2 * halfAngle;
const local = R_EARTH * (Math.cos(theta) - Math.cos(halfAngle));
const x = xA + u * (xB - xA);
const y = bottom - local * pxPerM;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// antennas: placed at ends of arc
const yAground = bottom; // ends are on chord level (cos(±halfAngle) - cos(halfAngle) = 0)
const yBground = bottom;
const h1px = s.values.h1 * pxPerM;
const h2px = s.values.h2 * pxPerM;
const yA = yAground - h1px;
const yB = yBground - h2px;
// horizon distances
const dH1 = Math.sqrt(2 * R_EARTH * s.values.h1) / 1000;
const dH2 = Math.sqrt(2 * R_EARTH * s.values.h2) / 1000;
const reachable = dH1 + dH2 > dKm;
// straight-line ray between antennas (the chord)
ctx.save();
ctx.strokeStyle = reachable ? COLORS.cyan : COLORS.red;
ctx.setLineDash(reachable ? [] : [6, 4]);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xA, yA);
ctx.lineTo(xB, yB);
ctx.stroke();
ctx.restore();
drawAntenna(ctx, xA, yAground, h1px, COLORS.orange);
drawAntenna(ctx, xB, yBground, h2px, COLORS.orange);
// mark bulge at center (peak of arc)
const peakY = bottom - bulgeM * pxPerM;
ctx.fillStyle = COLORS.yellow;
ctx.beginPath();
ctx.arc(cx, peakY, 3, 0, Math.PI * 2);
ctx.fill();
labelText(ctx, `bulge ${bulgeM.toFixed(1)} m`, cx, peakY - 8, COLORS.yellow, 'center');
s.setReadout('horizonA', `${dH1.toFixed(1)} km`);
s.setReadout('horizonB', `${dH2.toFixed(1)} km`);
s.setReadout('bulge', `${bulgeM.toFixed(1)} m`);
s.setReadout('status', reachable ? 'above horizon' : 'below horizon');
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 3: Fresnel zones ----------
function sceneFresnel() {
const s = scene('scene-fresnel', {
height: 320,
controls: [
{ key: 'freq', label: 'Frequency', min: Math.log10(50e6), max: Math.log10(10e9), value: Math.log10(2.4e9), step: 0.01,
format: v => {
const f = Math.pow(10, v);
return f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : (f / 1e6).toFixed(0) + ' MHz';
} },
{ key: 'dist', label: 'Link distance', min: 2, max: 80, value: 20, unit: ' km', format: v => v.toFixed(1) + ' km' },
{ key: 'hill', label: 'Obstacle height', min: -50, max: 60, value: 0, unit: ' m', format: v => v.toFixed(0) + ' m' },
],
readout: [
{ key: 'f1', label: 'F1 radius at midpoint' },
{ key: 'clearance', label: '60% F1 clear?' },
],
caption: 'Radio waves dont ride a pencil-thin line. They fill an ellipsoid between the antennas called the first Fresnel zone. Blocking even part of it bleeds signal. Higher frequencies have thinner ellipsoids, which is why 10 GHz behaves far worse over obstacles than 50 MHz.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const freq = Math.pow(10, s.values.freq);
const lambda = C / freq;
const dM = s.values.dist * 1000;
const margin = 40;
const yCenter = h / 2;
const xA = margin, xB = w - margin;
const px = (xB - xA) / dM; // px per meter horizontal
// F1 radius at midpoint: sqrt(λ * d1 * d2 / (d1+d2)), d1=d2=d/2 -> sqrt(λ*d/4)
const F1Max = Math.sqrt(lambda * dM / 4);
// Vertical scale is based only on distance (using the lowest-frequency case at 50 MHz)
// so the ellipse actually shrinks/grows continuously across the full frequency slider.
const lambdaMax = C / 50e6;
const maxVisible = Math.sqrt(lambdaMax * dM / 4);
const pxPerV = (h / 2 - 30) / maxVisible;
// draw ellipse (first Fresnel zone projection)
ctx.save();
ctx.strokeStyle = COLORS.cyan;
ctx.fillStyle = 'rgba(86, 182, 194, 0.15)';
ctx.lineWidth = 1.5;
ctx.beginPath();
const N = 80;
for (let i = 0; i <= N; i++) {
const u = i / N; // 0..1
const d1 = u * dM, d2 = dM - d1;
const r = d1 > 0 && d2 > 0 ? Math.sqrt(lambda * d1 * d2 / dM) : 0;
const x = xA + u * (xB - xA);
const y = yCenter - r * pxPerV;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
for (let i = N; i >= 0; i--) {
const u = i / N;
const d1 = u * dM, d2 = dM - d1;
const r = d1 > 0 && d2 > 0 ? Math.sqrt(lambda * d1 * d2 / dM) : 0;
const x = xA + u * (xB - xA);
const y = yCenter + r * pxPerV;
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
// 60% zone
ctx.save();
ctx.strokeStyle = COLORS.yellow;
ctx.setLineDash([4, 4]);
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const u = i / N;
const d1 = u * dM, d2 = dM - d1;
const r = d1 > 0 && d2 > 0 ? 0.6 * Math.sqrt(lambda * d1 * d2 / dM) : 0;
const x = xA + u * (xB - xA);
const y = yCenter - r * pxPerV;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const u = i / N;
const d1 = u * dM, d2 = dM - d1;
const r = d1 > 0 && d2 > 0 ? 0.6 * Math.sqrt(lambda * d1 * d2 / dM) : 0;
const x = xA + u * (xB - xA);
const y = yCenter + r * pxPerV;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
// LOS ray
ctx.strokeStyle = COLORS.fg;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xA, yCenter); ctx.lineTo(xB, yCenter);
ctx.stroke();
// antennas
ctx.fillStyle = COLORS.orange;
ctx.beginPath(); ctx.arc(xA, yCenter, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(xB, yCenter, 4, 0, Math.PI * 2); ctx.fill();
// Hill rising at midpoint. A quadratic Bezier's point at t=0.5 is at
// (P0 + 2*P1 + P2) / 4, so to make the visible apex land exactly at hillTopY
// the control point y must be 2*hillTopY - hillBottomY.
const hillH = s.values.hill;
const hillTopY = yCenter - hillH * pxPerV;
const hillBottomY = h - 10;
const hx = (xA + xB) / 2;
const hw = 80;
const controlY = 2 * hillTopY - hillBottomY;
ctx.fillStyle = '#3a4150';
ctx.strokeStyle = COLORS.dim;
ctx.beginPath();
ctx.moveTo(hx - hw, hillBottomY);
ctx.quadraticCurveTo(hx, controlY, hx + hw, hillBottomY);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Mark the 60% F1 floor at the hill's location (below LOS by 0.6*F1).
const floor60Y = yCenter + 0.6 * F1Max * pxPerV;
ctx.save();
ctx.strokeStyle = COLORS.yellow;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(hx - 40, floor60Y);
ctx.lineTo(hx + 40, floor60Y);
ctx.stroke();
ctx.restore();
// Clearance: distance from hill apex to the 60% floor.
// Positive when the hill is below the floor (path is clear enough).
const clearanceM = -hillH - 0.6 * F1Max;
const fractionOfF1 = (-hillH) / F1Max; // how far below LOS the hill sits, in F1 units
s.setReadout('f1', `${F1Max.toFixed(2)} m`);
let clearanceText;
if (clearanceM >= 0) {
clearanceText = `clear by ${clearanceM.toFixed(2)} m (${fractionOfF1.toFixed(2)} F1)`;
} else {
clearanceText = `short by ${(-clearanceM).toFixed(2)} m (${fractionOfF1.toFixed(2)} F1)`;
}
s.setReadout('clearance', clearanceText);
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 4: Knife-edge diffraction ----------
// ITU-R P.526 approximation:
// J(v) = 6.9 + 20*log10( sqrt((v - 0.1)^2 + 1) + v - 0.1 ) for v > -0.78
// J(v) = 0 otherwise
function J_knifeEdge(v) {
if (v <= -0.78) return 0;
return 6.9 + 20 * Math.log10(Math.sqrt(Math.pow(v - 0.1, 2) + 1) + v - 0.1);
}
function sceneKnifeEdge() {
const s = scene('scene-knife', {
height: 340,
controls: [
{ key: 'h', label: 'Obstacle height above LOS', min: -30, max: 60, value: 0, unit: ' m', format: v => v.toFixed(1) + ' m' },
{ key: 'freq', label: 'Frequency', min: Math.log10(50e6), max: Math.log10(10e9), value: Math.log10(1.296e9), step: 0.01,
format: v => {
const f = Math.pow(10, v);
return f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : (f / 1e6).toFixed(0) + ' MHz';
} },
{ key: 'dist', label: 'Link distance', min: 5, max: 100, value: 30, unit: ' km', format: v => v.toFixed(1) + ' km' },
],
readout: [
{ key: 'v', label: 'ν' },
{ key: 'loss', label: 'Diffraction loss' },
],
caption: 'A sharp ridge standing just above the line of sight doesnt stop the signal dead. Some power diffracts into the shadow. The Fresnel-Kirchhoff parameter ν captures the geometry, and the resulting loss in dB follows the ITU-R P.526 curve shown on the right.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const freq = Math.pow(10, s.values.freq);
const lambda = C / freq;
const dM = s.values.dist * 1000;
const d1 = dM / 2, d2 = dM / 2;
// Split canvas into diagram (left) and plot (right).
const splitX = w * 0.52;
// ---- Left: path geometry ----
const margin = 40;
const yLOS = h * 0.5;
const xA = margin, xB = splitX - margin;
const pxPerM = 2.5;
const obsH = s.values.h;
const obsX = (xA + xB) / 2;
const obsTopY = yLOS - obsH * pxPerM;
const obsBottomY = h - 45;
// Reference gridlines in the left panel (light)
ctx.save();
ctx.strokeStyle = COLORS.grid;
ctx.globalAlpha = 0.4;
ctx.setLineDash([2, 4]);
for (let m = -20; m <= 60; m += 20) {
const y = yLOS - m * pxPerM;
if (y > 10 && y < obsBottomY) {
ctx.beginPath(); ctx.moveTo(xA, y); ctx.lineTo(xB, y); ctx.stroke();
labelText(ctx, `${m > 0 ? '+' : ''}${m} m`, xA - 4, y, COLORS.dim, 'right', 'middle');
}
}
ctx.restore();
// LOS line
ctx.strokeStyle = COLORS.dim;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(xA, yLOS); ctx.lineTo(xB, yLOS); ctx.stroke();
labelText(ctx, 'line of sight', (xA + xB) / 2, yLOS - 6, COLORS.dim, 'center');
// knife edge
ctx.fillStyle = '#3a4150';
ctx.strokeStyle = COLORS.dim;
ctx.beginPath();
ctx.moveTo(obsX - 3, obsBottomY);
ctx.lineTo(obsX - 3, obsTopY);
ctx.lineTo(obsX, obsTopY - 8);
ctx.lineTo(obsX + 3, obsTopY);
ctx.lineTo(obsX + 3, obsBottomY);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Height measure: arrow from LOS to obstacle tip showing h
if (Math.abs(obsH) > 0.5) {
ctx.strokeStyle = COLORS.yellow;
ctx.fillStyle = COLORS.yellow;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(obsX + 14, yLOS);
ctx.lineTo(obsX + 14, obsTopY);
ctx.stroke();
// arrowheads
const dir = obsH > 0 ? -1 : 1;
ctx.beginPath();
ctx.moveTo(obsX + 14, obsTopY);
ctx.lineTo(obsX + 11, obsTopY + 4 * dir);
ctx.lineTo(obsX + 17, obsTopY + 4 * dir);
ctx.closePath();
ctx.fill();
labelText(ctx, `h = ${obsH.toFixed(1)} m`, obsX + 20, (yLOS + obsTopY) / 2, COLORS.yellow, 'left', 'middle');
}
// Compute v
const v = obsH * Math.sqrt(2 * (d1 + d2) / (lambda * d1 * d2));
const lossDb = J_knifeEdge(v);
const color = lossDb > 10 ? COLORS.red : lossDb > 3 ? COLORS.yellow : COLORS.cyan;
// Ray — straight line along LOS, dashed if obstructed
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash(obsH > 0 ? [6, 4] : []);
ctx.beginPath(); ctx.moveTo(xA, yLOS); ctx.lineTo(xB, yLOS); ctx.stroke();
ctx.setLineDash([]);
// Diffraction arcs over the edge tip (schematic)
if (obsH > -30) {
ctx.strokeStyle = color;
ctx.globalAlpha = 0.4;
for (let i = 1; i <= 3; i++) {
ctx.beginPath();
ctx.arc(obsX, obsTopY - 8, i * 10, Math.PI * 1.1, Math.PI * 1.9);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
drawAntenna(ctx, xA, yLOS, 0, COLORS.orange);
drawAntenna(ctx, xB, yLOS, 0, COLORS.orange);
labelText(ctx, 'A', xA, yLOS + 14, COLORS.orange, 'center');
labelText(ctx, 'B', xB, yLOS + 14, COLORS.orange, 'center');
// Distance bracket along the bottom
const bracketY = obsBottomY + 18;
ctx.strokeStyle = COLORS.dim;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xA, bracketY - 3); ctx.lineTo(xA, bracketY + 3);
ctx.moveTo(xA, bracketY); ctx.lineTo(xB, bracketY);
ctx.moveTo(xB, bracketY - 3); ctx.lineTo(xB, bracketY + 3);
ctx.stroke();
labelText(ctx, `d = ${(dM / 1000).toFixed(1)} km (d₁ = d₂ = ${(dM / 2000).toFixed(1)} km)`, (xA + xB) / 2, bracketY + 14, COLORS.dim, 'center');
// ---- Right: J(v) plot with axes and annotations ----
const pX = splitX + 40, pY = 15, pW = w - pX - 15, pH = h - 55;
const vMin = -3, vMax = 4;
const lossMin = -2, lossMax = 35;
const xOf = vv => pX + (vv - vMin) / (vMax - vMin) * pW;
const yOf = ll => pY + (1 - (ll - lossMin) / (lossMax - lossMin)) * pH;
// Background shading: free space region (ν < -0.78)
ctx.fillStyle = 'rgba(152, 195, 121, 0.07)';
ctx.fillRect(xOf(vMin), pY, xOf(-0.78) - xOf(vMin), pH);
ctx.fillStyle = 'rgba(224, 108, 117, 0.05)';
ctx.fillRect(xOf(0), pY, xOf(vMax) - xOf(0), pH);
// Grid with tick labels
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
for (let vv = -3; vv <= 4; vv++) {
ctx.beginPath(); ctx.moveTo(xOf(vv), pY); ctx.lineTo(xOf(vv), pY + pH); ctx.stroke();
labelText(ctx, String(vv), xOf(vv), pY + pH + 12, COLORS.dim, 'center');
}
for (let ll = 0; ll <= 30; ll += 10) {
ctx.beginPath(); ctx.moveTo(pX, yOf(ll)); ctx.lineTo(pX + pW, yOf(ll)); ctx.stroke();
labelText(ctx, String(ll), pX - 4, yOf(ll), COLORS.dim, 'right', 'middle');
}
// Frame
ctx.strokeStyle = COLORS.grid;
ctx.strokeRect(pX, pY, pW, pH);
// Axis titles
labelText(ctx, 'ν (obstacle above LOS →)', pX + pW / 2, pY + pH + 28, COLORS.dim, 'center');
ctx.save();
ctx.translate(pX - 30, pY + pH / 2);
ctx.rotate(-Math.PI / 2);
labelText(ctx, 'diffraction loss (dB)', 0, 0, COLORS.dim, 'center');
ctx.restore();
// Reference: grazing line at ν=0, 6 dB.
ctx.save();
ctx.strokeStyle = COLORS.dim;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(xOf(0), pY); ctx.lineTo(xOf(0), pY + pH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(pX, yOf(6)); ctx.lineTo(pX + pW, yOf(6)); ctx.stroke();
ctx.restore();
labelText(ctx, 'grazing (ν=0, 6 dB)', xOf(0) + 4, yOf(6) - 4, COLORS.dim);
// Region labels
labelText(ctx, 'free space', xOf(-2), pY + 14, COLORS.green);
labelText(ctx, 'deep shadow', xOf(3.2), pY + 14, COLORS.red);
// J(v) curve
ctx.strokeStyle = COLORS.blue;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i <= 200; i++) {
const vv = vMin + (vMax - vMin) * i / 200;
const ll = J_knifeEdge(vv);
const x = xOf(vv);
const y = yOf(ll);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
labelText(ctx, 'J(ν) — ITU-R P.526', xOf(1.2), yOf(12), COLORS.blue);
// Current point with readout annotation
const px = xOf(v), py = yOf(lossDb);
ctx.fillStyle = COLORS.yellow;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = COLORS.yellow;
ctx.setLineDash([2, 3]);
ctx.beginPath();
ctx.moveTo(px, py); ctx.lineTo(px, pY + pH);
ctx.moveTo(px, py); ctx.lineTo(pX, py);
ctx.stroke();
ctx.setLineDash([]);
labelText(ctx, `ν = ${v.toFixed(2)}, ${lossDb.toFixed(1)} dB`, px + 8, py - 8, COLORS.yellow);
s.setReadout('v', v.toFixed(2));
s.setReadout('loss', `${lossDb.toFixed(1)} dB`);
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 5: Rounded obstacle ----------
function sceneRounded() {
const s = scene('scene-round', {
height: 320,
controls: [
{ key: 'h', label: 'Hill height above LOS', min: -10, max: 50, value: 10, unit: ' m', format: v => v.toFixed(1) + ' m' },
{ key: 'radius', label: 'Hill radius of curvature', min: 20, max: 5000, value: 500, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'freq', label: 'Frequency', min: Math.log10(50e6), max: Math.log10(10e9), value: Math.log10(1.296e9), step: 0.01,
format: v => {
const f = Math.pow(10, v);
return f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : (f / 1e6).toFixed(0) + ' MHz';
} },
],
readout: [
{ key: 'knife', label: 'Knife-edge loss' },
{ key: 'extra', label: 'Rounding penalty' },
{ key: 'total', label: 'Total' },
],
caption: 'Real hills arent knife-edges. A rounded crest reflects more energy sideways and downward rather than diffracting cleanly around the top, adding extra loss on top of the knife-edge baseline.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const freq = Math.pow(10, s.values.freq);
const lambda = C / freq;
const dM = 30_000;
const d1 = dM / 2, d2 = dM / 2;
const margin = 30;
const yLOS = h * 0.5;
const xA = margin, xB = w - margin;
const pxPerM = 3;
ctx.strokeStyle = COLORS.grid;
ctx.beginPath(); ctx.moveTo(xA, yLOS); ctx.lineTo(xB, yLOS); ctx.stroke();
const obsH = s.values.h;
const radius = s.values.radius;
const obsX = (xA + xB) / 2;
const obsTopY = yLOS - obsH * pxPerM;
// Draw rounded hill, approximate as a wide rounded arch whose curvature we show by width relative to scene
const visualRadiusPx = Math.min(300, radius * 0.08);
ctx.fillStyle = '#3a4150';
ctx.strokeStyle = COLORS.dim;
ctx.beginPath();
ctx.moveTo(obsX - 200, h - 10);
ctx.bezierCurveTo(
obsX - visualRadiusPx, h - 10,
obsX - visualRadiusPx, obsTopY,
obsX, obsTopY,
);
ctx.bezierCurveTo(
obsX + visualRadiusPx, obsTopY,
obsX + visualRadiusPx, h - 10,
obsX + 200, h - 10,
);
ctx.lineTo(obsX + 200, h);
ctx.lineTo(obsX - 200, h);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Knife-edge loss
const v = obsH * Math.sqrt(2 * (d1 + d2) / (lambda * d1 * d2));
const J = J_knifeEdge(v);
// Rounded addition (simplified from ITU-R P.526, method 4 spherical earth):
// m = R*((d1+d2)/(d1*d2))^(1/3) * (π/λ)^(2/3)
// T(m) ≈ 7.2*sqrt(m) - (2 - 12.5*n)*m + 3.6*m^1.5 - 0.8*m^2 (with n ≈ v)
// Simplify: T ≈ 7.2*sqrt(m) with bounded growth.
const m = radius * Math.pow((d1 + d2) / (d1 * d2), 1 / 3) * Math.pow(Math.PI / lambda, 2 / 3);
let T = obsH > 0 ? 7.2 * Math.sqrt(m) : 0;
if (T > 30) T = 30;
const total = J + T;
const color = total > 15 ? COLORS.red : total > 6 ? COLORS.yellow : COLORS.cyan;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash(obsH > 0 ? [6, 4] : []);
ctx.beginPath();
ctx.moveTo(xA, yLOS);
ctx.lineTo(xB, yLOS);
ctx.stroke();
ctx.setLineDash([]);
drawAntenna(ctx, xA, yLOS, 0, COLORS.orange);
drawAntenna(ctx, xB, yLOS, 0, COLORS.orange);
s.setReadout('knife', `${J.toFixed(1)} dB`);
s.setReadout('extra', `${T.toFixed(1)} dB`);
s.setReadout('total', `${total.toFixed(1)} dB`);
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 6: Refractivity profile ----------
// Standard atmosphere: N = 315 * exp(-0.136 * h_km). dN/dh ≈ -40 N/km near surface.
function sceneRefractivity() {
const s = scene('scene-refractivity', {
height: 340,
controls: [
{ key: 'surface', label: 'Surface N-units (N_s)', min: 280, max: 420, value: 315, unit: '', format: v => v.toFixed(0) + ' N' },
{ key: 'slope', label: 'Lapse rate dN/dh', min: -200, max: 20, value: -40, unit: ' N/km', format: v => v.toFixed(0) + ' N/km' },
],
readout: [
{ key: 'regime', label: 'Regime' },
{ key: 'keff', label: 'Effective Earth factor k' },
],
caption: 'Refractivity N measures how much slower radio waves travel than in a vacuum, in parts per million. It falls off with altitude. The steeper that fall, the more rays curve downward, and at 157 N/km the ray curves as hard as the Earth itself.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 60, r: 20, t: 20, b: 40 };
const pX = margin.l, pY = margin.t;
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
ctx.strokeStyle = COLORS.grid;
ctx.strokeRect(pX, pY, pW, pH);
// x = N (200..450), y = altitude km (0..5)
const nMin = 180, nMax = 440;
const altMax = 5;
const xOf = n => pX + (n - nMin) / (nMax - nMin) * pW;
const yOf = a => pY + (1 - a / altMax) * pH;
// gridlines
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
for (let n = 200; n <= 440; n += 40) {
ctx.beginPath(); ctx.moveTo(xOf(n), pY); ctx.lineTo(xOf(n), pY + pH); ctx.stroke();
labelText(ctx, String(n), xOf(n), pY + pH + 14, COLORS.dim, 'center');
}
for (let a = 0; a <= 5; a++) {
ctx.beginPath(); ctx.moveTo(pX, yOf(a)); ctx.lineTo(pX + pW, yOf(a)); ctx.stroke();
labelText(ctx, `${a} km`, pX - 6, yOf(a), COLORS.dim, 'right', 'middle');
}
labelText(ctx, 'N-units', pX + pW / 2, pY + pH + 28, COLORS.dim, 'center');
labelText(ctx, 'Altitude', pX - 50, pY - 5, COLORS.dim);
// Standard atmosphere reference (dashed)
ctx.strokeStyle = COLORS.dim;
ctx.setLineDash([4, 4]);
ctx.beginPath();
for (let i = 0; i <= 50; i++) {
const a = altMax * i / 50;
const n = 315 * Math.exp(-0.136 * a);
const x = xOf(n), y = yOf(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]);
labelText(ctx, 'standard', xOf(315 * Math.exp(-0.136 * 4)), yOf(4) - 4, COLORS.dim);
// User profile: linear with slope
const nSurface = s.values.surface;
const slope = s.values.slope; // per km
ctx.strokeStyle = COLORS.cyan;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let i = 0; i <= 50; i++) {
const a = altMax * i / 50;
const n = nSurface + slope * a;
const x = xOf(n), y = yOf(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Threshold bands: super-refraction < -79, trapping < -157
ctx.fillStyle = 'rgba(224, 108, 117, 0.08)';
// annotate regime
let regime = 'standard refraction';
if (slope > -40) regime = 'sub-refractive';
if (slope <= -79) regime = 'super-refraction';
if (slope <= -157) regime = 'TRAPPING (duct forms)';
// k factor: 1/(1 + R_km * 1e-6 * dN/dh) with R_km = 6371
const k = 1 / (1 + (R_EARTH / 1000) * 1e-6 * slope);
s.setReadout('regime', regime);
s.setReadout('keff', k.toFixed(2));
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 7 / 8: Ray tracing + super-refraction ----------
// Launch horizontal rays above a curved Earth; ray curvature from refractivity lapse.
// Using effective-earth approximation: in the k-factor frame, rays go straight and Earth is
// scaled by k. Equivalent.
function sceneRayTrace() {
const s = scene('scene-raytrace', {
height: 360,
controls: [
{ key: 'slope', label: 'dN/dh', min: -300, max: 0, value: -40, unit: ' N/km', format: v => v.toFixed(0) + ' N/km' },
{ key: 'height', label: 'Launch height', min: 2, max: 200, value: 20, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'angle', label: 'Launch angle', min: -0.5, max: 0.5, value: 0, unit: '°', format: v => v.toFixed(2) + '°' },
],
readout: [
{ key: 'k', label: 'k (effective Earth)' },
{ key: 'regime', label: 'Regime' },
],
caption: 'A real atmosphere bends rays. The gentler the lapse, the less bend. Crank the lapse past 157 N/km and something dramatic happens: the ray curves tighter than the Earth does, and what was a horizon becomes an upward-bending surface that traps the signal.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const slope = s.values.slope;
const Rkm = R_EARTH / 1000;
const k = 1 / (1 + Rkm * 1e-6 * slope);
// Effective-Earth radius. When k is negative (trapping), Reff flips and the
// scene should show an essentially flat-curving ground — clamp huge.
let Reff = k * R_EARTH;
if (k < 0 || !isFinite(k)) Reff = 1e12;
const margin = { l: 60, r: 20, t: 25, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const distKm = 120;
const distM = distKm * 1000;
const maxDropM = 500; // vertical range below the launch-point tangent
const launchAltM = 80; // launch ground sits a bit below top to leave room for antenna + sky
const pxPerM_x = pW / distM;
const pxPerM_y = pH / (maxDropM + launchAltM);
const xLaunch = margin.l + 10;
const launchGroundY = margin.t + launchAltM * pxPerM_y;
// Ground surface: drops with d²/(2·Reff) going right. Clipped to panel.
const bottomY = margin.t + pH;
const groundYatDx = dx_m => Math.min(
bottomY,
launchGroundY + (dx_m * dx_m) / (2 * Reff) * pxPerM_y,
);
// Ground fill (clipped curve)
ctx.fillStyle = '#2b3039';
ctx.beginPath();
const NN = 160;
for (let i = 0; i <= NN; i++) {
const dx_m = distM * i / NN;
const x = xLaunch + dx_m * pxPerM_x;
const y = groundYatDx(dx_m);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.lineTo(margin.l + pW, bottomY);
ctx.lineTo(margin.l, bottomY);
ctx.closePath();
ctx.fill();
// Ground line (same clipped curve, stroked)
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1.2;
ctx.beginPath();
for (let i = 0; i <= NN; i++) {
const dx_m = distM * i / NN;
const x = xLaunch + dx_m * pxPerM_x;
const y = groundYatDx(dx_m);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Altitude gridlines above launch — skip any within 15 m of the antenna top
// so the tick labels don't collide with the TX label.
const launchTopAlt = s.values.height;
ctx.strokeStyle = COLORS.grid;
ctx.globalAlpha = 0.4;
ctx.setLineDash([2, 4]);
for (let alt = 50; alt <= 300; alt += 50) {
if (Math.abs(alt - launchTopAlt) < 15) continue;
const y = launchGroundY - alt * pxPerM_y;
if (y > margin.t + 2) {
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
labelText(ctx, `${alt} m`, margin.l - 4, y, COLORS.dim, 'right', 'middle');
}
}
// Distance ticks on the horizon line (not on the ground curve, which can be below panel)
for (let d = 20; d <= distKm; d += 20) {
const x = xLaunch + d * 1000 * pxPerM_x;
labelText(ctx, `${d} km`, x, margin.t + pH + 14, COLORS.dim, 'center');
}
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Tangent at launch (dashed), showing what a straight ray would do
ctx.strokeStyle = COLORS.dim;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.moveTo(xLaunch, launchGroundY - s.values.height * pxPerM_y);
ctx.lineTo(margin.l + pW, launchGroundY - s.values.height * pxPerM_y);
ctx.stroke();
ctx.setLineDash([]);
// Ray: straight in effective-Earth frame.
const startX = xLaunch;
const startY = launchGroundY - s.values.height * pxPerM_y;
const angleRad = s.values.angle * Math.PI / 180;
const rayColor = slope <= -157 ? COLORS.red : slope <= -79 ? COLORS.yellow : COLORS.cyan;
ctx.strokeStyle = rayColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(startX, startY);
const stepM = 200;
let hit = null;
for (let dxm = stepM; dxm <= distM; dxm += stepM) {
const dym = Math.tan(angleRad) * dxm;
const x = startX + dxm * pxPerM_x;
const y = startY - dym * pxPerM_y;
const groundY = groundYatDx(dxm);
if (y >= groundY) {
// linear interpolate intersection between previous step and this step
const prevDxm = dxm - stepM;
const prevY = startY - Math.tan(angleRad) * prevDxm * pxPerM_y;
const prevGround = groundYatDx(prevDxm);
const t = (prevGround - prevY) / ((y - prevY) - (groundY - prevGround));
const hitDxm = prevDxm + t * stepM;
const hitX = startX + hitDxm * pxPerM_x;
const hitY = groundYatDx(hitDxm);
ctx.lineTo(hitX, hitY);
hit = { x: hitX, y: hitY, dxm: hitDxm };
break;
}
ctx.lineTo(x, y);
}
if (!hit) {
const x = margin.l + pW;
const dxm = distM;
const dym = Math.tan(angleRad) * dxm;
ctx.lineTo(x, startY - dym * pxPerM_y);
}
ctx.stroke();
if (hit) {
ctx.fillStyle = rayColor;
ctx.beginPath(); ctx.arc(hit.x, hit.y, 5, 0, Math.PI * 2); ctx.fill();
labelText(ctx, `${(hit.dxm / 1000).toFixed(1)} km`, hit.x, hit.y - 10, rayColor, 'center');
}
// Antenna
drawAntenna(ctx, xLaunch, launchGroundY, s.values.height * pxPerM_y, COLORS.orange);
labelText(ctx, 'TX', xLaunch + 8, launchGroundY - s.values.height * pxPerM_y - 6, COLORS.orange);
// Legend
labelText(ctx, 'effective-Earth view (ground curves away at d²/2R)', margin.l + 4, margin.t + 14, COLORS.dim);
// Regime readouts
let regime = 'standard';
if (slope > -40) regime = 'sub-refractive';
if (slope <= -79) regime = 'super-refractive';
if (slope <= -157) regime = 'trapping';
s.setReadout('k', k < 0 || !isFinite(k) ? '∞' : k.toFixed(2));
s.setReadout('regime', regime);
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 9: Duct formation with multiple rays ----------
function sceneDuct() {
const s = scene('scene-duct', {
height: 360,
controls: [
{ key: 'thickness', label: 'Duct thickness', min: 20, max: 500, value: 150, unit: ' m', format: v => v.toFixed(0) + ' m' },
{ key: 'strength', label: 'Duct strength (ΔN)', min: 5, max: 80, value: 30, unit: '', format: v => '' + v.toFixed(0) + ' N' },
{ key: 'launch', label: 'Launch elevation angle', min: -1, max: 2, value: 0.2, step: 0.01, unit: '°', format: v => v.toFixed(2) + '°' },
],
readout: [
{ key: 'trapped', label: 'Trapped rays' },
{ key: 'critical', label: 'Critical angle' },
],
caption: 'A duct is a thin layer with steep downward refractivity. Rays launched within a narrow elevation window hit the top of the layer, bend back down, bounce off the ground or a lower layer, and repeat. The signal hops along inside the duct for hundreds of kilometers, far past the ordinary radio horizon.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 40, r: 15, t: 15, b: 30 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const maxAltM = 800;
const distKm = 300;
const pxPerM_x = pW / (distKm * 1000);
const pxPerM_y = pH / maxAltM;
const groundY = margin.t + pH;
const duct = s.values.thickness;
const dN = s.values.strength;
// Ground
ctx.fillStyle = '#2b3039';
ctx.fillRect(margin.l, groundY, pW, h - groundY);
ctx.strokeStyle = COLORS.grid;
ctx.beginPath(); ctx.moveTo(margin.l, groundY); ctx.lineTo(margin.l + pW, groundY); ctx.stroke();
// Duct layer highlight
ctx.fillStyle = 'rgba(86, 182, 194, 0.09)';
ctx.fillRect(margin.l, groundY - duct * pxPerM_y, pW, duct * pxPerM_y);
ctx.strokeStyle = COLORS.cyan;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(margin.l, groundY - duct * pxPerM_y);
ctx.lineTo(margin.l + pW, groundY - duct * pxPerM_y);
ctx.stroke();
ctx.setLineDash([]);
labelText(ctx, `duct top (${duct} m)`, margin.l + pW - 6, groundY - duct * pxPerM_y - 4, COLORS.cyan, 'right');
// Within the duct, effective-earth ray curvature corresponds to dN/dh = -dN/thickness_km.
// Outside, standard atmosphere.
const lapseInside = -dN / (duct / 1000); // N/km
const lapseOutside = -40;
const insideCurvature = -lapseInside * 1e-9; // 1/m, positive = curves downward
const outsideCurvature = -lapseOutside * 1e-9;
// Net downward curvature = insideCurvature - earthCurvature (1/R). For trapping, insideCurvature > 1/R.
const earthCurvature = 1 / R_EARTH;
// Critical angle (approx, for a surface duct): α_c = sqrt(2*ΔN*1e-6)
const alphaCrit = Math.sqrt(Math.max(0, 2 * dN * 1e-6));
const alphaCritDeg = alphaCrit * 180 / Math.PI;
// Trace multiple rays from left side at various angles around launch angle.
const angles = [];
const launchDeg = s.values.launch;
for (let i = -3; i <= 3; i++) angles.push(launchDeg + i * 0.15);
let trapped = 0;
angles.forEach(aDeg => {
const aRad = aDeg * Math.PI / 180;
// Ray in meters: y(x), starting at (0, 5m).
let xm = 0, ym = 5;
let vyx = Math.tan(aRad); // dy/dx
const stepXm = 500;
const isTrapped = Math.abs(aRad) < alphaCrit && dN > 0;
if (isTrapped) trapped++;
const color = isTrapped ? COLORS.green : COLORS.orange;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.moveTo(margin.l + xm * pxPerM_x, groundY - ym * pxPerM_y);
while (xm < distKm * 1000) {
// Pick curvature based on current altitude vs duct top
const insideDuct = ym < duct;
const kappa = insideDuct
? insideCurvature - earthCurvature
: outsideCurvature - earthCurvature;
// Update vy over step
// d²y/dx² = -kappa (flipping sign so positive kappa bends down). In effective-earth frame we already subtracted earthCurvature; the remaining bend is refraction-relative-to-earth. Use: dy/dx changes by -kappa * dx (trapping when insideCurvature > earthCurvature, i.e. kappa > 0 -> vy decreases -> ray bends down).
vyx += -kappa * stepXm;
ym += vyx * stepXm;
xm += stepXm;
if (ym < 0) {
// ground reflection
ym = -ym;
vyx = -vyx;
}
if (ym > maxAltM) { break; }
ctx.lineTo(margin.l + xm * pxPerM_x, groundY - ym * pxPerM_y);
}
ctx.stroke();
ctx.globalAlpha = 1;
});
// antenna at origin
drawAntenna(ctx, margin.l, groundY, 12, COLORS.orange);
s.setReadout('trapped', `${trapped} of ${angles.length}`);
s.setReadout('critical', `±${alphaCritDeg.toFixed(2)}°`);
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 10: Duct types ----------
function sceneDuctTypes() {
const root = document.getElementById('scene-ducttypes');
if (!root) return;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '320px';
root.appendChild(canvas);
const btnRow = document.createElement('div');
btnRow.className = 'nlos-buttons';
const types = [
{ key: 'surface', label: 'Surface duct', desc: 'Steep refractivity gradient begins right at the ground. Common over warm seas and after nocturnal cooling over land.' },
{ key: 'elevated', label: 'Elevated duct', desc: 'Trapped layer sits above the surface. Both endpoints of the link may need to be inside it, or close enough that their rays enter the layer.' },
{ key: 'evap', label: 'Evaporation duct', desc: 'Very thin (540 m) duct formed by the humidity gradient immediately above warm water. Dominates over-water microwave propagation.' },
];
let active = 'surface';
const buttons = {};
types.forEach(t => {
const b = document.createElement('button');
b.textContent = t.label;
b.setAttribute('aria-pressed', t.key === active ? 'true' : 'false');
b.addEventListener('click', () => {
active = t.key;
Object.entries(buttons).forEach(([k, btn]) => btn.setAttribute('aria-pressed', k === active ? 'true' : 'false'));
cap.textContent = types.find(x => x.key === active).desc;
});
buttons[t.key] = b;
btnRow.appendChild(b);
});
root.appendChild(btnRow);
const cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = types.find(x => x.key === active).desc;
root.appendChild(cap);
const { ctx, getSize } = setupCanvas(canvas);
function draw() {
const { w, h } = getSize();
clear(ctx, w, h);
// Two stacked/side plots: refractivity profile (left) and ray paths (right).
const leftW = w * 0.40;
// ---- Left panel: N(h) profile ----
const pX = 70, pY = 30, pW = leftW - 85, pH = h - 60;
const altMax = 500;
const xMinN = 280, xMaxN = 400;
const xOfN = n => pX + (n - xMinN) / (xMaxN - xMinN) * pW;
const yOf = a => pY + (1 - a / altMax) * pH;
// Panel title
labelText(ctx, 'Refractivity profile', pX, pY - 14, COLORS.fg);
// baseline N(h) = 315*exp(-0.136*h_km) modified per duct type
function Nprofile(hM) {
const hk = hM / 1000;
const base = 315 * Math.exp(-0.136 * hk);
if (active === 'surface') {
if (hM < 100) return base - (100 - hM) * 1.0;
return base;
}
if (active === 'elevated') {
if (hM < 200) return base;
if (hM < 350) return base - (hM - 200) * 0.5;
return base - 75;
}
if (active === 'evap') {
if (hM < 30) return base - (30 - hM) * 0.8;
return base;
}
return base;
}
// Duct band shading (profile)
ctx.fillStyle = 'rgba(86, 182, 194, 0.10)';
if (active === 'surface') ctx.fillRect(pX, yOf(100), pW, pH - (yOf(100) - pY));
if (active === 'elevated') ctx.fillRect(pX, yOf(350), pW, yOf(200) - yOf(350));
if (active === 'evap') ctx.fillRect(pX, yOf(30), pW, pH - (yOf(30) - pY));
// Gridlines + altitude labels
ctx.strokeStyle = COLORS.grid;
for (let a = 0; a <= 500; a += 100) {
ctx.beginPath(); ctx.moveTo(pX, yOf(a)); ctx.lineTo(pX + pW, yOf(a)); ctx.stroke();
labelText(ctx, `${a}`, pX - 6, yOf(a), COLORS.dim, 'right', 'middle');
}
for (let n = xMinN; n <= xMaxN; n += 20) {
ctx.beginPath(); ctx.moveTo(xOfN(n), pY); ctx.lineTo(xOfN(n), pY + pH); ctx.stroke();
labelText(ctx, `${n}`, xOfN(n), pY + pH + 12, COLORS.dim, 'center');
}
ctx.strokeRect(pX, pY, pW, pH);
// Axis titles
labelText(ctx, 'altitude (m)', pX - 45, pY + pH / 2 - 50, COLORS.dim, 'left');
ctx.save();
ctx.translate(pX - 46, pY + pH / 2);
ctx.rotate(-Math.PI / 2);
labelText(ctx, 'altitude (m)', 0, 0, COLORS.dim, 'center');
ctx.restore();
labelText(ctx, 'N-units (refractivity)', pX + pW / 2, pY + pH + 26, COLORS.dim, 'center');
// N(h) curve
ctx.strokeStyle = COLORS.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i <= 80; i++) {
const a = altMax * i / 80;
const n = Nprofile(a);
const x = xOfN(n);
const y = yOf(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Annotation arrow pointing at the steep-gradient zone
let annotAltM, annotText;
if (active === 'surface') { annotAltM = 50; annotText = 'steep drop → traps signal below 100 m'; }
else if (active === 'elevated') { annotAltM = 275; annotText = 'steep drop in band → traps signal inside'; }
else { annotAltM = 15; annotText = 'steep drop in lowest ~30 m'; }
const annN = Nprofile(annotAltM);
const ax = xOfN(annN), ay = yOf(annotAltM);
ctx.strokeStyle = COLORS.yellow;
ctx.fillStyle = COLORS.yellow;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ax + 30, ay);
ctx.lineTo(ax + 6, ay);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ax + 6, ay);
ctx.lineTo(ax + 12, ay - 4);
ctx.lineTo(ax + 12, ay + 4);
ctx.closePath();
ctx.fill();
labelText(ctx, annotText, ax + 34, ay, COLORS.yellow, 'left', 'middle');
// ---- Right panel: ray paths ----
const qX = leftW + 55, qY = 30, qW = w - qX - 15, qH = h - 60;
const distKm = 200;
const xrayOf = d => qX + d / distKm * qW;
const yrayOf = a => qY + (1 - a / altMax) * qH;
labelText(ctx, 'Signal paths inside the duct', qX, qY - 14, COLORS.fg);
// Duct band shading (ray panel)
ctx.fillStyle = 'rgba(86, 182, 194, 0.10)';
if (active === 'surface') ctx.fillRect(qX, yrayOf(100), qW, qH - (yrayOf(100) - qY));
if (active === 'elevated') ctx.fillRect(qX, yrayOf(350), qW, yrayOf(200) - yrayOf(350));
if (active === 'evap') ctx.fillRect(qX, yrayOf(30), qW, qH - (yrayOf(30) - qY));
// Gridlines + tick labels
ctx.strokeStyle = COLORS.grid;
for (let a = 0; a <= 500; a += 100) {
ctx.beginPath(); ctx.moveTo(qX, yrayOf(a)); ctx.lineTo(qX + qW, yrayOf(a)); ctx.stroke();
labelText(ctx, `${a}`, qX - 6, yrayOf(a), COLORS.dim, 'right', 'middle');
}
for (let d = 0; d <= distKm; d += 50) {
ctx.beginPath(); ctx.moveTo(xrayOf(d), qY); ctx.lineTo(xrayOf(d), qY + qH); ctx.stroke();
labelText(ctx, `${d}`, xrayOf(d), qY + qH + 12, COLORS.dim, 'center');
}
ctx.strokeRect(qX, qY, qW, qH);
labelText(ctx, 'distance (km)', qX + qW / 2, qY + qH + 26, COLORS.dim, 'center');
ctx.save();
ctx.translate(qX - 46, qY + qH / 2);
ctx.rotate(-Math.PI / 2);
labelText(ctx, 'altitude (m)', 0, 0, COLORS.dim, 'center');
ctx.restore();
// Ground
ctx.fillStyle = '#2b3039';
ctx.fillRect(qX, yrayOf(0), qW, qY + qH - yrayOf(0));
// Duct-band labels
if (active === 'surface') labelText(ctx, 'duct region (ground to ~100 m)', qX + 8, yrayOf(100) - 4, COLORS.cyan);
if (active === 'elevated') labelText(ctx, 'duct region (200350 m)', qX + 8, yrayOf(350) - 4, COLORS.cyan);
if (active === 'evap') labelText(ctx, 'duct region (sea surface to ~30 m)', qX + 8, yrayOf(30) - 4, COLORS.cyan);
// Illustrative signal paths (schematic)
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 1.5;
if (active === 'surface') {
for (let i = 0; i < 4; i++) {
ctx.beginPath();
const phase = i * 50;
for (let d = 0; d <= distKm; d++) {
const a = 10 + 80 * Math.abs(Math.sin((d + phase) * 0.06));
const x = xrayOf(d), y = yrayOf(a);
if (d === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
} else if (active === 'elevated') {
for (let i = 0; i < 3; i++) {
ctx.beginPath();
const phase = i * 80;
for (let d = 0; d <= distKm; d++) {
const a = 275 + 65 * Math.sin((d + phase) * 0.07);
const x = xrayOf(d), y = yrayOf(a);
if (d === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
} else {
for (let i = 0; i < 5; i++) {
ctx.beginPath();
const phase = i * 30;
for (let d = 0; d <= distKm; d++) {
const a = 3 + 22 * Math.abs(Math.sin((d + phase) * 0.2));
const x = xrayOf(d), y = yrayOf(a);
if (d === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
}
// Legend at bottom-right of ray panel
const lgX = qX + qW - 160, lgY = qY + 4;
ctx.fillStyle = 'rgba(40, 44, 52, 0.85)';
ctx.fillRect(lgX, lgY, 154, 26);
ctx.strokeStyle = COLORS.grid;
ctx.strokeRect(lgX, lgY, 154, 26);
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(lgX + 8, lgY + 13); ctx.lineTo(lgX + 28, lgY + 13);
ctx.stroke();
labelText(ctx, 'trapped signal path', lgX + 34, lgY + 13, COLORS.fg, 'left', 'middle');
requestAnimationFrame(draw);
}
draw();
}
// ---------- Scene 11: Real-data path ----------
function sceneReal() {
const root = document.getElementById('scene-real');
if (!root) return;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '300px';
root.appendChild(canvas);
const cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = 'A stylized rendering of a 10 GHz path from my QTH to the NTMS beacon on the Texas Womans University dorms. No line-of-sight exists on the terrain profile, yet the path works on ducting days. The rendered profile is schematic; real-time forecasts live at prop.w5isp.com.';
root.appendChild(cap);
const { ctx, getSize } = setupCanvas(canvas);
// Real SRTM 30 m elevation samples along the actual 65 km great-circle path
// from the W5ISP QTH (33.2462°N, -96.4265°W) to the W5HN/B beacon on the
// TWU dorms (33.2294°N, -97.1276°W). Values in meters MSL.
const realElev = [
181, 181, 174, 183, 192, 210, 200, 188, 178, 197,
220, 213, 208, 201, 221, 229, 217, 189, 185, 171,
181, 185, 172, 184, 190, 182, 161, 176, 183, 193, 240
];
const pathKm = 65;
// Interpolate to 101 smooth points while preserving the peaks/valleys.
const terrainRaw = [];
for (let i = 0; i <= 100; i++) {
const t = i / 100 * (realElev.length - 1);
const i0 = Math.floor(t);
const i1 = Math.min(i0 + 1, realElev.length - 1);
const f = t - i0;
terrainRaw.push(realElev[i0] * (1 - f) + realElev[i1] * f);
}
// Add Earth curvature on top of the real elevations. A path-profile diagram
// typically uses the k = 4/3 effective Earth so the mid-path bulge corresponds
// to what a straight ray (under standard atmosphere) actually sees.
const kEff = 4 / 3;
const Reff = kEff * R_EARTH;
const pathM = pathKm * 1000;
const terrain = [];
for (let i = 0; i <= 100; i++) {
const d = i / 100 * pathM; // meters along path from source
const bulge = (d * (pathM - d)) / (2 * Reff);
terrain.push(terrainRaw[i] + bulge);
}
// Mast heights (approx, from the prop.w5isp.com path URL: 30 ft src, 280 ft dst).
const hA = 9; // ~30 ft source mast
const hB = 85; // ~280 ft TWU dorm tower
// Work out display range based on curved profile.
const minElev = Math.min(...terrain);
const maxFeature = Math.max(...terrain) + hB;
const baseline = minElev - 20;
const altMax = (maxFeature - baseline) + 120;
const maxBulge = (pathM / 2) * (pathM / 2) / (2 * Reff);
function draw(t) {
const { w, h } = getSize();
clear(ctx, w, h);
const margin = { l: 46, r: 15, t: 18, b: 32 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const xOf = i => margin.l + i / 100 * pW;
// Elevations are passed in as real meters; shift by baseline.
const yOf = a => margin.t + (1 - (a - baseline) / altMax) * pH;
// Altitude axis ticks (every 50 m of real elevation)
ctx.strokeStyle = COLORS.grid;
ctx.globalAlpha = 0.5;
for (let e = Math.ceil(baseline / 50) * 50; e < baseline + altMax; e += 50) {
ctx.setLineDash([2, 4]);
ctx.beginPath(); ctx.moveTo(margin.l, yOf(e)); ctx.lineTo(margin.l + pW, yOf(e)); ctx.stroke();
ctx.setLineDash([]);
labelText(ctx, `${e} m`, margin.l - 4, yOf(e), COLORS.dim, 'right', 'middle');
}
// Distance ticks (every 10 km)
for (let d = 0; d <= pathKm; d += 10) {
const x = margin.l + d / pathKm * pW;
labelText(ctx, `${d}`, x, margin.t + pH + 14, COLORS.dim, 'center');
}
labelText(ctx, 'distance (km)', margin.l + pW / 2, margin.t + pH + 26, COLORS.dim, 'center');
ctx.globalAlpha = 1;
// Elevated duct band (real elevation range, e.g. 330410 m MSL)
const ductBottom = 330, ductTop = 410;
ctx.fillStyle = 'rgba(86, 182, 194, 0.09)';
ctx.fillRect(margin.l, yOf(ductTop), pW, yOf(ductBottom) - yOf(ductTop));
ctx.strokeStyle = COLORS.cyan;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(margin.l, yOf(ductTop)); ctx.lineTo(margin.l + pW, yOf(ductTop)); ctx.stroke();
ctx.beginPath(); ctx.moveTo(margin.l, yOf(ductBottom)); ctx.lineTo(margin.l + pW, yOf(ductBottom)); ctx.stroke();
ctx.setLineDash([]);
labelText(ctx, 'elevated duct', margin.l + 8, yOf(ductTop) + 14, COLORS.cyan);
// Earth-curvature annotation near the middle
labelText(ctx, `Earth bulge at midpoint: ${maxBulge.toFixed(0)} m (k = 4/3)`,
margin.l + pW / 2, margin.t + pH - 6, COLORS.dim, 'center');
// Terrain fill
ctx.fillStyle = '#2b3039';
ctx.beginPath();
ctx.moveTo(margin.l, yOf(terrain[0]));
for (let i = 1; i <= 100; i++) ctx.lineTo(xOf(i), yOf(terrain[i]));
ctx.lineTo(margin.l + pW, margin.t + pH);
ctx.lineTo(margin.l, margin.t + pH);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = COLORS.grid;
ctx.beginPath();
ctx.moveTo(margin.l, yOf(terrain[0]));
for (let i = 1; i <= 100; i++) ctx.lineTo(xOf(i), yOf(terrain[i]));
ctx.stroke();
// Straight LOS (blocked by terrain)
const elevA = terrain[0] + hA;
const elevB = terrain[100] + hB;
const yA = yOf(elevA);
const yB = yOf(elevB);
ctx.strokeStyle = COLORS.red;
ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(xOf(0), yA); ctx.lineTo(xOf(100), yB); ctx.stroke();
ctx.setLineDash([]);
labelText(ctx, 'straight LOS: blocked by terrain', xOf(50), (yA + yB) / 2 - 6, COLORS.red, 'center');
// Ducted path: climb into the duct, bounce inside, descend to RX
const ductMid = (ductBottom + ductTop) / 2;
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 2;
ctx.beginPath();
const phase = (t || 0) * 0.0008;
for (let i = 0; i <= 100; i++) {
let a;
if (i < 12) {
a = elevA + (ductMid - elevA) * (i / 12);
} else if (i > 88) {
a = elevB + (ductMid - elevB) * ((100 - i) / 12);
} else {
a = ductMid + 28 * Math.sin(i * 0.4 + phase);
}
const x = xOf(i), y = yOf(a);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Antennas — tower height shown in meters (scaled to screen)
const hPxA = hA / altMax * pH;
const hPxB = hB / altMax * pH;
drawAntenna(ctx, xOf(0), yOf(terrain[0]), hPxA, COLORS.orange);
drawAntenna(ctx, xOf(100), yOf(terrain[100]), hPxB, COLORS.orange);
labelText(ctx, 'My QTH', xOf(0) + 4, yOf(elevA) - 8, COLORS.orange);
labelText(ctx, 'NTMS beacon', xOf(100) - 4, yOf(elevB) - 8, COLORS.orange, 'right');
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ---------- boot ----------
function boot() {
// Tag the article wrapper so CSS vars apply to descendants.
const host = document.querySelector('.nlos, [data-nlos]');
if (host) host.classList.add('nlos');
sceneLOS();
sceneEarthCurve();
sceneFresnel();
sceneKnifeEdge();
sceneRounded();
sceneRefractivity();
sceneRayTrace();
sceneDuct();
sceneDuctTypes();
sceneReal();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();