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