From 964713202e49d017631e4eda23f1093c63a92288 Mon Sep 17 00:00:00 2001 From: Adegbola Adeniyi Date: Sun, 13 Sep 2026 08:06:07 +0100 Subject: [PATCH] feat: add Kirchhoff's Circuit Laws simulation Interactive DC circuit simulation (single loop, series, parallel, two-loop network) with draggable voltmeter and ammeter, plus its config, catalogue entry, overview, companion article, thumbnail and regenerated sitemap/routes. --- app/(core)/data/articles/index.js | 2 + .../kirchhoffs-circuit-laws-explained.js | 437 +++++++ app/(core)/data/chapters.js | 12 + app/(core)/data/configs/KirchhoffCircuit.js | 206 ++++ app/(core)/data/simulationOverviews.js | 45 + public/sitemap.xml | 52 +- public/thumbnails/kirchhoff-circuit.webp | Bin 0 -> 39580 bytes routes.js | 42 +- simulations/KirchhoffCircuit.jsx | 1039 +++++++++++++++++ 9 files changed, 1800 insertions(+), 35 deletions(-) create mode 100644 app/(core)/data/articles/kirchhoffs-circuit-laws-explained.js create mode 100644 app/(core)/data/configs/KirchhoffCircuit.js create mode 100644 public/thumbnails/kirchhoff-circuit.webp create mode 100644 simulations/KirchhoffCircuit.jsx diff --git a/app/(core)/data/articles/index.js b/app/(core)/data/articles/index.js index 03d13659..ce8f75a5 100644 --- a/app/(core)/data/articles/index.js +++ b/app/(core)/data/articles/index.js @@ -10,6 +10,7 @@ import { class12PhysicsBlog } from "./class-12-physics.js"; import { whatIsPhysicsBlog } from "./what-is-physics.js"; import { threeBodyProblemBlog } from "./physics-behind-three-body-problem.js"; import { piCollisionBlog } from "./pi-from-block-collisions-explained.js"; +import { kirchhoffLawsBlog } from "./kirchhoffs-circuit-laws-explained.js"; export const allBlogs = { [whatIsPhysicsBlog.slug]: whatIsPhysicsBlog, @@ -23,6 +24,7 @@ export const allBlogs = { [projectileParabolicBlog.slug]: projectileParabolicBlog, [threeBodyProblemBlog.slug]: threeBodyProblemBlog, [piCollisionBlog.slug]: piCollisionBlog, + [kirchhoffLawsBlog.slug]: kirchhoffLawsBlog, }; export const blogsArray = Object.values(allBlogs); diff --git a/app/(core)/data/articles/kirchhoffs-circuit-laws-explained.js b/app/(core)/data/articles/kirchhoffs-circuit-laws-explained.js new file mode 100644 index 00000000..65b45437 --- /dev/null +++ b/app/(core)/data/articles/kirchhoffs-circuit-laws-explained.js @@ -0,0 +1,437 @@ +import TAGS, { LEVELS, DIFFICULTIES } from "../tags.js"; + +export const kirchhoffLawsBlog = { + id: "bb-012", + slug: "kirchhoffs-circuit-laws-explained", + name: "Kirchhoff's Circuit Laws Explained", + desc: "Why current splits at a junction and why voltages around a loop always add to zero — with a fully worked two-loop network, sign conventions that actually survive contact with a problem, and the nodal method the simulation uses to solve any circuit you build.", + tags: [ + LEVELS.upperSecondary, + DIFFICULTIES.extended, + TAGS.PHYSICS, + TAGS.ELECTROMAGNETISM, + TAGS.ENERGY, + ], + date: "11/09/2026", + theory: { + sections: [ + { + title: "Introduction", + blocks: [ + { + type: "paragraph", + text: "Ohm's law tells you what a single resistor does. It says nothing about what happens when three resistors and two batteries are wired into a loop that feeds back on itself. For that you need two statements that Gustav Kirchhoff published in 1845, while he was still a student: the **junction rule** and the **loop rule**.", + }, + { + type: "paragraph", + text: "Neither is a new law of nature. The junction rule is conservation of charge, written for a wire. The loop rule is conservation of energy, written for a charge that goes all the way around and comes back to where it started. Everything else in DC circuit analysis — series resistance, parallel resistance, potential dividers, internal resistance — falls out of those two.", + }, + { + type: "callout", + calloutType: "info", + title: "What the simulation shows", + text: "Pick one of four circuits, set the EMFs and resistances, and the readout solves the network and then **checks itself in front of you**: it walks each loop term by term and shows the running sum landing on zero, and it shows the current into each junction matching the current out. If the physics were wrong, those rows would not balance.", + }, + ], + }, + + { + title: "The vocabulary: nodes, branches and loops", + blocks: [ + { + type: "paragraph", + text: "Before either law makes sense you need three words, and the first one is the one people get wrong.", + }, + { + type: "list", + ordered: false, + items: [ + "A **node** (or junction) is a point where the circuit divides. Crucially, **everything joined by plain wire is the same node** — an ideal wire has no resistance, so it has no potential difference across it. A long stretch of wire with nothing in it is not two nodes, it is one.", + "A **branch** is a path from one node to another with components in it. Every component in a branch carries the same current, because there is nowhere else for the charge to go.", + "A **loop** is any closed path you can trace through the circuit and return to your starting point.", + ], + }, + { + type: "callout", + calloutType: "tip", + title: "Smart Tip!", + text: "In the simulation, nodes are the labelled white dots and each one shows its own potential. Notice that the parallel circuit has only **two** nodes, even though it looks busy — the entire top rail is one node and the entire bottom rail is another. That single observation explains why parallel components all have the same voltage across them.", + }, + ], + }, + + { + title: "Kirchhoff's current law (the junction rule)", + blocks: [ + { + type: "paragraph", + text: "Charge does not pile up in a wire. Whatever flows into a junction in one second must flow out of it in the same second, so:", + }, + { + type: "formula", + latex: + "\\sum_{\\text{into node}} I \\;=\\; \\sum_{\\text{out of node}} I", + }, + { + type: "paragraph", + text: "It is often more convenient to count everything as **leaving** the node, letting currents that actually flow inwards come out negative. Then the law is simply:", + }, + { + type: "formula", + latex: "\\sum_{k} I_k = 0", + }, + { + type: "note", + text: "This is the form the simulation's solver uses. One such equation is written for every node except one, which is chosen as the zero-volt reference.", + }, + { + type: "callout", + calloutType: "warning", + title: "Attention!", + text: "The junction rule is about **current**, not charge carriers, and not energy. Electrons are not consumed by a lamp. The same number per second leave the lamp as entered it — what the lamp takes is energy, and that is the other law's business.", + }, + ], + }, + + { + title: "Kirchhoff's voltage law (the loop rule)", + blocks: [ + { + type: "paragraph", + text: "Electric potential is a property of a point in the circuit. If you walk from a point all the way around a loop and arrive back at that same point, you must be back at the same potential — there is only one value there. So the rises and drops you accumulated on the way round have to cancel exactly:", + }, + { + type: "formula", + latex: "\\sum_{\\text{closed loop}} \\Delta V = 0", + }, + { + type: "paragraph", + text: "Physically: a cell of EMF $E$ does $qE$ joules of work pushing charge $q$ through itself, and every resistance the charge then passes through takes some of that energy back out as heat. Round a full loop, the energy given equals the energy taken. KVL is conservation of energy per unit charge.", + }, + { + type: "callout", + calloutType: "info", + title: "Where the readout comes from", + text: "The `KVL loop` rows in the simulation are literally this walk, written out: one signed number for each EMF you pass through and one for each resistance you pass through, followed by the total. Change any slider and watch every term move while the total stays pinned at 0.00 V.", + }, + ], + }, + + { + title: "Sign conventions — the part that actually goes wrong", + blocks: [ + { + type: "paragraph", + text: "Almost every mistake in Kirchhoff problems is a sign, not a formula. Two habits make it reliable.", + }, + { + type: "paragraph", + text: "**First: guess the current directions and commit.** You do not need to guess correctly. Mark an arrow on every branch, do the algebra, and if a current comes out negative that simply means the real flow is the other way. A negative answer is information, not an error.", + }, + { + type: "paragraph", + text: "**Second: pick a direction to walk each loop and apply the same two rules at every component you meet.**", + }, + { + type: "list", + ordered: false, + items: [ + "Crossing a **cell** from its − plate to its + plate is a **rise** of $+E$. Crossing the other way is $-E$.", + "Crossing a **resistance** **along** your assumed current direction is a **drop** of $-IR$. Crossing it against the current direction is $+IR$.", + ], + }, + { + type: "note", + text: "Internal resistance is not special. A real cell is an ideal EMF in series with a resistance r, so crossing it gives you both a +E term and a −Ir term, one after the other. The simulation prints them as two separate numbers for exactly this reason.", + }, + ], + }, + + { + title: "A fully worked example: the two-loop network", + blocks: [ + { + type: "paragraph", + text: "This is the circuit the simulation opens on, with its default values. Two cells and three resistors are wired between the same pair of nodes, $A$ at the top and $B$ at the bottom.", + }, + { + type: "table", + columns: ["Branch", "Contents", "Total R", "EMF"], + data: [ + { + Branch: "Left (B → A)", + Contents: "Cell E₁ with internal r₁, then R₁", + "Total R": "0.5 + 4 = 4.5 Ω", + EMF: "12 V", + }, + { + Branch: "Middle (A → B)", + Contents: "R₂ alone", + "Total R": "6 Ω", + EMF: "0 V", + }, + { + Branch: "Right (B → A)", + Contents: "Cell E₂ with internal r₂, then R₃", + "Total R": "0.5 + 3 = 3.5 Ω", + EMF: "6 V", + }, + ], + }, + { + type: "paragraph", + text: "Take $B$ as the zero of potential and let $V_A$ be the unknown. Each branch current follows from Ohm's law applied across the whole branch — the potential you start at, minus the potential you end at, plus whatever the cell adds, all over the resistance:", + }, + { + type: "formula", + latex: + "I_1 = \\frac{12 - V_A}{4.5}, \\qquad I_2 = \\frac{V_A}{6}, \\qquad I_3 = \\frac{6 - V_A}{3.5}", + }, + { + type: "paragraph", + text: "Now apply the junction rule at $A$. Two branches feed it and one drains it:", + }, + { + type: "formula", + latex: "I_1 + I_3 = I_2", + }, + { + type: "formula", + latex: + "\\frac{12 - V_A}{4.5} + \\frac{6 - V_A}{3.5} = \\frac{V_A}{6}", + }, + { + type: "paragraph", + text: "That is one equation in one unknown. Collecting terms gives $0.6746\\,V_A = 4.3810$, so:", + }, + { + type: "formula", + latex: + "V_A = 6.494\\ \\text{V} \\;\\Rightarrow\\; I_1 = 1.224\\ \\text{A},\\quad I_2 = 1.082\\ \\text{A},\\quad I_3 = -0.141\\ \\text{A}", + }, + { + type: "callout", + calloutType: "success", + title: "Read that third current again", + text: "$I_3$ came out **negative**. The 6 V cell is not driving current into the circuit at all — the 12 V cell has pushed node $A$ up to 6.49 V, which is above E₂, so current is being forced **backwards** through the second cell. It is being charged. This is exactly what happens when you connect a charger to a battery, and it is why you should never dismiss a negative current as a mistake.", + }, + { + type: "paragraph", + text: "Check it with the loop rule. Walking loop ① up the left branch and back down the middle:", + }, + { + type: "formula", + latex: + "\\underbrace{+12}_{E_1} \\;\\underbrace{-\\,0.61}_{I_1 r_1} \\;\\underbrace{-\\,4.89}_{I_1 R_1} \\;\\underbrace{-\\,6.49}_{I_2 R_2} \\;=\\; 0", + }, + { + type: "toggle", + title: "Why does the middle term come out as a rise for loop ②?", + content: + "Walking loop ② up the right branch you pass E₂ as a rise of +6 V, then meet r₂ and R₃ — but $I_3$ is negative, so $-I_3 R$ is **positive**. The two resistances therefore add +0.07 V and +0.42 V instead of subtracting, and the loop still closes on the same $-6.49$ V drop through R₂. The algebra handles the reversed flow for you, provided you never quietly flip a sign by hand.", + }, + ], + }, + + { + title: "Series and parallel are just special cases", + blocks: [ + { + type: "paragraph", + text: "The rules you were taught first are what the two laws reduce to in simple topologies. Switch the simulation between the `Series` and `Parallel` presets and watch the readout.", + }, + { + type: "paragraph", + text: "**Series.** A chain of resistors has no junctions between them, so KCL leaves no choice: the current is identical in every branch. KVL round the single loop then gives $E = I(r + R_1 + R_2 + R_3)$, which is the same as one resistor of value:", + }, + { + type: "formula", + latex: "R_{\\text{eq}} = R_1 + R_2 + R_3", + }, + { + type: "paragraph", + text: "**Parallel.** All three resistors sit between the same two nodes, so they all have the same potential difference $V$ across them. KCL at the top node says the branch currents add to the total:", + }, + { + type: "formula", + latex: + "I = \\frac{V}{R_1} + \\frac{V}{R_2} + \\frac{V}{R_3} \\;\\Rightarrow\\; \\frac{1}{R_{\\text{eq}}} = \\frac{1}{R_1} + \\frac{1}{R_2} + \\frac{1}{R_3}", + }, + { + type: "callout", + calloutType: "tip", + title: "Try this", + text: "In the parallel preset, drag the ammeter onto each branch in turn and add the three readings. With the defaults they come to 2.182 + 1.455 + 2.909 = 6.545 A, which is exactly the reading on the cell's branch. Then lower R₁ and watch the **smallest** resistor take the **largest** share.", + }, + ], + }, + + { + title: "Real cells: EMF, internal resistance and terminal voltage", + blocks: [ + { + type: "paragraph", + text: "An ideal cell would hold its terminals at $E$ volts no matter how much current you drew. A real one cannot: its own chemistry has resistance $r$, and the current has to get through that too. What the outside world actually sees is the **terminal voltage**:", + }, + { + type: "formula", + latex: "V_{\\text{term}} = E - I r", + }, + { + type: "paragraph", + text: "This is why headlights dim when you crank an engine, and why a battery reads a healthy voltage with nothing attached and sags the moment it has to do work. Put the simulation on the single-loop preset, drag the voltmeter leads onto the two nodes, and lower $R_1$: as the current climbs, the reading falls away from $E$.", + }, + { + type: "callout", + calloutType: "warning", + title: "Why the simulation will not let r be zero", + text: "A branch with no resistance at all is a short circuit: the equations demand infinite current and the matrix becomes singular. That is not a numerical wobble, it is the model honestly reporting that an ideal EMF across an ideal wire is unphysical. Every resistance in the simulation is therefore held at or above 0.01 Ω.", + }, + ], + }, + + { + title: "Power: where the energy actually goes", + blocks: [ + { + type: "paragraph", + text: "Each cell delivers $P = EI$ into the circuit, and each resistance turns $P = I^2R$ of it into heat. Because KVL is conservation of energy, multiplying the loop equation by the current proves these must be equal overall:", + }, + { + type: "formula", + latex: + "\\sum_{\\text{cells}} E_k I_k \\;=\\; \\sum_{\\text{resistances}} I_k^{2} R_k", + }, + { + type: "paragraph", + text: "The simulation shows both totals side by side, and they agree to the last decimal in every circuit — that is a genuine check on the solution, not decoration. In the default two-loop network it comes to 13.84 W each way, and note that E₂'s contribution to the left-hand side is **negative**: it is absorbing energy, not supplying it.", + }, + { + type: "note", + text: "The glow on each resistor is scaled by its own I²R, so brightness is a fair visual proxy for how hot that component is getting. In the parallel preset the smallest resistor glows brightest.", + }, + ], + }, + + { + title: "How the simulation solves an arbitrary circuit", + blocks: [ + { + type: "paragraph", + text: "Guessing loops by hand does not scale. The simulation uses **nodal analysis**, which turns the junction rule into a matrix equation and solves it by Gaussian elimination — the same method any circuit simulator uses.", + }, + { + type: "paragraph", + text: "Every branch is treated as an EMF in series with a resistance, so its current is a function of the potentials at its two ends. Substituting that into $\\sum I = 0$ at each node gives one linear equation per unknown potential:", + }, + { + type: "formula", + latex: + "\\sum_b \\frac{1}{R_b}\\,V_n \\;-\\; \\sum_b \\frac{1}{R_b}\\,V_{\\text{other}} \\;=\\; -\\sum_b \\frac{s_b E_b}{R_b}", + }, + { + type: "note", + text: "Here s = +1 when the branch is oriented away from node n and −1 when it points into it. One node is grounded at 0 V and its row is dropped, which is what makes the system solvable at all — only potential **differences** are physical.", + }, + { + type: "code", + language: "javascript", + code: ` +// Assemble the conductance system G·v = i, one row per unsolved node. +for (const branch of branches) { + const g = 1 / branch.R; + for (const [id, s] of [[branch.from, 1], [branch.to, -1]]) { + const row = unknowns.get(id); + if (row === undefined) continue; // the grounded node has no row + G[row][row] += g; + const col = unknowns.get(s === 1 ? branch.to : branch.from); + if (col !== undefined) G[row][col] -= g; + rhs[row] -= s * branch.emf * g; + } +} + +// Solve for the node potentials, then read every branch current back off them. +const V = gaussian(G, rhs); +for (const branch of branches) { + branch.I = (V[branch.from] - V[branch.to] + branch.emf) / branch.R; +}`, + }, + { + type: "paragraph", + text: "Unlike the rest of the site's simulations, there is nothing to integrate here — no positions, no velocities, no timestep. A resistive circuit settles instantly, so its state is the **exact** solution of a linear system, recomputed from scratch every frame. The only thing that advances with time is the animation of the charge carriers.", + }, + ], + }, + + { + title: "Simulation Parameters", + blocks: [ + { + type: "table", + columns: ["Parameter", "Symbol", "Description"], + data: [ + { + Parameter: "Circuit", + Symbol: "—", + Description: + "Which network to build: single loop, series, parallel, or the two-loop network with two cells.", + }, + { + Parameter: "First cell EMF", + Symbol: "E₁", + Description: + "Energy the main cell gives each coulomb of charge, in volts. Used by every preset.", + }, + { + Parameter: "First cell internal resistance", + Symbol: "r₁", + Description: + "The main cell's own resistance (Ω). Raising it lowers the terminal voltage under load.", + }, + { + Parameter: "Second cell EMF", + Symbol: "E₂", + Description: + "The opposing cell in the two-loop network (V). Ignored by the other presets.", + }, + { + Parameter: "Second cell internal resistance", + Symbol: "r₂", + Description: "Internal resistance of the second cell (Ω).", + }, + { + Parameter: "Resistors", + Symbol: "R₁, R₂, R₃", + Description: + "The external resistances (Ω). Which of them appear depends on the circuit selected.", + }, + ], + }, + ], + }, + + { + title: "Things worth trying", + blocks: [ + { + type: "list", + ordered: true, + items: [ + "On the two-loop circuit, raise **E₂** past 6.49 V and watch $I_3$ flip from negative to positive — the moment the second cell stops being charged and starts driving.", + "Set **E₁** and **E₂** equal, then make R₁ and R₃ equal too. The two branches become symmetric and carry identical currents; the middle branch takes their sum.", + "On the series circuit, drag the ammeter from branch to branch. The reading never changes. Then switch to parallel and repeat — now every branch is different.", + "Push **r₁** to 5 Ω on the single loop with a small R₁. Most of the energy is now dissipated inside the cell itself: a flat battery, getting warm and delivering very little.", + "Turn off the charge-flow animation and the arrows, and try to predict the direction of every current before turning them back on.", + ], + }, + { + type: "callout", + calloutType: "success", + title: "Success!", + text: "If you can look at any circuit, mark arbitrary current arrows, write one junction equation per node and one loop equation per mesh, and then trust a negative answer when you get one — you can solve any DC network that will ever be put in front of you.", + }, + ], + }, + ], + }, +}; diff --git a/app/(core)/data/chapters.js b/app/(core)/data/chapters.js index d2b1c0a3..8d937d2d 100644 --- a/app/(core)/data/chapters.js +++ b/app/(core)/data/chapters.js @@ -186,6 +186,18 @@ const chapters = [ tags: [TAGS.MATH, TAGS.TRIGONOMETRY], thumbnail: "/thumbnails/trigonometric-circle.webp", }, + { + id: 17, + name: "Kirchhoff's Circuit Laws", + desc: "Build and solve DC circuits online: single loop, series, parallel and a two-loop network with two cells. Watch charge flow, drag a voltmeter and an ammeter onto the circuit, and see Kirchhoff's current and voltage laws balance to zero in real time.", + link: "/simulations/KirchhoffCircuit", + level: "upperSecondary", + alsoFor: ["lowerSecondary"], + difficulty: "extended", + tags: [TAGS.ELECTROMAGNETISM, TAGS.ENERGY, TAGS.INTERACTIVE], + thumbnail: "/thumbnails/kirchhoff-circuit.webp", + relatedBlogSlug: "kirchhoffs-circuit-laws-explained", + }, ]; export default chapters; diff --git a/app/(core)/data/configs/KirchhoffCircuit.js b/app/(core)/data/configs/KirchhoffCircuit.js new file mode 100644 index 00000000..f586e054 --- /dev/null +++ b/app/(core)/data/configs/KirchhoffCircuit.js @@ -0,0 +1,206 @@ +// app/(core)/data/configs/KirchhoffCircuit.js +// +// UI schema and readout for the Kirchhoff circuit simulation. The circuit +// topology, the nodal solver and the drawing all live in +// simulations/KirchhoffCircuit.jsx — nothing physical belongs here. + +/** + * The circuits the learner can switch between. The value is the preset id the + * simulation builds; changing it changes *what exists*, so the simulation + * rebuilds the world rather than merely re-reading a parameter. + */ +export const CIRCUIT_PRESETS = [ + { value: "single", label: "Single loop — E, r, R₁" }, + { value: "series", label: "Series — R₁ + R₂ + R₃" }, + { value: "parallel", label: "Parallel — R₁ ∥ R₂ ∥ R₃" }, + { value: "twoLoop", label: "Two-loop network — E₁ and E₂" }, +]; + +/** + * Defaults are chosen so the two-loop network opens on its most instructive + * state: E₂ ends up with a *negative* branch current, i.e. the second cell is + * being charged by the first. See the article for the worked numbers. + */ +export const INITIAL_INPUTS = { + preset: "twoLoop", + + emf1: 12, + internalR1: 0.5, + emf2: 6, + internalR2: 0.5, + + r1: 4, + r2: 6, + r3: 3, + + showChargeFlow: true, + showCurrentArrows: true, + showGlow: true, + showProbes: true, + + wireColor: "#94a3b8", +}; + +export const INPUT_FIELDS = [ + { + name: "preset", + label: "Circuit", + type: "select", + options: CIRCUIT_PRESETS, + }, + + { + name: "emf1", + label: "First cell EMF", + symbol: "E₁", + unit: "V", + type: "number", + min: 0, + max: 24, + step: 0.5, + }, + { + name: "internalR1", + label: "First cell internal resistance", + symbol: "r₁", + unit: "Ω", + type: "number", + min: 0.1, + max: 5, + step: 0.1, + }, + { + name: "emf2", + label: "Second cell EMF", + symbol: "E₂", + unit: "V", + type: "number", + min: 0, + max: 24, + step: 0.5, + }, + { + name: "internalR2", + label: "Second cell internal resistance", + symbol: "r₂", + unit: "Ω", + type: "number", + min: 0.1, + max: 5, + step: 0.1, + }, + + { + name: "r1", + label: "First resistor", + symbol: "R₁", + unit: "Ω", + type: "number", + min: 0.5, + max: 40, + step: 0.5, + }, + { + name: "r2", + label: "Second resistor", + symbol: "R₂", + unit: "Ω", + type: "number", + min: 0.5, + max: 40, + step: 0.5, + }, + { + name: "r3", + label: "Third resistor", + symbol: "R₃", + unit: "Ω", + type: "number", + min: 0.5, + max: 40, + step: 0.5, + }, + + { name: "showChargeFlow", label: "Animate charge flow", type: "checkbox" }, + { name: "showCurrentArrows", label: "Show current arrows", type: "checkbox" }, + { name: "showGlow", label: "Glow with power dissipated", type: "checkbox" }, + { name: "showProbes", label: "Show meter probes", type: "checkbox" }, + { name: "wireColor", label: "Wire color", type: "color" }, +]; + +const volts = (v) => `${v.toFixed(2)} V`; +const amps = (i) => `${i.toFixed(3)} A`; +const watts = (w) => `${w.toFixed(2)} W`; + +/** A signed term formatted for a running sum: "+12.00", "−5.51". */ +const term = (v) => + `${Math.abs(v) >= 5e-3 && v < 0 ? "−" : "+"}${Math.abs(v).toFixed(2)}`; + +/** + * Residuals are floating-point dust, not physics — treat anything tiny as 0. + * `snap` also keeps a −1e-16 sum from printing as "−0.00 V", which in a readout + * whose whole point is "this lands on zero" reads like a failure. + */ +const TOLERANCE = 1e-6; +const snap = (v) => (Math.abs(v) < TOLERANCE ? 0 : v); +const verdict = (residual) => (Math.abs(residual) < TOLERANCE ? "✓" : "✗"); + +export const SimInfoMapper = (state) => { + if (!state || !state.ok) { + return { + Circuit: state?.presetLabel ?? "—", + Status: "No solution — check that every resistance is above zero", + }; + } + + const rows = { Circuit: state.presetLabel }; + + for (const node of state.nodes) { + rows[`V (${node.label})`] = node.isGround + ? "0.00 V (reference)" + : volts(node.V); + } + + for (const branch of state.branches) { + const direction = + branch.I < 0 ? ` (flows ${branch.to}→${branch.from})` : ""; + rows[`I (${branch.label})`] = `${amps(branch.I)}${direction}`; + } + + // Kirchhoff's current law: what goes into a junction comes back out. + for (const node of state.kcl) { + rows[`KCL at ${node.label}`] = + `in ${node.inSum.toFixed(3)} = out ${node.outSum.toFixed(3)} A ${verdict( + node.residual + )}`; + } + + // Kirchhoff's voltage law: walk the loop, add every rise and drop, land on 0. + for (const loop of state.loops) { + rows[`KVL loop ${loop.label}`] = + `${loop.terms.map(term).join(" ")} = ${snap(loop.sum).toFixed( + 2 + )} V ${verdict(loop.sum)}`; + } + + for (const part of state.dissipation) { + rows[`P (${part.label})`] = watts(part.P); + } + + // Kept the same length as the row above so the two totals, which should always + // agree, line up in the panel instead of one of them wrapping. + rows["P delivered by cells"] = watts(state.powerDelivered); + rows["P dissipated as heat"] = watts(state.powerDissipated); + + if (state.voltmeter) { + rows["Voltmeter"] = + `${state.voltmeter.from} → ${state.voltmeter.to} = ${volts( + state.voltmeter.reading + )}`; + } + if (state.ammeter) { + rows["Ammeter"] = `${state.ammeter.label} = ${amps(state.ammeter.reading)}`; + } + + return rows; +}; diff --git a/app/(core)/data/simulationOverviews.js b/app/(core)/data/simulationOverviews.js index ca9d001d..9a38c8c9 100644 --- a/app/(core)/data/simulationOverviews.js +++ b/app/(core)/data/simulationOverviews.js @@ -443,6 +443,51 @@ const simulationOverviews = { }, ], }, + + KirchhoffCircuit: { + intro: + "Four DC circuits — a single loop, three resistors in series, three in parallel, and a two-loop network driven by two cells — solved live by Kirchhoff's laws. Charge carriers drift along each wire at a speed set by that branch's current, resistors glow with the power they dissipate, and the readout walks each loop term by term so you can watch the voltages add up to exactly zero.", + controls: [ + "Circuit layout", + "EMF of each cell", + "Internal resistance of each cell", + "The three resistances", + ], + concepts: [ + "Kirchhoff's current law (junction rule) as charge conservation", + "Kirchhoff's voltage law (loop rule) as energy conservation", + "Ohm's law and equivalent resistance in series and parallel", + "EMF, internal resistance and terminal voltage", + "Power delivered by a cell and dissipated as heat", + "Sign conventions: a negative branch current means a cell is charging", + ], + formulas: [ + { + label: "Ohm's law", + latex: "V = IR", + }, + { + label: "Junction rule (KCL)", + latex: "\\sum_{\\text{into node}} I = \\sum_{\\text{out of node}} I", + }, + { + label: "Loop rule (KVL)", + latex: "\\sum_{\\text{closed loop}} \\Delta V = 0", + }, + { + label: "Current in a branch of EMF E and resistance R", + latex: "I = \\dfrac{V_{\\text{from}} - V_{\\text{to}} + E}{R}", + }, + { + label: "Terminal voltage of a real cell", + latex: "V_{\\text{term}} = E - I r", + }, + { + label: "Power dissipated in a resistance", + latex: "P = I^{2} R", + }, + ], + }, }; export default simulationOverviews; diff --git a/public/sitemap.xml b/public/sitemap.xml index e194e18e..6b970597 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,31 +2,31 @@ https://physicshub.github.io/ - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 1.0 https://physicshub.github.io/contribute - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z monthly 0.8 https://physicshub.github.io/simulations - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.9 https://physicshub.github.io/about - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z monthly 0.7 https://physicshub.github.io/blog - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.9 @@ -98,91 +98,103 @@ https://physicshub.github.io/simulations/BouncingBall - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/VectorsOperations - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/BallAcceleration - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/BallGravity - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/SpringConnection - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/SimplePendulum - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/ParabolicMotion - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/InclinedPlane - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/CircularMotion - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/ThreeBody - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/HorizontalSpring - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/DoublePendulum - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/CollisionSimulation - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/PiCollisions - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z weekly 0.7 https://physicshub.github.io/simulations/TrigonometricCircle - 2026-09-04T00:00:00.000Z + 2026-09-13T00:00:00.000Z + weekly + 0.7 + + + https://physicshub.github.io/blog/kirchhoffs-circuit-laws-explained + 2026-09-11T00:00:00.000Z + monthly + 0.8 + + + https://physicshub.github.io/simulations/KirchhoffCircuit + 2026-09-13T00:00:00.000Z weekly 0.7 diff --git a/public/thumbnails/kirchhoff-circuit.webp b/public/thumbnails/kirchhoff-circuit.webp new file mode 100644 index 0000000000000000000000000000000000000000..16317b4d56534831b39c3a38fe2028763d516965 GIT binary patch literal 39580 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 literal 0 HcmV?d00001 diff --git a/routes.js b/routes.js index ab18918d..d884feb5 100644 --- a/routes.js +++ b/routes.js @@ -99,90 +99,102 @@ export const routes = [ path: "/simulations/BouncingBall", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/VectorsOperations", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/BallAcceleration", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/BallGravity", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/SpringConnection", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/SimplePendulum", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/ParabolicMotion", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/InclinedPlane", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/CircularMotion", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/ThreeBody", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/HorizontalSpring", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/DoublePendulum", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/CollisionSimulation", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/PiCollisions", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", }, { path: "/simulations/TrigonometricCircle", changefreq: "weekly", priority: 0.7, - lastmod: "2026-09-04", + lastmod: "2026-09-13", + }, + { + path: "/blog/kirchhoffs-circuit-laws-explained", + changefreq: "monthly", + priority: 0.8, + lastmod: "2026-09-11", + }, + { + path: "/simulations/KirchhoffCircuit", + changefreq: "weekly", + priority: 0.7, + lastmod: "2026-09-13", }, ]; diff --git a/simulations/KirchhoffCircuit.jsx b/simulations/KirchhoffCircuit.jsx new file mode 100644 index 00000000..1e18da1e --- /dev/null +++ b/simulations/KirchhoffCircuit.jsx @@ -0,0 +1,1039 @@ +"use client"; + +/** + * Kirchhoff's circuit laws — a DC resistive network solved by nodal analysis. + * + * This simulation deliberately leaves the force/integrate pipeline, for the same + * kind of reason PiCollisions and DoublePendulum do: a resistive circuit has no + * dynamics to integrate. Its state is the solution of a *linear system* — one + * KCL equation per node — and that solution is exact, so stepping towards it + * would only add error. An element solves the system every frame instead, and + * the bodies in the world are the draggable meter probes, nothing else. + * + * Circuit model. A branch runs from one node to another and carries a series of + * parts, each with a resistance and (for a cell) an EMF. Positive EMF drives + * conventional current from `from` to `to` *inside* the branch, so + * + * I_branch = (V_from - V_to + E_branch) / R_branch + * + * Substituting that into "current leaving a node sums to zero" gives the usual + * conductance system G·v = i, assembled in `solveCircuit` below. + */ + +import { + createSimulation, + Dragging, + drawSegment, + drawCoil, + toScreen, +} from "../app/(core)/engine/index.js"; +import { toMeters } from "../app/(core)/constants/Utils.js"; +import { + CIRCUIT_PRESETS, + INITIAL_INPUTS, + INPUT_FIELDS, + SimInfoMapper, +} from "../app/(core)/data/configs/KirchhoffCircuit.js"; + +/** Smallest resistance a part may take. Keeps the matrix away from singular. */ +const MIN_R = 0.01; +/** Charge dots per branch, and how fast one metre of wire flows per amp. */ +const DOTS_PER_BRANCH = 7; +const FLOW_METRES_PER_AMP = 0.5; +const MAX_FLOW_SPEED = 2.5; +/** Power (W) at which a resistor's glow is roughly saturated. */ +const GLOW_REFERENCE_POWER = 2; +/** + * Width of the sim-info panel overlaying the canvas (15rem + its inset), and the + * viewport below which it stops sitting beside the circuit. Both mirror + * `.sim-info-panel` in styles/components/simulation.css — keep them in step. + */ +const INFO_PANEL_PX = 260; +const INFO_PANEL_DOCKS_BELOW_PX = 768; + +const COLORS = { + node: "#e2e8f0", + resistor: "#f59e0b", + battery: "#22d3ee", + charge: "#facc15", + arrow: "#38bdf8", + text: "#e2e8f0", + muted: "#94a3b8", + probePlus: "#ef4444", + probeMinus: "#e2e8f0", + ammeter: "#a855f7", +}; + +const num = (v, fallback = 0) => + Number.isFinite(Number(v)) ? Number(v) : fallback; +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + +// ----------------------------------------------------------------------------- +// The circuits. Positions are normalised to [0, 1]² and mapped into the canvas +// at build time, so every preset is resolution independent. +// ----------------------------------------------------------------------------- + +const cell = (label, emfKey, rKey) => [ + { + kind: "battery", + label, + emf: (i) => num(i[emfKey]), + R: (i) => num(i[rKey]), + }, +]; +const resistor = (label, key) => ({ + kind: "resistor", + label, + R: (i) => num(i[key]), +}); + +const PRESETS = { + single: { + nodes: [ + { id: "A", at: [0.5, 0.84] }, + { id: "B", at: [0.5, 0.16] }, + ], + ground: "B", + branches: [ + { + id: "b0", + label: "cell", + from: "B", + to: "A", + via: [ + [0.12, 0.16], + [0.12, 0.84], + ], + parts: cell("E₁", "emf1", "internalR1"), + }, + { + id: "b1", + label: "R₁", + from: "A", + to: "B", + via: [ + [0.88, 0.84], + [0.88, 0.16], + ], + parts: [resistor("R₁", "r1")], + }, + ], + loops: [ + { + label: "①", + walk: [ + ["b0", 1], + ["b1", 1], + ], + }, + ], + }, + + series: { + nodes: [ + { id: "A", at: [0.1, 0.82] }, + { id: "B", at: [0.37, 0.82] }, + { id: "C", at: [0.63, 0.82] }, + { id: "D", at: [0.9, 0.82] }, + ], + ground: "D", + branches: [ + { + id: "b0", + label: "cell", + from: "D", + to: "A", + via: [ + [0.9, 0.18], + [0.1, 0.18], + ], + parts: cell("E₁", "emf1", "internalR1"), + }, + { + id: "b1", + label: "R₁", + from: "A", + to: "B", + parts: [resistor("R₁", "r1")], + }, + { + id: "b2", + label: "R₂", + from: "B", + to: "C", + parts: [resistor("R₂", "r2")], + }, + { + id: "b3", + label: "R₃", + from: "C", + to: "D", + parts: [resistor("R₃", "r3")], + }, + ], + loops: [ + { + label: "①", + walk: [ + ["b0", 1], + ["b1", 1], + ["b2", 1], + ["b3", 1], + ], + }, + ], + }, + + parallel: { + nodes: [ + { id: "T", at: [0.5, 0.85] }, + { id: "B", at: [0.5, 0.15] }, + ], + ground: "B", + branches: [ + { + id: "b0", + label: "cell", + from: "B", + to: "T", + via: [ + [0.1, 0.15], + [0.1, 0.85], + ], + parts: cell("E₁", "emf1", "internalR1"), + }, + { + id: "b1", + label: "R₁", + from: "T", + to: "B", + via: [ + [0.4, 0.85], + [0.4, 0.15], + ], + parts: [resistor("R₁", "r1")], + }, + { + id: "b2", + label: "R₂", + from: "T", + to: "B", + via: [ + [0.63, 0.85], + [0.63, 0.15], + ], + parts: [resistor("R₂", "r2")], + }, + { + id: "b3", + label: "R₃", + from: "T", + to: "B", + via: [ + [0.86, 0.85], + [0.86, 0.15], + ], + parts: [resistor("R₃", "r3")], + }, + ], + loops: [ + { + label: "①", + walk: [ + ["b0", 1], + ["b1", 1], + ], + }, + { + label: "②", + walk: [ + ["b1", -1], + ["b2", 1], + ], + }, + { + label: "③", + walk: [ + ["b2", -1], + ["b3", 1], + ], + }, + ], + }, + + twoLoop: { + nodes: [ + { id: "A", at: [0.5, 0.86] }, + { id: "B", at: [0.5, 0.14] }, + ], + ground: "B", + branches: [ + { + id: "b1", + label: "E₁ branch", + from: "B", + to: "A", + via: [ + [0.1, 0.14], + [0.1, 0.86], + ], + parts: [...cell("E₁", "emf1", "internalR1"), resistor("R₁", "r1")], + }, + { + id: "b2", + label: "R₂ branch", + from: "A", + to: "B", + parts: [resistor("R₂", "r2")], + }, + { + id: "b3", + label: "E₂ branch", + from: "B", + to: "A", + via: [ + [0.9, 0.14], + [0.9, 0.86], + ], + parts: [...cell("E₂", "emf2", "internalR2"), resistor("R₃", "r3")], + }, + ], + loops: [ + { + label: "①", + walk: [ + ["b1", 1], + ["b2", 1], + ], + }, + { + label: "②", + walk: [ + ["b3", 1], + ["b2", 1], + ], + }, + ], + }, +}; + +const presetLabel = (id) => + CIRCUIT_PRESETS.find((option) => option.value === id)?.label ?? id; + +/** The preset the inputs ask for, falling back to the default circuit. */ +const presetFor = (inputs) => + PRESETS[inputs.preset] ? inputs.preset : INITIAL_INPUTS.preset; + +// ----------------------------------------------------------------------------- +// Geometry +// ----------------------------------------------------------------------------- + +/** + * Map a preset's normalised coordinates into metres inside the canvas. + * + * The left inset also has to clear the sim-info panel, which is absolutely + * positioned over the top-left of the canvas and matters more here than in most + * simulations: this readout is the KVL/KCL proof, so it is worth keeping open, + * and a circuit hidden behind it would be useless. On a narrow viewport the + * panel docks to the bottom edge instead, so there the circuit keeps the full + * width — which is where it is scarcest. + */ +function layout(preset, bounds, viewportWidth) { + const padRight = bounds.width * 0.08; + const padLeft = + viewportWidth >= INFO_PANEL_DOCKS_BELOW_PX + ? clamp(toMeters(INFO_PANEL_PX), bounds.width * 0.12, bounds.width * 0.38) + : padRight; + const padY = bounds.height * 0.16; + const w = Math.max(0.1, bounds.width - padLeft - padRight); + const h = Math.max(0.1, bounds.height - 2 * padY); + const place = ([x, y]) => ({ x: padLeft + x * w, y: padY + y * h }); + + const nodes = preset.nodes.map((node) => ({ ...node, pos: place(node.at) })); + const byId = new Map(nodes.map((node) => [node.id, node])); + + const branches = preset.branches.map((branch) => { + const points = [ + byId.get(branch.from).pos, + ...(branch.via ?? []).map(place), + byId.get(branch.to).pos, + ]; + const lengths = points + .slice(1) + .map((q, i) => Math.hypot(q.x - points[i].x, q.y - points[i].y)); + return { + ...branch, + points, + lengths, + total: lengths.reduce((sum, l) => sum + l, 0), + // Components belong on the branch's long straight run, never spread over + // its whole path — placing them by total arc length parks them on the + // corners. Every preset routes a branch as one run with a stub at each + // end (or a single straight segment), so the middle segment is it. + run: Math.floor(lengths.length / 2), + }; + }); + + return { nodes, byId, branches, ground: preset.ground, loops: preset.loops }; +} + +/** Point and unit tangent at fraction `t` of a branch's arc length. */ +function pointOn(branch, t) { + const { points, lengths, total } = branch; + let distance = clamp(t, 0, 1) * total; + for (let i = 0; i < lengths.length; i++) { + const length = lengths[i]; + if (distance <= length || i === lengths.length - 1) { + const f = length > 0 ? clamp(distance / length, 0, 1) : 0; + const a = points[i]; + const b = points[i + 1]; + return { + x: a.x + (b.x - a.x) * f, + y: a.y + (b.y - a.y) * f, + dx: length > 0 ? (b.x - a.x) / length : 1, + dy: length > 0 ? (b.y - a.y) / length : 0, + }; + } + distance -= length; + } + return { ...points[0], dx: 1, dy: 0 }; +} + +/** + * One line of canvas text in the simulation's font. Every label here — component + * values, node potentials, meter readings — goes through this, so they cannot + * drift apart typographically. + */ +function drawLabel(p, x, y, text, options = {}) { + p.push(); + p.noStroke(); + p.textFont("Poppins"); + p.textSize(options.size ?? (p.width < 640 ? 11 : 13)); + p.textAlign(options.alignX ?? p.CENTER, options.alignY ?? p.BOTTOM); + p.fill(options.color ?? COLORS.text); + p.text(text, x, y); + p.pop(); +} + +/** Point and unit tangent at fraction `f` of a single segment of a branch. */ +function pointOnRun(branch, f) { + const i = branch.run; + const a = branch.points[i]; + const b = branch.points[i + 1]; + const length = branch.lengths[i] || 1; + const t = clamp(f, 0, 1); + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + dx: (b.x - a.x) / length, + dy: (b.y - a.y) / length, + }; +} + +// ----------------------------------------------------------------------------- +// The solver +// ----------------------------------------------------------------------------- + +/** Gaussian elimination with partial pivoting. Returns null if singular. */ +function gaussian(A, b) { + const n = b.length; + if (n === 0) return []; + const M = A.map((row, i) => [...row, b[i]]); + + for (let col = 0; col < n; col++) { + let pivot = col; + for (let r = col + 1; r < n; r++) { + if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r; + } + if (Math.abs(M[pivot][col]) < 1e-12) return null; + [M[col], M[pivot]] = [M[pivot], M[col]]; + for (let r = col + 1; r < n; r++) { + const factor = M[r][col] / M[col][col]; + for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c]; + } + } + + const x = new Array(n).fill(0); + for (let r = n - 1; r >= 0; r--) { + let sum = M[r][n]; + for (let c = r + 1; c < n; c++) sum -= M[r][c] * x[c]; + x[r] = sum / M[r][r]; + } + return x.every(Number.isFinite) ? x : null; +} + +/** + * Solve the network for the current inputs. + * + * KCL at a non-ground node n, summing the current *leaving* n: + * leaving through a branch = s·(V_from − V_to + E)/R, s = +1 if n is `from` + * and since V_from − V_to = s·(Vₙ − V_other) with s² = 1, that is + * (Vₙ − V_other)/R + s·E/R. + * Setting the sum to zero gives the row + * Σ(1/R)·Vₙ − Σ(1/R)·V_other = −Σ s·E/R. + */ +function solveCircuit(circuit, inputs) { + const unknowns = new Map(); + for (const node of circuit.nodes) { + if (node.id !== circuit.ground) unknowns.set(node.id, unknowns.size); + } + const n = unknowns.size; + + const branches = circuit.branches.map((branch) => { + const parts = branch.parts.map((part) => ({ + kind: part.kind, + label: part.label, + R: Math.max(MIN_R, num(part.R?.(inputs))), + emf: num(part.emf?.(inputs)), + })); + return { + ref: branch, + parts, + R: parts.reduce((sum, part) => sum + part.R, 0), + emf: parts.reduce((sum, part) => sum + part.emf, 0), + I: 0, + }; + }); + + const G = Array.from({ length: n }, () => new Array(n).fill(0)); + const rhs = new Array(n).fill(0); + + for (const branch of branches) { + const g = 1 / branch.R; + for (const [id, s] of [ + [branch.ref.from, 1], + [branch.ref.to, -1], + ]) { + const row = unknowns.get(id); + if (row === undefined) continue; // the ground row is dropped + G[row][row] += g; + const otherId = s === 1 ? branch.ref.to : branch.ref.from; + const col = unknowns.get(otherId); + if (col !== undefined) G[row][col] -= g; + rhs[row] -= s * branch.emf * g; + } + } + + const x = gaussian(G, rhs); + if (!x) return { ok: false, branches, potentials: new Map() }; + + const potentials = new Map([[circuit.ground, 0]]); + for (const [id, row] of unknowns) potentials.set(id, x[row]); + + for (const branch of branches) { + branch.I = + (potentials.get(branch.ref.from) - + potentials.get(branch.ref.to) + + branch.emf) / + branch.R; + } + + return { ok: true, branches, potentials }; +} + +/** The KVL walk for each declared loop: every rise and drop, in order. */ +function loopTerms(circuit, solution) { + const byId = new Map(solution.branches.map((b) => [b.ref.id, b])); + return circuit.loops.map((loop) => { + const terms = []; + for (const [id, s] of loop.walk) { + const branch = byId.get(id); + if (!branch) continue; + // Traversing from → to, the potential change is E − I·R. Walking the + // branch backwards negates it, and visits its parts in reverse order. + const parts = s > 0 ? branch.parts : [...branch.parts].reverse(); + for (const part of parts) { + // A cell always contributes its EMF term, even at 0 V: the walk has to + // account for every component you pass, or the readout stops matching + // the circuit you can see. + if (part.kind === "battery") terms.push(s * part.emf); + terms.push(-s * branch.I * part.R); + } + } + return { + label: loop.label, + terms, + sum: terms.reduce((sum, t) => sum + t, 0), + }; + }); +} + +/** KCL at every node: current in against current out. */ +function nodeBalance(circuit, solution) { + return circuit.nodes.map((node) => { + let inSum = 0; + let outSum = 0; + for (const branch of solution.branches) { + const s = + branch.ref.from === node.id ? 1 : branch.ref.to === node.id ? -1 : 0; + if (s === 0) continue; + const leaving = s * branch.I; + if (leaving >= 0) outSum += leaving; + else inSum -= leaving; + } + return { label: node.id, inSum, outSum, residual: outSum - inSum }; + }); +} + +// ----------------------------------------------------------------------------- +// Elements +// ----------------------------------------------------------------------------- + +/** + * Advances each branch's charge animation, using the solution the `update` hook + * has already put on `refs.solution`. This is the one part of the simulation that + * belongs on the clock, which is why it is the only part in a step hook. + */ +function chargeClock(circuit, refs) { + return { + attach() { + refs.phases = Object.fromEntries(circuit.branches.map((b) => [b.id, 0])); + }, + + beforeStep(ctx) { + const solution = refs.solution; + if (!solution?.ok) return; + // Dots move along a branch at a speed proportional to its current, so + // direction and magnitude are both visible; the cap keeps a near-short + // circuit from strobing. + for (const branch of solution.branches) { + const speed = clamp( + branch.I * FLOW_METRES_PER_AMP, + -MAX_FLOW_SPEED, + MAX_FLOW_SPEED + ); + const total = branch.ref.total || 1; + const phase = refs.phases[branch.ref.id] + (speed * ctx.dt) / total; + refs.phases[branch.ref.id] = phase - Math.floor(phase); + } + }, + }; +} + +/** Wires, resistors, cells and node dots. */ +function circuitRenderer(circuit, refs) { + const drawResistor = (p, point, glow) => { + const half = 0.22; + const a = { x: point.x - point.dx * half, y: point.y - point.dy * half }; + const b = { x: point.x + point.dx * half, y: point.y + point.dy * half }; + p.drawingContext.shadowBlur = glow * 26; + p.drawingContext.shadowColor = COLORS.resistor; + drawCoil(p, a, b, { + coils: 10, + width: 7, + color: COLORS.resistor, + weight: 3, + }); + p.drawingContext.shadowBlur = 0; + }; + + // The long plate is the + terminal, and it faces `to` — which is the end the + // EMF drives conventional current towards inside the branch. + const drawBattery = (p, point) => { + const nx = -point.dy; + const ny = point.dx; + const plate = (offset, halfHeight, weight) => { + const cx = point.x + point.dx * offset; + const cy = point.y + point.dy * offset; + drawSegment( + p, + { x: cx - nx * halfHeight, y: cy - ny * halfHeight }, + { x: cx + nx * halfHeight, y: cy + ny * halfHeight }, + { color: COLORS.battery, weight } + ); + }; + plate(0.05, 0.15, 3); // long plate, towards `to` → positive terminal + plate(-0.05, 0.08, 6); // short thick plate → negative terminal + }; + + return { + zIndex: -20, + + render(ctx) { + const { p } = ctx; + const solution = refs.solution; + const wire = ctx.inputs.wireColor || "#94a3b8"; + const glowOn = ctx.inputs.showGlow !== false; + const small = p.width < 640; + + for (const branch of circuit.branches) { + for (let i = 0; i < branch.points.length - 1; i++) { + drawSegment(p, branch.points[i], branch.points[i + 1], { + color: wire, + weight: 3, + }); + } + } + + for (const branch of circuit.branches) { + const solved = solution?.branches.find((b) => b.ref.id === branch.id); + const parts = solved ? solved.parts : branch.parts.map(() => null); + const count = branch.parts.length; + + branch.parts.forEach((part, index) => { + const point = pointOnRun(branch, (index + 1) / (count + 1)); + const resolved = parts[index]; + + if (part.kind === "battery") { + drawBattery(p, point); + } else { + // Brightness saturates with dissipated power, so a resistor carrying + // more current really does look hotter — P = I²R. + const power = resolved ? solved.I * solved.I * resolved.R : 0; + const glow = + glowOn && solution?.ok + ? 1 - Math.exp(-power / GLOW_REFERENCE_POWER) + : 0; + drawResistor(p, point, glow); + } + + // Label beside a vertical component, above a horizontal one, so it + // never lands on top of the symbol it names. + const screen = toScreen(point); + const vertical = Math.abs(point.dy) > Math.abs(point.dx); + const text = + part.kind === "battery" && resolved + ? `${part.label} ${resolved.emf.toFixed(1)} V` + : `${part.label}${resolved ? ` ${resolved.R.toFixed(2)} Ω` : ""}`; + + if (vertical) { + drawLabel(p, screen.x + 22, screen.y, text, { + alignX: p.LEFT, + alignY: p.CENTER, + }); + } else { + drawLabel(p, screen.x, screen.y - 20, text); + } + }); + } + + for (const node of circuit.nodes) { + const screen = toScreen(node.pos); + const potential = solution?.potentials.get(node.id); + p.push(); + p.noStroke(); + p.fill(COLORS.node); + p.circle(screen.x, screen.y, 12); + p.pop(); + + const label = + node.id === circuit.ground + ? `${node.id} (0 V)` + : `${node.id}${ + Number.isFinite(potential) ? ` ${potential.toFixed(2)} V` : "" + }`; + drawLabel(p, screen.x, screen.y - 12, label, { size: small ? 12 : 14 }); + } + }, + }; +} + +/** Arrows showing the direction conventional current actually flows. */ +function currentArrows(circuit, refs) { + return { + zIndex: 6, + render(ctx) { + if (ctx.inputs.showCurrentArrows === false) return; + const solution = refs.solution; + if (!solution?.ok) return; + const { p } = ctx; + + for (const solved of solution.branches) { + if (Math.abs(solved.I) < 1e-9) continue; + const point = pointOnRun(solved.ref, 0.12); + const sign = Math.sign(solved.I); + const screen = toScreen(point); + // Screen Y grows downward, so the tangent's y component flips. + const angle = Math.atan2(-point.dy * sign, point.dx * sign); + + p.push(); + p.translate(screen.x, screen.y); + p.rotate(angle); + p.noStroke(); + p.fill(COLORS.arrow); + p.triangle(-7, -6, -7, 6, 10, 0); + p.pop(); + } + }, + }; +} + +/** Charge carriers drifting along each branch at a speed set by its current. */ +function chargeFlow(circuit, refs) { + return { + zIndex: 5, + render(ctx) { + if (ctx.inputs.showChargeFlow === false) return; + const solution = refs.solution; + if (!solution?.ok) return; + const { p } = ctx; + + p.push(); + p.noStroke(); + for (const solved of solution.branches) { + if (Math.abs(solved.I) < 1e-6) continue; + const phase = refs.phases?.[solved.ref.id] ?? 0; + const alpha = clamp(60 + Math.abs(solved.I) * 90, 60, 255); + p.fill(COLORS.charge + Math.round(alpha).toString(16).padStart(2, "0")); + for (let k = 0; k < DOTS_PER_BRANCH; k++) { + let t = phase + k / DOTS_PER_BRANCH; + t -= Math.floor(t); + const point = pointOn(solved.ref, t); + const screen = toScreen(point); + p.circle(screen.x, screen.y, 7); + } + } + p.pop(); + }, + }; +} + +/** + * The voltmeter leads and the ammeter clamp. + * + * The probe bodies are invisible and kinematic — they never integrate, they only + * follow the pointer through `Dragging` — and this element snaps them onto the + * circuit and draws them itself, so the meters look like meters rather than like + * the engine's default circles. + */ +function probes(circuit, refs, bodies) { + const nearestNode = (position) => + circuit.nodes.reduce((best, node) => { + const d = Math.hypot(node.pos.x - position.x, node.pos.y - position.y); + return !best || d < best.d ? { node, d } : best; + }, null).node; + + /** Nearest point on any branch, sampled along its arc length. */ + const nearestOnBranch = (position) => { + let best = null; + for (const branch of circuit.branches) { + for (let k = 1; k < 24; k++) { + const t = k / 24; + const point = pointOn(branch, t); + const d = Math.hypot(point.x - position.x, point.y - position.y); + if (!best || d < best.d) best = { branch, t, point, d }; + } + } + return best; + }; + + /** A meter reading in a dark pill, so it stays legible over a wire. */ + const readingBox = (p, screen, text, color) => { + p.push(); + p.textFont("Poppins"); + p.textSize(p.width < 640 ? 11 : 13); + p.rectMode(p.CENTER); + p.noStroke(); + p.fill(15, 23, 42, 225); + p.rect(screen.x, screen.y - 26, p.textWidth(text) + 16, 22, 6); + p.pop(); + drawLabel(p, screen.x, screen.y - 26, text, { + color, + alignY: p.CENTER, + }); + }; + + return { + zIndex: 20, + + render(ctx) { + if (ctx.inputs.showProbes === false) { + refs.voltmeter = null; + refs.ammeter = null; + return; + } + const { p } = ctx; + const solution = refs.solution; + + // --- Voltmeter: two leads, each snapped to the node nearest the pointer. + const plusNode = nearestNode(bodies.probePlus.state.position); + const minusNode = nearestNode(bodies.probeMinus.state.position); + if (!bodies.probePlus.isDragged) { + bodies.probePlus.state.position.set(plusNode.pos.x, plusNode.pos.y); + } + if (!bodies.probeMinus.isDragged) { + bodies.probeMinus.state.position.set(minusNode.pos.x, minusNode.pos.y); + } + + drawSegment(p, plusNode.pos, minusNode.pos, { + color: COLORS.muted, + weight: 2, + dashed: true, + }); + + const plusScreen = toScreen(plusNode.pos); + const minusScreen = toScreen(minusNode.pos); + p.push(); + p.noStroke(); + p.fill(COLORS.probePlus); + p.circle(plusScreen.x, plusScreen.y, 16); + p.fill(COLORS.probeMinus); + p.circle(minusScreen.x, minusScreen.y, 16); + p.pop(); + + const reading = + solution?.ok && plusNode.id !== minusNode.id + ? solution.potentials.get(plusNode.id) - + solution.potentials.get(minusNode.id) + : 0; + refs.voltmeter = { + from: plusNode.id, + to: minusNode.id, + reading, + }; + readingBox( + p, + { + x: (plusScreen.x + minusScreen.x) / 2, + y: (plusScreen.y + minusScreen.y) / 2, + }, + `V ${reading.toFixed(2)} V`, + COLORS.probePlus + ); + + // --- Ammeter: clamps onto whichever branch is nearest the pointer. + const hit = nearestOnBranch(bodies.ammeter.state.position); + if (hit) { + if (!bodies.ammeter.isDragged) { + bodies.ammeter.state.position.set(hit.point.x, hit.point.y); + } + const solved = solution?.branches.find( + (b) => b.ref.id === hit.branch.id + ); + const current = solution?.ok && solved ? solved.I : 0; + refs.ammeter = { label: hit.branch.label, reading: current }; + + const screen = toScreen(hit.point); + p.push(); + p.noFill(); + p.stroke(COLORS.ammeter); + p.strokeWeight(3); + p.circle(screen.x, screen.y, 26); + p.pop(); + drawLabel(p, screen.x, screen.y + 1, "A", { + color: COLORS.ammeter, + alignY: p.CENTER, + size: 13, + }); + readingBox(p, screen, `${current.toFixed(3)} A`, COLORS.ammeter); + } + }, + }; +} + +// ----------------------------------------------------------------------------- + +export default createSimulation({ + config: { INITIAL_INPUTS, INPUT_FIELDS, SimInfoMapper }, + + build({ p, world, inputs, bounds, refs }) { + const presetId = presetFor(inputs); + const circuit = layout( + PRESETS[presetId], + { width: bounds.width, height: bounds.height }, + p.windowWidth + ); + + refs.preset = presetId; + refs.circuit = circuit; + refs.solution = null; + + // Probes are the only bodies here: kinematic so the World never integrates + // them, invisible because this simulation draws them as meters. + const probeBody = (label, color, at) => + world.addBody({ + label, + color, + at, + size: 0.26, + kinematic: true, + visible: false, + }); + + const first = circuit.nodes[0].pos; + const last = circuit.nodes[circuit.nodes.length - 1].pos; + const clampStart = pointOnRun(circuit.branches[0], 0.85); + const bodies = { + probePlus: probeBody("V+", COLORS.probePlus, [first.x, first.y]), + probeMinus: probeBody("V−", COLORS.probeMinus, [last.x, last.y]), + ammeter: probeBody("A", COLORS.ammeter, [clampStart.x, clampStart.y]), + }; + + world.add( + chargeClock(circuit, refs), + circuitRenderer(circuit, refs), + chargeFlow(circuit, refs), + currentArrows(circuit, refs), + probes(circuit, refs, bodies), + Dragging({ + bodies: Object.values(bodies), + enabled: () => inputs.showProbes !== false, + }) + ); + + return { circuit, ...bodies }; + }, + + // Switching preset changes *what exists*, so the world is rebuilt rather than + // re-parameterised. Every other input is read live by the solve below. + // + // The solve lives here rather than in a step hook because `update` runs every + // frame whether or not the clock is running, and a learner will drag a slider + // while paused. It is also the only ordering that guarantees every renderer + // and the readout see the same solution: `update` runs before the steps, which + // run before `render`. + update({ inputs, refs, rebuild }) { + const wanted = presetFor(inputs); + if (refs.preset !== wanted) rebuild(); + refs.solution = solveCircuit(refs.circuit, inputs); + }, + + info({ inputs, refs }) { + const circuit = refs.circuit; + const solution = refs.solution; + if (!circuit || !solution) return null; + + const label = presetLabel(refs.preset ?? inputs.preset); + if (!solution.ok) return { state: { ok: false, presetLabel: label } }; + + const dissipation = []; + let powerDelivered = 0; + let powerDissipated = 0; + for (const branch of solution.branches) { + for (const part of branch.parts) { + const heat = branch.I * branch.I * part.R; + powerDissipated += heat; + powerDelivered += part.emf * branch.I; + if (part.kind === "resistor") { + dissipation.push({ label: part.label, P: heat }); + } + } + } + + return { + state: { + ok: true, + presetLabel: label, + nodes: circuit.nodes.map((node) => ({ + label: node.id, + V: solution.potentials.get(node.id) ?? 0, + isGround: node.id === circuit.ground, + })), + branches: solution.branches.map((branch) => ({ + label: branch.ref.label, + from: branch.ref.from, + to: branch.ref.to, + I: branch.I, + })), + kcl: nodeBalance(circuit, solution), + loops: loopTerms(circuit, solution), + dissipation, + powerDelivered, + powerDissipated, + voltmeter: refs.voltmeter ?? null, + ammeter: refs.ammeter ?? null, + }, + context: {}, + }; + }, +});