Files
Learn_System/frontend/js/labs/circuit.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

1309 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CircuitSim — Enhanced Electric Circuits Simulation v2
* MNA solver · L-shape wires · Drag · Undo/Redo · Tooltip
* New: Capacitor · Diode · LED · AC source · Junction dots
* Keyboard: W R B S L C D A V E · Del · Ctrl+Z/Y
*/
'use strict';
function distToSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (len2 < 1) return Math.hypot(px - x1, py - y1);
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function _compLen(c) {
return Math.max(1, Math.abs(c.x2 - c.x1) + Math.abs(c.y2 - c.y1));
}
class CircuitSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// State
this.components = [];
this.addMode = 'wire';
this.R_value = 10;
this.U_value = 9;
this.C_value = 100; // µF (display label)
this.acFreq = 2; // Hz for AC source
this.ledColor = '#7BF5A4';
// Interaction
this._drawing = null; // {x1, y1} while dragging new component
this._ghostEnd = null; // {x2, y2} cursor snap
this._hovered = null; // id of hovered component
this._selected = null; // id of selected component
this._dragIdx = null; // index of component being dragged
this._dragStart = null; // {gx, gy} grid anchor at drag start
this._dragOrigPos = null; // original {x1,y1,x2,y2} before drag
this._didDrag = false; // whether mouse actually moved during drag
// Solver
this._nextId = 0;
this._solution = null;
this._diodeR = new Map(); // component id <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> effective R
this._simTime = 0;
this._hasAC = false;
// Animation
this._raf = null;
this._lastTs = null;
// Undo stack
this._history = [];
this._historyIdx = -1;
// Grid
this.GW = 22;
this.GH = 14;
this.CELL = 40;
this.ox = 0;
this.oy = 0;
this.W = 0;
this.H = 0;
this.onUpdate = null;
this.onModeChange = null; // called when keyboard changes addMode
this._keyHandler = null; // stored for destroy()
this.fit();
this._bindEvents();
}
/* ─── Geometry ─────────────────────────────────────────────────────────── */
_nodePixel(gx, gy) {
return { x: this.ox + gx * this.CELL, y: this.oy + gy * this.CELL };
}
_snapGrid(px, py) {
return {
gx: Math.max(0, Math.min(this.GW, Math.round((px - this.ox) / this.CELL))),
gy: Math.max(0, Math.min(this.GH, Math.round((py - this.oy) / this.CELL)))
};
}
/* ─── Undo / Redo ──────────────────────────────────────────────────────── */
_pushHistory() {
this._history.splice(this._historyIdx + 1);
this._history.push(JSON.stringify(this.components.map(c => ({
id: c.id, type: c.type,
x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2,
value: c.value, open: c.open,
ledColor: c.ledColor, acFreq: c.acFreq
}))));
if (this._history.length > 20) this._history.shift();
this._historyIdx = this._history.length - 1;
}
undo() {
if (this._historyIdx <= 0) return;
this._historyIdx--;
this._applyHistory();
}
redo() {
if (this._historyIdx >= this._history.length - 1) return;
this._historyIdx++;
this._applyHistory();
}
_applyHistory() {
const snap = JSON.parse(this._history[this._historyIdx]);
this._diodeR.clear();
this.components = snap.map(s => {
if (s.type === 'diode' || s.type === 'led') this._diodeR.set(s.id, 1e9);
return { ...s, _I: 0, _v1: 0, _v2: 0, _t: Math.random() };
});
this._nextId = Math.max(0, ...this.components.map(c => c.id + 1));
this._selected = null;
this._solve();
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ─── Component resistance ─────────────────────────────────────────────── */
_compR(c) {
switch (c.type) {
case 'wire': return 0.001;
case 'ammeter': return 0.001;
case 'voltmeter': return 1e7;
case 'resistor': return Math.max(0.001, c.value || 10);
case 'lamp': return 20;
case 'capacitor': return 1e7; // open circuit in DC
case 'switch': return c.open ? 1e9 : 0.001;
case 'diode':
case 'led': return this._diodeR.get(c.id) ?? 1e9;
default: return 1;
}
}
/* ─── MNA Solver ───────────────────────────────────────────────────────── */
_buildNodes() {
const allKeys = new Set();
for (const c of this.components) {
allKeys.add(`${c.x1},${c.y1}`);
allKeys.add(`${c.x2},${c.y2}`);
}
const parent = new Map();
for (const k of allKeys) parent.set(k, k);
const find = k => {
while (parent.get(k) !== k) {
parent.set(k, parent.get(parent.get(k)));
k = parent.get(k);
}
return k;
};
const union = (a, b) => parent.set(find(a), find(b));
for (const c of this.components) {
if (c.type === 'wire' || (c.type === 'switch' && !c.open)) {
union(`${c.x1},${c.y1}`, `${c.x2},${c.y2}`);
}
}
const roots = new Set();
for (const k of allKeys) roots.add(find(k));
const nodeId = new Map();
let n = 0;
for (const r of roots) nodeId.set(r, n++);
return { nNodes: n, nodeOf: key => nodeId.get(find(key)) };
}
_buildMatrix(nodeOf, nNodes) {
const vsrcs = this.components.filter(c => c.type === 'battery' || c.type === 'ac');
const nb = vsrcs.length;
if (nb === 0) return null;
const size = nNodes - 1 + nb;
if (size <= 0) return null;
const G = Array.from({ length: size }, () => new Float64Array(size));
const I = new Float64Array(size);
const stamp = (r, c, v) => {
if (r >= 0 && c >= 0 && r < size && c < size) G[r][c] += v;
};
for (const comp of this.components) {
if (comp.type === 'battery' || comp.type === 'ac') continue;
const n1 = nodeOf(`${comp.x1},${comp.y1}`);
const n2 = nodeOf(`${comp.x2},${comp.y2}`);
const R = this._compR(comp);
if (R >= 1e7) continue;
const g = 1 / R;
stamp(n1 - 1, n1 - 1, g);
stamp(n2 - 1, n2 - 1, g);
stamp(n1 - 1, n2 - 1, -g);
stamp(n2 - 1, n1 - 1, -g);
}
for (let b = 0; b < nb; b++) {
const vs = vsrcs[b];
const n1 = nodeOf(`${vs.x1},${vs.y1}`); // negative
const n2 = nodeOf(`${vs.x2},${vs.y2}`); // positive
const row = nNodes - 1 + b;
const U = vs.type === 'ac'
? (vs.value || 9) * Math.sin(2 * Math.PI * (vs.acFreq || this.acFreq) * this._simTime)
: (vs.value || 9);
if (n2 > 0) { stamp(row, n2 - 1, 1); stamp(n2 - 1, row, 1); }
if (n1 > 0) { stamp(row, n1 - 1, -1); stamp(n1 - 1, row, -1); }
I[row] = U;
}
return { G, I, nNodes, nodeOf, vsrcs, nb, size };
}
_gaussElim(G, I) {
const n = I.length;
for (let col = 0; col < n; col++) {
let maxRow = col;
for (let r = col + 1; r < n; r++) {
if (Math.abs(G[r][col]) > Math.abs(G[maxRow][col])) maxRow = r;
}
[G[col], G[maxRow]] = [G[maxRow], G[col]];
[I[col], I[maxRow]] = [I[maxRow], I[col]];
if (Math.abs(G[col][col]) < 1e-12) continue;
for (let r = col + 1; r < n; r++) {
const f = G[r][col] / G[col][col];
for (let c = col; c < n; c++) G[r][c] -= f * G[col][c];
I[r] -= f * I[col];
}
}
const x = new Float64Array(n);
for (let r = n - 1; r >= 0; r--) {
if (Math.abs(G[r][r]) < 1e-12) continue;
x[r] = I[r];
for (let c = r + 1; c < n; c++) x[r] -= G[r][c] * x[c];
x[r] /= G[r][r];
}
return x;
}
_solveOnce() {
if (this.components.length === 0) { this._solution = null; return; }
const { nNodes, nodeOf } = this._buildNodes();
if (nNodes < 2) { this._solution = null; return; }
const mat = this._buildMatrix(nodeOf, nNodes);
if (!mat) { this._solution = null; return; }
const { G, I, vsrcs, nb } = mat;
let x;
try { x = this._gaussElim(G, I); } catch { this._solution = null; return; }
const voltages = new Map();
voltages.set(0, 0);
for (let i = 1; i < nNodes; i++) voltages.set(i, x[i - 1] || 0);
for (const c of this.components) {
const n1 = nodeOf(`${c.x1},${c.y1}`);
const n2 = nodeOf(`${c.x2},${c.y2}`);
c._v1 = voltages.get(n1) ?? 0;
c._v2 = voltages.get(n2) ?? 0;
if (c.type === 'battery' || c.type === 'ac') {
const bi = vsrcs.indexOf(c);
c._I = bi >= 0 ? (x[nNodes - 1 + bi] || 0) : 0;
} else {
const R = this._compR(c);
c._I = R < 1e7 ? (c._v1 - c._v2) / R : 0;
}
}
this._solution = { solved: true, voltages };
}
_solve() {
// Init diode R
for (const c of this.components) {
if ((c.type === 'diode' || c.type === 'led') && !this._diodeR.has(c.id)) {
this._diodeR.set(c.id, 1e9);
}
}
// Iterative diode solve (Newton-style)
for (let iter = 0; iter < 6; iter++) {
this._solveOnce();
if (!this._solution?.solved) break;
let changed = false;
for (const c of this.components) {
if (c.type !== 'diode' && c.type !== 'led') continue;
const vd = (c._v1 ?? 0) - (c._v2 ?? 0);
const oldR = this._diodeR.get(c.id) ?? 1e9;
const newR = vd > 0.5 ? 0.5 : 1e9;
if (newR !== oldR) { this._diodeR.set(c.id, newR); changed = true; }
}
if (!changed) break;
}
this._hasAC = this.components.some(c => c.type === 'ac');
if (this.onUpdate) this.onUpdate(this.info());
}
/* ─── Animation ────────────────────────────────────────────────────────── */
_tick(ts) {
if (!this._raf) return;
const dt = Math.min((ts - (this._lastTs || ts)) / 1000, 0.05);
this._lastTs = ts;
this._simTime += dt;
if (this._hasAC) this._solveOnce(); // re-solve each frame for AC
for (const c of this.components) {
if (!c._I || Math.abs(c._I) < 0.001) continue;
const speed = Math.min(Math.abs(c._I) * 0.6, 8) / (this.CELL * _compLen(c));
c._t = ((c._t || 0) + speed * dt * 60) % 1;
}
this.draw();
this._raf = requestAnimationFrame(ts => this._tick(ts));
}
start() {
if (this._raf) return;
this._lastTs = null;
this._raf = requestAnimationFrame(ts => this._tick(ts));
}
stop() {
cancelAnimationFrame(this._raf);
this._raf = null;
}
destroy() {
this.stop();
if (this._keyHandler) {
document.removeEventListener('keydown', this._keyHandler);
this._keyHandler = null;
}
}
/* ─── Color helpers ────────────────────────────────────────────────────── */
_voltColor(v, alpha = 1) {
if (!this._solution?.solved) return `rgba(180,180,200,${alpha})`;
const t = Math.tanh(v / 6);
if (t >= 0) {
return `rgba(${Math.round(239+t*16)},${Math.round(71-t*30)},${Math.round(111-t*80)},${alpha})`;
} else {
const s = -t;
return `rgba(${Math.round(76-s*40)},${Math.round(201+s*20)},${Math.round(240-s*20)},${alpha})`;
}
}
/* ─── Grid ─────────────────────────────────────────────────────────────── */
_drawGrid(ctx) {
for (let gx = 0; gx <= this.GW; gx++) {
for (let gy = 0; gy <= this.GH; gy++) {
const { x, y } = this._nodePixel(gx, gy);
ctx.beginPath();
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fill();
}
}
if (this._ghostEnd) {
const { x, y } = this._nodePixel(this._ghostEnd.x2, this._ghostEnd.y2);
ctx.beginPath();
ctx.arc(x, y, 4.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.shadowBlur = 8; ctx.shadowColor = '#fff';
ctx.fill(); ctx.shadowBlur = 0;
}
}
/* ─── Junction dots ────────────────────────────────────────────────────── */
_drawJunctions(ctx) {
const count = new Map();
for (const c of this.components) {
const k1 = `${c.x1},${c.y1}`, k2 = `${c.x2},${c.y2}`;
count.set(k1, (count.get(k1) || 0) + 1);
count.set(k2, (count.get(k2) || 0) + 1);
}
ctx.shadowBlur = 6;
ctx.shadowColor = '#fff';
for (const [key, cnt] of count) {
if (cnt < 3) continue;
const [gx, gy] = key.split(',').map(Number);
const { x, y } = this._nodePixel(gx, gy);
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
}
ctx.shadowBlur = 0;
}
/* ─── Node voltage labels ──────────────────────────────────────────────── */
_drawNodeLabels(ctx) {
if (!this._solution?.solved) return;
const shown = new Set();
ctx.font = 'bold 8px Manrope, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
for (const c of this.components) {
for (const [gx, gy, v] of [[c.x1, c.y1, c._v1], [c.x2, c.y2, c._v2]]) {
const key = `${gx},${gy}`;
if (shown.has(key) || Math.abs(v) < 0.05) continue;
shown.add(key);
const { x, y } = this._nodePixel(gx, gy);
ctx.fillStyle = v >= 0 ? 'rgba(239,140,140,0.75)' : 'rgba(100,190,240,0.75)';
ctx.fillText(v.toFixed(1) + 'V', x, y - 7);
}
}
ctx.textBaseline = 'alphabetic';
}
/* ─── Wire line ─────────────────────────────────────────────────────────── */
_drawWireLine(ctx, p1, p2, v1, v2, lineWidth, glow) {
if (Math.hypot(p2.x - p1.x, p2.y - p1.y) < 0.5) return;
ctx.save();
ctx.lineWidth = lineWidth || 3;
ctx.lineCap = 'round';
if (glow) { ctx.shadowBlur = 10; ctx.shadowColor = this._voltColor((v1 + v2) / 2, 1); }
try {
const grad = ctx.createLinearGradient(p1.x, p1.y, p2.x, p2.y);
grad.addColorStop(0, this._voltColor(v1, 1));
grad.addColorStop(1, this._voltColor(v2, 1));
ctx.strokeStyle = grad;
} catch { ctx.strokeStyle = this._voltColor((v1 + v2) / 2, 1); }
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
/* ─── Component draw methods ───────────────────────────────────────────── */
_drawWire(ctx, c, p1, p2, hasI) {
this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI);
}
_drawResistor(ctx, c, p1, p2, mx, my, hasI) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
const ux = dx / len, uy = dy / len;
const half = len * 0.2;
const sP1 = { x: mx - ux*half, y: my - uy*half };
const sP2 = { x: mx + ux*half, y: my + uy*half };
this._drawWireLine(ctx, p1, sP1, c._v1, c._v1, 3, hasI);
this._drawWireLine(ctx, sP2, p2, c._v2, c._v2, 3, hasI);
// Power heat color
const power = Math.abs((c._I ?? 0) ** 2 * this._compR(c));
const heat = Math.min(1, power / 5);
ctx.save();
ctx.translate(mx, my);
ctx.rotate(Math.atan2(dy, dx));
const rw = half * 2, rh = 12;
ctx.beginPath();
ctx.roundRect(-rw/2, -rh/2, rw, rh, 2);
ctx.fillStyle = `rgb(${Math.round(13 + heat*200)},${Math.round(13 + heat*60)},13)`;
ctx.fill();
ctx.strokeStyle = this._voltColor((c._v1+c._v2)/2, 0.9);
ctx.lineWidth = 1.5; ctx.stroke();
// Zigzag
ctx.beginPath();
const zs = 6, zStep = rw / zs;
ctx.moveTo(-rw/2, 0);
for (let i = 0; i < zs; i++) ctx.lineTo(-rw/2+(i+0.5)*zStep, i%2===0?-4:4);
ctx.lineTo(rw/2, 0);
ctx.strokeStyle = this._voltColor((c._v1+c._v2)/2, 0.7);
ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
ctx.font = '9px Manrope,sans-serif';
ctx.fillStyle = heat > 0.3 ? `rgba(255,${Math.round(200-heat*150)},80,0.85)` : 'rgba(255,255,255,0.55)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(`R=${c.value}\u202fΩ`, mx - uy*14, my + ux*14 - 4);
ctx.textBaseline = 'alphabetic';
}
_drawBattery(ctx, c, p1, p2, mx, my, hasI) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
const ux = len>0?dx/len:1, uy = len>0?dy/len:0;
const half = Math.min(this.CELL*0.45, len*0.38);
const sP1 = {x:mx-ux*half, y:my-uy*half};
const sP2 = {x:mx+ux*half, y:my+uy*half};
this._drawWireLine(ctx, p1, sP1, c._v1, c._v1, 3, hasI);
this._drawWireLine(ctx, sP2, p2, c._v2, c._v2, 3, hasI);
ctx.save();
ctx.translate(mx, my);
ctx.rotate(Math.atan2(dy, dx));
// Two cell pairs
for (let i = 0; i < 2; i++) {
const ox = (i - 0.5) * 12;
ctx.beginPath(); ctx.moveTo(ox+5,-11); ctx.lineTo(ox+5,11);
ctx.strokeStyle='#EF476F'; ctx.lineWidth=1.5; ctx.lineCap='round'; ctx.stroke();
ctx.beginPath(); ctx.moveTo(ox-5,-7); ctx.lineTo(ox-5,7);
ctx.strokeStyle='#4CC9F0'; ctx.lineWidth=4; ctx.stroke();
}
ctx.restore();
ctx.font = 'bold 10px Manrope,sans-serif'; ctx.textAlign='center';
ctx.fillStyle='#EF476F';
ctx.fillText('+', p2.x + uy*14 - ux*8, p2.y - ux*14 - uy*8);
ctx.fillStyle='#4CC9F0';
ctx.fillText('\u2212', p1.x + uy*14 + ux*8, p1.y - ux*14 + uy*8);
ctx.font = '9px Manrope,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.textBaseline = 'bottom';
ctx.fillText(`${c.value}\u202fV`, mx - uy*18, my + ux*18);
ctx.textBaseline = 'alphabetic';
}
_drawAC(ctx, c, p1, p2, mx, my, hasI) {
const dx = p2.x-p1.x, dy = p2.y-p1.y;
const len = Math.hypot(dx,dy);
const ux = dx/len, uy = dy/len;
const r = 15;
this._drawWireLine(ctx, p1, {x:mx-ux*r,y:my-uy*r}, c._v1,c._v1,3,hasI);
this._drawWireLine(ctx, {x:mx+ux*r,y:my+uy*r}, p2, c._v2,c._v2,3,hasI);
ctx.save();
ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2);
ctx.fillStyle='#0d0d2b'; ctx.fill();
ctx.strokeStyle='#FFD166'; ctx.lineWidth=1.5; ctx.stroke();
ctx.beginPath(); ctx.strokeStyle='#FFD166'; ctx.lineWidth=1.2;
for (let i=0;i<=24;i++) {
const t=(i/24)*2*Math.PI;
const sx=(i/24-0.5)*r*1.5, sy=-Math.sin(t)*5;
i===0?ctx.moveTo(sx,sy):ctx.lineTo(sx,sy);
}
ctx.stroke();
ctx.restore();
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(255,213,102,0.75)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(`${c.value}V ${c.acFreq||this.acFreq}Hz`, mx-uy*20, my+ux*20-4);
ctx.textBaseline='alphabetic';
}
_drawSwitch(ctx, c, p1, p2, mx, my, hasI) {
if (!c.open) {
this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI);
} else {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
const ux=dx/len, uy=dy/len;
const gap=this.CELL*0.35;
const gapP1={x:mx-ux*gap,y:my-uy*gap};
this._drawWireLine(ctx,p1,gapP1,c._v1,c._v1,3,false);
this._drawWireLine(ctx,{x:mx+ux*gap,y:my+uy*gap},p2,c._v2,c._v2,3,false);
// Open arm
const armAngle=Math.PI/5, armLen=gap*2;
const nx=-uy, ny=ux;
ctx.save();
ctx.strokeStyle=this._voltColor(c._v1,0.9);
ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(gapP1.x, gapP1.y);
ctx.lineTo(gapP1.x+ux*armLen*Math.cos(armAngle)-nx*armLen*Math.sin(armAngle),
gapP1.y+uy*armLen*Math.cos(armAngle)-ny*armLen*Math.sin(armAngle));
ctx.stroke();
ctx.restore();
}
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
const ux=len>0?dx/len:1, uy=len>0?dy/len:0;
ctx.font='9px Manrope,sans-serif';
ctx.fillStyle=c.open?'rgba(239,71,111,0.7)':'rgba(76,201,240,0.7)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(c.open?'OPEN':'SW', mx-uy*14, my+ux*14-4);
ctx.textBaseline='alphabetic';
}
_drawLamp(ctx, c, p1, p2, mx, my, hasI) {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
const ux=len>0?dx/len:1, uy=len>0?dy/len:0;
const r=10;
this._drawWireLine(ctx,p1,{x:mx-ux*r,y:my-uy*r},c._v1,c._v1,3,hasI);
this._drawWireLine(ctx,{x:mx+ux*r,y:my+uy*r},p2,c._v2,c._v2,3,hasI);
const power=Math.abs((c._I||0)**2*this._compR(c));
const bright=Math.min(1, power/3);
ctx.save();
if (bright>0.05) {
const grd=ctx.createRadialGradient(mx,my,r,mx,my,r+35*bright);
grd.addColorStop(0,`rgba(255,220,100,${bright*0.6})`);
grd.addColorStop(1,'rgba(255,200,50,0)');
ctx.fillStyle=grd;
ctx.beginPath(); ctx.arc(mx,my,r+35*bright,0,Math.PI*2); ctx.fill();
}
ctx.beginPath(); ctx.arc(mx,my,r,0,Math.PI*2);
if (bright>0.05) {
const ig=ctx.createRadialGradient(mx,my,0,mx,my,r);
ig.addColorStop(0,`rgba(255,240,180,${bright})`);
ig.addColorStop(1,'rgba(20,20,50,0.9)');
ctx.fillStyle=ig;
ctx.shadowBlur=20*bright; ctx.shadowColor='#FFD166';
} else { ctx.fillStyle='#0d0d2b'; }
ctx.fill();
ctx.strokeStyle=this._voltColor((c._v1+c._v2)/2,0.9);
ctx.lineWidth=1.5; ctx.shadowBlur=0; ctx.stroke();
const cr=r*0.6;
ctx.strokeStyle=bright>0.05?`rgba(255,240,180,${0.4+bright*0.6})`:'rgba(255,255,255,0.4)';
ctx.lineWidth=1.5;
ctx.beginPath();
ctx.moveTo(mx-cr,my-cr); ctx.lineTo(mx+cr,my+cr);
ctx.moveTo(mx+cr,my-cr); ctx.lineTo(mx-cr,my+cr);
ctx.stroke();
ctx.restore();
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.5)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText('L', mx-uy*14, my+ux*14-4);
if (this._solution?.solved && Math.abs(c._I)>0.001) {
ctx.fillStyle='rgba(255,209,102,0.8)';
ctx.fillText(`${power.toFixed(2)}W`, mx-uy*14, my+ux*14+6);
}
ctx.textBaseline='alphabetic';
}
_drawCapacitor(ctx, c, p1, p2, mx, my) {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
const ux=dx/len, uy=dy/len;
const nx=-uy, ny=ux;
const pg=6, ph=12; // plate gap half, plate half-length
const sP1={x:mx-ux*pg,y:my-uy*pg};
const sP2={x:mx+ux*pg,y:my+uy*pg};
this._drawWireLine(ctx,p1,sP1,c._v1,c._v1,3,false);
this._drawWireLine(ctx,sP2,p2,c._v2,c._v2,3,false);
// Plates
ctx.save();
ctx.lineWidth=2.5; ctx.lineCap='round';
ctx.strokeStyle=this._voltColor(c._v1,0.9);
ctx.beginPath(); ctx.moveTo(sP1.x-nx*ph,sP1.y-ny*ph); ctx.lineTo(sP1.x+nx*ph,sP1.y+ny*ph); ctx.stroke();
ctx.strokeStyle=this._voltColor(c._v2,0.9);
ctx.beginPath(); ctx.moveTo(sP2.x-nx*ph,sP2.y-ny*ph); ctx.lineTo(sP2.x+nx*ph,sP2.y+ny*ph); ctx.stroke();
// Charge fill
const vd=Math.abs((c._v2||0)-(c._v1||0));
if (vd>0.1 && this._solution?.solved) {
const alpha=Math.min(0.35, vd/12);
const cg=ctx.createLinearGradient(sP1.x,sP1.y,sP2.x,sP2.y);
cg.addColorStop(0,this._voltColor(c._v1,alpha));
cg.addColorStop(1,this._voltColor(c._v2,alpha));
ctx.fillStyle=cg;
ctx.beginPath();
ctx.moveTo(sP1.x-nx*ph,sP1.y-ny*ph); ctx.lineTo(sP1.x+nx*ph,sP1.y+ny*ph);
ctx.lineTo(sP2.x+nx*ph,sP2.y+ny*ph); ctx.lineTo(sP2.x-nx*ph,sP2.y-ny*ph);
ctx.closePath(); ctx.fill();
}
ctx.restore();
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.55)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(`C=${c.value}µF`, mx-uy*16, my+ux*16-4);
ctx.textBaseline='alphabetic';
}
_drawDiode(ctx, c, p1, p2, mx, my, hasI) {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
const ux=dx/len, uy=dy/len;
const tw=10, th=11;
this._drawWireLine(ctx,p1,{x:mx-ux*tw,y:my-uy*tw},c._v1,c._v1,3,hasI);
this._drawWireLine(ctx,{x:mx+ux*tw,y:my+uy*tw},p2,c._v2,c._v2,3,hasI);
const on=(this._diodeR.get(c.id)??1e9)<1;
const col=on?'#7BF5A4':'rgba(255,255,255,0.65)';
ctx.save();
ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
ctx.beginPath();
ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath();
ctx.fillStyle=on?'rgba(123,245,164,0.25)':'rgba(255,255,255,0.06)';
ctx.fill();
ctx.strokeStyle=col; ctx.lineWidth=1.6; ctx.stroke();
ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke();
ctx.restore();
ctx.font='9px Manrope,sans-serif';
ctx.fillStyle=on?'rgba(123,245,164,0.8)':'rgba(255,255,255,0.4)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(on?'ON':'OFF', mx-uy*16, my+ux*16-4);
ctx.textBaseline='alphabetic';
}
_drawLED(ctx, c, p1, p2, mx, my, hasI) {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
const ux=dx/len, uy=dy/len;
const tw=9, th=10;
this._drawWireLine(ctx,p1,{x:mx-ux*tw,y:my-uy*tw},c._v1,c._v1,3,hasI);
this._drawWireLine(ctx,{x:mx+ux*tw,y:my+uy*tw},p2,c._v2,c._v2,3,hasI);
const on=(this._diodeR.get(c.id)??1e9)<1;
const col=c.ledColor||this.ledColor;
if (on) {
const grd=ctx.createRadialGradient(mx,my,0,mx,my,38);
grd.addColorStop(0,col+'50'); grd.addColorStop(1,col+'00');
ctx.fillStyle=grd;
ctx.beginPath(); ctx.arc(mx,my,38,0,Math.PI*2); ctx.fill();
}
ctx.save();
ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx));
ctx.beginPath();
ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath();
ctx.fillStyle=on?col+'55':col+'18'; ctx.fill();
ctx.strokeStyle=on?col:col+'90'; ctx.lineWidth=1.5; ctx.stroke();
ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke();
if (on) {
ctx.strokeStyle=col; ctx.lineWidth=1.2;
for (let s=-1;s<=1;s+=2) {
ctx.beginPath();
ctx.moveTo(tw+3, s*5);
ctx.lineTo(tw+11, s*5-s*6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+8,s*5-s*6);
ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+11,s*5-s*3);
ctx.stroke();
}
}
ctx.restore();
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle=col+'cc';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText('LED', mx-uy*16, my+ux*16-4);
ctx.textBaseline='alphabetic';
}
_drawAmmeter(ctx, c, p1, p2, mx, my) {
const dx=p2.x-p1.x, dy=p2.y-p1.y, len=Math.hypot(dx,dy);
const ux=len>0?dx/len:1, uy=len>0?dy/len:0, r=9;
this._drawWireLine(ctx,p1,{x:mx-ux*r,y:my-uy*r},c._v1,c._v1,3,false);
this._drawWireLine(ctx,{x:mx+ux*r,y:my+uy*r},p2,c._v2,c._v2,3,false);
ctx.beginPath(); ctx.arc(mx,my,r,0,Math.PI*2);
ctx.fillStyle='#0d0d2b'; ctx.fill();
ctx.strokeStyle='rgba(76,201,240,0.9)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.font='bold 9px Manrope,sans-serif'; ctx.fillStyle='#4CC9F0';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('A',mx,my);
if (this._solution?.solved) {
ctx.font='8px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.55)';
ctx.textBaseline='top'; ctx.fillText(`${(c._I||0).toFixed(3)}A`,mx,my+r+3);
}
ctx.textBaseline='alphabetic';
}
_drawVoltmeter(ctx, c, p1, p2, mx, my) {
const dx=p2.x-p1.x, dy=p2.y-p1.y, len=Math.hypot(dx,dy);
const ux=len>0?dx/len:1, uy=len>0?dy/len:0, r=9;
this._drawWireLine(ctx,p1,{x:mx-ux*r,y:my-uy*r},c._v1,c._v1,2,false);
this._drawWireLine(ctx,{x:mx+ux*r,y:my+uy*r},p2,c._v2,c._v2,2,false);
ctx.beginPath(); ctx.arc(mx,my,r,0,Math.PI*2);
ctx.fillStyle='#0d0d2b'; ctx.fill();
ctx.strokeStyle='rgba(239,71,111,0.9)'; ctx.lineWidth=1.5; ctx.stroke();
ctx.font='bold 9px Manrope,sans-serif'; ctx.fillStyle='#EF476F';
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('V',mx,my);
if (this._solution?.solved) {
ctx.font='8px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.55)';
ctx.textBaseline='top'; ctx.fillText(`${((c._v1||0)-(c._v2||0)).toFixed(2)}V`,mx,my+r+3);
}
ctx.textBaseline='alphabetic';
}
/* ─── Animated current (comet trail) ──────────────────────────────────── */
_drawAnimDots(ctx) {
if (!this._solution?.solved) return;
for (const c of this.components) {
if (!c._I || Math.abs(c._I) < 0.05) continue;
const p1=this._nodePixel(c.x1,c.y1), p2=this._nodePixel(c.x2,c.y2);
const N=Math.max(1,Math.min(5,Math.ceil(Math.abs(c._I))));
const dir=c._I>0?1:-1;
for (let i=0;i<N;i++) {
const phase=((c._t||0)+i/N)%1;
const tt=dir>0?phase:1-phase;
// Comet trail: 5 fading dots
for (let tr=0;tr<5;tr++) {
const trailT=dir>0?Math.max(0,tt-tr*0.035):Math.min(1,tt+tr*0.035);
const tx=p1.x+(p2.x-p1.x)*trailT;
const ty=p1.y+(p2.y-p1.y)*trailT;
const alpha=(1-tr/5)*(tr===0?1:0.5);
const sz=Math.max(0, tr===0?3:2.5-tr*0.4);
ctx.beginPath(); ctx.arc(tx,ty,sz,0,Math.PI*2);
if (tr===0) {
ctx.fillStyle='#FFD166';
ctx.shadowColor='#FFD166'; ctx.shadowBlur=8;
} else {
ctx.shadowBlur=0;
ctx.fillStyle=`rgba(255,209,102,${alpha})`;
}
ctx.fill();
}
ctx.shadowBlur=0;
}
}
}
/* ─── Components dispatch ──────────────────────────────────────────────── */
_drawComponents(ctx) {
for (const c of this.components) {
const p1=this._nodePixel(c.x1,c.y1), p2=this._nodePixel(c.x2,c.y2);
const mx=(p1.x+p2.x)/2, my=(p1.y+p2.y)/2;
const isHov=this._hovered===c.id, isSel=this._selected===c.id;
const hasI=!!(this._solution?.solved && Math.abs(c._I)>0.001);
// Selection / hover highlight
if (isSel || isHov) {
ctx.save();
ctx.globalAlpha=isSel?0.28:0.15;
ctx.fillStyle=isSel?'#FFD166':'#fff';
const bx=Math.min(p1.x,p2.x)-10, by=Math.min(p1.y,p2.y)-10;
const bw=Math.abs(p2.x-p1.x)+20, bh=Math.abs(p2.y-p1.y)+20;
ctx.beginPath(); ctx.roundRect(bx,by,Math.max(bw,20),Math.max(bh,20),5); ctx.fill();
ctx.restore();
}
switch (c.type) {
case 'wire': this._drawWire(ctx,c,p1,p2,hasI); break;
case 'resistor': this._drawResistor(ctx,c,p1,p2,mx,my,hasI); break;
case 'battery': this._drawBattery(ctx,c,p1,p2,mx,my,hasI); break;
case 'ac': this._drawAC(ctx,c,p1,p2,mx,my,hasI); break;
case 'switch': this._drawSwitch(ctx,c,p1,p2,mx,my,hasI); break;
case 'lamp': this._drawLamp(ctx,c,p1,p2,mx,my,hasI); break;
case 'capacitor': this._drawCapacitor(ctx,c,p1,p2,mx,my); break;
case 'diode': this._drawDiode(ctx,c,p1,p2,mx,my,hasI); break;
case 'led': this._drawLED(ctx,c,p1,p2,mx,my,hasI); break;
case 'ammeter': this._drawAmmeter(ctx,c,p1,p2,mx,my); break;
case 'voltmeter': this._drawVoltmeter(ctx,c,p1,p2,mx,my); break;
}
}
}
/* ─── Ghost while drawing ──────────────────────────────────────────────── */
_drawGhost(ctx) {
if (!this._drawing || !this._ghostEnd) return;
const p1=this._nodePixel(this._drawing.x1,this._drawing.y1);
const g2=this._ghostEnd;
ctx.save();
ctx.globalAlpha=0.45; ctx.strokeStyle='#FFD166';
ctx.lineWidth=3; ctx.lineCap='round'; ctx.setLineDash([6,4]);
if (this.addMode==='wire' && this._drawing.x1!==g2.x2 && this._drawing.y1!==g2.y2) {
const corner=this._nodePixel(g2.x2, this._drawing.y1);
const p2=this._nodePixel(g2.x2,g2.y2);
ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(corner.x,corner.y); ctx.lineTo(p2.x,p2.y); ctx.stroke();
} else {
const p2=this._nodePixel(g2.x2,g2.y2);
ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(p2.x,p2.y); ctx.stroke();
}
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(p1.x,p1.y,5,0,Math.PI*2);
ctx.fillStyle='#FFD166'; ctx.globalAlpha=0.75; ctx.fill();
ctx.restore();
}
/* ─── Tooltip ──────────────────────────────────────────────────────────── */
_drawTooltip(ctx) {
if (!this._hovered || !this._solution?.solved) return;
const c=this.components.find(x=>x.id===this._hovered);
if (!c) return;
const p1=this._nodePixel(c.x1,c.y1), p2=this._nodePixel(c.x2,c.y2);
const mx=(p1.x+p2.x)/2, my=(p1.y+p2.y)/2;
const R=this._compR(c), I=c._I??0;
const U=Math.abs((c._v1??0)-(c._v2??0));
const P=Math.abs(I*I*Math.min(R,1e6));
const lines=[];
if (c.type==='resistor'||c.type==='lamp'||c.type==='wire')
lines.push(`R = ${R<1?R.toFixed(4):R.toFixed(1)} Ω`);
if (Math.abs(I)>0.0001) lines.push(`I = ${I.toFixed(4)} А`);
if (U>0.001) lines.push(`U = ${U.toFixed(3)} В`);
if (P>0.0001&&P<1e5) lines.push(`P = ${P.toFixed(4)} Вт`);
if (!lines.length) return;
const lh=14, pad=7, tw=96, th=lines.length*lh+pad*2;
let tx=mx+18, ty=my-th/2;
if (tx+tw>this.W-4) tx=mx-tw-18;
if (ty<4) ty=4;
if (ty+th>this.H-4) ty=this.H-th-4;
ctx.fillStyle='rgba(6,6,22,0.93)';
ctx.strokeStyle='rgba(255,255,255,0.14)';
ctx.lineWidth=1;
ctx.beginPath(); ctx.roundRect(tx,ty,tw,th,5); ctx.fill(); ctx.stroke();
ctx.font='10px Manrope,sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top';
lines.forEach((l,i)=>{
ctx.fillStyle='rgba(255,255,255,0.78)';
ctx.fillText(l, tx+pad, ty+pad+i*lh);
});
ctx.textBaseline='alphabetic';
}
/* ─── Short circuit warning ────────────────────────────────────────────── */
_drawShortCircuitWarning(ctx) {
if (!this._solution?.solved) return;
const batt=this.components.find(c=>c.type==='battery'||c.type==='ac');
if (!batt||Math.abs(batt._I)<50) return;
const a=0.12+0.08*Math.sin(this._simTime*12);
ctx.fillStyle=`rgba(239,71,111,${a})`;
ctx.fillRect(0,0,this.W,this.H);
ctx.fillStyle='rgba(239,71,111,0.92)';
ctx.font='bold 18px Manrope,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('Короткое замыкание!', this.W/2, this.H/2);
ctx.textBaseline='alphabetic';
}
/* ─── Hint ─────────────────────────────────────────────────────────────── */
_drawHint(ctx) {
ctx.font='15px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.18)';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('Выберите инструмент и нарисуйте схему', this.W/2, this.H/2-14);
ctx.font='11px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.09)';
ctx.fillText('ПКМ — удалить · Dbl‑клик на ключе — переключить · Del — удалить выбранный', this.W/2, this.H/2+12);
ctx.font='10px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.07)';
ctx.fillText('Ctrl+Z / Ctrl+Y — Undo/Redo · W R B S L C D A V E — горячие клавиши', this.W/2, this.H/2+30);
ctx.textBaseline='alphabetic';
}
/* ─── Main draw ────────────────────────────────────────────────────────── */
draw() {
const ctx=this.ctx, W=this.W, H=this.H;
if (!W||!H) return;
ctx.clearRect(0,0,W,H);
ctx.fillStyle='#080818'; ctx.fillRect(0,0,W,H);
this._drawGrid(ctx);
this._drawComponents(ctx);
this._drawJunctions(ctx);
this._drawNodeLabels(ctx);
this._drawAnimDots(ctx);
this._drawShortCircuitWarning(ctx);
if (this._drawing&&this._ghostEnd) this._drawGhost(ctx);
this._drawTooltip(ctx);
if (this.components.length===0) this._drawHint(ctx);
}
/* ─── Events ───────────────────────────────────────────────────────────── */
_bindEvents() {
const cvs=this.canvas;
const pos = e => {
const r=cvs.getBoundingClientRect(), s=e.touches?e.touches[0]:e;
return { x:s.clientX-r.left, y:s.clientY-r.top };
};
const snap = p => this._snapGrid(p.x,p.y);
const hitComp = p => {
for (let i=this.components.length-1;i>=0;i--) {
const c=this.components[i];
const q1=this._nodePixel(c.x1,c.y1), q2=this._nodePixel(c.x2,c.y2);
if (distToSegment(p.x,p.y,q1.x,q1.y,q2.x,q2.y)<13) return i;
}
return -1;
};
// ── Keyboard shortcuts ──
this._keyHandler = e => {
const simEl=document.getElementById('sim-circuit');
if (!simEl||simEl.style.display==='none') return;
if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return;
if ((e.ctrlKey||e.metaKey)&&e.key==='z') { e.preventDefault(); this.undo(); return; }
if ((e.ctrlKey||e.metaKey)&&(e.key==='y'||(e.shiftKey&&e.key==='z'))) { e.preventDefault(); this.redo(); return; }
const modeMap={w:'wire',r:'resistor',b:'battery',s:'switch',l:'lamp',c:'capacitor',d:'diode',a:'ammeter',v:'voltmeter',e:'erase'};
const newMode=modeMap[e.key.toLowerCase()];
if (newMode) { this.addMode=newMode; if (this.onModeChange) this.onModeChange(newMode); }
if ((e.key==='Delete'||e.key==='Backspace')&&this._selected!==null) {
const idx=this.components.findIndex(c=>c.id===this._selected);
if (idx>=0) { this._pushHistory(); this.components.splice(idx,1); this._selected=null; this._solve(); this.draw(); }
}
if (e.key==='Escape') {
this._selected=null; this._drawing=null; this._ghostEnd=null; this._dragIdx=null;
if (!this._raf) this.draw();
}
};
document.addEventListener('keydown', this._keyHandler);
// ── Mouse ──
cvs.addEventListener('mousedown', e=>{
if (e.button!==0) return;
const p=pos(e), g=snap(p), hi=hitComp(p);
if (this.addMode==='erase') {
if (hi>=0) { this._pushHistory(); this.components.splice(hi,1); this._solve(); this.draw(); }
return;
}
if (hi>=0) {
// Start potential drag / select
this._selected=this.components[hi].id;
this._dragIdx=hi;
this._dragStart=g;
this._dragOrigPos={...this.components[hi]};
this._didDrag=false;
this.draw(); return;
}
this._selected=null;
this._drawing={x1:g.gx,y1:g.gy};
this._ghostEnd={x2:g.gx,y2:g.gy};
});
cvs.addEventListener('mousemove', e=>{
const p=pos(e), g=snap(p);
this._ghostEnd={x2:g.gx,y2:g.gy};
const hi=hitComp(p);
this._hovered=hi>=0?this.components[hi].id:null;
if (this._dragIdx!==null&&this._dragStart) {
const dx=g.gx-this._dragStart.gx, dy=g.gy-this._dragStart.gy;
if (dx!==0||dy!==0) {
this._didDrag=true;
const c=this.components[this._dragIdx], o=this._dragOrigPos;
c.x1=Math.max(0,Math.min(this.GW,o.x1+dx));
c.y1=Math.max(0,Math.min(this.GH,o.y1+dy));
c.x2=Math.max(0,Math.min(this.GW,o.x2+dx));
c.y2=Math.max(0,Math.min(this.GH,o.y2+dy));
this._solve();
}
}
if (!this._raf) this.draw();
});
cvs.addEventListener('mouseup', e=>{
if (e.button!==0) return;
if (this._dragIdx!==null) {
if (this._didDrag) this._pushHistory();
this._dragIdx=null; this._dragStart=null; this._dragOrigPos=null; this._didDrag=false;
if (!this._raf) this.draw(); return;
}
if (!this._drawing) return;
const p=pos(e), g=snap(p);
const {x1,y1}=this._drawing;
const x2=g.gx, y2=g.gy;
this._drawing=null; this._ghostEnd=null;
if (x1===x2&&y1===y2) { if (!this._raf) this.draw(); return; }
this._pushHistory();
// L-shape wires: split diagonal into two orthogonal segments
if (this.addMode==='wire'&&x1!==x2&&y1!==y2) {
this._add('wire',x1,y1,x2,y1);
this._add('wire',x2,y1,x2,y2);
} else {
this.addComponent(this.addMode,x1,y1,x2,y2);
return;
}
this._solve(); this.draw();
if (this.onUpdate) this.onUpdate(this.info());
});
cvs.addEventListener('contextmenu', e=>{
e.preventDefault();
const p=pos(e), i=hitComp(p);
if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._selected=null; this._solve(); this.draw(); }
});
cvs.addEventListener('dblclick', e=>{
const p=pos(e), i=hitComp(p);
if (i>=0&&this.components[i].type==='switch') {
this._pushHistory(); this.components[i].open=!this.components[i].open; this._solve(); this.draw();
}
});
cvs.addEventListener('mouseleave', ()=>{
this._ghostEnd=null; this._drawing=null; this._hovered=null;
if (this._dragIdx!==null) { this._dragIdx=null; this._dragStart=null; this._dragOrigPos=null; this._didDrag=false; }
if (!this._raf) this.draw();
});
// Touch
cvs.addEventListener('touchstart', e=>{
e.preventDefault();
const p=pos(e), g=snap(p);
if (this.addMode==='erase') {
const i=this._hitCompFromSnap(p);
if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._solve(); this.draw(); }
return;
}
this._drawing={x1:g.gx,y1:g.gy}; this._ghostEnd={x2:g.gx,y2:g.gy};
},{passive:false});
cvs.addEventListener('touchmove', e=>{
e.preventDefault();
const p=pos(e), g=snap(p);
this._ghostEnd={x2:g.gx,y2:g.gy};
if (!this._raf) this.draw();
},{passive:false});
cvs.addEventListener('touchend', e=>{
e.preventDefault();
if (!this._drawing) return;
const {x1,y1}=this._drawing;
const x2=this._ghostEnd?.x2??x1, y2=this._ghostEnd?.y2??y1;
this._drawing=null; this._ghostEnd=null;
if (x1===x2&&y1===y2) { if (!this._raf) this.draw(); return; }
this._pushHistory();
if (this.addMode==='wire'&&x1!==x2&&y1!==y2) {
this._add('wire',x1,y1,x2,y1); this._add('wire',x2,y1,x2,y2);
this._solve(); this.draw(); if (this.onUpdate) this.onUpdate(this.info());
} else {
this.addComponent(this.addMode,x1,y1,x2,y2);
}
},{passive:false});
}
/* ─── CRUD ─────────────────────────────────────────────────────────────── */
addComponent(type, x1, y1, x2, y2) {
const value = type==='resistor'?this.R_value
: type==='battery'||type==='ac'?this.U_value
: type==='capacitor'?this.C_value
: undefined;
this._add(type,x1,y1,x2,y2,value);
this._solve(); this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
_add(type, x1, y1, x2, y2, value) {
const id=this._nextId++;
if (type==='diode'||type==='led') this._diodeR.set(id,1e9);
this.components.push({
id, type, x1, y1, x2, y2,
value: value??undefined,
open: false,
ledColor: type==='led'?(this.ledColor||'#7BF5A4'):undefined,
acFreq: type==='ac'?this.acFreq:undefined,
_I:0, _v1:0, _v2:0, _t:Math.random()
});
}
/* ─── Presets ──────────────────────────────────────────────────────────── */
preset(name) {
this.components=[]; this._nextId=0; this._diodeR.clear(); this._selected=null;
switch (name) {
case 'serial':
this._add('battery',1,7,1,4,9);
this._add('wire',1,4,8,4);
this._add('resistor',8,4,13,4,10);
this._add('resistor',13,4,19,4,20);
this._add('wire',19,4,19,7);
this._add('wire',19,7,1,7);
break;
case 'parallel':
this._add('battery',1,7,1,4,12);
this._add('wire',1,4,8,4);
this._add('wire',8,4,8,3);
this._add('resistor',8,3,16,3,10);
this._add('wire',16,3,16,4);
this._add('wire',8,4,8,6);
this._add('resistor',8,6,16,6,20);
this._add('wire',16,6,16,4);
this._add('wire',16,4,19,4);
this._add('wire',19,4,19,7);
this._add('wire',19,7,1,7);
break;
case 'lamp':
this._add('battery',2,8,2,4,9);
this._add('wire',2,4,8,4);
this._add('switch',8,4,13,4);
this._add('lamp',13,4,18,4);
this._add('wire',18,4,20,4);
this._add('wire',20,4,20,8);
this._add('wire',20,8,2,8);
break;
case 'divider':
this._add('battery',2,7,2,4,12);
this._add('wire',2,4,9,4);
this._add('resistor',9,4,14,4,10);
this._add('resistor',14,4,19,4,10);
this._add('wire',19,4,19,7);
this._add('wire',19,7,2,7);
this._add('voltmeter',14,4,14,7);
this._add('wire',14,7,19,7);
break;
case 'bridge':
this._add('battery',1,7,1,4,12);
this._add('wire',1,4,5,4);
this._add('resistor',5,4,10,2,10);
this._add('resistor',5,4,10,6,20);
this._add('resistor',10,2,16,4,10);
this._add('resistor',10,6,16,4,30);
this._add('ammeter',10,2,10,6);
this._add('wire',16,4,19,4);
this._add('wire',19,4,19,7);
this._add('wire',19,7,1,7);
break;
case 'diode':
this._add('battery',2,7,2,4,9);
this._add('wire',2,4,7,4);
this._add('diode',7,4,13,4);
this._add('resistor',13,4,19,4,100);
this._add('ammeter',19,4,19,7);
this._add('wire',19,7,2,7);
break;
case 'led':
this._add('battery',2,7,2,4,9);
this._add('wire',2,4,7,4);
this._add('led',7,4,13,4);
this._add('resistor',13,4,19,4,47);
this._add('wire',19,4,19,7);
this._add('wire',19,7,2,7);
break;
case 'rc':
this._add('battery',2,7,2,4,9);
this._add('wire',2,4,6,4);
this._add('switch',6,4,10,4);
this._add('resistor',10,4,15,4,100);
this._add('capacitor',15,4,19,4,100);
this._add('wire',19,4,19,7);
this._add('wire',19,7,2,7);
break;
case 'ac':
this._add('ac',2,7,2,4,9);
this._add('wire',2,4,9,4);
this._add('resistor',9,4,15,4,10);
this._add('wire',15,4,19,4);
this._add('wire',19,4,19,7);
this._add('wire',19,7,2,7);
break;
default: break;
}
this._pushHistory();
this._solve(); this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ─── Fit ──────────────────────────────────────────────────────────────── */
fit() {
this.W=this.canvas.offsetWidth||800;
this.H=this.canvas.offsetHeight||500;
const dpr=window.devicePixelRatio||1;
this.canvas.width=this.W*dpr;
this.canvas.height=this.H*dpr;
this.ctx.setTransform(1,0,0,1,0,0);
this.ctx.scale(dpr,dpr);
this.CELL=Math.max(24,Math.floor(Math.min((this.W-40)/this.GW,(this.H-40)/this.GH)));
this.ox=Math.round((this.W-this.CELL*this.GW)/2);
this.oy=Math.round((this.H-this.CELL*this.GH)/2);
this._solve(); this.draw();
}
/* ─── Info ─────────────────────────────────────────────────────────────── */
info() {
const solved=this._solution?.solved??false;
const batt=this.components.find(c=>c.type==='battery'||c.type==='ac');
return {
components: this.components.length,
solved,
voltage: batt?batt.value:0,
current: batt?Math.abs(batt._I).toFixed(3):'—',
power: batt?(Math.abs(batt._I)*(batt.value||0)).toFixed(2):'—',
totalR: (batt&&batt._I&&Math.abs(batt._I)>0.0001)?((batt.value||0)/Math.abs(batt._I)).toFixed(1):'—',
addMode: this.addMode
};
}
/* ─── Clear ────────────────────────────────────────────────────────────── */
clear() {
this._pushHistory();
this.components=[]; this._nextId=0; this._diodeR.clear(); this._selected=null;
this._solve(); this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
}