w5isp.com/static/js/transistor.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

827 lines
30 KiB
JavaScript

// BJT amplifier explainer. Vanilla JS + 2D canvas.
// Scoped to the single post via <link>/<script> in its markdown.
(() => {
'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',
};
// ----- device model -----
// Simplified BJT (NPN) in forward active / saturation.
// I_C = beta * I_B in active region.
// V_CE cannot go below V_CEsat ≈ 0.2 V (saturation floor).
// I_C capped at (V_CC - V_CEsat) / R_C when the transistor saturates.
// Use the large-signal relation I_C = I_S * exp(V_BE / V_T) for the
// transfer curve scene so the input-vs-output shape is genuinely
// nonlinear around the knees.
const BETA = 150;
const V_T = 0.026; // thermal voltage at ~300 K
const V_BE_ON = 0.65; // knee of the B-E diode
const V_CE_SAT = 0.2;
// ----- 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();
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();
}
// ----- schematic primitives -----
function drawResistor(ctx, x1, y1, x2, y2, color = C.fg) {
// Zigzag resistor between the two endpoints.
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
const dx = x2 - x1, dy = y2 - y1;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const px = -uy, py = ux;
const leadLen = (L - 28) / 2;
const startX = x1 + ux * leadLen, startY = y1 + uy * leadLen;
const endX = x2 - ux * leadLen, endY = y2 - uy * leadLen;
ctx.beginPath();
ctx.moveTo(x1, y1); ctx.lineTo(startX, startY);
const zigs = 6;
for (let i = 0; i < zigs; i++) {
const t = (i + 1) / zigs;
const cx = startX + (endX - startX) * t;
const cy = startY + (endY - startY) * t;
const offset = (i % 2 === 0 ? 4 : -4);
ctx.lineTo(cx + px * offset, cy + py * offset);
}
ctx.lineTo(endX, endY);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawNPN(ctx, cx, cy, color = C.cyan) {
// Circle + baseline + C/E terminals with the emitter arrow.
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.arc(cx, cy, 26, 0, Math.PI * 2);
ctx.stroke();
// Base vertical bar
ctx.beginPath();
ctx.moveTo(cx - 12, cy - 14);
ctx.lineTo(cx - 12, cy + 14);
ctx.stroke();
// Base lead
ctx.beginPath();
ctx.moveTo(cx - 26, cy); ctx.lineTo(cx - 12, cy);
ctx.stroke();
// Collector lead (top right)
ctx.beginPath();
ctx.moveTo(cx - 12, cy - 6); ctx.lineTo(cx + 14, cy - 22);
ctx.lineTo(cx + 14, cy - 34);
ctx.stroke();
// Emitter lead (bottom right) with arrow
ctx.beginPath();
ctx.moveTo(cx - 12, cy + 6); ctx.lineTo(cx + 14, cy + 22);
ctx.lineTo(cx + 14, cy + 34);
ctx.stroke();
// Arrow on emitter
const ax = cx + 2, ay = cy + 13;
const angle = Math.atan2(22 - 6, 14 - (-12));
ctx.save();
ctx.translate(ax, ay);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-7, -3);
ctx.lineTo(-7, 3);
ctx.closePath();
ctx.fill();
ctx.restore();
ctx.restore();
}
function drawGround(ctx, x, y, color = C.dim) {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x - 10, y); ctx.lineTo(x + 10, y);
ctx.moveTo(x - 7, y + 4); ctx.lineTo(x + 7, y + 4);
ctx.moveTo(x - 4, y + 8); ctx.lineTo(x + 4, y + 8);
ctx.stroke();
}
// ----- Scene 1: the valve -----
function sceneValve() {
const s = scene('scene-valve', {
height: 320,
controls: [
{ key: 'ib', label: 'Base current I_B', min: 0, max: 80, value: 25, step: 0.5, format: v => v.toFixed(1) + ' µA' },
],
readout: [
{ key: 'ic', label: 'Collector current I_C' },
{ key: 'beta', label: 'β (I_C / I_B)' },
],
caption: 'A BJT is a valve. A small current flowing into the base lets a much larger current flow through the collector. The ratio between them is β, which for a garden-variety small-signal transistor sits around 100 to 300 and drifts with temperature, signal level, and whatever mood the factory was in that day.',
});
if (!s) return;
function draw(t) {
const { w, h } = s.getSize();
clear(s.ctx, w, h);
const ctx = s.ctx;
const ib_uA = s.values.ib;
const ib = ib_uA * 1e-6;
const ic = ib * BETA;
// Layout
const cx = w * 0.42;
const cy = h * 0.55;
// V_CC rail at top and ground at bottom
const topY = 30, botY = h - 30;
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(w * 0.18, topY); ctx.lineTo(w * 0.75, topY); ctx.stroke();
text(ctx, '+12 V', w * 0.78, topY + 4, C.dim);
// Collector resistor from V_CC down to collector terminal
drawResistor(ctx, cx + 14, topY, cx + 14, cy - 34, C.orange);
text(ctx, 'R_C = 1 kΩ', cx + 24, (topY + cy - 34) / 2, C.orange);
// Transistor
drawNPN(ctx, cx, cy);
text(ctx, 'C', cx + 20, cy - 30, C.dim);
text(ctx, 'B', cx - 34, cy + 4, C.dim);
text(ctx, 'E', cx + 20, cy + 40, C.dim);
// Emitter to ground
ctx.strokeStyle = C.dim;
ctx.beginPath();
ctx.moveTo(cx + 14, cy + 34); ctx.lineTo(cx + 14, botY); ctx.stroke();
drawGround(ctx, cx + 14, botY);
// Base current source (just a labeled arrow into the base)
const baseInX = cx - 26;
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(baseInX - 70, cy);
ctx.lineTo(baseInX - 4, cy);
ctx.stroke();
// arrowhead
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.moveTo(baseInX, cy);
ctx.lineTo(baseInX - 8, cy - 4);
ctx.lineTo(baseInX - 8, cy + 4);
ctx.closePath();
ctx.fill();
text(ctx, `I_B = ${ib_uA.toFixed(1)} µA`, baseInX - 70, cy - 10, C.cyan);
// Collector current (scaled arrow width by I_C)
const icWidth = 2 + Math.min(10, ic * 1000);
ctx.strokeStyle = C.green;
ctx.lineWidth = icWidth;
ctx.beginPath();
ctx.moveTo(cx + 14, topY + 6);
ctx.lineTo(cx + 14, cy - 44);
ctx.stroke();
// flowing electrons, animate dots going down the collector
const speed = 0.001 + ic * 20;
const segments = 6;
ctx.fillStyle = C.green;
for (let i = 0; i < segments; i++) {
const phase = ((t || 0) * speed + i / segments) % 1;
const y = topY + 6 + phase * (cy - 44 - topY - 6);
ctx.beginPath(); ctx.arc(cx + 14, y, 2, 0, Math.PI * 2); ctx.fill();
}
text(ctx, `I_C = ${(ic * 1000).toFixed(2)} mA`, cx + 30, topY + cy / 2, C.green);
s.setReadout('ic', `${(ic * 1000).toFixed(2)} mA`);
s.setReadout('beta', BETA.toString());
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ----- Scene 2: Characteristic curves + load line -----
function sceneCurves() {
const s = scene('scene-curves', {
height: 360,
controls: [
{ key: 'ib', label: 'Base current I_B', min: 0, max: 60, value: 25, step: 0.5, format: v => v.toFixed(1) + ' µA' },
{ key: 'rc', label: 'Collector resistor R_C', min: 200, max: 4000, value: 1000, step: 50, format: v => (v / 1000).toFixed(2) + ' kΩ' },
{ key: 'vcc', label: 'Supply V_CC', min: 3, max: 18, value: 12, step: 0.1, format: v => v.toFixed(1) + ' V' },
],
readout: [
{ key: 'vce', label: 'V_CE' },
{ key: 'ic', label: 'I_C' },
{ key: 'state', label: 'Region' },
],
caption: 'Plot collector current against collector-to-emitter voltage for a family of base currents and you get the characteristic curves. Drop a resistor into the collector leg and you get a straight load line instead, because the supply only has so much to give. The Q-point is wherever the load line meets your chosen base-current curve.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
clear(s.ctx, w, h);
const ctx = s.ctx;
const margin = { l: 60, r: 20, t: 30, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const vcc = s.values.vcc;
const rc = s.values.rc;
const ib = s.values.ib * 1e-6;
const vMax = Math.max(vcc, 16);
const iMax_mA = Math.max(10, (vcc / rc) * 1000 * 1.15);
const xOf = v => margin.l + (v / vMax) * pW;
const yOf = i => margin.t + (1 - i / iMax_mA) * pH;
// Axes
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let v = 0; v <= vMax; v += 2) {
const x = xOf(v);
ctx.beginPath(); ctx.moveTo(x, margin.t); ctx.lineTo(x, margin.t + pH); ctx.stroke();
text(ctx, `${v}`, x, margin.t + pH + 14, C.dim, 'center');
}
const iStep = iMax_mA > 20 ? 5 : 2;
for (let i = 0; i <= iMax_mA; i += iStep) {
const y = yOf(i);
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
text(ctx, `${i}`, margin.l - 6, y, C.dim, 'right', 'middle');
}
text(ctx, 'V_CE (V)', margin.l + pW / 2, margin.t + pH + 28, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 40, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'I_C (mA)', 0, 0, C.dim, 'center');
ctx.restore();
// Family of curves for a range of I_B values
const ibValues_uA = [10, 20, 30, 40, 50, 60];
ibValues_uA.forEach(ib_uA => {
const icFlat_mA = ib_uA * 1e-6 * BETA * 1000;
const cappedFlat = Math.min(icFlat_mA, (vcc - V_CE_SAT) / rc * 1000);
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1;
ctx.beginPath();
// steep rise through saturation then flat
const vKnee = V_CE_SAT + 0.4;
for (let v = 0; v <= vMax; v += 0.1) {
let ic;
if (v < V_CE_SAT) ic = 0;
else if (v < vKnee) ic = cappedFlat * (v - V_CE_SAT) / (vKnee - V_CE_SAT);
else ic = cappedFlat * (1 + 0.005 * (v - vKnee)); // mild Early effect
const x = xOf(v), y = yOf(ic);
if (v === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// label
text(ctx, `${ib_uA} µA`, xOf(vMax) - 4, yOf(cappedFlat) - 4, C.dim, 'right');
});
// Highlight the curve for the currently-selected I_B
{
const icFlat_mA = ib * BETA * 1000;
const cappedFlat = Math.min(icFlat_mA, (vcc - V_CE_SAT) / rc * 1000);
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
const vKnee = V_CE_SAT + 0.4;
for (let v = 0; v <= vMax; v += 0.05) {
let ic;
if (v < V_CE_SAT) ic = 0;
else if (v < vKnee) ic = cappedFlat * (v - V_CE_SAT) / (vKnee - V_CE_SAT);
else ic = cappedFlat * (1 + 0.005 * (v - vKnee));
const x = xOf(v), y = yOf(ic);
if (v === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
}
// Load line: I_C = (V_CC - V_CE) / R_C
ctx.strokeStyle = C.orange;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xOf(0), yOf(vcc / rc * 1000));
ctx.lineTo(xOf(vcc), yOf(0));
ctx.stroke();
text(ctx, `load line V_CC/R_C = ${(vcc / rc * 1000).toFixed(1)} mA`, xOf(vcc * 0.3), yOf(vcc / rc * 1000 * 0.7) - 4, C.orange);
// Q point: intersect load line with active-region I_C
const icActive_mA = Math.min(ib * BETA * 1000, (vcc - V_CE_SAT) / rc * 1000);
const vceQ = vcc - icActive_mA * 1e-3 * rc;
const qX = xOf(Math.max(V_CE_SAT, vceQ));
const qY = yOf(icActive_mA);
ctx.fillStyle = C.yellow;
ctx.beginPath(); ctx.arc(qX, qY, 6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = C.yellow;
ctx.setLineDash([2, 3]);
ctx.beginPath();
ctx.moveTo(qX, qY); ctx.lineTo(qX, margin.t + pH);
ctx.moveTo(qX, qY); ctx.lineTo(margin.l, qY);
ctx.stroke();
ctx.setLineDash([]);
text(ctx, `Q: ${Math.max(V_CE_SAT, vceQ).toFixed(2)} V, ${icActive_mA.toFixed(2)} mA`, qX + 8, qY - 8, C.yellow);
let state = 'active';
if (ib * BETA * 1000 > (vcc - V_CE_SAT) / rc * 1000 - 0.05) state = 'saturation';
if (ib < 1e-9) state = 'cutoff';
s.setReadout('vce', `${Math.max(V_CE_SAT, vceQ).toFixed(2)} V`);
s.setReadout('ic', `${icActive_mA.toFixed(2)} mA`);
s.setReadout('state', state);
requestAnimationFrame(draw);
}
draw();
}
// ----- Scene 3: Transfer curve -----
function sceneTransfer() {
const s = scene('scene-transfer', {
height: 340,
controls: [
{ key: 'vin', label: 'V_in (V)', min: 0, max: 1.5, value: 0.7, step: 0.005, format: v => v.toFixed(3) + ' V' },
{ key: 'rc', label: 'R_C', min: 200, max: 4000, value: 1000, step: 50, format: v => (v / 1000).toFixed(2) + ' kΩ' },
],
readout: [
{ key: 'vout', label: 'V_out' },
{ key: 'slope', label: 'Slope at V_in (V/V)' },
{ key: 'region', label: 'Region' },
],
caption: 'Sweep the input voltage from 0 to ~1 V and watch V_out. Below the B-E turn-on there is no collector current, so V_out just sits at V_CC. Past the knee the collector current rises exponentially in V_BE until it runs out of headroom and slams into V_CEsat. The middle portion, the steep diagonal, is the useful bit. Any amplifier is just an attempt to live there on purpose.',
});
if (!s) return;
const VCC = 12;
const IS = 1e-12; // saturation current for I_C = I_S * exp(V_BE / V_T)
function icOfVbe(vbe) {
return IS * Math.exp(vbe / V_T);
}
function voutOfVin(vin, rc) {
const ic = icOfVbe(vin);
const drop = ic * rc;
return Math.max(V_CE_SAT, VCC - drop);
}
function draw() {
const { w, h } = s.getSize();
clear(s.ctx, w, h);
const ctx = s.ctx;
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 vinMin = 0.5, vinMax = 0.9;
const voutMin = 0, voutMax = VCC;
const xOf = v => margin.l + (v - vinMin) / (vinMax - vinMin) * pW;
const yOf = v => margin.t + (1 - (v - voutMin) / (voutMax - voutMin)) * pH;
// Frame + ticks
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let v = 0.5; v <= 0.9; v += 0.1) {
const x = xOf(v);
ctx.beginPath(); ctx.moveTo(x, margin.t); ctx.lineTo(x, margin.t + pH); ctx.stroke();
text(ctx, v.toFixed(1), x, margin.t + pH + 14, C.dim, 'center');
}
for (let v = 0; v <= VCC; v += 2) {
const y = yOf(v);
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
text(ctx, v.toFixed(0), margin.l - 6, y, C.dim, 'right', 'middle');
}
text(ctx, 'V_in (V_BE)', margin.l + pW / 2, margin.t + pH + 28, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 44, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'V_out (V_CE)', 0, 0, C.dim, 'center');
ctx.restore();
// Shade the three regions lightly
// cutoff: V_BE < ~0.6 -> V_out ≈ V_CC
// active: ~0.6 to ~0.72
// saturation: above
const cutoffEdge = 0.6, satEdge = 0.72;
ctx.fillStyle = 'rgba(152, 195, 121, 0.07)';
ctx.fillRect(xOf(cutoffEdge), margin.t, xOf(satEdge) - xOf(cutoffEdge), pH);
text(ctx, 'cutoff', xOf(vinMin) + 6, margin.t + 14, C.dim);
text(ctx, 'active', (xOf(cutoffEdge) + xOf(satEdge)) / 2, margin.t + 14, C.green, 'center');
text(ctx, 'saturation', xOf(vinMax) - 4, margin.t + 14, C.red, 'right');
// Transfer curve
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
const N = 400;
for (let i = 0; i <= N; i++) {
const v = vinMin + (vinMax - vinMin) * i / N;
const out = voutOfVin(v, s.values.rc);
const x = xOf(v), y = yOf(out);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Current operating point and slope
const vin = s.values.vin;
const vout = voutOfVin(vin, s.values.rc);
const eps = 1e-4;
const slope = (voutOfVin(vin + eps, s.values.rc) - voutOfVin(vin - eps, s.values.rc)) / (2 * eps);
// tangent line at the Q-point (showing local gain)
const lineHalf = 0.03;
const x0 = xOf(vin - lineHalf), y0 = yOf(vout - slope * lineHalf);
const x1 = xOf(vin + lineHalf), y1 = yOf(vout + slope * lineHalf);
ctx.strokeStyle = C.yellow;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
ctx.setLineDash([]);
const px = xOf(vin), py = yOf(vout);
ctx.fillStyle = C.yellow;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
text(ctx, `(${vin.toFixed(3)} V, ${vout.toFixed(2)} V)`, px + 8, py - 8, C.yellow);
let region = 'active';
if (vin < cutoffEdge) region = 'cutoff';
if (vout <= V_CE_SAT + 0.05) region = 'saturation';
s.setReadout('vout', `${vout.toFixed(2)} V`);
s.setReadout('slope', slope.toFixed(1));
s.setReadout('region', region);
requestAnimationFrame(draw);
}
draw();
}
// ----- Scene 4: The amplifier in action -----
function sceneAmplifier() {
const s = scene('scene-amp', {
height: 420,
controls: [
{ key: 'bias', label: 'Bias V_BE', min: 0.55, max: 0.85, value: 0.67, step: 0.001, format: v => v.toFixed(3) + ' V' },
{ key: 'amp', label: 'Input amplitude (mV peak)', min: 1, max: 40, value: 10, step: 0.5, format: v => v.toFixed(1) + ' mV' },
{ key: 'freq', label: 'Frequency', min: 0.5, max: 3, value: 1, step: 0.05, format: v => v.toFixed(1) + ' Hz (visual)' },
],
readout: [
{ key: 'gain', label: 'Output/input (V/V)' },
{ key: 'swing', label: 'Output swing (V pk-pk)' },
{ key: 'status', label: 'Status' },
],
caption: 'A DC bias sets the operating point. A small AC signal riding on that bias walks V_BE back and forth by a few millivolts, which walks I_C by a few milliamps, which walks V_CE by a few volts because the collector resistor amplifies the current change back into a voltage change. Watch the input sine (below) produce the bigger, inverted output sine (above). That is amplification.',
threeCol: true,
});
if (!s) return;
const VCC = 12;
const RC = 1000;
const IS = 1e-12;
function voutOfVbe(vbe) {
const ic = IS * Math.exp(vbe / V_T);
return Math.max(V_CE_SAT, VCC - ic * RC);
}
function draw(t) {
const { w, h } = s.getSize();
clear(s.ctx, w, h);
const ctx = s.ctx;
const margin = { l: 60, r: 20, t: 20, b: 30 };
const pW = w - margin.l - margin.r;
// Two stacked panels: output on top (bigger), input below (smaller)
const panelGap = 24;
const outH = (h - margin.t - margin.b - panelGap) * 0.62;
const inH = (h - margin.t - margin.b - panelGap) * 0.38;
const outY = margin.t;
const inY = margin.t + outH + panelGap;
// Time axis: 2 seconds visible
const tSpan = 2;
const phase = ((t || 0) / 1000) * s.values.freq;
const xOfT = tau => margin.l + (tau / tSpan) * pW;
// Generate samples
const samples = [];
const N = 260;
const bias = s.values.bias;
const ampV = s.values.amp * 1e-3;
const freq = s.values.freq;
for (let i = 0; i <= N; i++) {
const tau = tSpan * i / N;
const vbe = bias + ampV * Math.sin(2 * Math.PI * (freq * tau - phase));
const vout = voutOfVbe(vbe);
samples.push({ tau, vbe, vout });
}
const vbeMin = bias - ampV * 1.4;
const vbeMax = bias + ampV * 1.4;
const voutsOnly = samples.map(p => p.vout);
const rawOutMin = Math.min(...voutsOnly);
const rawOutMax = Math.max(...voutsOnly);
const outCenter = (rawOutMin + rawOutMax) / 2;
const outSpan = Math.max(1.5, (rawOutMax - rawOutMin) * 1.3);
const voutMin = outCenter - outSpan / 2;
const voutMax = outCenter + outSpan / 2;
// --- Output panel ---
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, outY, pW, outH);
text(ctx, 'V_out (amplified, inverted)', margin.l + 6, outY + 14, C.green);
const yOfOut = v => outY + (1 - (v - voutMin) / (voutMax - voutMin)) * outH;
// Rails
if (VCC <= voutMax && VCC >= voutMin) {
ctx.strokeStyle = C.red;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(margin.l, yOfOut(VCC)); ctx.lineTo(margin.l + pW, yOfOut(VCC)); ctx.stroke();
ctx.setLineDash([]);
text(ctx, 'V_CC rail', margin.l + pW - 4, yOfOut(VCC) - 4, C.red, 'right');
}
if (V_CE_SAT <= voutMax && V_CE_SAT >= voutMin) {
ctx.strokeStyle = C.red;
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(margin.l, yOfOut(V_CE_SAT)); ctx.lineTo(margin.l + pW, yOfOut(V_CE_SAT)); ctx.stroke();
ctx.setLineDash([]);
text(ctx, 'V_CEsat', margin.l + pW - 4, yOfOut(V_CE_SAT) + 12, C.red, 'right');
}
// y ticks (few)
for (let v = Math.ceil(voutMin); v <= Math.floor(voutMax); v += 2) {
const y = yOfOut(v);
if (y < outY + 4 || y > outY + outH - 4) continue;
text(ctx, `${v} V`, margin.l - 4, y, C.dim, 'right', 'middle');
}
// Output curve
ctx.strokeStyle = C.green;
ctx.lineWidth = 2;
ctx.beginPath();
samples.forEach((p, i) => {
const x = xOfT(p.tau), y = yOfOut(p.vout);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
// --- Input panel ---
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, inY, pW, inH);
text(ctx, 'V_BE (bias + signal)', margin.l + 6, inY + 14, C.cyan);
const yOfIn = v => inY + (1 - (v - vbeMin) / (vbeMax - vbeMin)) * inH;
// Bias line
ctx.strokeStyle = C.dim;
ctx.setLineDash([2, 3]);
ctx.beginPath();
ctx.moveTo(margin.l, yOfIn(bias)); ctx.lineTo(margin.l + pW, yOfIn(bias)); ctx.stroke();
ctx.setLineDash([]);
text(ctx, `bias ${(bias * 1000).toFixed(0)} mV`, margin.l + pW - 4, yOfIn(bias) - 4, C.dim, 'right');
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
samples.forEach((p, i) => {
const x = xOfT(p.tau), y = yOfIn(p.vbe);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
// ticks
text(ctx, `${(vbeMin * 1000).toFixed(0)} mV`, margin.l - 4, yOfIn(vbeMin) + 2, C.dim, 'right', 'top');
text(ctx, `${(vbeMax * 1000).toFixed(0)} mV`, margin.l - 4, yOfIn(vbeMax) - 2, C.dim, 'right', 'bottom');
// Metrics
const outPkPk = rawOutMax - rawOutMin;
const inPkPk = 2 * ampV;
const gain = -outPkPk / inPkPk * (samples[0].vout > outCenter ? 1 : 1); // sign handled in status text
let status = 'linear';
if (rawOutMax >= VCC - 0.05 || rawOutMin <= V_CE_SAT + 0.05) status = 'clipping';
s.setReadout('gain', `-${(outPkPk / inPkPk).toFixed(1)}`);
s.setReadout('swing', `${outPkPk.toFixed(2)} V`);
s.setReadout('status', status);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ----- Scene 5: Gain from the small-signal model -----
function sceneGain() {
const s = scene('scene-gain', {
height: 340,
controls: [
{ key: 'ic', label: 'Bias I_C', min: 0.1, max: 10, value: 2, step: 0.05, format: v => v.toFixed(2) + ' mA' },
{ key: 'rc', label: 'R_C', min: 100, max: 5000, value: 1000, step: 50, format: v => (v / 1000).toFixed(2) + ' kΩ' },
],
readout: [
{ key: 'gm', label: 'Transconductance g_m' },
{ key: 'av', label: 'Voltage gain A_v' },
{ key: 'rin', label: 'Input resistance r_π' },
],
caption: 'Around any linear operating point the BJT obeys a simple rule: the collector current changes at a rate g_m = I_C / V_T per volt of base drive. The voltage gain from input to output is then just g_m times the collector resistor. More bias current means more gain, until you run out of rail or the transistor gets hot.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
clear(s.ctx, w, h);
const ctx = s.ctx;
const margin = { l: 60, r: 20, t: 25, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
// Plot gain vs I_C for the currently selected R_C
const rc = s.values.rc;
const icRange = [0.05, 10]; // mA
const gainOf = ic_mA => (ic_mA * 1e-3 / V_T) * rc;
const maxGain = gainOf(icRange[1]);
const xOf = ic => margin.l + (ic - icRange[0]) / (icRange[1] - icRange[0]) * pW;
const yOf = g => margin.t + (1 - g / maxGain) * pH;
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let ic = 1; ic <= 10; ic++) {
const x = xOf(ic);
ctx.beginPath(); ctx.moveTo(x, margin.t); ctx.lineTo(x, margin.t + pH); ctx.stroke();
text(ctx, `${ic}`, x, margin.t + pH + 14, C.dim, 'center');
}
const gStep = maxGain > 300 ? 100 : 50;
for (let g = 0; g <= maxGain; g += gStep) {
const y = yOf(g);
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
text(ctx, `-${g}`, margin.l - 6, y, C.dim, 'right', 'middle');
}
text(ctx, 'bias I_C (mA)', margin.l + pW / 2, margin.t + pH + 28, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 44, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'voltage gain |A_v|', 0, 0, C.dim, 'center');
ctx.restore();
// Curve
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
const N = 200;
for (let i = 0; i <= N; i++) {
const ic = icRange[0] + (icRange[1] - icRange[0]) * i / N;
const g = gainOf(ic);
const x = xOf(ic), y = yOf(g);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Current operating point
const ic = s.values.ic;
const gm = ic * 1e-3 / V_T;
const av = gm * rc;
const rpi = BETA / gm;
const px = xOf(ic), py = yOf(av);
ctx.fillStyle = C.yellow;
ctx.beginPath(); ctx.arc(px, py, 6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = C.yellow;
ctx.setLineDash([2, 3]);
ctx.beginPath();
ctx.moveTo(px, py); ctx.lineTo(px, margin.t + pH);
ctx.moveTo(px, py); ctx.lineTo(margin.l, py);
ctx.stroke();
ctx.setLineDash([]);
text(ctx, `A_v = -${av.toFixed(0)}`, px + 8, py - 8, C.yellow);
s.setReadout('gm', `${(gm * 1000).toFixed(1)} mA/V`);
s.setReadout('av', `-${av.toFixed(0)} V/V`);
s.setReadout('rin', `${(rpi / 1000).toFixed(2)}`);
requestAnimationFrame(draw);
}
draw();
}
function boot() {
const host = document.querySelector('.scene-host, [data-scene-host]');
if (host) host.classList.add('scene-host');
sceneValve();
sceneCurves();
sceneTransfer();
sceneAmplifier();
sceneGain();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();