Files
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

1670 lines
63 KiB
JavaScript
Raw Permalink 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.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 <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, 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<loopN;i++) {
const cx=-totalW/2+loopR+i*loopR*2;
ctx.beginPath();
ctx.arc(cx, 0, loopR, Math.PI, 0, false);
ctx.stroke();
}
ctx.restore();
const L_mH = c.L_value ?? this.L_value ?? 10;
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(200,160,255,0.75)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(`L=${L_mH} мГн`, 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;
const drawLEDBody = () => {
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;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 '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'