/** * 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.L_value = 10; // mH for inductor this.acFreq = 2; // Hz for AC source this.ledColor = '#7BF5A4'; // Features this._heatmapOn = false; // power heatmap overlay toggle this._oscPanel = null; // oscilloscope canvas element (injected from outside) // 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 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, L_value: c.L_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 'inductor': return 0.001; // short circuit in DC; AC handled in _buildMatrix 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}`); let R = this._compR(comp); // AC overrides: inductor → |jωL|, capacitor → 1/|jωC| if (this._hasAC && comp.type === 'inductor') { const freq = this.acFreq || 2; const L_H = (comp.L_value || this.L_value || 10) * 1e-3; // mH → H const Xl = 2 * Math.PI * freq * L_H; R = Math.max(0.001, Xl); } else if (this._hasAC && comp.type === 'capacitor') { const freq = this.acFreq || 2; const C_F = (comp.value || this.C_value || 100) * 1e-6; // µF → F const Xc = 1 / (2 * Math.PI * freq * C_F); R = Math.max(0.001, Xc); } 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() { // Detect AC presence first so _buildMatrix uses correct impedances this._hasAC = this.components.some(c => c.type === 'ac'); // 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; } 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; } if (window.LabFX) LabFX.particles.update(dt); // heat shimmer: emit smoke above hottest resistor every 5 frames if (window.LabFX && this._heatmapOn && this._solution?.solved) { if (!this._smokeFrame) this._smokeFrame = 0; this._smokeFrame++; if (this._smokeFrame % 5 === 0) { const dissipators = this.components.filter(c => c.type === 'resistor' || c.type === 'lamp'); if (dissipators.length) { let hottest = dissipators[0], hotP = 0; for (const c of dissipators) { const P = Math.abs((c._I ?? 0) ** 2 * this._compR(c)); if (P > hotP) { hotP = P; hottest = c; } } if (hotP > 0.1) { const p1 = this._nodePixel(hottest.x1, hottest.y1), p2 = this._nodePixel(hottest.x2, hottest.y2); const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; LabFX.particles.emit({ ctx: this.ctx, x: mx, y: my - 10, count: 1, color: 'rgba(255,180,100,0.15)', speed: 8, spread: 0.5, angle: -Math.PI / 2, gravity: -50, life: 1500, shape: 'smoke', size: 8, fade: true }); } } } } this.draw(); if (this._oscPanel && this._oscPanel.offsetParent !== null) { this.drawOscilloscope(this._oscPanel); } 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) { if (window.LabFX && hasI && Math.abs(c._I) > 0) { const intensity = Math.min(20, 6 + Math.abs(c._I) * 2); LabFX.glow.drawGlow(ctx, () => this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI), { color: '#06D6E0', intensity }); } else { 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'; } _drawInductor(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 nx=-uy, ny=ux; const hw=18; // half-width of coil region const sP1={x:mx-ux*hw, y:my-uy*hw}; const sP2={x:mx+ux*hw, y:my+uy*hw}; this._drawWireLine(ctx, p1, sP1, c._v1, c._v1, 3, hasI); this._drawWireLine(ctx, sP2, p2, c._v2, c._v2, 3, hasI); // 3 half-circle loops ctx.save(); ctx.translate(mx, my); ctx.rotate(Math.atan2(dy, dx)); const loopR=6, loopN=3; const totalW=loopN*loopR*2; ctx.strokeStyle=this._voltColor((c._v1+c._v2)/2, 0.9); ctx.lineWidth=2; ctx.lineCap='round'; for (let i=0;i { 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(); }; if (window.LabFX && on) { LabFX.glow.drawGlow(ctx, drawLEDBody, { color: col, intensity: 18, layers: 2 }); } else { drawLEDBody(); } 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;i0?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 'inductor': this._drawInductor(ctx,c,p1,p2,mx,my,hasI); 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'; if (window.LabFX) { const now4 = performance.now(); if (!this._shortFXt || now4 - this._shortFXt > 600) { this._shortFXt = now4; const p1=this._nodePixel(batt.x1,batt.y1), p2=this._nodePixel(batt.x2,batt.y2); const sx=(p1.x+p2.x)/2, sy=(p1.y+p2.y)/2; LabFX.particles.emit({ ctx, x: sx, y: sy, count: 12, color: '#EF476F', speed: 80, spread: Math.PI*2, life: 300, shape: 'spark', size: 3, glow: true }); LabFX.sound.play('spark'); LabFX.shake(this.canvas, { intensity: 5, durMs: 200 }); } } } /* ─── 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 I D A V E — горячие клавиши', this.W/2, this.H/2+30); ctx.textBaseline='alphabetic'; } /* ─── Power heatmap overlay ─────────────────────────────────────────────── */ _drawHeatmap(ctx) { if (!this._heatmapOn || !this._solution?.solved) return; // Compute power for each dissipating component const dissipators = this.components.filter(c => c.type === 'resistor' || c.type === 'lamp' || c.type === 'led' || c.type === 'diode' || c.type === 'inductor' ); if (!dissipators.length) return; const powers = dissipators.map(c => { const R = this._compR(c); return Math.abs((c._I ?? 0) ** 2 * Math.min(R, 1e6)); }); const Pmax = Math.max(...powers, 1e-9); for (let i = 0; i < dissipators.length; i++) { const c = dissipators[i]; 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 t = powers[i] / Pmax; // 0..1 if (t < 0.01) continue; // Color: cool blue (t=0) → orange (t=0.5) → red (t=1) const r = Math.round(30 + t * 225); const g = Math.round(100 - t * 100); const b = Math.round(220 - t * 220); const radius = 28 + t * 24; const grd = ctx.createRadialGradient(mx, my, 0, mx, my, radius); grd.addColorStop(0, `rgba(${r},${g},${b},${(0.18 + t * 0.32).toFixed(2)})`); grd.addColorStop(1, `rgba(${r},${g},${b},0)`); ctx.beginPath(); ctx.arc(mx, my, radius, 0, Math.PI * 2); ctx.fillStyle = grd; ctx.fill(); } } /* ─── Oscilloscope render ────────────────────────────────────────────────── */ drawOscilloscope(oscCanvas) { if (!oscCanvas) return; const W = oscCanvas.width || 300; const H = oscCanvas.height || 180; const ctx = oscCanvas.getContext('2d'); ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#06060e'; ctx.fillRect(0, 0, W, H); // Grid lines ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; for (let x = 0; x <= W; x += W / 6) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } for (let y = 0; y <= H; y += H / 4) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } // Axis labels ctx.font = '8px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.textAlign = 'left'; ctx.fillText('U (В)', 4, 10); ctx.textAlign = 'right'; ctx.fillText('I (А)', W - 4, 10); const sel = this._selected !== null ? this.components.find(c => c.id === this._selected) : null; if (!sel || !this._solution?.solved) { ctx.font = '11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Выбери компонент', W / 2, H / 2); ctx.textBaseline = 'alphabetic'; return; } const N = 100; const Uvals = new Float64Array(N); const Ivals = new Float64Array(N); if (this._hasAC) { // Compute 2 periods of AC signal const freq = this.acFreq || 2; const T = 1 / freq; const tSpan = 2 * T; const savedTime = this._simTime; for (let k = 0; k < N; k++) { this._simTime = savedTime + (k / (N - 1)) * tSpan - tSpan / 2; this._solveOnce(); const c = this.components.find(cc => cc.id === sel.id); if (c) { Uvals[k] = (c._v1 ?? 0) - (c._v2 ?? 0); Ivals[k] = c._I ?? 0; } } this._simTime = savedTime; this._solveOnce(); // restore } else { // DC: horizontal lines const U0 = (sel._v1 ?? 0) - (sel._v2 ?? 0); const I0 = sel._I ?? 0; Uvals.fill(U0); Ivals.fill(I0); } const Umax = Math.max(...Uvals.map(Math.abs), 1e-9); const Imax = Math.max(...Ivals.map(Math.abs), 1e-9); const padY = 18, innerH = H - padY * 2; const drawLine = (vals, maxVal, color) => { ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; for (let k = 0; k < N; k++) { const px = (k / (N - 1)) * W; const py = padY + innerH / 2 - (vals[k] / maxVal) * (innerH / 2 - 2); k === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); } ctx.stroke(); }; drawLine(Uvals, Umax, '#a78bfa'); // violet for U drawLine(Ivals, Imax, '#22d3ee'); // cyan for I // Scale labels ctx.font = '8px Manrope,sans-serif'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#a78bfa'; ctx.textAlign = 'left'; ctx.fillText(Umax.toFixed(2) + 'В', 4, padY); ctx.fillStyle = '#22d3ee'; ctx.textAlign = 'right'; ctx.fillText(Imax.toFixed(3) + 'А', W - 4, padY); 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._drawHeatmap(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); if (window.LabFX) LabFX.particles.draw(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',i:'inductor',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(); if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.3 }); } 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(); if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.3 }); } }); 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(); if (window.LabFX) LabFX.sound.play('click', { pitch: 1.5 }); } }); 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; const L_value = type==='inductor' ? this.L_value : undefined; this._add(type,x1,y1,x2,y2,value,L_value); if (window.LabFX) LabFX.sound.play('click', { pitch: 0.9 }); this._solve(); this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } _add(type, x1, y1, x2, y2, value, L_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, L_value: L_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; case 'rlc': { // RLC series: AC → R → L → C this._add('ac',2,7,2,4,9); this._add('wire',2,4,6,4); this._add('resistor',6,4,10,4,10); this._add('inductor',10,4,14,4,undefined,10); this._add('capacitor',14,4,19,4,100); 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()); } } /* ─── lab UI init ─────────────────────────────────── */ var cirSim = null; var reacSim = null; var flaskSim = null; function _openCircuit() { document.getElementById('sim-topbar-title').textContent = 'Электрические цепи'; _simShow('sim-circuit'); _simShow('ctrl-circuit'); requestAnimationFrame(() => requestAnimationFrame(() => { const canvas = document.getElementById('circuit-canvas'); if (!cirSim) { cirSim = new CircuitSim(canvas); cirSim.onUpdate = _circUpdateUI; cirSim.onModeChange = (mode) => { document.querySelectorAll('.circ-tool-btn').forEach(b => { b.classList.toggle('active', b.dataset.tool === mode); }); document.querySelectorAll('.circ-top-btn').forEach(b => { b.classList.toggle('active', b.id === 'ctool-' + mode); }); }; } else { cirSim.stop(); } cirSim.fit(); if (cirSim.components.length === 0) cirSim.preset('serial'); cirSim.start(); _circUpdateUI(cirSim.info()); })); } function circTool(tool, el) { if (cirSim) cirSim.addMode = tool; document.querySelectorAll('.circ-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool)); document.querySelectorAll('.circ-top-btn').forEach(b => b.classList.toggle('active', b.id === 'ctool-' + tool)); } function circPreset(name) { if (!cirSim) return; cirSim.preset(name); } function circRChange() { const v = +document.getElementById('sl-circR').value; document.getElementById('circ-R-val').textContent = v + ' Ω'; if (cirSim) cirSim.R_value = v; } function circUChange() { const v = +document.getElementById('sl-circU').value; document.getElementById('circ-U-val').textContent = v + ' В'; if (cirSim) cirSim.U_value = v; } function circCChange() { const v = +document.getElementById('sl-circC').value; document.getElementById('circ-C-val').textContent = v + ' µF'; if (cirSim) cirSim.C_value = v; } function circFChange() { const v = +document.getElementById('sl-circF').value; document.getElementById('circ-F-val').textContent = v + ' Гц'; if (cirSim) cirSim.acFreq = v; } function circLChange() { const v = +document.getElementById('sl-circL').value; document.getElementById('circ-L-val').textContent = v + ' мГн'; if (cirSim) cirSim.L_value = v; } function circToggleHeat() { if (!cirSim) return; cirSim._heatmapOn = !cirSim._heatmapOn; const btn = document.getElementById('ctool-heat'); if (btn) btn.classList.toggle('active', cirSim._heatmapOn); if (!cirSim._raf) cirSim.draw(); } function circToggleOsc() { const panel = document.getElementById('osc-panel'); if (!panel) return; const visible = panel.style.display !== 'none'; panel.style.display = visible ? 'none' : 'block'; const btn = document.getElementById('btn-osc-toggle'); if (btn) btn.classList.toggle('active', !visible); if (cirSim) { cirSim._oscPanel = visible ? null : document.getElementById('osc-canvas'); if (!visible && cirSim._oscPanel) cirSim.drawOscilloscope(cirSim._oscPanel); } } function _circUpdateUI(info) { if (!info) return; document.getElementById('cirbar-comps').textContent = info.components; document.getElementById('cirbar-U').textContent = info.voltage ? info.voltage + ' В' : '—'; document.getElementById('cirbar-I').textContent = info.current ? info.current + ' А' : '—'; document.getElementById('cirbar-P').textContent = info.power ? info.power + ' Вт' : '—'; const st = document.getElementById('cirbar-status'); st.textContent = info.solved ? 'Замкнута' : 'Разомкнута'; st.style.color = info.solved ? '#7BF5A4' : '#EF476F'; } /* ════════════════════════════════ ХИМИЯ (unified: кинетика + колба + ОВР + ионный обмен) ════════════════════════════════ */ let _chemMode = 'kinetics'; // 'kinetics' | 'flask' | 'redox' | 'ionex'