New post cascaded-noise-figure (scheduled 2026-04-29) with four interactive scenes on noise floor, single-stage NF, the Friis formula as a block builder, and LNA placement. Upgrade the three static TCP scenes (data, sliding window, UDP) to animated packet flow using a shared drawPartialPacket primitive. Expand IDEAS.md with roughly thirty more post concepts across electronics, networking, ham radio, microwave, and longer arcs. Add outbound links to relevant first-mentions across posts: Fresnel zone, effective Earth radius, atmospheric duct, HRRR, BJT, common-emitter, TCP/UDP RFCs, slow start, QUIC, Friis, noise figure, Boltzmann constant. Scrub Unicode arrows and multiplication symbols out of prose and scene labels.
1705 lines
65 KiB
JavaScript
1705 lines
65 KiB
JavaScript
// 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 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: 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 (5–40 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 (200–350 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. 330–410 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();
|
||
}
|
||
})();
|