Files
Learn_System/frontend/js/labs/logic.js
T
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

893 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ══════════════════════════════════════════════════════════
LogicSim — Логические схемы
Canvas-based digital logic circuit builder.
Exports: LogicSim class, logicTool(), logicPreset(), logicClearAll(), _openLogic()
══════════════════════════════════════════════════════════ */
/* ── Gate definitions ── */
const GATE_DEFS = {
INPUT: { ins: 0, outs: 1, label: 'IN', w: 56, h: 36 },
CLOCK: { ins: 0, outs: 1, label: 'CLK', w: 56, h: 36 },
OUTPUT: { ins: 1, outs: 0, label: 'OUT', w: 56, h: 36 },
AND: { ins: 2, outs: 1, label: 'AND', w: 64, h: 44 },
OR: { ins: 2, outs: 1, label: 'OR', w: 64, h: 44 },
NOT: { ins: 1, outs: 1, label: 'NOT', w: 56, h: 36 },
XOR: { ins: 2, outs: 1, label: 'XOR', w: 64, h: 44 },
NAND: { ins: 2, outs: 1, label: 'NAND', w: 64, h: 44 },
NOR: { ins: 2, outs: 1, label: 'NOR', w: 64, h: 44 },
XNOR: { ins: 2, outs: 1, label: 'XNOR', w: 64, h: 44 },
BUFFER: { ins: 1, outs: 1, label: 'BUF', w: 56, h: 36 },
};
const PORT_R = 5; // port dot radius
const GRID = 20; // snap grid size
/* ── evaluate a single gate ── */
function evalGate(type, inputs) {
const a = inputs[0] || 0;
const b = inputs[1] || 0;
switch (type) {
case 'AND': return a & b;
case 'OR': return a | b;
case 'NOT': return a ? 0 : 1;
case 'XOR': return a ^ b;
case 'NAND': return (a & b) ? 0 : 1;
case 'NOR': return (a | b) ? 0 : 1;
case 'XNOR': return (a ^ b) ? 0 : 1;
case 'BUFFER': return a;
case 'INPUT': return a; // value from state.value
case 'CLOCK': return a;
case 'OUTPUT': return a;
default: return 0;
}
}
/* ═══════════════════════════════════════════════════════════
LogicSim
═══════════════════════════════════════════════════════════ */
class LogicSim {
constructor(canvas, exprEl, tableEl) {
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
this._exprEl = exprEl; // element for boolean expression display
this._tableEl = tableEl; // element for truth table
this._gates = []; // { id, type, x, y, value, label, freq, _phase }
this._wires = []; // { from: {gateId, port:'out'|'in0'|'in1'}, to: {gateId, port} }
this._nextId = 1;
this._tool = 'select'; // 'select' | type key
this._drag = null;
this._wireStart = null; // { gateId, side:'out', px, py }
this._history = [];
this._histIdx = -1;
this._raf = null;
this._clockRaf = null;
this._fxLastT = 0;
this._bindEvents();
this._startClock();
this._startDrawLoop();
}
/* ── port pixel positions ── */
_portPx(gate, port) {
const def = GATE_DEFS[gate.type];
const hw = def.w / 2, hh = def.h / 2;
const cx = gate.x, cy = gate.y;
if (port === 'out') return { x: cx + hw, y: cy };
if (port === 'in0') {
if (def.ins === 1) return { x: cx - hw, y: cy };
return { x: cx - hw, y: cy - hh / 2 };
}
if (port === 'in1') return { x: cx - hw, y: cy + hh / 2 };
return { x: cx, y: cy };
}
/* ── snap to grid ── */
_snap(v) { return Math.round(v / GRID) * GRID; }
/* ── find gate near point ── */
_hitGate(px, py) {
for (let i = this._gates.length - 1; i >= 0; i--) {
const g = this._gates[i];
const def = GATE_DEFS[g.type];
if (Math.abs(px - g.x) <= def.w / 2 + 2 && Math.abs(py - g.y) <= def.h / 2 + 2) return g;
}
return null;
}
/* ── find port near point; returns { gateId, port, px, py } or null ── */
_hitPort(px, py) {
for (const g of this._gates) {
const def = GATE_DEFS[g.type];
const ports = [];
if (def.outs > 0) ports.push('out');
if (def.ins >= 1) ports.push('in0');
if (def.ins >= 2) ports.push('in1');
for (const port of ports) {
const p = this._portPx(g, port);
if (Math.hypot(px - p.x, py - p.y) <= 10) {
return { gateId: g.id, port, px: p.x, py: p.y };
}
}
}
return null;
}
/* ── canvas coordinates from event ── */
_pos(e) {
const r = this._canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
/* ══ Event binding ══ */
_bindEvents() {
const c = this._canvas;
c.addEventListener('mousedown', this._onDown.bind(this));
c.addEventListener('mousemove', this._onMove.bind(this));
c.addEventListener('mouseup', this._onUp.bind(this));
c.addEventListener('dblclick', this._onDbl.bind(this));
c.addEventListener('contextmenu', e => { e.preventDefault(); this._onRightClick(e); });
window.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'z') { e.preventDefault(); this.undo(); }
if (e.ctrlKey && e.key === 'y') { e.preventDefault(); this.redo(); }
});
}
_onDown(e) {
if (e.button !== 0) return;
const { x, y } = this._pos(e);
if (this._tool !== 'select') {
// place a new gate
const type = this._tool;
const def = GATE_DEFS[type];
if (!def) return;
this._pushHistory();
const g = this._addGate(type, this._snap(x), this._snap(y));
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
this._propagate();
this._updatePanels();
this.draw();
return;
}
// select tool: check port first (wire drawing)
const hitP = this._hitPort(x, y);
if (hitP && (hitP.port === 'out')) {
this._wireStart = hitP;
this._mouseX = x; this._mouseY = y;
return;
}
// check gate drag
const g = this._hitGate(x, y);
if (g) {
this._drag = { gate: g, ox: x - g.x, oy: y - g.y };
}
}
_onMove(e) {
const { x, y } = this._pos(e);
this._mouseX = x; this._mouseY = y;
if (this._drag) {
this._drag.gate.x = this._snap(x - this._drag.ox);
this._drag.gate.y = this._snap(y - this._drag.oy);
this._propagate();
this._updatePanels();
this.draw();
return;
}
if (this._wireStart) {
this.draw();
return;
}
// hover cursor
const hp = this._hitPort(x, y);
this._canvas.style.cursor = hp ? 'crosshair' : (this._hitGate(x, y) ? 'grab' : 'default');
}
_onUp(e) {
if (this._drag) {
this._pushHistory();
this._drag = null;
return;
}
if (this._wireStart) {
const { x, y } = this._pos(e);
const hitP = this._hitPort(x, y);
if (hitP && (hitP.port === 'in0' || hitP.port === 'in1') && hitP.gateId !== this._wireStart.gateId) {
// check not already wired
const exists = this._wires.some(w => w.to.gateId === hitP.gateId && w.to.port === hitP.port);
if (!exists) {
this._pushHistory();
this._wires.push({ from: { gateId: this._wireStart.gateId, port: this._wireStart.port }, to: { gateId: hitP.gateId, port: hitP.port } });
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.3 });
this._propagate();
this._updatePanels();
}
}
this._wireStart = null;
this.draw();
}
}
_onDbl(e) {
const { x, y } = this._pos(e);
const g = this._hitGate(x, y);
if (g && (g.type === 'INPUT')) {
this._pushHistory();
g.value = g.value ? 0 : 1;
if (window.LabFX) {
LabFX.sound.play('bounce', { pitch: 1.2 });
LabFX.particles.emit({ ctx: this._ctx, x: g.x, y: g.y, count: 5, color: '#00ff88',
speed: 20, spread: Math.PI * 2, angle: 0, gravity: 0, life: 400, fade: true,
glow: true, shape: 'spark', size: 3, sizeFade: true });
}
this._propagate();
this._updatePanels();
this.draw();
}
if (g && g.type === 'OUTPUT') {
// rename label cycle: OUT → OUT₁ → OUT₂ → OUT (no-op, just show)
}
}
_onRightClick(e) {
const { x, y } = this._pos(e);
// delete wire near click
for (let i = this._wires.length - 1; i >= 0; i--) {
const w = this._wires[i];
const g1 = this._gateById(w.from.gateId); if (!g1) continue;
const g2 = this._gateById(w.to.gateId); if (!g2) continue;
const p1 = this._portPx(g1, w.from.port);
const p2 = this._portPx(g2, w.to.port);
if (this._distToSeg(x, y, p1.x, p1.y, p2.x, p2.y) < 8) {
this._pushHistory();
this._wires.splice(i, 1);
this._propagate();
this._updatePanels();
this.draw();
return;
}
}
// delete gate
const g = this._hitGate(x, y);
if (g) {
this._pushHistory();
this._wires = this._wires.filter(w => w.from.gateId !== g.id && w.to.gateId !== g.id);
this._gates = this._gates.filter(gg => gg.id !== g.id);
if (window.LabFX) LabFX.sound.play('fizz', { volume: 0.2 });
this._propagate();
this._updatePanels();
this.draw();
}
}
_distToSeg(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 === 0) return Math.hypot(px - ax, py - ay);
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/* ══ Gate management ══ */
_addGate(type, x, y) {
const id = this._nextId++;
const def = GATE_DEFS[type];
const g = { id, type, x, y, value: 0, label: def.label };
if (type === 'INPUT') { g.label = String.fromCharCode(64 + this._gates.filter(gg => gg.type === 'INPUT').length + 1); }
if (type === 'OUTPUT') { g.label = 'OUT' + (this._gates.filter(gg => gg.type === 'OUTPUT').length + 1 > 1 ? (this._gates.filter(gg => gg.type === 'OUTPUT').length + 1) : ''); }
if (type === 'CLOCK') { g.freq = 1; g._phase = 0; }
this._gates.push(g);
return g;
}
_gateById(id) { return this._gates.find(g => g.id === id) || null; }
/* ══ Logic propagation (topological sort + eval) ══ */
_propagate() {
// Build in-degree map
const inDeg = {};
this._gates.forEach(g => { inDeg[g.id] = 0; });
const deps = {}; // id -> [id of gates this gate depends on]
this._gates.forEach(g => { deps[g.id] = []; });
this._wires.forEach(w => {
inDeg[w.to.gateId]++;
deps[w.to.gateId].push(w.from.gateId);
});
// Kahn's algorithm
const queue = this._gates.filter(g => inDeg[g.id] === 0).map(g => g.id);
const sorted = [];
const visited = new Set();
while (queue.length) {
const id = queue.shift();
if (visited.has(id)) continue;
visited.add(id);
sorted.push(id);
// find wires going FROM this gate
this._wires.forEach(w => {
if (w.from.gateId === id) {
inDeg[w.to.gateId]--;
if (inDeg[w.to.gateId] === 0) queue.push(w.to.gateId);
}
});
}
// any unvisited (loops): add them to sorted anyway
this._gates.forEach(g => { if (!visited.has(g.id)) sorted.push(g.id); });
for (const id of sorted) {
const g = this._gateById(id);
if (!g) continue;
if (g.type === 'INPUT' || g.type === 'CLOCK') continue; // value set externally
const ins = this._getInputValues(g);
g.value = evalGate(g.type, ins);
}
}
_getInputValues(gate) {
const def = GATE_DEFS[gate.type];
const vals = new Array(def.ins).fill(0);
this._wires.forEach(w => {
if (w.to.gateId !== gate.id) return;
const src = this._gateById(w.from.gateId);
if (!src) return;
const idx = w.to.port === 'in0' ? 0 : 1;
vals[idx] = src.value;
});
return vals;
}
/* ══ Clock ══ */
_startClock() {
let last = 0;
const tick = (now) => {
this._clockRaf = requestAnimationFrame(tick);
const dt = (now - last) / 1000;
const dtMs = now - (last || now);
last = now;
let changed = false;
this._gates.forEach(g => {
if (g.type !== 'CLOCK') return;
g._phase = (g._phase || 0) + dt * (g.freq || 1);
const newVal = g._phase % 1 < 0.5 ? 1 : 0;
if (newVal !== g.value) { g.value = newVal; changed = true; }
});
if (window.LabFX && dtMs > 0) LabFX.particles.update(dtMs);
if (changed) {
this._propagate();
this._updatePanels();
this.draw();
}
};
this._clockRaf = requestAnimationFrame(tick);
}
/* ══ Undo / Redo ══ */
_pushHistory() {
const snap = JSON.stringify({ gates: this._gates, wires: this._wires, nextId: this._nextId });
this._history = this._history.slice(0, this._histIdx + 1);
this._history.push(snap);
if (this._history.length > 50) this._history.shift();
this._histIdx = this._history.length - 1;
}
undo() {
if (this._histIdx <= 0) return;
this._histIdx--;
this._restoreHistory(this._history[this._histIdx]);
}
redo() {
if (this._histIdx >= this._history.length - 1) return;
this._histIdx++;
this._restoreHistory(this._history[this._histIdx]);
}
_restoreHistory(snap) {
const s = JSON.parse(snap);
this._gates = s.gates;
this._wires = s.wires;
this._nextId = s.nextId;
this._propagate();
this._updatePanels();
this.draw();
}
/* ══ Fit canvas to element ══ */
fit() {
const el = this._canvas.parentElement || this._canvas;
const dpr = window.devicePixelRatio || 1;
const w = el.clientWidth || 800;
const h = el.clientHeight || 500;
this._canvas.width = w * dpr;
this._canvas.height = h * dpr;
this._canvas.style.width = w + 'px';
this._canvas.style.height = h + 'px';
this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.draw();
}
/* ══ Drawing ══ */
draw() {
const ctx = this._ctx;
const W = this._canvas.width / (window.devicePixelRatio || 1);
const H = this._canvas.height / (window.devicePixelRatio || 1);
ctx.clearRect(0, 0, W, H);
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += GRID) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += GRID) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
// wires
this._wires.forEach(w => this._drawWire(ctx, w));
// ghost wire while dragging
if (this._wireStart) {
const p = this._wireStart;
ctx.beginPath();
ctx.moveTo(p.px, p.py);
ctx.lineTo(this._mouseX || p.px, this._mouseY || p.py);
ctx.strokeStyle = 'rgba(255,255,100,0.7)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
// gates
this._gates.forEach(g => this._drawGate(ctx, g));
if (window.LabFX) LabFX.particles.draw(ctx);
}
_drawWire(ctx, w) {
const g1 = this._gateById(w.from.gateId);
const g2 = this._gateById(w.to.gateId);
if (!g1 || !g2) return;
const p1 = this._portPx(g1, w.from.port);
const p2 = this._portPx(g2, w.to.port);
const val = g1.value;
ctx.beginPath();
// L-route
const mx = (p1.x + p2.x) / 2;
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(mx, p1.y);
ctx.lineTo(mx, p2.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = val ? '#00ff88' : 'rgba(255,255,255,0.25)';
ctx.lineWidth = val ? 2.2 : 1.5;
ctx.stroke();
// Wire HIGH: animated dot flowing along path
if (val && window.LabFX) {
const frac = ((performance.now() * 0.001) % 1);
// interpolate along L-route: seg1 p1→(mx,p1.y), seg2 (mx,p1.y)→(mx,p2.y), seg3 (mx,p2.y)→p2
const seg1 = Math.abs(mx - p1.x);
const seg2 = Math.abs(p2.y - p1.y);
const seg3 = Math.abs(p2.x - mx);
const total = seg1 + seg2 + seg3 || 1;
const dist = frac * total;
let dx, dy;
if (dist <= seg1) {
dx = p1.x + (mx - p1.x) * (dist / (seg1 || 1));
dy = p1.y;
} else if (dist <= seg1 + seg2) {
dx = mx;
dy = p1.y + (p2.y - p1.y) * ((dist - seg1) / (seg2 || 1));
} else {
dx = mx + (p2.x - mx) * ((dist - seg1 - seg2) / (seg3 || 1));
dy = p2.y;
}
ctx.save();
ctx.beginPath();
ctx.arc(dx, dy, 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#06D6E0';
ctx.shadowColor = '#06D6E0';
ctx.shadowBlur = 8;
ctx.fill();
ctx.restore();
}
}
_drawGate(ctx, g) {
const def = GATE_DEFS[g.type];
const hw = def.w / 2, hh = def.h / 2;
const x = g.x, y = g.y;
// OUTPUT LED glow via LabFX
const isHigh = g.value === 1;
if (g.type === 'OUTPUT' && isHigh && window.LabFX) {
LabFX.glow.drawGlow(ctx, () => {
ctx.beginPath();
ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
ctx.fill();
}, { color: '#00FF80', intensity: 18, layers: 2 });
}
// gate body
ctx.beginPath();
ctx.roundRect(x - hw, y - hh, def.w, def.h, 6);
let fill = 'rgba(30,30,60,0.9)';
if (g.type === 'INPUT') fill = isHigh ? 'rgba(0,220,100,0.35)' : 'rgba(60,60,100,0.8)';
if (g.type === 'CLOCK') fill = isHigh ? 'rgba(0,180,255,0.35)' : 'rgba(40,40,100,0.8)';
if (g.type === 'OUTPUT') fill = isHigh ? 'rgba(255,80,80,0.55)' : 'rgba(60,30,30,0.8)';
ctx.fillStyle = fill;
ctx.fill();
const borderCol = g.type === 'OUTPUT' ? (isHigh ? '#ff6060' : 'rgba(255,255,255,0.2)')
: g.type === 'INPUT' ? (isHigh ? '#00cc66' : 'rgba(255,255,255,0.2)')
: g.type === 'CLOCK' ? (isHigh ? '#00aaff' : 'rgba(255,255,255,0.2)')
: 'rgba(155,93,229,0.6)';
ctx.strokeStyle = borderCol;
ctx.lineWidth = 1.5;
ctx.stroke();
// label
ctx.fillStyle = isHigh ? '#fff' : 'rgba(255,255,255,0.75)';
ctx.font = `bold ${def.ins <= 1 ? 10 : 9}px Manrope,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lbl = (g.type === 'INPUT' || g.type === 'OUTPUT') ? g.label : def.label;
ctx.fillText(lbl, x, y);
// value badge (INPUT / OUTPUT / CLOCK)
if (g.type === 'INPUT' || g.type === 'OUTPUT' || g.type === 'CLOCK') {
ctx.fillStyle = isHigh ? '#00ff88' : 'rgba(255,255,255,0.3)';
ctx.font = 'bold 9px Manrope,sans-serif';
ctx.fillText(isHigh ? '1' : '0', x + hw - 10, y - hh + 9);
}
// ports
this._drawPorts(ctx, g);
}
_drawPorts(ctx, g) {
const def = GATE_DEFS[g.type];
const ports = [];
if (def.outs > 0) ports.push('out');
if (def.ins >= 1) ports.push('in0');
if (def.ins >= 2) ports.push('in1');
for (const port of ports) {
const p = this._portPx(g, port);
const isOut = port === 'out';
const srcGate = isOut ? g : null;
const val = isOut ? g.value : this._getInputValues(g)[port === 'in0' ? 0 : 1];
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = val ? '#00ff88' : 'rgba(255,255,255,0.3)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
/* ══ Boolean expression panel ══ */
_updatePanels() {
this._updateExprPanel();
this._updateTruthTable();
}
_buildExpr(gateId, depth) {
if (depth > 20) return '…';
const g = this._gateById(gateId);
if (!g) return '?';
if (g.type === 'INPUT') return g.label;
if (g.type === 'CLOCK') return g.label || 'CLK';
const srcOf = (port) => {
const w = this._wires.find(ww => ww.to.gateId === gateId && ww.to.port === port);
return w ? this._buildExpr(w.from.gateId, depth + 1) : '0';
};
const a = g.type !== 'NOT' && g.type !== 'BUFFER' ? srcOf('in0') : srcOf('in0');
const b = srcOf('in1');
switch (g.type) {
case 'AND': return `(${a}${b})`;
case 'OR': return `(${a} ${b})`;
case 'NOT': return ${a}`;
case 'XOR': return `(${a}${b})`;
case 'NAND': return `¬(${a}${b})`;
case 'NOR': return `¬(${a} ${b})`;
case 'XNOR': return `¬(${a}${b})`;
case 'BUFFER': return a;
default: return '?';
}
}
_updateExprPanel() {
if (!this._exprEl) return;
const outputs = this._gates.filter(g => g.type === 'OUTPUT');
if (outputs.length === 0) {
this._exprEl.textContent = 'Добавьте OUTPUT для вывода выражения';
return;
}
const lines = outputs.map(out => {
const w = this._wires.find(ww => ww.to.gateId === out.id);
if (!w) return `${out.label} = ?`;
const expr = this._buildExpr(w.from.gateId, 0);
return `${out.label} = ${expr}`;
});
this._exprEl.textContent = lines.join(' | ');
}
_updateTruthTable() {
if (!this._tableEl) return;
const inputs = this._gates.filter(g => g.type === 'INPUT');
const outputs = this._gates.filter(g => g.type === 'OUTPUT');
if (inputs.length === 0 || outputs.length === 0) {
this._tableEl.innerHTML = '<span style="color:rgba(255,255,255,0.35)">Добавьте INPUT и OUTPUT</span>';
return;
}
const n = inputs.length;
if (n > 6) {
this._tableEl.innerHTML = '<span style="color:rgba(255,255,255,0.35)">Слишком много входов (макс 6)</span>';
return;
}
const rows = 1 << n;
// save current values
const savedVals = inputs.map(g => g.value);
let html = '<table class="logic-tt"><thead><tr>';
inputs.forEach(g => { html += `<th>${g.label}</th>`; });
outputs.forEach(g => { html += `<th>${g.label}</th>`; });
html += '</tr></thead><tbody>';
// determine current row
const curRow = savedVals.reduce((acc, v, i) => acc | (v << (n - 1 - i)), 0);
for (let r = 0; r < rows; r++) {
inputs.forEach((g, i) => { g.value = (r >> (n - 1 - i)) & 1; });
this._propagate();
const isCur = r === curRow;
html += `<tr${isCur ? ' class="logic-tt-cur"' : ''}>`;
inputs.forEach((g, i) => { html += `<td>${(r >> (n - 1 - i)) & 1}</td>`; });
outputs.forEach(g => { html += `<td style="color:${g.value ? '#00ff88' : 'rgba(255,255,255,0.4)'}">${g.value}</td>`; });
html += '</tr>';
}
html += '</tbody></table>';
this._tableEl.innerHTML = html;
// restore
inputs.forEach((g, i) => { g.value = savedVals[i]; });
this._propagate();
}
/* ══ Presets ══ */
preset(name) {
this._pushHistory();
this._gates = [];
this._wires = [];
this._nextId = 1;
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.0, volume: 0.3 });
const add = (type, x, y) => this._addGate(type, x, y);
const wire = (a, ap, b, bp) => this._wires.push({ from: { gateId: a.id, port: ap }, to: { gateId: b.id, port: bp } });
switch (name) {
case 'half-adder': {
const A = add('INPUT', 80, 120); A.label = 'A';
const B = add('INPUT', 80, 200); B.label = 'B';
const xor = add('XOR', 200, 120);
const and = add('AND', 200, 200);
const S = add('OUTPUT', 320, 120); S.label = 'S';
const C = add('OUTPUT', 320, 200); C.label = 'C';
wire(A, 'out', xor, 'in0'); wire(B, 'out', xor, 'in1');
wire(A, 'out', and, 'in0'); wire(B, 'out', and, 'in1');
wire(xor, 'out', S, 'in0');
wire(and, 'out', C, 'in0');
break;
}
case 'full-adder': {
const A = add('INPUT', 60, 100); A.label = 'A';
const B = add('INPUT', 60, 180); B.label = 'B';
const Cin = add('INPUT', 60, 260); Cin.label = 'Cin';
const xr1 = add('XOR', 180, 140);
const xr2 = add('XOR', 300, 140);
const an1 = add('AND', 180, 220);
const an2 = add('AND', 300, 220);
const or1 = add('OR', 400, 220);
const S = add('OUTPUT', 420, 140); S.label = 'S';
const Cout = add('OUTPUT', 520, 220); Cout.label = 'Cout';
wire(A, 'out', xr1, 'in0'); wire(B, 'out', xr1, 'in1');
wire(xr1, 'out', xr2, 'in0'); wire(Cin, 'out', xr2, 'in1');
wire(A, 'out', an1, 'in0'); wire(B, 'out', an1, 'in1');
wire(xr1, 'out', an2, 'in0'); wire(Cin, 'out', an2, 'in1');
wire(an1, 'out', or1, 'in0'); wire(an2, 'out', or1, 'in1');
wire(xr2, 'out', S, 'in0');
wire(or1, 'out', Cout, 'in0');
break;
}
case 'rs-latch': {
// Cross-coupled NOR gates: Q=NOR(R,Qbar), Qbar=NOR(S,Q)
// We simplify: R→NOR1, S→NOR2 cross-coupled; initial state stabilised
const R = add('INPUT', 80, 120); R.label = 'R';
const S = add('INPUT', 80, 220); S.label = 'S';
const nr1 = add('NOR', 220, 120);
const nr2 = add('NOR', 220, 220);
const Q = add('OUTPUT', 340, 120); Q.label = 'Q';
const Qb = add('OUTPUT', 340, 220); Qb.label = 'Q̅';
wire(R, 'out', nr1, 'in0');
wire(S, 'out', nr2, 'in1');
// Cross connections — we add them and propagate will stabilise
wire(nr2, 'out', nr1, 'in1');
wire(nr1, 'out', nr2, 'in0');
wire(nr1, 'out', Q, 'in0');
wire(nr2, 'out', Qb, 'in0');
// run propagation twice to stabilise
this._propagate(); this._propagate();
break;
}
case 'd-latch': {
// D latch: Q = D when CLK=1, holds otherwise
// SR from D: S=D∧CLK, R=¬D∧CLK
const D = add('INPUT', 60, 140); D.label = 'D';
const CLK = add('CLOCK', 60, 220); CLK.label = 'CLK'; CLK.freq = 1;
const notD = add('NOT', 160, 180);
const an1 = add('AND', 260, 120);
const an2 = add('AND', 260, 220);
const nr1 = add('NOR', 360, 120);
const nr2 = add('NOR', 360, 220);
const Q = add('OUTPUT', 480, 120); Q.label = 'Q';
const Qb = add('OUTPUT', 480, 220); Qb.label = 'Q̅';
wire(D, 'out', notD, 'in0');
wire(D, 'out', an1, 'in0'); wire(CLK, 'out', an1, 'in1');
wire(notD, 'out', an2, 'in0'); wire(CLK, 'out', an2, 'in1');
wire(an1, 'out', nr1, 'in0'); wire(nr2, 'out', nr1, 'in1');
wire(an2, 'out', nr2, 'in1'); wire(nr1, 'out', nr2, 'in0');
wire(nr1, 'out', Q, 'in0');
wire(nr2, 'out', Qb, 'in0');
break;
}
case 'decoder-2to4': {
const A = add('INPUT', 60, 100); A.label = 'A';
const B = add('INPUT', 60, 200); B.label = 'B';
const nA = add('NOT', 160, 100);
const nB = add('NOT', 160, 200);
const g0 = add('AND', 280, 80);
const g1 = add('AND', 280, 160);
const g2 = add('AND', 280, 240);
const g3 = add('AND', 280, 320);
const o0 = add('OUTPUT', 400, 80); o0.label = 'Y0';
const o1 = add('OUTPUT', 400, 160); o1.label = 'Y1';
const o2 = add('OUTPUT', 400, 240); o2.label = 'Y2';
const o3 = add('OUTPUT', 400, 320); o3.label = 'Y3';
wire(A, 'out', nA, 'in0');
wire(B, 'out', nB, 'in0');
wire(nA, 'out', g0, 'in0'); wire(nB, 'out', g0, 'in1');
wire(A, 'out', g1, 'in0'); wire(nB, 'out', g1, 'in1');
wire(nA, 'out', g2, 'in0'); wire(B, 'out', g2, 'in1');
wire(A, 'out', g3, 'in0'); wire(B, 'out', g3, 'in1');
wire(g0, 'out', o0, 'in0');
wire(g1, 'out', o1, 'in0');
wire(g2, 'out', o2, 'in0');
wire(g3, 'out', o3, 'in0');
break;
}
case 'mux-2to1': {
// Y = (A ∧ ¬S) (B ∧ S)
const A = add('INPUT', 60, 100); A.label = 'A';
const B = add('INPUT', 60, 200); B.label = 'B';
const Sel= add('INPUT', 60, 300); Sel.label = 'S';
const nS = add('NOT', 160, 300);
const an1= add('AND', 280, 120);
const an2= add('AND', 280, 240);
const or1= add('OR', 380, 180);
const Y = add('OUTPUT', 480, 180); Y.label = 'Y';
wire(Sel, 'out', nS, 'in0');
wire(A, 'out', an1, 'in0'); wire(nS, 'out', an1, 'in1');
wire(B, 'out', an2, 'in0'); wire(Sel, 'out', an2, 'in1');
wire(an1, 'out', or1, 'in0'); wire(an2, 'out', or1, 'in1');
wire(or1, 'out', Y, 'in0');
break;
}
default: {
const A = add('INPUT', 100, 160); A.label = 'A';
const B = add('INPUT', 100, 240); B.label = 'B';
const g = add('AND', 240, 200);
const O = add('OUTPUT', 360, 200);
wire(A, 'out', g, 'in0'); wire(B, 'out', g, 'in1');
wire(g, 'out', O, 'in0');
}
}
this._propagate();
this._updatePanels();
this.draw();
}
/* ── Clear ── */
clear() {
this._pushHistory();
this._gates = [];
this._wires = [];
this._propagate();
this._updatePanels();
this.draw();
}
/* ── Continuous draw loop for wire animations ── */
_startDrawLoop() {
const loop = () => {
this._raf = requestAnimationFrame(loop);
// Only redraw if any wire is HIGH (animated dot)
const anyHigh = this._wires.some(w => {
const g = this._gateById(w.from.gateId);
return g && g.value === 1;
});
if (anyHigh) this.draw();
};
this._raf = requestAnimationFrame(loop);
}
/* ── Destroy ── */
destroy() {
if (this._clockRaf) cancelAnimationFrame(this._clockRaf);
if (this._raf) cancelAnimationFrame(this._raf);
}
/* ── Set tool ── */
setTool(t) { this._tool = t; }
}
/* ═══════════════════════════════════════════════════════════
Global helpers called from HTML
═══════════════════════════════════════════════════════════ */
var logicSim = null;
var _logicTableOpen = true;
function logicTool(t, el) {
if (logicSim) logicSim.setTool(t);
document.querySelectorAll('.lgc-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === t));
}
function logicPreset(name) {
if (logicSim) logicSim.preset(name);
}
function logicClearAll() {
if (logicSim) logicSim.clear();
}
function logicToggleTable() {
_logicTableOpen = !_logicTableOpen;
const panel = document.getElementById('logic-tt-panel');
if (panel) panel.style.display = _logicTableOpen ? '' : 'none';
const btn = document.getElementById('btn-logic-tt');
if (btn) btn.classList.toggle('active', _logicTableOpen);
}
function _openLogic() {
document.getElementById('sim-topbar-title').textContent = 'Логические схемы';
_simShow('sim-logic');
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('logic-canvas');
const exprEl = document.getElementById('logic-expr');
const tableEl = document.getElementById('logic-tt-body');
if (!logicSim) {
logicSim = new LogicSim(canvas, exprEl, tableEl);
} else {
// re-attach panels in case DOM was re-created
logicSim._exprEl = exprEl;
logicSim._tableEl = tableEl;
}
logicSim.fit();
if (logicSim._gates.length === 0) logicSim.preset('half-adder');
logicSim._updatePanels();
logicSim.draw();
// select tool active by default
logicTool('select', null);
}));
}