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.
This commit is contained in:
Graham McIntire 2026-04-22 10:58:27 -05:00
parent 608476ef0d
commit c1f07190f3
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 1122 additions and 4 deletions

View file

@ -4,6 +4,9 @@ on:
push:
branches:
- main
# Daily rebuild so posts dated in the future are picked up on their publish day.
schedule:
- cron: "5 5 * * *"
jobs:
deploy:

53
IDEAS.md Normal file
View file

@ -0,0 +1,53 @@
# Future interactive post ideas
Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scenes scoped to a single post via `<link>` and `<script>` tags in the markdown. Each idea is a candidate; most of these are one-post-sized if focused.
## Electronics
- **How a transistor actually amplifies.** Draggable base current / gate voltage on a NMOS or NPN schematic, live I-V curve on the side, carrier flow animation in the channel or base region. Payoff: "small signal gain" stops being a formula and becomes something you can see.
- **LC resonance from scratch.** Tank circuit with sliders for L, C, and drive frequency. Show the phasors rotating, impedance magnitude/phase sweeping, and a scope trace of voltage vs current. Payoff scene: pumping the tank at resonance and watching it ring.
- **An oscillator starting up.** Colpitts or similar. Start from noise, show the limit cycle forming, let the reader nudge the loop gain and see it either die off or run away.
- **Feedback loop stability.** Drag the poles and zeros around on the s-plane, watch the step response and the bode plot change. The moment a pole crosses into the right half plane and everything goes sideways.
- **A superhet receiver, piece by piece.** Antenna, RF amp, mixer, LO, IF filter, detector. Tunable signals scrolling through the spectrum, user drags the LO, watches the desired signal slide into the IF passband. Image rejection as a visible thing.
- **A switching power supply.** Buck converter with a slider for duty cycle. Inductor current ramping and freewheeling, output ripple. Makes the "magic efficiency" of SMPS feel earned.
- **Noise figure, cascaded.** Build a receiver chain by dragging in blocks (LNA, mixer, IF amp), each with a noise figure. Live link budget at the bottom. The Friis formula stops feeling abstract.
## Computers / networking
- **A TCP connection, from the packets' perspective.** Client on the left, server on the right, sequence numbers and window sizes visible, user can introduce loss or latency and watch congestion control react. Slow start, fast recovery, the window collapsing on a single dropped packet.
- **How DNS actually resolves a name.** Recursive resolver, root, TLD, authoritative, caching. User types a hostname, watches the queries fan out with their TTLs.
- **What a CPU does per cycle.** A tiny toy ISA executing step by step. Pipeline stages, hazards, stalls. Adds a register renamer in a second scene and you get a decent intuition for why modern CPUs are fast.
- **Cache coherence (MESI).** Two cores, a shared line of memory, user issues reads and writes, arrows show the invalidations flying around. The reason your "obviously correct" lock-free code isn't is usually on this graph somewhere.
- **BGP finds a route.** A handful of ASes with peering relationships, user picks source and destination, watches announcements propagate and the path emerge. Bonus scene: a single misconfigured announcement leaking and causing a global outage.
- **Virtual memory and page tables.** Process issues a virtual address, the MMU walks the levels, TLB hits and misses, page fault handling. Good payoff if tied to a real program's memory map.
## Ham radio
- **SSB, from audio to RF.** Mic input on the left, phasing or filter method in the middle, RF out on the right. Show the audio spectrum, then the DSB-SC product, then USB after filtering or phasing. Bonus: demodulate it back on the receive side with a BFO offset and watch Donald Duck.
- **FT8 end to end.** 77 bits of payload, LDPC encoding, Costas sync, GFSK shaping, Gaussian noise, decoder pulling it back out under the noise floor. The part where you add enough AWGN to make the signal invisible and the decoder still pulls it is the Adams moment.
- **An SDR front end.** Antenna, bandpass, mixer with quadrature LO, anti-alias, ADC, decimation filters. User chooses the tuning frequency and the SDR slides through the spectrum.
- **Great circle and grid square math.** Enter two grid squares, get the path, bearing, distance. Drag either endpoint on a globe. Small but useful.
- **HF skip vs MUF.** Ionospheric layers with time-of-day and solar-cycle sliders. Rays launching at various angles, reflecting (or punching through) D/E/F layers, landing somewhere else on the globe. Great contrast piece to the NLOS post.
- **Feedline loss as a function of length, frequency, and type.** Slider for length, buttons for RG-58/RG-8/LMR-400/heliax. A visible chunk of transmit power disappearing into the coax as frequency climbs.
## Microwave / advanced RF
- **Rain scatter.** A rain cell, two stations not in LOS, common volume in the cell. Slider for rain rate, frequency. Show how a signal bounces off the precipitation and arrives from a totally wrong direction. Natural follow-up to the NLOS post.
- **Meteor scatter.** A meteor streaking through the ionosphere leaving a plasma trail, signal reflecting during the brief window. Ping lengths, burst rates. An argument for why 6 meters is worth owning a rig for.
- **EME (moonbounce).** Lunar geometry, path length, Doppler spread from libration, degree of polarization rotation from Faraday. Answer to "why do I need a big dish for this" becomes visually obvious.
- **Phased array beam steering.** Array of elements, user slides the phase taper, the beam sweeps. Grating lobes appear if spacing is too wide. Explains why Starlink and every modern radar system looks the way it does.
- **Smith chart, but friendly.** Drag a load impedance, watch reflection coefficient, SWR, and return loss update. Add a matching network with an L-network or stub and watch it slide to the center.
- **Parabolic dish aperture.** Slider for diameter and frequency, see gain vs beamwidth vs sidelobes. Illumination taper as a second control, showing the efficiency / sidelobe trade.
- **ITU-R P.838 rain attenuation.** Pick a frequency, a rain rate, and a path length. Curve of attenuation over the full microwave band. Useful companion to anything on 10 GHz and up.
## Longer arcs
A couple of ideas that would be a whole series, not one post.
- **"Build a receiver from first principles."** Start at an antenna picking up an EM wave, end at a speaker playing audio. Every stage a scene. Takes the shape of a short book.
- **"What the atmosphere is doing right now."** Live data from prop.w5isp.com, rendered as an interactive cross-section of North Texas atmosphere at the current hour. Refractivity aloft, ducts forming and collapsing, paths scored against real conditions. Operational rather than explanatory.
- **"Every component in my station, demystified."** One post per piece of gear. Transverter, PA, sequencer, rotator, preamp. Each one a scene that makes the internal thing you pay for make sense.
## House style
Keep each post focused, one topic, deep. No survey posts. Interactive bits earn the page weight, static bits carry the prose load. No em-dashes. Adams when it fits, silence when it doesn't.

View file

@ -0,0 +1,83 @@
+++
title = "How a transistor actually amplifies"
date = 2026-04-23
draft = false
+++
<link rel="stylesheet" href="/css/scene.css">
<div class="scene-host" data-scene-host>
The phrase *small-signal gain* is one of those engineering expressions that manages to sound both technical and unhelpful at the same time. A transistor amplifies. Fine. How?
The answer, when you work through it, is not that a transistor makes electrons stronger. Nothing in the device multiplies anything in a physical sense. It controls how hard a separate, already-energized current is allowed to flow, and then a nearby resistor turns that variable current back into a variable voltage, and the variable voltage is bigger than the one you started with. That is it. Everything else is details, and the details are where it gets fun.
We'll build this up one scene at a time using an NPN bipolar junction transistor, the sort of thing that still comes in a TO-92 three-legged black lump if you buy a bag from Mouser.
## What is a BJT?
A bipolar junction transistor is a three-terminal semiconductor device: a sandwich of doped silicon with a wire attached to each of its three layers. The NPN kind we'll be using stacks n-type silicon, p-type silicon, and n-type silicon, in that order. The three terminals attached to those layers are called, from outside to middle to outside, the **collector**, the **base**, and the **emitter**.
"N-type" and "p-type" are just two versions of silicon that have been doped with impurities to give them either a surplus of free electrons (n-type) or a surplus of the absence of free electrons, which solid-state physicists call *holes* and treat like positive charge carriers that move around independently. The *bipolar* in the name is an admission that both kinds of carriers end up doing useful work inside the device. Most semiconductor designs try to avoid that. The BJT embraces it.
The practical consequence is the behavior in the rest of this post: a small current flowing into the base lets a much larger current flow from the collector to the emitter. That is the whole trick, and once you accept that it happens, the amplifier parts follow.
## A transistor is a valve
Start with the classical way a ham thinks about a BJT. A small current flowing into the base allows a much larger current to flow down through the collector. The ratio between the two is called β, and for most small-signal transistors it sits somewhere between 100 and 300. It is also the one number on the datasheet that is most likely to be wrong about your specific part.
<div id="scene-valve"></div>
That's it. Move the base current up, the collector current moves up in lockstep. You have not yet amplified a voltage, because nothing in the picture is a voltage source. All you've done is built a current-controlled current source. Interesting, but not a radio.
## The load line: where current meets a resistor
To get a voltage out, stick a resistor in series with the collector. The supply provides V_CC. The transistor pulls some collector current. The voltage dropped across the resistor is whatever Ohm's law says it has to be, and V_CE is whatever's left over.
<div id="scene-curves"></div>
The family of curves shows how I_C depends on V_CE for various base currents. They're nearly flat because an ideal BJT is a current source, not a resistor. The orange line is the load line, the set of all (V_CE, I_C) points compatible with your V_CC and R_C. Wherever the load line meets the base-current curve you chose, that is the operating point (or, more ceremoniously, the Q-point). Turn the base current up, Q-point slides up the load line toward the top-left. Turn it up too far and you hit the saturation wall, where V_CE flattens against the supply rail minus a few tenths of a volt and the transistor gives up on being a current source. Turn it down to zero and you're in cutoff, V_CE sitting at the full V_CC because no current is flowing.
The useful region is the one in the middle. The entire game of building an amplifier is making sure you stay there on purpose.
## What's happening as V_BE changes
The base current is actually controlled by the voltage across the base-emitter junction, V_BE. That junction is a forward-biased diode, and diodes respond exponentially to voltage: small changes near the knee produce large changes in current, until eventually you drop the entire supply across the collector resistor and can't go any further.
<div id="scene-transfer"></div>
Drag V_in (which is V_BE here) and watch V_out. The three regions of the earlier scene show up as three regions of the curve: a flat portion at the top where nothing happens, a steep diagonal in the middle where a few millivolts of input produce volts of output, and a flat portion at the bottom where you're welded to the rail. The slope of the steep part is the voltage gain. It is not an especially well-behaved function of V_in, but it's enormous, and that's enough.
Notice that the amplifier is upside down. V_in going up makes V_out go down. This is the standard "common-emitter" amplifier, and its inversion is a consequence of the fact that more base drive means more collector current means more voltage dropped across R_C means less voltage left on the collector. The inversion does not bother anyone except people reading their first schematic.
## Amplification, live
Pick a bias near the middle of the steep part. Add a small AC signal on top of the bias. The signal walks V_BE back and forth by a few millivolts. That walks I_C by a few milliamps (multiplied by the exponential). That walks V_CE by a few volts (multiplied by R_C). The result is a bigger, flipped version of the input.
<div id="scene-amp"></div>
Crank the input amplitude high enough and the output swing starts slamming into the rails. The peaks clip. A real amplifier design is mostly the art of picking a bias point such that the signal you care about fits cleanly between V_CC and V_CEsat with some margin. Hi-fi engineers and ham radio operators fight about exactly how much margin. The transistor does not have strong opinions on this either way.
## Where the number comes from
All of the above is captured in two small formulas that turn the exponential behavior into linear behavior near a chosen operating point.
The rate at which collector current changes per volt of base drive is called transconductance:
> g_m = I_C / V_T
where V_T is the thermal voltage, about 26 mV at room temperature. That is a number that comes directly from the physics of a PN junction and has been the same temperature coefficient since semiconductor devices were invented.
The voltage gain of this common-emitter stage is then:
> A_v = -g_m · R_C
<div id="scene-gain"></div>
A BJT biased at 2 mA with a 1 kΩ collector resistor has a transconductance of about 77 mA/V and a voltage gain of about 77. Run it at 5 mA and the gain climbs to around 190. Run it at 100 μA and you have a gain of about 4 and should probably consider a different amplifier topology. None of this depends on β, by the way, which is why a well-designed amplifier doesn't care very much that β varies from part to part.
Everything else, and there is a lot of *else*, is decoration on this core. Bypass capacitors, emitter degeneration, cascode stages, differential pairs, current mirrors, Darlingtons, bootstraps. They all exist to make the basic idea in this post more linear, more bandwidth-y, more stable with temperature, or less dependent on a specific transistor's specific quirks. But underneath the decoration there's still a valve controlling a current, and a resistor turning that current into a voltage, and the voltage is bigger than the one you started with. The rest is bookkeeping.
</div>
<script src="/js/transistor.js" defer></script>

94
static/css/scene.css Normal file
View file

@ -0,0 +1,94 @@
/* Generic interactive-scene styles. Scoped to posts that opt in by
wrapping content in `.scene-host` and by linking this stylesheet
from the post's markdown. */
.scene-host {
--s-bg: #1f232b;
--s-panel: #242932;
--s-fg: #d4d8df;
--s-dim: #7d8594;
--s-grid: #323844;
--s-blue: #61afef;
--s-cyan: #56b6c2;
--s-green: #98c379;
--s-orange: #d19a66;
--s-red: #e06c75;
--s-yellow: #e5c07b;
--s-magenta: #c678dd;
}
.scene-box {
margin: 2.5rem 0;
background: var(--s-bg);
border: 1px solid var(--s-grid);
border-radius: 6px;
padding: 0.75rem 0.75rem 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
.scene-box canvas {
display: block;
width: 100%;
height: auto;
touch-action: none;
cursor: grab;
background: transparent;
}
.scene-box canvas:active { cursor: grabbing; }
.scene-controls {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
margin-top: 0.75rem;
font-size: 0.9rem;
}
@media (min-width: 640px) {
.scene-controls { grid-template-columns: repeat(2, 1fr); }
.scene-controls.scene-controls--three { grid-template-columns: repeat(3, 1fr); }
}
.scene-control { display: flex; flex-direction: column; gap: 0.2rem; }
.scene-control label {
color: var(--s-dim);
font-size: 0.8rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.scene-control label .scene-value {
color: var(--s-fg);
font-variant-numeric: tabular-nums;
}
.scene-control input[type="range"] {
width: 100%;
accent-color: var(--s-cyan);
}
.scene-box .scene-caption {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--s-dim);
line-height: 1.45;
}
.scene-box .scene-readout {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
padding: 0.5rem 0;
margin-top: 0.25rem;
color: var(--s-fg);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.scene-box .scene-readout .scene-readout-item span:first-child {
color: var(--s-dim);
margin-right: 0.35rem;
}

827
static/js/transistor.js Normal file
View file

@ -0,0 +1,827 @@
// 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();
}
})();

View file

@ -24,10 +24,21 @@
</ul>
{% endif %}
{# Filter out future-dated posts so they appear automatically on their publish date
(combined with a daily scheduled build in .forgejo/workflows/deploy.yml). #}
{% set current_ts = now(timestamp=true) %}
{% set_global published_pages = [] %}
{% set blog_section = get_section(path="blog/_index.md") %}
{% if blog_section.pages | length > 0 %}
{% for page in blog_section.pages %}
{% set page_ts = page.date | date(format="%s") | int %}
{% if page_ts <= current_ts %}
{% set_global published_pages = published_pages | concat(with=page) %}
{% endif %}
{% endfor %}
{% if published_pages | length > 0 %}
<h2>Blog</h2>
{% set latest = blog_section.pages | first %}
{% set latest = published_pages | first %}
<article>
<h2><a href="{{ latest.permalink }}">{{ latest.title }}</a></h2>
{% if latest.date %}
@ -41,10 +52,10 @@
<p><a href="{{ latest.permalink }}">Read more &rarr;</a></p>
</article>
{% if blog_section.pages | length > 1 %}
{% if published_pages | length > 1 %}
<h2>Recent Posts</h2>
<ul>
{% for page in blog_section.pages | slice(start=1, end=6) %}
{% for page in published_pages | slice(start=1, end=6) %}
<li>
{% if page.date %}
<time datetime="{{ page.date | date(format="%Y-%m-%d") }}">{{ page.date | date(format="%Y-%m-%d") }}</time>

47
templates/section.html Normal file
View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} - {{ config.title }}{% endblock title %}
{% block content %}
<h1>{{ section.title }}</h1>
<div>
{{ section.content | safe }}
</div>
<ul class="blog">
{% set current_ts = now(timestamp=true) %}
{% for page in paginator.pages %}
{% set page_ts = page.date | date(format="%s") | int %}
{% if page_ts <= current_ts %}
<li>
<article>
<h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
{% if page.date %}
<p>published: <time datetime="{{ page.date | date(format="%Y-%m-%d") }}">{{ page.date | date(format="%Y-%m-%d") }}</time></p>
{% endif %}
{% if page.taxonomies.tags %}
<p class="tags">
tags: [
{% for tag in page.taxonomies.tags %}
<a href="{{ get_taxonomy_url(kind="tags", name=tag) }}">#{{ tag }}</a>{% if not loop.last %}, {% endif %}
{% endfor %}
]
{% endif %}
{% if page.description %}
<p>{{ page.description }}</p>
{% endif %}
</article>
</li>
{% endif %}
{% endfor %}
</ul>
<nav>
{% if paginator.previous %}
<a href="{{ paginator.previous }}">&lt; Prev</a>
{% endif %}
<span>Page {{ paginator.current_index }} of {{ paginator.number_pagers }}</span>
{% if paginator.next %}
<a href="{{ paginator.next }}">Next &gt;</a>
{% endif %}
</nav>
{% endblock content %}