6afe928c0d
ФУНДАМЕНТ (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>
893 lines
31 KiB
JavaScript
893 lines
31 KiB
JavaScript
'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);
|
||
}));
|
||
}
|