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.
616 lines
23 KiB
JavaScript
616 lines
23 KiB
JavaScript
// 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();
|
||
}
|
||
})();
|