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>
485 lines
22 KiB
JavaScript
485 lines
22 KiB
JavaScript
'use strict';
|
|
/* ====================================================================
|
|
IonExSim — Реакции ионного обмена
|
|
==================================================================== */
|
|
|
|
class IonExSim {
|
|
|
|
/* ── Данные реакций ──────────────────────────────────────────────── */
|
|
|
|
static RXN = {
|
|
ba_so4: {
|
|
name: 'BaCl₂ + Na₂SO₄',
|
|
left: [{ f: 'Ba²⁺', color: '#4FC3F7', count: 7 }, { f: 'Cl⁻', color: '#AED581', count: 14 }],
|
|
right: [{ f: 'Na⁺', color: '#FFD54F', count: 14 }, { f: 'SO₄²⁻', color: '#F48FB1', count: 7 }],
|
|
reacts: ['Ba²⁺', 'SO₄²⁻'],
|
|
spectators: ['Cl⁻', 'Na⁺'],
|
|
product: { f: 'BaSO₄', color: '#E0E0E0' },
|
|
mol: 'BaCl₂ + Na₂SO₄ <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> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaCl',
|
|
full_ion: 'Ba²⁺ + 2Cl⁻ + 2Na⁺ + SO₄²⁻ <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> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2Cl⁻',
|
|
net_ion: 'Ba²⁺ + SO₄²⁻ <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> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
|
type: 'precip', pcolor: '#E0E0E0', pname: 'BaSO₄ — белый осадок',
|
|
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#E0E0E0',
|
|
},
|
|
ag_cl: {
|
|
name: 'AgNO₃ + NaCl',
|
|
left: [{ f: 'Ag⁺', color: '#E0E0E0', count: 10 }, { f: 'NO₃⁻', color: '#FFCC02', count: 10 }],
|
|
right: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }],
|
|
reacts: ['Ag⁺', 'Cl⁻'],
|
|
spectators: ['NO₃⁻', 'Na⁺'],
|
|
product: { f: 'AgCl', color: '#F5F5F5' },
|
|
mol: 'AgNO₃ + NaCl <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> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + NaNO₃',
|
|
full_ion: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ <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> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na⁺ + NO₃⁻',
|
|
net_ion: 'Ag⁺ + Cl⁻ <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> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
|
type: 'precip', pcolor: '#F5F5F5', pname: 'AgCl — белый творожистый осадок',
|
|
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F5F5F5',
|
|
},
|
|
co3_hcl: {
|
|
name: 'Na₂CO₃ + HCl',
|
|
left: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 5 }],
|
|
right: [{ f: 'H⁺', color: '#EF5350', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }],
|
|
reacts: ['CO₃²⁻', 'H⁺'],
|
|
spectators: ['Na⁺', 'Cl⁻'],
|
|
product: { f: 'CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', color: '#B0BEC5' },
|
|
mol: 'Na₂CO₃ + 2HCl <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> 2NaCl + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
|
|
full_ion: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ <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> 2Na⁺ + 2Cl⁻ + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
|
|
net_ion: 'CO₃²⁻ + 2H⁺ <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> CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
|
|
type: 'gas', gcolor: '#B0BEC5', gname: 'CO₂ — углекислый газ',
|
|
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', signColor: '#B0BEC5',
|
|
},
|
|
pb_i: {
|
|
name: 'Pb(NO₃)₂ + KI',
|
|
left: [{ f: 'Pb²⁺', color: '#F48FB1', count: 6 }, { f: 'NO₃⁻', color: '#FFCC02', count: 12 }],
|
|
right: [{ f: 'K⁺', color: '#80CBC4', count: 12 }, { f: 'I⁻', color: '#CE93D8', count: 12 }],
|
|
reacts: ['Pb²⁺', 'I⁻'],
|
|
spectators: ['NO₃⁻', 'K⁺'],
|
|
product: { f: 'PbI₂', color: '#F9A825' },
|
|
mol: 'Pb(NO₃)₂ + 2KI <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> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KNO₃',
|
|
full_ion: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + 2I⁻ <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> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + 2NO₃⁻',
|
|
net_ion: 'Pb²⁺ + 2I⁻ <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> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
|
type: 'precip', pcolor: '#F9A825', pname: 'PbI₂ — ярко-жёлтый осадок',
|
|
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F9A825',
|
|
},
|
|
ca_co3: {
|
|
name: 'CaCl₂ + Na₂CO₃',
|
|
left: [{ f: 'Ca²⁺', color: '#FF8A65', count: 8 }, { f: 'Cl⁻', color: '#AED581', count: 16 }],
|
|
right: [{ f: 'Na⁺', color: '#FFD54F', count: 16 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 8 }],
|
|
reacts: ['Ca²⁺', 'CO₃²⁻'],
|
|
spectators: ['Cl⁻', 'Na⁺'],
|
|
product: { f: 'CaCO₃', color: '#F5F5F5' },
|
|
mol: 'CaCl₂ + Na₂CO₃ <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> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaCl',
|
|
full_ion: 'Ca²⁺ + 2Cl⁻ + 2Na⁺ + CO₃²⁻ <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> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2Cl⁻',
|
|
net_ion: 'Ca²⁺ + CO₃²⁻ <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> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
|
|
type: 'precip', pcolor: '#F5F5F5', pname: 'CaCO₃ — белый осадок (мел)',
|
|
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F5F5F5',
|
|
},
|
|
};
|
|
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.rxnId = 'ba_so4';
|
|
this._raf = null;
|
|
this._last = 0;
|
|
this._t = 0;
|
|
this._phase = 'idle'; // idle | mixing | pairing | done
|
|
this._prog = 0;
|
|
this._stepIdx = 0;
|
|
this._stepTimer = 0;
|
|
this._ions = [];
|
|
this._pairs = [];
|
|
this._precip = [];
|
|
this._gas = [];
|
|
this.W = 0; this.H = 0;
|
|
this.onUpdate = null;
|
|
this.fit();
|
|
this._initIons();
|
|
}
|
|
|
|
fit() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const W = this.canvas.offsetWidth || 600;
|
|
const H = this.canvas.offsetHeight || 400;
|
|
this.canvas.width = Math.round(W * dpr);
|
|
this.canvas.height = Math.round(H * dpr);
|
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
this.W = W; this.H = H;
|
|
this._initIons();
|
|
}
|
|
|
|
setReaction(id) {
|
|
if (!IonExSim.RXN[id]) return;
|
|
this.rxnId = id;
|
|
this.reset();
|
|
}
|
|
|
|
reset() {
|
|
this._phase = 'idle'; this._prog = 0;
|
|
this._stepIdx = 0; this._stepTimer = 0;
|
|
this._pairs = []; this._precip = []; this._gas = [];
|
|
this._initIons();
|
|
this.draw();
|
|
}
|
|
|
|
_initIons() {
|
|
const { W, H } = this;
|
|
const rxn = IonExSim.RXN[this.rxnId];
|
|
const bTop = H * 0.10, bBot = H * 0.78;
|
|
this._ions = [];
|
|
/* Left beaker ions */
|
|
rxn.left.forEach(spec => {
|
|
for (let i = 0; i < spec.count; i++) {
|
|
this._ions.push({
|
|
x: W * 0.10 + Math.random() * W * 0.36,
|
|
y: bTop + 20 + Math.random() * (bBot - bTop - 40),
|
|
vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8,
|
|
spec: spec.f, color: spec.color,
|
|
r: 8 + Math.random() * 3,
|
|
phase: Math.random() * Math.PI * 2,
|
|
active: true, side: 'L',
|
|
reacts: rxn.reacts.includes(spec.f),
|
|
paired: false,
|
|
});
|
|
}
|
|
});
|
|
/* Right beaker ions */
|
|
rxn.right.forEach(spec => {
|
|
for (let i = 0; i < spec.count; i++) {
|
|
this._ions.push({
|
|
x: W * 0.54 + Math.random() * W * 0.36,
|
|
y: bTop + 20 + Math.random() * (bBot - bTop - 40),
|
|
vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8,
|
|
spec: spec.f, color: spec.color,
|
|
r: 8 + Math.random() * 3,
|
|
phase: Math.random() * Math.PI * 2,
|
|
active: true, side: 'R',
|
|
reacts: rxn.reacts.includes(spec.f),
|
|
paired: false,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
start() {
|
|
if (this._phase !== 'idle') this.reset();
|
|
this._phase = 'mixing'; this._prog = 0;
|
|
if (this._raf) return;
|
|
this._last = performance.now();
|
|
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
|
|
this._raf = requestAnimationFrame(loop);
|
|
}
|
|
|
|
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
|
|
|
|
/* ── Физика ─────────────────────────────────────────────────────── */
|
|
|
|
_tick(t) {
|
|
const dt = Math.min((t - this._last) / 1000, 0.05);
|
|
this._last = t; this._t += dt;
|
|
const { W, H } = this;
|
|
const rxn = IonExSim.RXN[this.rxnId];
|
|
|
|
if (this._phase === 'mixing') {
|
|
this._prog = Math.min(1, this._prog + dt * 0.32);
|
|
this._ions.forEach(ion => {
|
|
if (!ion.active) return;
|
|
const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.72;
|
|
const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.38;
|
|
ion.vx += (tx - ion.x) * 0.003 * this._prog;
|
|
ion.vy += (ty - ion.y) * 0.003 * this._prog;
|
|
ion.vx += (Math.random() - 0.5) * 0.7;
|
|
ion.vy += (Math.random() - 0.5) * 0.7;
|
|
ion.vx *= 0.88; ion.vy *= 0.88;
|
|
ion.x += ion.vx; ion.y += ion.vy;
|
|
ion.phase += dt * 2;
|
|
this._clampIon(ion);
|
|
});
|
|
if (this._prog >= 1) { this._phase = 'pairing'; this._prog = 0; }
|
|
}
|
|
|
|
if (this._phase === 'pairing') {
|
|
this._prog = Math.min(1, this._prog + dt * 0.16);
|
|
this._stepTimer += dt;
|
|
if (this._stepTimer > 1.5 && this._stepIdx < 2) { this._stepIdx++; this._stepTimer = 0; }
|
|
|
|
this._ions.forEach(ion => {
|
|
if (!ion.active || ion.paired) return;
|
|
ion.vx += (Math.random() - 0.5) * 0.8;
|
|
ion.vy += (Math.random() - 0.5) * 0.8;
|
|
ion.vx *= 0.88; ion.vy *= 0.88;
|
|
ion.x += ion.vx; ion.y += ion.vy;
|
|
ion.phase += dt * 2;
|
|
this._clampIon(ion);
|
|
});
|
|
|
|
/* Pair up reactive ions */
|
|
if (Math.random() < 0.10 * (0.5 + this._prog)) this._doPair(rxn);
|
|
|
|
/* Animate pairs */
|
|
this._pairs.forEach(p => {
|
|
p.flashT = Math.max(0, p.flashT - dt * 2.5);
|
|
if (rxn.type === 'precip') {
|
|
p.vy = Math.min(p.vy + 0.15, 5);
|
|
p.y += p.vy;
|
|
if (p.y >= H * 0.78 && !p.settled) {
|
|
p.y = H * 0.78; p.vy = 0; p.settled = true;
|
|
this._precip.push({ x: p.x, y: p.y, r: p.r, id: p.id });
|
|
}
|
|
} else if (rxn.type === 'gas') {
|
|
p.vy = Math.max(p.vy - 0.08, -4);
|
|
p.y += p.vy;
|
|
p.alpha = Math.max(0, p.alpha - 0.004);
|
|
}
|
|
});
|
|
this._pairs = this._pairs.filter(p => !p.settled && (p.alpha === undefined || p.alpha > 0));
|
|
|
|
if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 2; }
|
|
}
|
|
|
|
if (this._phase === 'done') {
|
|
this._ions.forEach(ion => {
|
|
if (!ion.active || ion.paired) return;
|
|
ion.vx += (Math.random() - 0.5) * 0.45;
|
|
ion.vy += (Math.random() - 0.5) * 0.45;
|
|
ion.vx *= 0.92; ion.vy *= 0.92;
|
|
ion.x += ion.vx; ion.y += ion.vy;
|
|
ion.phase += dt;
|
|
this._clampIon(ion);
|
|
});
|
|
}
|
|
|
|
this.draw();
|
|
if (this.onUpdate) this.onUpdate(this.info());
|
|
}
|
|
|
|
_clampIon(ion) {
|
|
const { W, H } = this;
|
|
const bTop = H * 0.10, bBot = H * 0.78;
|
|
if (ion.x < ion.r + 6) { ion.x = ion.r + 6; ion.vx *= -0.5; }
|
|
if (ion.x > W - ion.r - 6) { ion.x = W - ion.r - 6; ion.vx *= -0.5; }
|
|
if (ion.y < bTop + ion.r) { ion.y = bTop + ion.r; ion.vy *= -0.5; }
|
|
if (ion.y > bBot - ion.r) { ion.y = bBot - ion.r; ion.vy *= -0.5; }
|
|
}
|
|
|
|
_doPair(rxn) {
|
|
const r1 = rxn.reacts[0], r2 = rxn.reacts[1];
|
|
const pool1 = this._ions.filter(i => i.active && !i.paired && i.spec === r1);
|
|
const pool2 = this._ions.filter(i => i.active && !i.paired && i.spec === r2);
|
|
if (!pool1.length || !pool2.length) return;
|
|
const a = pool1[Math.floor(Math.random() * pool1.length)];
|
|
const b = pool2[Math.floor(Math.random() * pool2.length)];
|
|
a.paired = true; b.paired = true;
|
|
a.active = false; b.active = false;
|
|
this._pairs.push({
|
|
id: this._t + Math.random(),
|
|
x: (a.x + b.x) / 2, y: (a.y + b.y) / 2,
|
|
vy: rxn.type === 'gas' ? -2 : 0,
|
|
r: 7, flashT: 1, settled: false, alpha: 1,
|
|
});
|
|
}
|
|
|
|
/* ── Рендеринг ──────────────────────────────────────────────────── */
|
|
|
|
draw() {
|
|
const { ctx, W, H } = this;
|
|
const rxn = IonExSim.RXN[this.rxnId];
|
|
|
|
ctx.fillStyle = '#07071A';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
/* Dot grid */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.07)';
|
|
for (let x = 0; x < W; x += 28) {
|
|
for (let y = 0; y < H; y += 28) {
|
|
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
|
|
}
|
|
}
|
|
|
|
if (this._phase === 'idle') {
|
|
this._drawTwoBeakers(ctx, W, H, rxn);
|
|
} else {
|
|
this._drawSingleBeaker(ctx, W, H);
|
|
}
|
|
|
|
this._drawIons(ctx, rxn);
|
|
this._drawPairs(ctx, rxn);
|
|
this._drawPrecipitate(ctx, rxn);
|
|
this._drawPanel(ctx, W, H, rxn);
|
|
}
|
|
|
|
_drawTwoBeakers(ctx, W, H, rxn) {
|
|
const drawB = (x, y, w, h, ions) => {
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(120,185,255,0.55)'; ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y); ctx.lineTo(x, y + h);
|
|
ctx.lineTo(x + w, y + h); ctx.lineTo(x + w, y);
|
|
ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(x - 4, y); ctx.lineTo(x + w + 4, y); ctx.stroke();
|
|
/* Rim highlight */
|
|
const hlg = ctx.createLinearGradient(x, y, x + 14, y + h);
|
|
hlg.addColorStop(0, 'rgba(200,230,255,0.15)');
|
|
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
|
|
ctx.strokeStyle = hlg; ctx.lineWidth = 5;
|
|
ctx.beginPath(); ctx.moveTo(x + 7, y + 6); ctx.lineTo(x + 7, y + h - 6); ctx.stroke();
|
|
/* Formula label */
|
|
ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = 'bold 11px monospace';
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
|
const label = ions.map(s => s.f).join(', ');
|
|
ctx.fillText(label, x + w / 2, y - 4);
|
|
ctx.restore();
|
|
};
|
|
const bTop = H * 0.10, bH = H * 0.70;
|
|
drawB(W * 0.04, bTop, W * 0.40, bH, rxn.left);
|
|
drawB(W * 0.56, bTop, W * 0.40, bH, rxn.right);
|
|
|
|
/* Mix arrow */
|
|
ctx.save();
|
|
const mx = W * 0.50, my = H * 0.44;
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 2;
|
|
ctx.fillStyle = 'rgba(255,255,255,0.22)';
|
|
ctx.beginPath(); ctx.moveTo(mx - 16, my); ctx.lineTo(mx + 16, my); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(mx + 10, my - 5); ctx.lineTo(mx + 16, my); ctx.lineTo(mx + 10, my + 5); ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawSingleBeaker(ctx, W, H) {
|
|
const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.72;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh);
|
|
ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by);
|
|
ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke();
|
|
const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh);
|
|
hlg.addColorStop(0, 'rgba(200,230,255,0.18)');
|
|
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
|
|
ctx.strokeStyle = hlg; ctx.lineWidth = 6;
|
|
ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawIons(ctx, rxn) {
|
|
this._ions.forEach(ion => {
|
|
if (!ion.active) return;
|
|
const isSpec = rxn.spectators.includes(ion.spec);
|
|
ctx.save();
|
|
ctx.globalAlpha = (isSpec && this._phase !== 'idle') ? 0.40 : 0.88;
|
|
ctx.shadowColor = ion.color;
|
|
ctx.shadowBlur = 7 + Math.sin(ion.phase) * 3;
|
|
ctx.beginPath(); ctx.arc(ion.x, ion.y, ion.r, 0, Math.PI * 2);
|
|
ctx.fillStyle = ion.color; ctx.fill();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.stroke();
|
|
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
|
|
/* Formula label */
|
|
const fs = Math.min(Math.round(ion.r * 0.60), 9);
|
|
ctx.fillStyle = 'rgba(0,0,0,0.80)';
|
|
ctx.font = `bold ${fs}px monospace`;
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(ion.spec, ion.x, ion.y);
|
|
ctx.restore();
|
|
});
|
|
}
|
|
|
|
_drawPairs(ctx, rxn) {
|
|
const pcolor = rxn.pcolor || rxn.gcolor || '#FFF';
|
|
this._pairs.forEach(p => {
|
|
ctx.save();
|
|
const alpha = p.alpha !== undefined ? p.alpha : 1;
|
|
ctx.globalAlpha = alpha * 0.92;
|
|
ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : pcolor;
|
|
ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
|
ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : pcolor;
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
|
|
/* Product label */
|
|
ctx.fillStyle = 'rgba(0,0,0,0.80)';
|
|
ctx.font = 'bold 7px monospace';
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(rxn.product.f, p.x, p.y);
|
|
ctx.restore();
|
|
});
|
|
}
|
|
|
|
_drawPrecipitate(ctx, rxn) {
|
|
if (rxn.type !== 'precip' || !this._precip.length) return;
|
|
ctx.save();
|
|
this._precip.forEach(p => {
|
|
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 3;
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
|
ctx.fillStyle = rxn.pcolor; ctx.fill();
|
|
});
|
|
ctx.restore();
|
|
if (this._precip.length > 4) {
|
|
ctx.save();
|
|
ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace';
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
|
|
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6;
|
|
ctx.fillText(`↓ ${rxn.pname}`, this.W / 2, this.H * 0.80 - 4);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
_drawPanel(ctx, W, H, rxn) {
|
|
const py = H * 0.82;
|
|
ctx.fillStyle = 'rgba(7,7,26,0.95)';
|
|
ctx.fillRect(0, py, W, H - py);
|
|
ctx.strokeStyle = 'rgba(100,165,255,0.22)'; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
|
|
|
|
if (this._phase === 'idle') {
|
|
ctx.fillStyle = '#37474F'; ctx.font = '11px monospace';
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText('← Нажми «Смешать» для начала реакции →', W / 2, py + (H - py) / 2);
|
|
return;
|
|
}
|
|
|
|
const steps = [
|
|
{ lbl: 'Молекулярное:', txt: rxn.mol, col: '#B0BEC5' },
|
|
{ lbl: 'Полное ионное:', txt: rxn.full_ion, col: '#CE93D8' },
|
|
{ lbl: 'Краткое ионное:', txt: rxn.net_ion, col: '#FFD166' },
|
|
];
|
|
|
|
const panH = H - py;
|
|
const n = Math.min(this._stepIdx + 1, steps.length);
|
|
for (let i = 0; i < n; i++) {
|
|
const s = steps[i];
|
|
const y = py + 11 + i * (panH * 0.29);
|
|
ctx.save();
|
|
if (i === this._stepIdx && this._phase !== 'done') {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.04)';
|
|
ctx.fillRect(8, y - 9, W - 16, 20);
|
|
}
|
|
ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace';
|
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(s.lbl, 14, y);
|
|
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
|
|
ctx.font = '9.5px monospace';
|
|
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y);
|
|
ctx.restore();
|
|
}
|
|
|
|
if (this._phase === 'done') {
|
|
ctx.save();
|
|
ctx.fillStyle = rxn.signColor; ctx.font = 'bold 10px monospace';
|
|
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
|
|
ctx.shadowColor = rxn.signColor; ctx.shadowBlur = 8;
|
|
const label = rxn.type === 'precip' ? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${rxn.sign} осадок` : `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${rxn.sign} газ`;
|
|
ctx.fillText(label, W - 14, py + 3);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
info() {
|
|
const rxn = IonExSim.RXN[this.rxnId];
|
|
return {
|
|
rxn: rxn.name,
|
|
phase: this._phase,
|
|
prog: Math.round(this._prog * 100),
|
|
precip: this._precip.length,
|
|
};
|
|
}
|
|
}
|