'use strict'; /* ══════════════════════════════════════════════════════════ CoulombSim — Coulomb's Law interactive simulation • Click canvas to place charge (+ or −) • Drag to reposition, double-click / right-click to remove • Layers: colormap, field lines, vector arrows, equipotentials, force arrows Electric field of point charge q at (cx,cy): Ex = K·q·(x-cx)/r³, Ey = K·q·(y-cy)/r³ Potential: V = K·q/r ══════════════════════════════════════════════════════════ */ class CoulombSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.charges = []; // [{x, y, q, id}] this._nextId = 0; this.addSign = +1; /* layers */ this.layers = { colormap: true, fieldlines: true, vectors: false, equipotentials: true, forces: false, }; /* interaction */ this._drag = null; // charge index being dragged this._hovered = null; // charge index under mouse this._downPos = null; // mousedown position for click vs drag detection this._mousePos = null; // {x, y} /* colormap cache */ this._cmDirty = true; this._cmCache = null; // ImageData /* cursor reading */ this._cursorE = null; // {ex, ey, mag, v} /* visual Coulomb constant */ this.K = 60000; /* dimensions */ this.W = 0; this.H = 0; /* callback */ this.onUpdate = null; this._bindEvents(); } /* ── Resize ─────────────────────────────────────────────── */ fit() { this.W = this.canvas.offsetWidth; this.H = this.canvas.offsetHeight; this.canvas.width = this.W * devicePixelRatio; this.canvas.height = this.H * devicePixelRatio; this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); this._cmDirty = true; this._cmCache = null; this.draw(); } /* ── Reset ──────────────────────────────────────────────── */ reset() { this.charges = []; this._nextId = 0; this._cmDirty = true; this._cmCache = null; this._drag = null; this._hovered = null; } /* ── Charge management ──────────────────────────────────── */ addCharge(x, y, q) { this.charges.push({ x, y, q, id: this._nextId++ }); this._cmDirty = true; this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } removeCharge(i) { if (i < 0 || i >= this.charges.length) return; this.charges.splice(i, 1); this._cmDirty = true; this._drag = null; this._hovered = null; this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } toggleLayer(name) { if (name in this.layers) { this.layers[name] = !this.layers[name]; this.draw(); } } setSign(s) { this.addSign = s >= 0 ? +1 : -1; } /* ── Presets ────────────────────────────────────────────── */ preset(name) { this.reset(); const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2; if (name === 'dipole') { this.addCharge(cx - d, cy, 1); this.addCharge(cx + d, cy, -1); } else if (name === 'equal') { this.addCharge(cx - d, cy, 1); this.addCharge(cx + d, cy, 1); } else if (name === 'quadrupole') { this.addCharge(cx - d, cy - d, 1); this.addCharge(cx + d, cy - d, -1); this.addCharge(cx + d, cy + d, 1); this.addCharge(cx - d, cy + d, -1); } else if (name === 'ring') { for (let i = 0; i < 6; i++) { const a = i * Math.PI / 3; this.addCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1); } } this._cmDirty = true; this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } /* ── Info ───────────────────────────────────────────────── */ info() { const pos = this.charges.filter(c => c.q > 0).length; const neg = this.charges.filter(c => c.q < 0).length; let maxE = 0; for (let x = 20; x < this.W; x += 40) for (let y = 20; y < this.H; y += 40) { const f = this._fieldAt(x, y); if (f.mag > maxE) maxE = f.mag; } const ce = this._cursorE; return { total: this.charges.length, positive: pos, negative: neg, maxE: maxE.toFixed(0), cursorE: ce ? ce.mag.toFixed(0) : '—', cursorV: ce ? ce.v.toFixed(0) : '—', }; } /* ── Physics ────────────────────────────────────────────── */ _fieldAt(x, y) { let ex = 0, ey = 0, v = 0; for (const c of this.charges) { const dx = x - c.x, dy = y - c.y; const r2 = dx * dx + dy * dy; if (r2 < 1) continue; const r = Math.sqrt(r2); const r3 = r2 * r; ex += this.K * c.q * dx / r3; ey += this.K * c.q * dy / r3; v += this.K * c.q / r; } return { ex, ey, mag: Math.hypot(ex, ey), v }; } /* ── HSL RGB helper ───────────────────────────────────── */ _hslToRgb(h, s, l) { h = ((h % 360) + 360) % 360; s /= 100; l /= 100; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs((h / 60) % 2 - 1)); const m = l - c / 2; let r = 0, g = 0, b = 0; if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; } else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } return [ Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255), ]; } /* ── Colormap ───────────────────────────────────────────── */ _drawColormap(ctx) { const W = this.W, H = this.H; const STEP = 3; if (this._cmDirty || !this._cmCache) { const imgW = Math.ceil(W / STEP); const imgH = Math.ceil(H / STEP); const img = ctx.createImageData(imgW, imgH); const d = img.data; for (let py = 0; py < imgH; py++) { for (let px = 0; px < imgW; px++) { const x = px * STEP + STEP / 2; const y = py * STEP + STEP / 2; const { mag, v } = this._fieldAt(x, y); /* hue based on potential sign */ let hue; if (v > 0) hue = 0 + (v / (v + 30000)) * 30; // 0–30 red-orange else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220–240 blue else hue = 0; const sat = 80; const lit = Math.tanh(mag / 3000) * 40 + Math.tanh(Math.abs(v) / 50000) * 25; const [r, g, b] = this._hslToRgb(hue, sat, lit); const idx = (py * imgW + px) * 4; d[idx] = r; d[idx + 1] = g; d[idx + 2] = b; d[idx + 3] = 200; } } this._cmCache = { img, imgW, imgH, STEP }; this._cmDirty = false; } const { img, imgW, imgH } = this._cmCache; /* draw scaled up */ const oc = document.createElement('canvas'); oc.width = imgW; oc.height = imgH; oc.getContext('2d').putImageData(img, 0, 0); ctx.save(); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'medium'; ctx.drawImage(oc, 0, 0, W, H); ctx.restore(); } /* ── Equipotentials ─────────────────────────────────────── */ _drawEquipotentials(ctx) { const W = this.W, H = this.H; const GRID = 8; const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000]; const cols = Math.ceil(W / GRID) + 1; const rows = Math.ceil(H / GRID) + 1; /* build V grid */ const vGrid = new Float64Array(cols * rows); for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v; ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 0.8; ctx.setLineDash([4, 4]); ctx.beginPath(); for (const level of LEVELS) { for (let r = 0; r < rows - 1; r++) { for (let c = 0; c < cols - 1; c++) { const v00 = vGrid[ r * cols + c ]; const v10 = vGrid[ r * cols + c + 1]; const v01 = vGrid[(r + 1) * cols + c ]; const v11 = vGrid[(r + 1) * cols + c + 1]; /* crossed edges: top, right, bottom, left */ const pts = []; const interp = (va, vb, xa, ya, xb, yb) => { const t = (level - va) / (vb - va); return [xa + t * (xb - xa), ya + t * (yb - ya)]; }; if ((v00 - level) * (v10 - level) < 0) pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID)); if ((v10 - level) * (v11 - level) < 0) pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID)); if ((v01 - level) * (v11 - level) < 0) pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID)); if ((v00 - level) * (v01 - level) < 0) pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID)); if (pts.length >= 2) { ctx.moveTo(pts[0][0], pts[0][1]); ctx.lineTo(pts[1][0], pts[1][1]); } } } } ctx.stroke(); ctx.restore(); } /* ── Vector arrows ──────────────────────────────────────── */ _drawVectors(ctx) { const GRID = 45; ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1; for (let x = GRID / 2; x < this.W; x += GRID) { for (let y = GRID / 2; y < this.H; y += GRID) { const { ex, ey, mag } = this._fieldAt(x, y); if (mag < 1e-6) continue; const len = Math.tanh(mag / 8000) * 18; const nx = ex / mag, ny = ey / mag; const x2 = x + nx * len, y2 = y + ny * len; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke(); /* arrowhead */ const ax = -ny * 3, ay = nx * 3; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - nx * 6 + ax, y2 - ny * 6 + ay); ctx.lineTo(x2 - nx * 6 - ax, y2 - ny * 6 - ay); ctx.closePath(); ctx.fill(); } } ctx.restore(); } /* ── Field lines ────────────────────────────────────────── */ _drawFieldLines(ctx) { const W = this.W, H = this.H, sim = this; const RAYS = 12; const STEP = 2.5; const MAX = 2500; const MARGIN = 5; const HIT_R = 12; const START_R = 18; function rkStep(x, y, h) { const f = (px, py) => { const e = sim._fieldAt(px, py); const m = Math.hypot(e.ex, e.ey) || 1e-10; return [e.ex / m, e.ey / m]; }; const [k1x, k1y] = f(x, y); const [k2x, k2y] = f(x + h * k1x / 2, y + h * k1y / 2); const [k3x, k3y] = f(x + h * k2x / 2, y + h * k2y / 2); const [k4x, k4y] = f(x + h * k3x, y + h * k3y); return [ x + h * (k1x + 2 * k2x + 2 * k3x + k4x) / 6, y + h * (k1y + 2 * k2y + 2 * k3y + k4y) / 6, ]; } const traceLine = (startX, startY, dir) => { const pts = [[startX, startY]]; let px = startX, py = startY; for (let s = 0; s < MAX; s++) { const [nx, ny] = rkStep(px, py, dir * STEP); if (nx < -MARGIN || nx > W + MARGIN || ny < -MARGIN || ny > H + MARGIN) break; /* stop near negative charges */ let hitNeg = false; for (const c of sim.charges) { if (c.q < 0 && Math.hypot(nx - c.x, ny - c.y) < HIT_R) { hitNeg = true; break; } } if (hitNeg) break; pts.push([nx, ny]); px = nx; py = ny; } return pts; }; ctx.save(); ctx.lineWidth = 1.2; for (const charge of this.charges) { const dir = charge.q > 0 ? 1 : -1; for (let i = 0; i < RAYS; i++) { const angle = (i / RAYS) * Math.PI * 2; const sx = charge.x + START_R * Math.cos(angle); const sy = charge.y + START_R * Math.sin(angle); const pts = traceLine(sx, sy, dir); if (pts.length < 2) continue; const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length - 1][0], pts[pts.length - 1][1]); grad.addColorStop(0, 'rgba(255,255,255,0.75)'); grad.addColorStop(0.5, 'rgba(255,255,255,0.35)'); grad.addColorStop(1, 'rgba(255,255,255,0.0)'); ctx.strokeStyle = grad; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]); ctx.stroke(); } } ctx.restore(); } /* ── Force arrows ───────────────────────────────────────── */ _drawForceArrows(ctx) { ctx.save(); for (let i = 0; i < this.charges.length; i++) { const ci = this.charges[i]; let fx = 0, fy = 0; for (let j = 0; j < this.charges.length; j++) { if (i === j) continue; const cj = this.charges[j]; const dx = ci.x - cj.x, dy = ci.y - cj.y; const r2 = dx * dx + dy * dy; if (r2 < 1) continue; const r3 = r2 * Math.sqrt(r2); const F = this.K * ci.q * cj.q; fx += F * dx / r3; fy += F * dy / r3; } const mag = Math.hypot(fx, fy); if (mag < 1e-6) continue; const len = Math.tanh(mag / 50000) * 55; const nx = fx / mag, ny = fy / mag; const x2 = ci.x + nx * len, y2 = ci.y + ny * len; ctx.strokeStyle = '#FFD166'; ctx.fillStyle = '#FFD166'; ctx.lineWidth = 2; ctx.shadowBlur = 10; ctx.shadowColor = '#FFD166'; ctx.beginPath(); ctx.moveTo(ci.x, ci.y); ctx.lineTo(x2, y2); ctx.stroke(); /* arrowhead */ const ax = -ny * 5, ay = nx * 5; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - nx * 10 + ax, y2 - ny * 10 + ay); ctx.lineTo(x2 - nx * 10 - ax, y2 - ny * 10 - ay); ctx.closePath(); ctx.fill(); ctx.shadowBlur = 0; } ctx.restore(); } /* ── Draw charges ───────────────────────────────────────── */ _drawCharges(ctx) { for (let i = 0; i < this.charges.length; i++) { const c = this.charges[i]; const r = 14 + Math.tanh(Math.abs(c.q) / 5) * 4; const pos = c.q > 0; ctx.save(); ctx.shadowBlur = 18; ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0'; /* hovered outer ring */ if (this._hovered === i) { ctx.beginPath(); ctx.arc(c.x, c.y, r + 6, 0, Math.PI * 2); ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)'; ctx.lineWidth = 2; ctx.stroke(); } /* body gradient */ const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.y, r); if (pos) { grd.addColorStop(0, '#FF7FA3'); grd.addColorStop(1, '#EF476F'); } else { grd.addColorStop(0, '#90E0FF'); grd.addColorStop(1, '#4CC9F0'); } ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI * 2); ctx.fillStyle = grd; ctx.fill(); /* label */ ctx.shadowBlur = 0; ctx.fillStyle = '#fff'; ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(pos ? '+' : '−', c.x, c.y + 1); ctx.restore(); } } /* ── Cursor E display ───────────────────────────────────── */ _drawCursorE(ctx) { const { ex, ey, mag, v } = this._cursorE; const { x, y } = this._mousePos; if (mag < 1e-6) return; const nx = ex / mag, ny = ey / mag; const len = 20; const x2 = x + nx * len, y2 = y + ny * len; ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke(); const ax = -ny * 4, ay = nx * 4; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - nx * 8 + ax, y2 - ny * 8 + ay); ctx.lineTo(x2 - nx * 8 - ax, y2 - ny * 8 - ay); ctx.closePath(); ctx.fill(); /* text */ const eStr = mag >= 1000 ? (mag / 1000).toFixed(1) + 'k' : mag.toFixed(0); const vStr = Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0); ctx.font = '11px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.shadowBlur = 4; ctx.shadowColor = '#000'; ctx.fillText(`|E| = ${eStr}`, x + 6, y - 14); ctx.fillText(`V = ${vStr}`, x + 6, y - 2); ctx.restore(); } /* ── Hint ───────────────────────────────────────────────── */ _drawHint(ctx) { const W = this.W, H = this.H; ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = '16px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30); /* simple circle icon */ ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2); ctx.stroke(); ctx.font = 'bold 22px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.fillText('+', W / 2, H / 2 - 13); ctx.restore(); } /* ── Main draw ──────────────────────────────────────────── */ draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; ctx.clearRect(0, 0, W, H); /* 1. background radial gradient */ const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2); bg.addColorStop(0, '#0D0D1A'); bg.addColorStop(1, '#050508'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); /* 2. subtle grid */ ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.025)'; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); ctx.restore(); if (this.charges.length > 0) { /* 3. colormap */ if (this.layers.colormap) this._drawColormap(ctx); /* 4. equipotentials */ if (this.layers.equipotentials) this._drawEquipotentials(ctx); /* 5. vectors */ if (this.layers.vectors) this._drawVectors(ctx); /* 6. field lines */ if (this.layers.fieldlines) this._drawFieldLines(ctx); /* 7. force arrows */ if (this.layers.forces) this._drawForceArrows(ctx); } /* 8. charges */ this._drawCharges(ctx); /* 9. cursor E */ if (this._cursorE && this._mousePos && this.charges.length > 0) this._drawCursorE(ctx); /* 10. hint if empty */ if (this.charges.length === 0) this._drawHint(ctx); } /* ── Events ─────────────────────────────────────────────── */ _bindEvents() { const canvas = this.canvas; const pos = e => { const r = canvas.getBoundingClientRect(); const s = e.touches ? e.touches[0] : e; return { x: s.clientX - r.left, y: s.clientY - r.top }; }; const hitIdx = p => { for (let i = this.charges.length - 1; i >= 0; i--) if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i; return -1; }; /* ── mousedown ── */ canvas.addEventListener('mousedown', e => { if (e.button !== 0) return; const p = pos(e); const hi = hitIdx(p); this._downPos = p; if (hi >= 0) this._drag = hi; }); /* ── mousemove ── */ canvas.addEventListener('mousemove', e => { const p = pos(e); this._mousePos = p; if (this._drag !== null) { this.charges[this._drag].x = p.x; this.charges[this._drag].y = p.y; this._cmDirty = true; this._cursorE = this._fieldAt(p.x, p.y); this.draw(); } else { this._hovered = hitIdx(p); this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null; this.draw(); } }); /* ── mouseup ── */ canvas.addEventListener('mouseup', e => { if (e.button !== 0) return; const p = pos(e); const wasDragging = this._drag !== null; if (wasDragging) { this._drag = null; this._cmDirty = true; this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; } /* click (no drag) */ const dp = this._downPos || p; const dist = Math.hypot(p.x - dp.x, p.y - dp.y); if (dist < 5) { const hi = hitIdx(p); if (hi < 0) this.addCharge(p.x, p.y, this.addSign); } if (this.onUpdate) this.onUpdate(this.info()); }); /* ── contextmenu (remove) ── */ canvas.addEventListener('contextmenu', e => { e.preventDefault(); const p = pos(e); const hi = hitIdx(p); if (hi >= 0) this.removeCharge(hi); }); /* ── dblclick (remove) ── */ canvas.addEventListener('dblclick', e => { const p = pos(e); const hi = hitIdx(p); if (hi >= 0) this.removeCharge(hi); }); /* ── mouseleave ── */ canvas.addEventListener('mouseleave', () => { this._cursorE = null; this._mousePos = null; this._hovered = null; this.draw(); }); /* ── touch support ── */ canvas.addEventListener('touchstart', e => { e.preventDefault(); const p = pos(e); const hi = hitIdx(p); this._downPos = p; if (hi >= 0) this._drag = hi; }, { passive: false }); canvas.addEventListener('touchmove', e => { e.preventDefault(); const p = pos(e); this._mousePos = p; if (this._drag !== null) { this.charges[this._drag].x = p.x; this.charges[this._drag].y = p.y; this._cmDirty = true; this._cursorE = this._fieldAt(p.x, p.y); this.draw(); } }, { passive: false }); canvas.addEventListener('touchend', e => { e.preventDefault(); const wasDragging = this._drag !== null; if (wasDragging) { this._drag = null; this._cmDirty = true; this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; } const p = pos({ touches: e.changedTouches }); const dp = this._downPos || p; const dist = Math.hypot(p.x - dp.x, p.y - dp.y); if (dist < 10) { const hi = hitIdx(p); if (hi < 0) this.addCharge(p.x, p.y, this.addSign); } if (this.onUpdate) this.onUpdate(this.info()); }, { passive: false }); } } /* ─── lab UI init ─────────────────────────────────── */ var csSim = null; function _openCoulomb() { document.getElementById('sim-topbar-title').textContent = 'Закон Кулона'; _simShow('sim-coulomb'); _simShow('ctrl-coulomb'); requestAnimationFrame(() => requestAnimationFrame(() => { const canvas = document.getElementById('coulomb-canvas'); if (!csSim) { csSim = new CoulombSim(canvas); csSim.onUpdate = _coulombUpdateUI; } csSim.fit(); if (csSim.charges.length === 0) csSim.preset('dipole'); _coulombUpdateUI(csSim.info()); })); } function coulombSign(s) { if (!csSim) return; csSim.setSign(s); document.getElementById('cbtn-pos').classList.toggle('active', s > 0); document.getElementById('cbtn-neg').classList.toggle('active', s < 0); document.getElementById('csign-pos').style.opacity = s > 0 ? '1' : '0.45'; document.getElementById('csign-neg').style.opacity = s < 0 ? '1' : '0.45'; } function coulombLayer(name, rowEl) { if (!csSim) return; csSim.toggleLayer(name); const on = csSim.layers[name]; rowEl.classList.toggle('active', on); const tog = rowEl.querySelector('.tri-toggle'); if (tog) { tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)'; const dot = tog.querySelector('span'); if (dot) dot.style.marginLeft = on ? '14px' : '2px'; } csSim.draw(); } function coulombPreset(name) { if (!csSim) return; csSim.preset(name); } function _coulombUpdateUI(info) { if (!info) return; document.getElementById('cs-total').textContent = info.total; document.getElementById('cs-curE').textContent = info.cursorE; document.getElementById('cs-curV').textContent = info.cursorV; document.getElementById('csbar-total').textContent = info.total; document.getElementById('csbar-pos').textContent = info.positive; document.getElementById('csbar-neg').textContent = info.negative; document.getElementById('csbar-maxE').textContent = info.maxE; document.getElementById('csbar-curE').textContent = info.cursorE; } /* ════════════════════════════════ ЭЛЕКТРИЧЕСКИЕ ЦЕПИ ════════════════════════════════ */