w5isp.com/static/js/transistor.js
Graham McIntire c1f07190f3
Scheduled post publishing + transistor amplifier post
Filter future-dated posts out of blog/homepage listings and add a
daily 05:05 UTC rebuild so posts go live on their date automatically.

New interactive post on BJT amplification with five canvas scenes
(valve, load line, transfer curve, amplifier in action, gain).
Shared scene.css for future interactive posts. Post is dated
2026-04-23 and will appear in listings once the date arrives.

Also drop IDEAS.md with candidate topics for future posts.
2026-04-22 10:58:27 -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();
}
})();