w5isp.com/static/js/noise.js
Graham McIntire 2097dbc018
Modernize why-signals-bend with project findings, polish scenes
Rewrite the NLOS post to reflect what microwaveprop has learned:
M-refractivity and dM/dh as the actual duct discriminant, the four
duct taxonomy with radiation ducts as the North Texas mechanism,
binary duct detection as a coin flip, low pressure beating high,
shallow boundary layer winning, PWAT sweet spot, seasonal pattern
(spring worst, summer best), troposcatter as the floor, and real
numbers for the 65 km QTH-to-W5HN/B path.

New interactive scenes: N vs M side-by-side refractivity profile,
pressure/HPBL/binary-duct bar chart, band-vs-dawn enhancement,
PWAT sweet spot with draggable cursor, monthly ducting probability,
and a 10 GHz link-budget decomposition showing how the 36 dB
diffraction gap gets closed.

Polish pass across all scenes: softer palette, rounded stroke
caps/joins, prettier slider styling in scene.css and nlos.css,
signal-pulse animations on NF cascade / single-stage / LNA
placement, trail persistence on the ray-trace scene, animated
glints along trapped rays in the duct scene, and animated TCP
sequence diagrams.

Add local-serve preview mode so future-dated posts show up on
zola serve but stay hidden on the deployed build.
2026-04-22 14:10:58 -05:00

807 lines
30 KiB
JavaScript
Raw Permalink 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.

// Cascaded noise figure explainer. Vanilla JS + 2D canvas. Scoped to one post.
(() => {
'use strict';
const C = {
bg: '#1a1d24',
panel: '#242932',
fg: '#d8dce4',
dim: '#8189a0',
grid: '#2d323d',
blue: '#78b5f3',
cyan: '#6dc5d3',
green: '#9dca83',
orange: '#d6a86a',
red: '#e58089',
yellow: '#e2c37d',
magenta: '#c78de0',
};
// ----- framework (same shape as other posts) -----
function setupCanvas(canvas) {
const ctx = canvas.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
const fit = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
};
fit();
new ResizeObserver(fit).observe(canvas);
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
}
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
const root = document.getElementById(id);
if (!root) return null;
root.classList.add('scene-box');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = `${height}px`;
root.appendChild(canvas);
const readoutEl = document.createElement('div');
readoutEl.className = 'scene-readout';
const readoutSpans = {};
readout.forEach(r => {
const item = document.createElement('span');
item.className = 'scene-readout-item';
const 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 = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
const values = {};
controls.forEach(c => {
const wrap = document.createElement('div');
wrap.className = 'scene-control';
const label = document.createElement('label');
const name = document.createElement('span'); name.textContent = c.label;
const valSpan = document.createElement('span'); valSpan.className = 'scene-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(2));
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 = 'scene-caption';
cap.textContent = caption;
root.appendChild(cap);
}
const { ctx, getSize } = setupCanvas(canvas);
const setReadout = (key, text) => {
if (readoutSpans[key]) readoutSpans[key].textContent = text;
};
return { canvas, ctx, getSize, values, setReadout };
}
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `${size}px system-ui, sans-serif`;
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(str, x, y);
ctx.restore();
}
// ----- math helpers -----
const T0 = 290; // reference temperature in kelvin
const K = 1.380649e-23; // Boltzmann in J/K
function kTB_dBm(T, B_Hz) {
// Power in watts: k*T*B. Convert to dBm.
const pW = K * T * B_Hz;
return 10 * Math.log10(pW / 1e-3);
}
function dB2lin(dB) { return Math.pow(10, dB / 10); }
function lin2dB(x) { return 10 * Math.log10(x); }
// Friis: F_total = F1 + (F2-1)/G1 + (F3-1)/(G1*G2) + ...
function cascadeNF(stages) {
// stages: [{ gainDb, nfDb }]
let F = 1;
let Gcum = 1;
const contributions = [];
stages.forEach((s, i) => {
const Fi = dB2lin(s.nfDb);
const add = i === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
const before = F;
F += add;
contributions.push({ beforeF: before, afterF: F, add, gainLin: dB2lin(s.gainDb) });
Gcum *= dB2lin(s.gainDb);
});
return { F, nfDb: lin2dB(F), Gcum, contributions };
}
// ----- Scene 1: thermal noise floor -----
function sceneNoiseFloor() {
const s = scene('scene-noise-floor', {
height: 360,
controls: [
{ key: 'bwLog', label: 'Bandwidth', min: 0, max: 7, value: 4, step: 0.01,
format: v => {
const hz = Math.pow(10, v);
if (hz >= 1e6) return (hz / 1e6).toFixed(2) + ' MHz';
if (hz >= 1e3) return (hz / 1e3).toFixed(2) + ' kHz';
return hz.toFixed(0) + ' Hz';
}
},
{ key: 'T', label: 'Temperature', min: 20, max: 500, value: 290, step: 1, format: v => v.toFixed(0) + ' K' },
{ key: 'signalDbm', label: 'Signal power', min: -180, max: -30, value: -110, step: 0.5, format: v => v.toFixed(1) + ' dBm' },
],
readout: [
{ key: 'floor', label: 'Noise floor' },
{ key: 'snr', label: 'SNR' },
],
caption: 'Every resistor at a temperature above absolute zero hisses. The noise power in a bandwidth B and at temperature T works out to k*T*B watts, which at room temperature is the famous 174 dBm per hertz of bandwidth. Widen the filter, raise the temperature, and the hiss rises. The signal has to sit above that hiss to be worth anything.',
threeCol: true,
});
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: 25, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
// y axis: power (dBm), range -180 to -20
const pMin = -180, pMax = -20;
const xOf = f => margin.l + f * pW; // f: 0..1
const yOf = p => margin.t + (1 - (p - pMin) / (pMax - pMin)) * pH;
// frame + gridlines
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let p = pMax; p >= pMin; p -= 20) {
const y = yOf(p);
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
text(ctx, `${p} dBm`, margin.l - 6, y, C.dim, 'right', 'middle');
}
text(ctx, 'frequency ->', margin.l + pW / 2, margin.t + pH + 26, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 48, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'power (dBm)', 0, 0, C.dim, 'center');
ctx.restore();
// Noise floor as a horizontal band plus a hashy line texture
const bw = Math.pow(10, s.values.bwLog);
const T = s.values.T;
const floorDbm = kTB_dBm(T, bw);
const floorY = yOf(floorDbm);
// subtle fill below noise floor
ctx.fillStyle = 'rgba(86,182,194,0.06)';
ctx.fillRect(margin.l, floorY, pW, margin.t + pH - floorY);
// noise floor line
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(margin.l, floorY); ctx.lineTo(margin.l + pW, floorY); ctx.stroke();
// simulated noise squiggle just above the floor
ctx.strokeStyle = C.cyan;
ctx.globalAlpha = 0.4;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= pW; i += 3) {
const jitter = (Math.sin(i * 0.23) + Math.sin(i * 0.71 + 1) + Math.sin(i * 1.37 + 2)) * 1.7;
const y = floorY - 1 - Math.abs(jitter);
if (i === 0) ctx.moveTo(margin.l + i, y); else ctx.lineTo(margin.l + i, y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// Signal peak
const sig = s.values.signalDbm;
const sigX = margin.l + pW * 0.55;
const sigY = yOf(sig);
ctx.strokeStyle = sig > floorDbm ? C.green : C.red;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(sigX - 30, floorY);
ctx.lineTo(sigX - 6, sigY + 4);
ctx.lineTo(sigX, sigY);
ctx.lineTo(sigX + 6, sigY + 4);
ctx.lineTo(sigX + 30, floorY);
ctx.stroke();
// Labels
text(ctx, `noise floor ${floorDbm.toFixed(1)} dBm`, margin.l + 8, floorY - 6, C.cyan);
text(ctx, `signal ${sig.toFixed(1)} dBm`, sigX, sigY - 10, sig > floorDbm ? C.green : C.red, 'center');
const snr = sig - floorDbm;
s.setReadout('floor', `${floorDbm.toFixed(1)} dBm`);
s.setReadout('snr', `${snr.toFixed(1)} dB`);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ----- Scene 2: single stage noise figure -----
function sceneSingleStage() {
const s = scene('scene-single-stage', {
height: 320,
controls: [
{ key: 'gain', label: 'Gain', min: 0, max: 40, value: 20, step: 0.5, format: v => v.toFixed(1) + ' dB' },
{ key: 'nf', label: 'Noise figure', min: 0.1, max: 20, value: 2.5, step: 0.1, format: v => v.toFixed(1) + ' dB' },
{ key: 'inSnr', label: 'Input SNR', min: 0, max: 40, value: 15, step: 0.5, format: v => v.toFixed(1) + ' dB' },
],
readout: [
{ key: 'outSnr', label: 'Output SNR' },
{ key: 'loss', label: 'SNR degradation' },
],
caption: 'Put a signal and some noise into an amplifier. The amplifier multiplies both by its gain, which by itself changes nothing about their ratio. But the amplifier also adds its own internal noise at the output, and that extra noise is what makes SNR drop. The noise figure is a compact way to say how much.',
threeCol: true,
});
if (!s) return;
const startWall = performance.now();
function rnd(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 30, r: 30, t: 30, b: 30 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const ampW = 140, ampH = 80;
const ampX = margin.l + (pW - ampW) / 2;
const ampY = margin.t + (pH - ampH) / 2;
const wireY = ampY + ampH / 2;
const wireStartX = margin.l + 10;
const wireEndX = margin.l + pW - 10;
// connecting wire
ctx.strokeStyle = C.dim;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(wireStartX, wireY); ctx.lineTo(ampX, wireY);
ctx.moveTo(ampX + ampW, wireY); ctx.lineTo(wireEndX, wireY);
ctx.stroke();
// amplifier triangle
ctx.fillStyle = C.panel;
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ampX, ampY);
ctx.lineTo(ampX + ampW, wireY);
ctx.lineTo(ampX, ampY + ampH);
ctx.closePath();
ctx.fill();
ctx.stroke();
text(ctx, `G = ${s.values.gain.toFixed(1)} dB`, ampX + ampW / 3, wireY - 6, C.fg, 'center');
text(ctx, `NF = ${s.values.nf.toFixed(1)} dB`, ampX + ampW / 3, wireY + 10, C.yellow, 'center');
const inSNR = s.values.inSnr;
const nf = s.values.nf;
const outSNR = inSNR - nf;
const barH = 40;
const barTop = ampY - barH - 10;
const barBottom = barTop + barH;
drawSignalNoiseBars(ctx, margin.l + 10, barTop, 80, barH, inSNR, 'input', C.green);
drawSignalNoiseBars(ctx, margin.l + pW - 90, barTop, 80, barH, outSNR, 'output', outSNR > 0 ? C.green : C.red);
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(margin.l + 50, barBottom);
ctx.lineTo(margin.l + 50, wireY);
ctx.moveTo(margin.l + pW - 50, barBottom);
ctx.lineTo(margin.l + pW - 50, wireY);
ctx.stroke();
// ---- Animated signal pulse + noise halo ----
const CYCLE = 3.5;
const now = ((performance.now() - startWall) / 1000) % CYCLE;
const tNorm = Math.min(1, now / (CYCLE * 0.85));
const packetX = wireStartX + tNorm * (wireEndX - wireStartX);
// Noise halo grows linearly through the amp. Map position to "noise level":
// before amp: input-noise baseline
// inside amp: baseline rising to (baseline * 10^(nf/10))
// after amp: output noise (baseline + amp noise), amplified.
const baseNoise = 4; // px of halo for pure input noise
let halo = baseNoise;
if (packetX < ampX) halo = baseNoise;
else if (packetX < ampX + ampW) {
const frac = (packetX - ampX) / ampW;
halo = baseNoise + (baseNoise * (dB2lin(nf) - 1)) * frac;
} else {
halo = baseNoise + (baseNoise * (dB2lin(nf) - 1));
}
// Jittering noise halo
const tMs = performance.now();
for (let i = 0; i < 24; i++) {
const ang = 2 * Math.PI * rnd(i + Math.floor(tMs / 80));
const r = halo * (0.4 + rnd(i + 200 + Math.floor(tMs / 60)));
const px = packetX + Math.cos(ang) * r;
const py = wireY + Math.sin(ang) * r;
ctx.fillStyle = C.cyan;
ctx.globalAlpha = 0.2 + 0.4 * rnd(i + 50);
ctx.beginPath();
ctx.arc(px, py, 1.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// Extra burst of internal noise emerging from the amp's apex while the
// packet is passing through it.
if (packetX >= ampX && packetX < ampX + ampW) {
for (let i = 0; i < 10; i++) {
const apex = { x: ampX + ampW, y: wireY };
const off = 6 + rnd(i + tMs / 30) * 18;
const ang = (rnd(i + 400 + tMs / 20) - 0.5) * 0.8;
ctx.fillStyle = C.yellow;
ctx.globalAlpha = 0.35;
ctx.beginPath();
ctx.arc(apex.x + Math.cos(ang) * off - 10, apex.y + Math.sin(ang) * off, 1.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// The signal itself — bright dot with a glow, brighter after the amp.
const signalPost = packetX > ampX + ampW;
ctx.fillStyle = C.green;
ctx.shadowColor = C.green;
ctx.shadowBlur = signalPost ? 14 : 8;
ctx.beginPath();
ctx.arc(packetX, wireY, signalPost ? 5.5 : 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
s.setReadout('outSnr', `${outSNR.toFixed(1)} dB`);
s.setReadout('loss', `${nf.toFixed(2)} dB (= NF)`);
requestAnimationFrame(draw);
}
// Draw a simple bar representing signal and noise levels. Both labels go
// ABOVE the bar so the space below is free for the connecting wire.
function drawSignalNoiseBars(ctx, x, y, w, h, snrDb, label, signalColor) {
// Fixed noise height at h*0.5, signal proportional to snr above that
const noiseH = h * 0.5;
const maxExtra = h * 0.5;
const sigExtra = Math.max(0, Math.min(1, snrDb / 30)) * maxExtra;
// noise
ctx.fillStyle = C.cyan;
ctx.globalAlpha = 0.6;
ctx.fillRect(x, y + h - noiseH, w, noiseH);
ctx.globalAlpha = 1;
// signal
ctx.fillStyle = signalColor;
ctx.fillRect(x, y + h - noiseH - sigExtra, w, sigExtra);
// border
ctx.strokeStyle = C.grid;
ctx.strokeRect(x, y, w, h);
// labels stacked above the bar
text(ctx, label, x + w / 2, y - 20, C.dim, 'center');
text(ctx, `SNR ${snrDb.toFixed(1)} dB`, x + w / 2, y - 6, C.fg, 'center');
}
requestAnimationFrame(draw);
}
// ----- Scene 3: cascade builder with Friis -----
function sceneCascade() {
// Block library
const LIB = [
{ id: 'none', name: '(empty)', gainDb: 0, nfDb: 0 },
{ id: 'feedline', name: 'feedline -3 dB',gainDb: -3, nfDb: 3 },
{ id: 'lna_low', name: 'LNA NF 0.6', gainDb: 20, nfDb: 0.6 },
{ id: 'lna_std', name: 'LNA NF 1.5', gainDb: 18, nfDb: 1.5 },
{ id: 'lna_cheap', name: 'LNA NF 3', gainDb: 15, nfDb: 3 },
{ id: 'mixer', name: 'Mixer (7 dB)', gainDb: -7, nfDb: 7 },
{ id: 'if_amp', name: 'IF amp', gainDb: 25, nfDb: 4 },
{ id: 'post_amp', name: 'Post-IF amp', gainDb: 20, nfDb: 6 },
];
const SLOTS = 5;
const s = scene('scene-cascade', {
height: 420,
controls: Array.from({ length: SLOTS }, (_, i) => ({
key: `slot${i}`,
label: `Stage ${i + 1}`,
min: 0,
max: LIB.length - 1,
value: i === 0 ? 2 : i === 1 ? 5 : i === 2 ? 6 : i === 3 ? 7 : 0,
step: 1,
format: v => LIB[Math.round(v)].name,
})),
readout: [
{ key: 'total', label: 'Total NF' },
{ key: 'gain', label: 'Total gain' },
],
caption: 'Slide each stage through the block library. The chain visualization updates, cumulative NF is drawn below each block, and the total NF at the output appears at the end. Try: LNA first vs feedline first. The first stage almost always dominates.',
threeCol: true,
});
if (!s) return;
function currentStages() {
const stages = [];
for (let i = 0; i < SLOTS; i++) {
const pick = LIB[Math.round(s.values[`slot${i}`])];
if (pick.id === 'none') continue;
stages.push({ gainDb: pick.gainDb, nfDb: pick.nfDb, name: pick.name });
}
return stages;
}
const startWall = performance.now();
// Deterministic-ish noise for the halo jitter
function rn(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const stages = currentStages();
const margin = { l: 20, r: 20, t: 40, b: 30 };
const pW = w - margin.l - margin.r;
const boxW = Math.min(150, (pW - (stages.length + 1) * 14) / Math.max(1, stages.length));
const boxH = 70;
const rowY = margin.t + 10;
const wireY = rowY + boxH / 2;
let x = margin.l;
// antenna
ctx.strokeStyle = C.fg;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 6, wireY);
ctx.lineTo(x + 6, wireY - 16);
ctx.moveTo(x, wireY - 16);
ctx.lineTo(x + 12, wireY - 16);
ctx.stroke();
text(ctx, 'ant', x + 6, wireY + 18, C.dim, 'center');
x += 28;
const chainStartX = x;
const cascade = cascadeNF(stages);
const cumAtStage = [];
{
let F = 1, Gcum = 1;
stages.forEach(st => {
const Fi = dB2lin(st.nfDb);
F += cumAtStage.length === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
Gcum *= dB2lin(st.gainDb);
cumAtStage.push({ F, G: Gcum, nfDb: lin2dB(F), gainDbCum: lin2dB(Gcum) });
});
}
// Track each block's x range for the animated packet.
const blockRanges = [];
stages.forEach((st, i) => {
// connecting wire
ctx.strokeStyle = C.dim;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - 14, wireY);
ctx.lineTo(x, wireY);
ctx.stroke();
// box
ctx.fillStyle = C.panel;
ctx.strokeStyle = st.gainDb < 0 ? C.orange : C.cyan;
ctx.lineWidth = 2;
ctx.fillRect(x, rowY, boxW, boxH);
ctx.strokeRect(x, rowY, boxW, boxH);
text(ctx, st.name, x + boxW / 2, rowY + 16, C.fg, 'center');
text(ctx, `G = ${st.gainDb.toFixed(1)} dB`, x + boxW / 2, rowY + 34, C.dim, 'center');
text(ctx, `NF = ${st.nfDb.toFixed(1)} dB`, x + boxW / 2, rowY + 52, C.yellow, 'center');
const cum = cumAtStage[i];
text(ctx, `cum NF: ${cum.nfDb.toFixed(2)} dB`, x + boxW / 2, rowY + boxH + 18, C.green, 'center');
text(ctx, `cum G: ${cum.gainDbCum.toFixed(1)} dB`, x + boxW / 2, rowY + boxH + 34, C.dim, 'center');
blockRanges.push({ x0: x, x1: x + boxW, stage: i });
x += boxW + 14;
});
const chainEndX = x - 14;
if (stages.length === 0) {
text(ctx, '(add stages via the sliders below)', margin.l + pW / 2, wireY, C.dim, 'center');
}
// Contribution bar chart
if (stages.length > 0) {
const totalAdd = cascade.contributions.reduce((a, b) => a + b.add, 0);
const chartX = margin.l, chartY = rowY + boxH + 60;
const chartH = 40;
let cx = chartX;
const chartW = Math.max(300, pW * 0.9);
cascade.contributions.forEach((c, i) => {
const frac = c.add / totalAdd;
const segW = frac * chartW;
ctx.fillStyle = i % 2 === 0 ? C.cyan : C.orange;
ctx.globalAlpha = 0.8;
ctx.fillRect(cx, chartY, segW, chartH);
ctx.globalAlpha = 1;
if (segW > 30) {
text(ctx, `${(frac * 100).toFixed(0)}%`, cx + segW / 2, chartY + chartH / 2 + 4, '#111', 'center');
}
cx += segW;
});
ctx.strokeStyle = C.grid;
ctx.strokeRect(chartX, chartY, chartW, chartH);
text(ctx, 'share of output noise contributed by each stage', chartX, chartY - 6, C.dim);
}
// ---- Animated signal + noise halo ----
if (stages.length > 0) {
const CYCLE = 4.5;
const now = ((performance.now() - startWall) / 1000) % CYCLE;
const tNorm = Math.min(1, now / (CYCLE * 0.85)); // spend the last 15% of the cycle paused at end
const packetX = chainStartX + tNorm * (chainEndX - chainStartX);
// Cumulative F at the packet's current x: find which stages the packet
// has fully passed through (strict less-than on x1) plus any partial
// progression through the current block.
let cumIndex = -1;
blockRanges.forEach(br => { if (packetX >= br.x1) cumIndex = br.stage; });
const inBlock = blockRanges.find(br => packetX >= br.x0 && packetX < br.x1);
let F_here = cumIndex >= 0 ? cumAtStage[cumIndex].F : 1;
if (inBlock) {
// smooth interpolation of F across the current block
const frac = (packetX - inBlock.x0) / (inBlock.x1 - inBlock.x0);
const Fbefore = inBlock.stage === 0 ? 1 : cumAtStage[inBlock.stage - 1].F;
const Fafter = cumAtStage[inBlock.stage].F;
F_here = Fbefore + (Fafter - Fbefore) * frac;
}
// Halo radius scales with the noise contributed so far (log-ish to keep small and large F both visible)
const haloR = 6 + 14 * Math.log10(Math.max(1.01, F_here));
// Draw noise halo as a jittering cyan cloud.
const tMs = performance.now();
ctx.save();
for (let i = 0; i < 28; i++) {
const ang = 2 * Math.PI * rn(i + Math.floor(tMs / 80));
const r = haloR * (0.5 + rn(i + 100 + Math.floor(tMs / 60)));
const px = packetX + Math.cos(ang) * r;
const py = wireY + Math.sin(ang) * r * 0.7;
ctx.fillStyle = C.cyan;
ctx.globalAlpha = 0.25 + 0.4 * rn(i + 50);
ctx.beginPath();
ctx.arc(px, py, 1.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// The signal pulse itself — a bright yellow dot
ctx.fillStyle = C.yellow;
ctx.shadowColor = C.yellow;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(packetX, wireY, 4.5, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Running readout of NF at the packet's current position
text(ctx, `NF at packet: ${lin2dB(F_here).toFixed(2)} dB`, packetX, wireY - haloR - 8, C.yellow, 'center');
ctx.restore();
}
s.setReadout('total', stages.length ? `${cascade.nfDb.toFixed(2)} dB` : 'n/a');
s.setReadout('gain', stages.length ? `${lin2dB(cascade.Gcum).toFixed(1)} dB` : 'n/a');
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ----- Scene 4: LNA placement -----
function sceneLNAPlacement() {
const s = scene('scene-lna-placement', {
height: 360,
controls: [
{ key: 'lossDb', label: 'Feedline loss', min: 0, max: 12, value: 4, step: 0.1, format: v => `${v.toFixed(1)} dB` },
{ key: 'lnaNf', label: 'LNA noise figure', min: 0.3, max: 6, value: 1.2, step: 0.1, format: v => `${v.toFixed(1)} dB` },
{ key: 'lnaGain',label: 'LNA gain', min: 6, max: 35, value: 20, step: 0.5, format: v => `${v.toFixed(1)} dB` },
],
readout: [
{ key: 'atTop', label: 'LNA at antenna' },
{ key: 'atRadio', label: 'LNA at the radio' },
{ key: 'diff', label: 'Difference' },
],
caption: 'The LNA can sit up at the antenna (before the feedline loss) or down at the radio (after it). The first arrangement lets the LNA see the full signal and swamps the feedline loss with its gain. The second arrangement lets the feedline loss sit in front of the LNA, which is the same as putting a 4 dB attenuator in front of your best amplifier.',
threeCol: true,
});
if (!s) return;
const startWall = performance.now();
function rnd(seed) {
const x = Math.sin(seed * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 30, r: 30, t: 30, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const rxRadio = { gainDb: 10, nfDb: 8, name: 'radio front-end' };
const feedline = { gainDb: -s.values.lossDb, nfDb: s.values.lossDb, name: `feedline ${s.values.lossDb.toFixed(1)} dB` };
const lna = { gainDb: s.values.lnaGain, nfDb: s.values.lnaNf, name: `LNA NF ${s.values.lnaNf.toFixed(1)}` };
const chainA = [lna, feedline, rxRadio];
const chainB = [feedline, lna, rxRadio];
const resA = cascadeNF(chainA);
const resB = cascadeNF(chainB);
const rowA_y = margin.t + 20;
const rowB_y = margin.t + pH - 60;
const CYCLE = 4.5;
const now = ((performance.now() - startWall) / 1000) % CYCLE;
const tNorm = Math.min(1, now / (CYCLE * 0.85));
drawChain(ctx, margin.l, rowA_y, pW, 'LNA at the antenna', chainA, resA, C.green, tNorm);
drawChain(ctx, margin.l, rowB_y, pW, 'LNA at the radio', chainB, resB, C.red, tNorm);
s.setReadout('atTop', `NF ${resA.nfDb.toFixed(2)} dB`);
s.setReadout('atRadio', `NF ${resB.nfDb.toFixed(2)} dB`);
s.setReadout('diff', `${(resB.nfDb - resA.nfDb).toFixed(2)} dB`);
requestAnimationFrame(draw);
}
function drawChain(ctx, x0, y0, totalW, label, stages, cascade, nfColor, tNorm) {
text(ctx, label, x0, y0 - 6, C.fg);
const boxW = (totalW - 80 - (stages.length - 1) * 10) / stages.length;
const boxH = 48;
const wireY = y0 + boxH / 2;
let x = x0;
// antenna
ctx.strokeStyle = C.fg;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 6, wireY);
ctx.lineTo(x + 6, wireY - 14);
ctx.moveTo(x, wireY - 14);
ctx.lineTo(x + 12, wireY - 14);
ctx.stroke();
x += 26;
const chainStartX = x;
const blockRanges = [];
stages.forEach((st, i) => {
ctx.strokeStyle = C.dim;
ctx.beginPath();
ctx.moveTo(x - 10, wireY); ctx.lineTo(x, wireY);
ctx.stroke();
ctx.fillStyle = C.panel;
ctx.strokeStyle = st.gainDb < 0 ? C.orange : C.cyan;
ctx.fillRect(x, y0, boxW, boxH);
ctx.strokeRect(x, y0, boxW, boxH);
text(ctx, st.name, x + boxW / 2, y0 + 18, C.fg, 'center');
text(ctx, `G=${st.gainDb.toFixed(1)} NF=${st.nfDb.toFixed(1)}`, x + boxW / 2, y0 + 36, C.dim, 'center');
blockRanges.push({ x0: x, x1: x + boxW, stage: i });
x += boxW + 10;
});
const chainEndX = x - 10;
// Cumulative F at each stage boundary
const cumF = [];
{
let F = 1, Gcum = 1;
stages.forEach(st => {
const Fi = dB2lin(st.nfDb);
F += cumF.length === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
Gcum *= dB2lin(st.gainDb);
cumF.push(F);
});
}
// Animated signal
const packetX = chainStartX + tNorm * (chainEndX - chainStartX);
let F_here = 1;
const inBlock = blockRanges.find(br => packetX >= br.x0 && packetX < br.x1);
if (inBlock) {
const frac = (packetX - inBlock.x0) / (inBlock.x1 - inBlock.x0);
const Fbefore = inBlock.stage === 0 ? 1 : cumF[inBlock.stage - 1];
const Fafter = cumF[inBlock.stage];
F_here = Fbefore + (Fafter - Fbefore) * frac;
} else {
let idx = -1;
blockRanges.forEach(br => { if (packetX >= br.x1) idx = br.stage; });
F_here = idx >= 0 ? cumF[idx] : 1;
}
const haloR = 4 + 11 * Math.log10(Math.max(1.01, F_here));
const tMs = performance.now();
for (let i = 0; i < 22; i++) {
const ang = 2 * Math.PI * rnd(i + Math.floor(tMs / 80) + label.length);
const r = haloR * (0.5 + rnd(i + 200 + Math.floor(tMs / 60) + label.length));
const px = packetX + Math.cos(ang) * r;
const py = wireY + Math.sin(ang) * r * 0.7;
ctx.fillStyle = C.cyan;
ctx.globalAlpha = 0.22 + 0.38 * rnd(i + 50);
ctx.beginPath();
ctx.arc(px, py, 1.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.fillStyle = C.yellow;
ctx.shadowColor = C.yellow;
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(packetX, wireY, 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// output NF label
text(ctx, `Total NF: ${cascade.nfDb.toFixed(2)} dB`, x0 + totalW, y0 + boxH + 18, nfColor, 'right');
}
requestAnimationFrame(draw);
}
function boot() {
const host = document.querySelector('.scene-host, [data-scene-host]');
if (host) host.classList.add('scene-host');
sceneNoiseFloor();
sceneSingleStage();
sceneCascade();
sceneLNAPlacement();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();