w5isp.com/static/js/noise.js
Graham McIntire 4e42bcf97f
Add cascaded-noise-figure post, animate TCP scenes, expand ideas
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.
2026-04-22 13:28:28 -05:00

616 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Cascaded noise figure explainer. Vanilla JS + 2D canvas. Scoped to one post.
(() => {
'use strict';
const C = {
bg: '#1f232b',
panel: '#242932',
fg: '#d4d8df',
dim: '#5c6370',
grid: '#323844',
blue: '#61afef',
cyan: '#56b6c2',
green: '#98c379',
orange: '#d19a66',
red: '#e06c75',
yellow: '#e5c07b',
magenta: '#c678dd',
};
// ----- 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);
};
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;
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;
// block in the center
const ampW = 140, ampH = 80;
const ampX = margin.l + (pW - ampW) / 2;
const ampY = margin.t + (pH - ampH) / 2;
// draw connecting wire
ctx.strokeStyle = C.dim;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(margin.l + 10, ampY + ampH / 2);
ctx.lineTo(ampX, ampY + ampH / 2);
ctx.moveTo(ampX + ampW, ampY + ampH / 2);
ctx.lineTo(margin.l + pW - 10, ampY + ampH / 2);
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, ampY + ampH / 2);
ctx.lineTo(ampX, ampY + ampH);
ctx.closePath();
ctx.fill();
ctx.stroke();
text(ctx, `G = ${s.values.gain.toFixed(1)} dB`, ampX + ampW / 3, ampY + ampH / 2 - 6, C.fg, 'center');
text(ctx, `NF = ${s.values.nf.toFixed(1)} dB`, ampX + ampW / 3, ampY + ampH / 2 + 10, C.yellow, 'center');
// Input/output bar visualizations
const inSNR = s.values.inSnr;
const gain = s.values.gain;
const nf = s.values.nf;
const outSNR = inSNR - nf;
const barH = 40;
const barTop = ampY - barH - 10;
const barBottom = barTop + barH;
// Input side
drawSignalNoiseBars(ctx, margin.l + 10, barTop, 80, barH, inSNR, 'input', C.green);
// Output side
drawSignalNoiseBars(ctx, margin.l + pW - 90, barTop, 80, barH, outSNR, 'output', outSNR > 0 ? C.green : C.red);
// Wires from each bar down to the main signal wire (no text in the way now)
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(margin.l + 50, barBottom);
ctx.lineTo(margin.l + 50, ampY + ampH / 2);
ctx.moveTo(margin.l + pW - 50, barBottom);
ctx.lineTo(margin.l + pW - 50, ampY + ampH / 2);
ctx.stroke();
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;
}
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 pH = h - margin.t - margin.b;
// Chain row: boxes for each stage
const boxW = Math.min(150, (pW - (stages.length + 1) * 14) / Math.max(1, stages.length));
const boxH = 70;
const rowY = margin.t + 10;
let x = margin.l;
// antenna symbol at the left
ctx.strokeStyle = C.fg;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 6, rowY + boxH / 2);
ctx.lineTo(x + 6, rowY + boxH / 2 - 16);
ctx.moveTo(x, rowY + boxH / 2 - 16);
ctx.lineTo(x + 12, rowY + boxH / 2 - 16);
ctx.stroke();
text(ctx, 'ant', x + 6, rowY + boxH / 2 + 18, C.dim, 'center');
x += 28;
// cascade compute
const cascade = cascadeNF(stages);
const cumAtStage = [];
{
let F = 1, Gcum = 1;
stages.forEach((st, i) => {
const Fi = dB2lin(st.nfDb);
F += i === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
Gcum *= dB2lin(st.gainDb);
cumAtStage.push({ F, G: Gcum, nfDb: lin2dB(F), gainDbCum: lin2dB(Gcum) });
});
}
stages.forEach((st, i) => {
// connecting wire
ctx.strokeStyle = C.dim;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - 14, rowY + boxH / 2);
ctx.lineTo(x, rowY + boxH / 2);
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');
// cumulative NF below this stage
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');
x += boxW + 14;
});
if (stages.length === 0) {
text(ctx, '(add stages via the sliders below)', margin.l + pW / 2, rowY + boxH / 2, C.dim, 'center');
}
// Contribution bar chart (how much of the final noise each stage adds)
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);
}
// Friis formula readout
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;
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);
// Draw two rows of chains with their NF numbers
const rowA_y = margin.t + 20;
const rowB_y = margin.t + pH - 60;
drawChain(ctx, margin.l, rowA_y, pW, 'LNA at the antenna', chainA, resA.nfDb, C.green);
drawChain(ctx, margin.l, rowB_y, pW, 'LNA at the radio', chainB, resB.nfDb, C.red);
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, nfDb, nfColor) {
text(ctx, label, x0, y0 - 6, C.fg);
const boxW = (totalW - 80 - (stages.length - 1) * 10) / stages.length;
const boxH = 48;
let x = x0;
// antenna
ctx.strokeStyle = C.fg;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 6, y0 + boxH / 2);
ctx.lineTo(x + 6, y0 + boxH / 2 - 14);
ctx.moveTo(x, y0 + boxH / 2 - 14);
ctx.lineTo(x + 12, y0 + boxH / 2 - 14);
ctx.stroke();
x += 26;
stages.forEach((st) => {
ctx.strokeStyle = C.dim;
ctx.beginPath();
ctx.moveTo(x - 10, y0 + boxH / 2); ctx.lineTo(x, y0 + boxH / 2);
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');
x += boxW + 10;
});
// output NF label
ctx.fillStyle = nfColor;
text(ctx, `Total NF: ${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();
}
})();