w5isp.com/static/js/nlos.js

2420 lines
93 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: '#1a1d24',
panel: '#242932',
grid: '#2d323d',
dim: '#8189a0',
fg: '#d8dce4',
blue: '#78b5f3',
cyan: '#6dc5d3',
green: '#9dca83',
orange: '#d6a86a',
red: '#e58089',
yellow: '#e2c37d',
magenta: '#c78de0',
};
// 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);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
};
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();
}
// Label with a dark rounded-rect background so it stays readable over busy backgrounds.
function pillLabel(ctx, text, x, y, color = COLORS.fg, align = 'left', baseline = 'middle') {
ctx.save();
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = align;
ctx.textBaseline = baseline;
const tw = ctx.measureText(text).width;
const padX = 5, padY = 3;
const h = 14 + padY * 2;
let bx;
if (align === 'right') bx = x - tw - padX;
else if (align === 'center') bx = x - tw / 2 - padX;
else bx = x - padX;
let by;
if (baseline === 'middle') by = y - h / 2;
else if (baseline === 'top') by = y - padY;
else by = y - 14 - padY;
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(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 Earth's 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 don't 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 doesn't 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 aren't 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: 360,
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: -300, 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' },
{ key: 'dmdh', label: 'dM/dh' },
],
caption: 'Two ways of drawing the same atmosphere. N-units fall off with altitude in any real atmosphere. Modified refractivity M = N + 0.157*h (h in meters) folds Earth\'s curvature in, so a ray in a medium with dM/dh = 0 travels parallel to the surface and a layer with dM/dh < 0 is a duct. Crank the slope past 157 N/km and watch the M curve flip its slope.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
// Split into two panels: N profile on the left, M profile on the right.
// Extra top margin so the N(h) / M(h) panel titles sit ABOVE the frame.
const margin = { l: 55, r: 24, t: 44, b: 40 };
const gap = 44;
const panelW = (w - margin.l - margin.r - gap) / 2;
const pY = margin.t;
const pH = h - margin.t - margin.b;
const altMax = 5; // km
const nMin = 180, nMax = 440;
// M range: at h=0 M=N, at h=altMax M=N+altMax*157. With N starting at 280-420,
// M ranges roughly 280 to 1200. Start the axis near the ground value so the
// panel isn't mostly empty left space.
const mMin = 260, mMax = 1260;
const leftX = margin.l;
const rightX = margin.l + panelW + gap;
const yOf = a => pY + (1 - a / altMax) * pH;
const xOfN = n => leftX + (n - nMin) / (nMax - nMin) * panelW;
const xOfM = m => rightX + (m - mMin) / (mMax - mMin) * panelW;
const nSurface = s.values.surface;
const slope = s.values.slope;
const dMdh_per_km = slope + 157;
function drawPanel(x0, xScale, xMin, xMax, axisTitle, panelTitle, userFn, stdFn, ductShade, cornerText, cornerColor) {
// Panel title ABOVE the frame so it never collides with the 5 km tick.
labelText(ctx, panelTitle, x0, pY - 10, COLORS.cyan);
if (cornerText) {
labelText(ctx, cornerText, x0 + panelW, pY - 10, cornerColor, 'right');
}
// Duct shading sits beneath everything else in the panel.
if (ductShade) {
ctx.fillStyle = 'rgba(109, 197, 211, 0.12)';
ctx.fillRect(x0, pY, panelW, pH);
}
ctx.strokeStyle = COLORS.grid;
ctx.strokeRect(x0, pY, panelW, pH);
// x-axis gridlines and tick labels. Skip the first and last so they
// never visually run into the neighboring panel's ticks.
const steps = 6;
const xStep = (xMax - xMin) / steps;
for (let i = 0; i <= steps; i++) {
const v = xMin + i * xStep;
const xv = xScale(v);
if (i > 0 && i < steps) {
ctx.beginPath(); ctx.moveTo(xv, pY); ctx.lineTo(xv, pY + pH); ctx.stroke();
labelText(ctx, String(Math.round(v)), xv, pY + pH + 14, COLORS.dim, 'center');
}
}
// y-axis
for (let a = 0; a <= 5; a++) {
ctx.beginPath(); ctx.moveTo(x0, yOf(a)); ctx.lineTo(x0 + panelW, yOf(a)); ctx.stroke();
// keep top and bottom ticks inset so 5 km doesn't clip at panel edge
const ty = a === 5 ? yOf(a) + 6 : a === 0 ? yOf(a) - 6 : yOf(a);
labelText(ctx, `${a} km`, x0 - 6, ty, COLORS.dim, 'right', 'middle');
}
labelText(ctx, axisTitle, x0 + panelW / 2, pY + pH + 28, COLORS.dim, 'center');
// Standard 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 xv = xScale(stdFn(a));
const yv = yOf(a);
if (i === 0) ctx.moveTo(xv, yv); else ctx.lineTo(xv, yv);
}
ctx.stroke();
ctx.setLineDash([]);
// User profile
ctx.strokeStyle = COLORS.cyan;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let i = 0; i <= 50; i++) {
const a = altMax * i / 50;
const xv = xScale(userFn(a));
const yv = yOf(a);
if (i === 0) ctx.moveTo(xv, yv); else ctx.lineTo(xv, yv);
}
ctx.stroke();
}
// LEFT: N profile
drawPanel(
leftX,
xOfN,
nMin, nMax,
'N-units',
'N(h)',
a => nSurface + slope * a,
a => 315 * Math.exp(-0.136 * a),
);
// RIGHT: M profile. Ducting shaded when user's dM/dh is negative.
const corner = dMdh_per_km < 0 ? 'dM/dh < 0 (duct)' : 'dM/dh > 0 (no duct)';
const cornerColor = dMdh_per_km < 0 ? COLORS.green : COLORS.dim;
drawPanel(
rightX,
xOfM,
mMin, mMax,
'M-units (M = N + 0.157*h)',
'M(h)',
a => (nSurface + slope * a) + 157 * a,
a => 315 * Math.exp(-0.136 * a) + 157 * a,
dMdh_per_km < 0,
corner,
cornerColor,
);
let regime = 'standard refraction';
if (slope > -40) regime = 'sub-refractive';
if (slope <= -79) regime = 'super-refraction';
if (slope <= -157) regime = 'trapping (duct)';
const k = 1 / (1 + (R_EARTH / 1000) * 1e-6 * slope);
s.setReadout('regime', regime);
s.setReadout('keff', k < 0 || !isFinite(k) ? '∞' : k.toFixed(2));
s.setReadout('dmdh', `${dMdh_per_km.toFixed(0)} M/km`);
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;
// Ray-persistence state: capture the current ray whenever a slider value
// changes and fade it out over TRAIL_LIFE seconds. Lets the reader see
// the family of trajectories as they drag dN/dh around.
const TRAIL_LIFE = 2.5;
const MAX_TRAILS = 40;
const trails = [];
let lastVals = null;
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, so 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;
const launchAltM = 80;
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([]);
// Compute the ray as a list of points.
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;
const rayPts = [{ x: startX, y: 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) {
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);
rayPts.push({ x: hitX, y: hitY });
hit = { x: hitX, y: hitY, dxm: hitDxm };
break;
}
rayPts.push({ x, y });
}
if (!hit) {
rayPts.push({ x: margin.l + pW, y: startY - Math.tan(angleRad) * distM * pxPerM_y });
}
// Capture a new trail whenever any slider value changed since last frame.
const nowMs = performance.now();
const cur = `${s.values.slope}|${s.values.height}|${s.values.angle}`;
if (lastVals !== null && cur !== lastVals) {
trails.push({ points: rayPts.map(p => ({ x: p.x, y: p.y })), color: rayColor, capturedAt: nowMs });
while (trails.length > MAX_TRAILS) trails.shift();
}
lastVals = cur;
// Draw fading trails first.
for (let i = trails.length - 1; i >= 0; i--) {
const tr = trails[i];
const age = (nowMs - tr.capturedAt) / 1000;
if (age > TRAIL_LIFE) { trails.splice(i, 1); continue; }
const alpha = (1 - age / TRAIL_LIFE) * 0.55;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = tr.color;
ctx.lineWidth = 1.2;
ctx.beginPath();
tr.points.forEach((p, j) => {
if (j === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.restore();
}
// Current ray on top.
ctx.strokeStyle = rayColor;
ctx.lineWidth = 2;
ctx.beginPath();
rayPts.forEach((p, j) => {
if (j === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.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 at d²/2R). Past rays fade as you move sliders.', 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, storing their point lists for later animation.
const angles = [];
const launchDeg = s.values.launch;
for (let i = -3; i <= 3; i++) angles.push(launchDeg + i * 0.15);
let trapped = 0;
const rayData = []; // per-ray: { points: [{x,y,s}], color, trapped }
angles.forEach(aDeg => {
const aRad = aDeg * Math.PI / 180;
let xm = 0, ym = 5;
let vyx = Math.tan(aRad);
const stepXm = 500;
const isTrapped = Math.abs(aRad) < alphaCrit && dN > 0;
if (isTrapped) trapped++;
const color = isTrapped ? COLORS.green : COLORS.orange;
const pts = [{
x: margin.l + xm * pxPerM_x,
y: groundY - ym * pxPerM_y,
s: 0, // arc length proxy, used for glint placement
}];
let sAcc = 0;
while (xm < distKm * 1000) {
const insideDuct = ym < duct;
const kappa = insideDuct ? insideCurvature - earthCurvature : outsideCurvature - earthCurvature;
vyx += -kappa * stepXm;
ym += vyx * stepXm;
xm += stepXm;
if (ym < 0) { ym = -ym; vyx = -vyx; }
if (ym > maxAltM) break;
const px = margin.l + xm * pxPerM_x;
const py = groundY - ym * pxPerM_y;
const last = pts[pts.length - 1];
sAcc += Math.hypot(px - last.x, py - last.y);
pts.push({ x: px, y: py, s: sAcc });
}
rayData.push({ points: pts, color, trapped: isTrapped, totalS: sAcc });
});
// Draw the ray paths.
rayData.forEach(r => {
ctx.strokeStyle = r.color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.9;
ctx.beginPath();
r.points.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
});
ctx.globalAlpha = 1;
// Animated glints traveling along each trapped ray.
const CYCLE = 4.0;
const now = (performance.now() / 1000) % CYCLE;
const tNorm = now / CYCLE;
rayData.forEach((r, idx) => {
if (!r.trapped || r.totalS <= 0) return;
const targetS = (tNorm + idx * 0.12) % 1 * r.totalS;
// find the segment that contains targetS
for (let i = 1; i < r.points.length; i++) {
const p0 = r.points[i - 1], p1 = r.points[i];
if (targetS >= p0.s && targetS <= p1.s) {
const f = (targetS - p0.s) / (p1.s - p0.s + 1e-6);
const gx = p0.x + (p1.x - p0.x) * f;
const gy = p0.y + (p1.y - p0.y) * f;
ctx.save();
ctx.fillStyle = COLORS.green;
ctx.shadowColor = COLORS.green;
ctx.shadowBlur = 12;
ctx.beginPath();
ctx.arc(gx, gy, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
break;
}
}
});
// 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: 'Radiation duct', desc: 'Forms at night under clear calm skies as the ground radiates heat to space faster than the air above it cools. 50-300 m deep, gradients 100 to 300 N/km. Peaks in the hour before dawn, erodes soon after sunrise. The dominant 10 GHz DX mechanism inland.' },
{ key: 'elevated', label: 'Subsidence duct', desc: 'Elevated layer under the axis of a high-pressure ridge, where sinking air warms and dries. Sits 500-2000 m up and can stay there for days. Both ends of the link usually need to be inside or aimed into the layer.' },
{ key: 'evap', label: 'Evaporation duct', desc: 'Very thin (5-40 m) duct formed by the humidity gradient right above warm water. There nearly 24/7 over the ocean. Irrelevant inland but dominates over-water microwave paths.' },
];
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 Woman's 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([]);
pillLabel(ctx, 'straight LOS: blocked by terrain', xOf(50), (yA + yB) / 2 - 8, COLORS.red, 'center', 'middle');
// 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);
pillLabel(ctx, 'My QTH', xOf(0) + 4, yOf(elevA) - 12, COLORS.orange, 'left', 'middle');
pillLabel(ctx, 'NTMS beacon', xOf(100) - 4, yOf(elevB) - 12, COLORS.orange, 'right', 'middle');
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ---------- Scene: what the data says ----------
function sceneData() {
const root = document.getElementById('scene-data');
if (!root) return;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '210px';
root.appendChild(canvas);
const cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = 'Median 10 GHz contact distance, bucketed three different ways. Surface pressure (lower works better than the folklore suggests), boundary-layer depth (shallow wins), and the yes/no ducting flag from HRRR (almost no signal). Each bar animates to full length on load.';
root.appendChild(cap);
const { ctx, getSize } = setupCanvas(canvas);
const startWall = performance.now();
// Real numbers from the project dataset.
const panels = [
{
title: 'Surface pressure',
unit: 'km median distance',
bars: [
{ label: '< 970 mb', value: 243, color: COLORS.green },
{ label: '970-1000', value: 190, color: COLORS.cyan },
{ label: '1000-1020', value: 150, color: COLORS.yellow },
{ label: '> 1020 mb', value: 103, color: COLORS.orange },
],
max: 260,
},
{
title: 'Boundary layer height (HPBL)',
unit: 'km median distance',
bars: [
{ label: '< 200 m', value: 230, color: COLORS.green },
{ label: '200-500', value: 195, color: COLORS.cyan },
{ label: '500-2000', value: 150, color: COLORS.yellow },
{ label: '> 2000 m', value: 100, color: COLORS.orange },
],
max: 260,
},
{
title: 'Binary duct flag',
unit: 'km median distance',
bars: [
{ label: 'duct: YES', value: 189, color: COLORS.cyan },
{ label: 'duct: NO', value: 192, color: COLORS.cyan },
],
max: 260,
note: 'statistically identical',
},
];
function draw() {
const { w, h } = getSize();
clear(ctx, w, h);
const margin = { l: 20, r: 20, t: 25, b: 30 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const gap = 20;
const colW = (pW - gap * (panels.length - 1)) / panels.length;
const now = (performance.now() - startWall) / 1000;
const animT = Math.min(1, now / 1.2); // 1.2 second reveal
panels.forEach((panel, ci) => {
const x0 = margin.l + ci * (colW + gap);
labelText(ctx, panel.title, x0, margin.t + 10, COLORS.fg);
labelText(ctx, panel.unit, x0, margin.t + 24, COLORS.dim);
const chartTop = margin.t + 38;
const chartBot = margin.t + pH - 10;
const chartH = chartBot - chartTop;
const barCount = panel.bars.length;
const barH = Math.min(26, (chartH - 6 * (barCount - 1)) / barCount);
const labelW = 70;
panel.bars.forEach((b, bi) => {
const by = chartTop + bi * (barH + 8);
// label on the left
labelText(ctx, b.label, x0 + labelW - 4, by + barH / 2, COLORS.dim, 'right', 'middle');
// bar
const fullW = colW - labelW - 40;
const w_actual = (b.value / panel.max) * fullW * animT;
ctx.fillStyle = b.color;
ctx.globalAlpha = 0.85;
ctx.beginPath();
// rounded rect for nicer look
const r = Math.min(4, barH / 2);
ctx.moveTo(x0 + labelW, by + r);
ctx.arcTo(x0 + labelW, by, x0 + labelW + r, by, r);
ctx.lineTo(x0 + labelW + w_actual - r, by);
ctx.arcTo(x0 + labelW + w_actual, by, x0 + labelW + w_actual, by + r, r);
ctx.lineTo(x0 + labelW + w_actual, by + barH - r);
ctx.arcTo(x0 + labelW + w_actual, by + barH, x0 + labelW + w_actual - r, by + barH, r);
ctx.lineTo(x0 + labelW + r, by + barH);
ctx.arcTo(x0 + labelW, by + barH, x0 + labelW, by + barH - r, r);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
// value label at end
if (animT > 0.5) {
ctx.globalAlpha = Math.min(1, (animT - 0.5) * 3);
labelText(ctx, `${b.value} km`, x0 + labelW + w_actual + 6, by + barH / 2, COLORS.fg, 'left', 'middle');
ctx.globalAlpha = 1;
}
});
if (panel.note && animT > 0.9) {
// Put the note right below the last bar so it doesn't float in empty space.
const lastBarBottom = chartTop + barCount * (barH + 8);
labelText(ctx, panel.note, x0 + labelW, lastBarBottom + 10, COLORS.dim);
}
});
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ---------- Scene: time-of-day x band heatmap ----------
function sceneBandTime() {
const root = document.getElementById('scene-band-time');
if (!root) return;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '260px';
root.appendChild(canvas);
const cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = 'Percent bonus to median contact distance during the dawn window vs the daily mean, per band. 10 GHz barely cares what time it is. 75 GHz cares more than anything else.';
root.appendChild(cap);
const { ctx, getSize } = setupCanvas(canvas);
const startWall = performance.now();
// Dawn-window enhancement by band, from the project\'s correlation runs.
const bands = [
{ label: '10 GHz', enh: 4 },
{ label: '24 GHz', enh: 28 },
{ label: '47 GHz', enh: 36 },
{ label: '75 GHz', enh: 360 },
];
const maxEnh = 400;
function draw() {
const { w, h } = getSize();
clear(ctx, w, h);
const margin = { l: 80, r: 60, t: 25, b: 30 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const now = (performance.now() - startWall) / 1000;
const animT = Math.min(1, now / 1.5);
labelText(ctx, 'Dawn-window enhancement over daily mean', margin.l, margin.t - 6, COLORS.fg);
const rowH = pH / bands.length;
bands.forEach((b, i) => {
const y = margin.t + i * rowH;
labelText(ctx, b.label, margin.l - 6, y + rowH / 2, COLORS.dim, 'right', 'middle');
// bar uses log-ish scale so small values are still visible
const t = Math.pow(b.enh / maxEnh, 0.45);
const bw = t * pW * animT;
// color ramps from cyan (low) to magenta (high)
const lerp = b.enh / maxEnh;
const color = lerp < 0.1 ? COLORS.cyan :
lerp < 0.3 ? COLORS.green :
lerp < 0.6 ? COLORS.yellow :
lerp < 0.85 ? COLORS.orange : COLORS.magenta;
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
const r = Math.min(4, rowH * 0.35);
const bh = rowH * 0.7;
const by = y + (rowH - bh) / 2;
ctx.beginPath();
ctx.moveTo(margin.l, by + r);
ctx.arcTo(margin.l, by, margin.l + r, by, r);
ctx.lineTo(margin.l + bw - r, by);
ctx.arcTo(margin.l + bw, by, margin.l + bw, by + r, r);
ctx.lineTo(margin.l + bw, by + bh - r);
ctx.arcTo(margin.l + bw, by + bh, margin.l + bw - r, by + bh, r);
ctx.lineTo(margin.l + r, by + bh);
ctx.arcTo(margin.l, by + bh, margin.l, by + bh - r, r);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
if (animT > 0.6) {
ctx.globalAlpha = Math.min(1, (animT - 0.6) * 3);
labelText(ctx, `+${b.enh}%`, margin.l + bw + 6, by + bh / 2, COLORS.fg, 'left', 'middle');
ctx.globalAlpha = 1;
}
});
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ---------- Scene: humidity sweet spot (PWAT vs distance) ----------
function scenePwat() {
const root = document.getElementById('scene-pwat');
if (!root) return;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '320px';
root.appendChild(canvas);
// Drag state
let pwat = 25;
const caption = document.createElement('div');
caption.className = 'nlos-caption';
caption.textContent = 'Median contact distance vs precipitable water. The 10 GHz curve has a clear hump around 25 mm (not too dry, not too wet). The 24 GHz curve is monotonically worse as humidity climbs, because the atmospheric absorption line at 22.235 GHz eats more signal the more water there is in the column. Drag the cursor to move the readout.';
root.appendChild(caption);
const { ctx, getSize } = setupCanvas(canvas);
const startWall = performance.now();
// Simplified model of the data shape: 10 GHz peaks near 25 mm, 24 GHz falls off.
function dist10(p) {
// Gaussian hump centered at 25 mm, plus a drift
const peak = 225, base = 140;
const sigma = 18;
return base + (peak - base) * Math.exp(-Math.pow((p - 25) / sigma, 2));
}
function dist24(p) {
// Decreasing with PWAT, saturating
return 160 - 70 * (1 - Math.exp(-p / 18));
}
canvas.addEventListener('pointerdown', handle);
canvas.addEventListener('pointermove', e => { if (e.buttons) handle(e); });
function handle(e) {
const { w } = getSize();
const margin = { l: 60, r: 30 };
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const pW = w - margin.l - margin.r;
const pMin = 0, pMax = 60;
const xFrac = Math.max(0, Math.min(1, (x - margin.l) / pW));
pwat = pMin + xFrac * (pMax - pMin);
}
function draw() {
const { w, h } = getSize();
clear(ctx, w, h);
const margin = { l: 60, r: 30, t: 40, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const pMin = 0, pMax = 60;
const dMin = 0, dMax = 260;
const xOf = p => margin.l + (p - pMin) / (pMax - pMin) * pW;
const yOf = d => margin.t + (1 - (d - dMin) / (dMax - dMin)) * pH;
// gridlines
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
for (let p = 0; p <= 60; p += 10) {
const x = xOf(p);
ctx.beginPath(); ctx.moveTo(x, margin.t); ctx.lineTo(x, margin.t + pH); ctx.stroke();
labelText(ctx, `${p}`, x, margin.t + pH + 14, COLORS.dim, 'center');
}
for (let d = 0; d <= 260; d += 50) {
const y = yOf(d);
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
labelText(ctx, `${d}`, margin.l - 6, y, COLORS.dim, 'right', 'middle');
}
ctx.strokeStyle = COLORS.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
labelText(ctx, 'precipitable water (mm)', margin.l + pW / 2, margin.t + pH + 28, COLORS.dim, 'center');
ctx.save();
ctx.translate(margin.l - 44, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
labelText(ctx, 'median distance (km)', 0, 0, COLORS.dim, 'center');
ctx.restore();
// Animate reveal
const elapsed = (performance.now() - startWall) / 1000;
const animT = Math.min(1, elapsed / 1.5);
function drawCurve(fn, color, label, labelAt) {
ctx.strokeStyle = color;
ctx.lineWidth = 2.5;
ctx.beginPath();
const N = 120;
const endI = Math.floor(N * animT);
for (let i = 0; i <= endI; i++) {
const p = pMin + (pMax - pMin) * i / N;
const x = xOf(p), y = yOf(fn(p));
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
if (animT >= 0.95) {
labelText(ctx, label, xOf(labelAt), yOf(fn(labelAt)) - 8, color);
}
}
// Only show fixed curve end-labels when cursor is far from them
const cursorX = xOf(pwat);
const showEnd10 = Math.abs(cursorX - xOf(25)) > 60;
const showEnd24 = Math.abs(cursorX - xOf(45)) > 60;
drawCurve(dist10, COLORS.cyan, showEnd10 ? '10 GHz' : '', 25);
drawCurve(dist24, COLORS.orange, showEnd24 ? '24 GHz' : '', 45);
// Current-point cursor
ctx.strokeStyle = COLORS.yellow;
ctx.setLineDash([3, 4]);
ctx.beginPath(); ctx.moveTo(cursorX, margin.t); ctx.lineTo(cursorX, margin.t + pH); ctx.stroke();
ctx.setLineDash([]);
const y10 = yOf(dist10(pwat));
const y24 = yOf(dist24(pwat));
ctx.fillStyle = COLORS.cyan;
ctx.beginPath(); ctx.arc(cursorX, y10, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = COLORS.orange;
ctx.beginPath(); ctx.arc(cursorX, y24, 5, 0, Math.PI * 2); ctx.fill();
// Flip readout labels to left side when cursor is past midpoint
const flipLeft = pwat > (pMin + pMax) / 2;
const align = flipLeft ? 'right' : 'left';
const offset = flipLeft ? -10 : 10;
pillLabel(ctx, `PWAT ${pwat.toFixed(0)} mm`, cursorX + offset, margin.t + 14, COLORS.yellow, align, 'middle');
pillLabel(ctx, `10 GHz: ${Math.round(dist10(pwat))} km`, cursorX + offset, y10 + 16, COLORS.cyan, align, 'middle');
pillLabel(ctx, `24 GHz: ${Math.round(dist24(pwat))} km`, cursorX + offset, y24 - 14, COLORS.orange, align, 'middle');
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ---------- Scene: seasonal ducting probability ----------
function sceneSeasonal() {
const root = document.getElementById('scene-seasonal');
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 cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = 'Fraction of HRRR profiles containing any duct, by month. The spring minimum (March near 11%) and the summer plateau (June and July near 75%) are the two features that surprise people who assume winter is the dead season.';
root.appendChild(cap);
const { ctx, getSize } = setupCanvas(canvas);
const startWall = performance.now();
// Monthly ducting-probability data (percent).
const months = [
{ name: 'Jan', pct: 20 },
{ name: 'Feb', pct: 16 },
{ name: 'Mar', pct: 11 },
{ name: 'Apr', pct: 24 },
{ name: 'May', pct: 48 },
{ name: 'Jun', pct: 69 },
{ name: 'Jul', pct: 77 },
{ name: 'Aug', pct: 64 },
{ name: 'Sep', pct: 41 },
{ name: 'Oct', pct: 26 },
{ name: 'Nov', pct: 18 },
{ name: 'Dec', pct: 12 },
];
function seasonColor(i) {
// Winter cyan, spring green, summer yellow/orange, fall amber
if (i <= 1 || i === 11) return COLORS.cyan;
if (i <= 3) return COLORS.green;
if (i <= 7) return COLORS.yellow;
return COLORS.orange;
}
function draw() {
const { w, h } = getSize();
clear(ctx, w, h);
const margin = { l: 50, r: 24, t: 35, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
labelText(ctx, 'monthly ducting probability (% of HRRR profiles)', margin.l, margin.t - 10, COLORS.fg);
const gap = 6;
const barW = (pW - gap * (months.length - 1)) / months.length;
const maxPct = 85;
// y ticks
ctx.strokeStyle = COLORS.grid;
for (let p = 0; p <= 80; p += 20) {
const y = margin.t + (1 - p / maxPct) * pH;
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
labelText(ctx, `${p}%`, margin.l - 6, y, COLORS.dim, 'right', 'middle');
}
const elapsed = (performance.now() - startWall) / 1000;
const animT = Math.min(1, elapsed / 1.6);
months.forEach((m, i) => {
const x = margin.l + i * (barW + gap);
const targetH = (m.pct / maxPct) * pH * animT;
const y = margin.t + pH - targetH;
const col = seasonColor(i);
ctx.fillStyle = col;
ctx.globalAlpha = 0.9;
const r = Math.min(5, barW / 3);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + barW, y, x + barW, y + r, r);
ctx.lineTo(x + barW, margin.t + pH);
ctx.lineTo(x, margin.t + pH);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
labelText(ctx, m.name, x + barW / 2, margin.t + pH + 14, COLORS.dim, 'center');
if (animT > 0.7) {
ctx.globalAlpha = Math.min(1, (animT - 0.7) * 3);
labelText(ctx, `${m.pct}`, x + barW / 2, y - 6, COLORS.fg, 'center');
ctx.globalAlpha = 1;
}
});
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ---------- Scene: the 10 GHz link budget ----------
function sceneBudget() {
const root = document.getElementById('scene-budget');
if (!root) return;
root.classList.add('nlos-scene');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '340px';
root.appendChild(canvas);
const cap = document.createElement('div');
cap.className = 'nlos-caption';
cap.textContent = 'Budget for a 200 km 10 GHz contact. Free space loss sets the starting depth. Diffraction against terrain adds 36 dB on average. Troposcatter, which is always present, recovers ~20 dB. A duct layered on top adds up to another 14 dB. Station gain and transmit EIRP close the rest. Labels animate in sequence to show the accumulation.';
root.appendChild(cap);
const { ctx, getSize } = setupCanvas(canvas);
const startWall = performance.now();
// Rough 200 km 10 GHz budget: TX 40 dBm, 30 dBi dish each end, NF 3 dB,
// 2 kHz CW bandwidth. Troposcatter recovers a chunk of the diffraction
// loss; duct enhancement is a bonus on top when the atmosphere cooperates.
const segments = [
{ label: 'Free-space path loss', dB: 158, color: COLORS.red, role: 'loss' },
{ label: 'Diffraction (terrain)', dB: 36, color: COLORS.orange, role: 'loss' },
{ label: 'Troposcatter recovery', dB: -20, color: COLORS.cyan, role: 'gain' },
{ label: 'Duct enhancement', dB: -14, color: COLORS.green, role: 'gain' },
{ label: 'Station (TX EIRP + RX gain)', dB: -168, color: COLORS.blue, role: 'gain' },
];
function draw() {
const { w, h } = getSize();
clear(ctx, w, h);
// Leave enough right margin for the end-of-bar value labels (up to
// "+158 dB" on the free-space row), and enough left margin for the
// row labels ("Station (TX EIRP + RX gain)").
const margin = { l: 210, r: 80, t: 30, b: 60 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
labelText(ctx, '10 GHz link budget (200 km path, ducting day)', margin.l - 200, margin.t - 12, COLORS.fg);
const elapsed = (performance.now() - startWall) / 1000;
const rowH = pH / segments.length;
const maxAbs = 180; // dB. Slightly larger than the largest segment (168) so labels fit.
let accumulated = 0;
segments.forEach((seg, i) => {
const y = margin.t + i * rowH;
const by = y + rowH * 0.15;
const bh = rowH * 0.55;
const rowStart = i * 0.4;
const rowAnim = Math.max(0, Math.min(1, (elapsed - rowStart) / 0.7));
if (rowAnim <= 0) { accumulated += seg.dB; return; }
labelText(ctx, seg.label, margin.l - 10, y + rowH / 2, COLORS.fg, 'right', 'middle');
const sign = seg.role === 'loss' ? '+' : '';
const mag = Math.abs(seg.dB);
const barLen = (mag / maxAbs) * pW * rowAnim;
ctx.fillStyle = seg.color;
ctx.globalAlpha = 0.9;
const r = 5;
ctx.beginPath();
ctx.moveTo(margin.l + r, by);
ctx.arcTo(margin.l + barLen, by, margin.l + barLen, by + r, r);
ctx.lineTo(margin.l + barLen, by + bh - r);
ctx.arcTo(margin.l + barLen, by + bh, margin.l + barLen - r, by + bh, r);
ctx.lineTo(margin.l + r, by + bh);
ctx.arcTo(margin.l, by + bh, margin.l, by + bh - r, r);
ctx.lineTo(margin.l, by + r);
ctx.arcTo(margin.l, by, margin.l + r, by, r);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
if (rowAnim >= 0.95) {
labelText(ctx, `${sign}${mag} dB`, margin.l + barLen + 8, by + bh / 2, COLORS.fg, 'left', 'middle');
}
accumulated += seg.dB;
});
// Separator line + centered result box below the bars.
const allDone = elapsed > 0.4 * segments.length + 0.7;
if (allDone) {
const totalAnim = Math.min(1, (elapsed - (0.4 * segments.length + 0.7)) / 0.6);
ctx.globalAlpha = totalAnim;
const sepY = margin.t + pH + 8;
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin.l, sepY);
ctx.lineTo(margin.l + pW, sepY);
ctx.stroke();
const marginDb = -accumulated; // positive = closes
const ok = marginDb >= 0;
const labelLeft = ok ? 'Result:' : 'Result:';
const valueLabel = ok ? `+${marginDb.toFixed(0)} dB` : `${marginDb.toFixed(0)} dB`;
const statusLabel = ok ? 'contact closes' : 'contact fails';
const resultY = sepY + 22;
// Keep the result aligned flush with the left of the bars row, so it
// visually lines up under the bar column.
labelText(ctx, labelLeft, margin.l, resultY, COLORS.dim, 'left', 'middle');
labelText(ctx, valueLabel, margin.l + 56, resultY, ok ? COLORS.green : COLORS.red, 'left', 'middle', 14);
labelText(ctx, statusLabel, margin.l + 120, resultY, ok ? COLORS.green : COLORS.red, 'left', 'middle');
ctx.globalAlpha = 1;
}
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();
sceneData();
sceneBandTime();
scenePwat();
sceneSeasonal();
sceneBudget();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();