Files
Learn_System/frontend/js/labs/electrolysis.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

542 lines
19 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';
/**
* ElectrolysisSim v2 — Электролиз водных растворов
* Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль
* Чистый рерайт: стабильная физика, ионная анимация, пузырьки, осадок.
*/
class ElectrolysisSim {
static F = 96485;
static BG = '#0b0b1a';
static FONT = 'Manrope, system-ui, sans-serif';
static ELECTROLYTES = {
NaCl: {
name: 'NaCl', displayName: 'NaCl (водный р-р)',
cation: 'Na\u207A', anion: 'Cl\u207B',
M: 2, n: 2, R: 8,
solColor: [160, 200, 230],
cathodeProduct: 'H\u2082', anodeProduct: 'Cl\u2082',
depositColor: null,
cathodeBubColor: 'rgba(160,210,255,0.55)',
anodeBubColor: 'rgba(180,255,140,0.50)',
cathodeEq: '2H\u2082O + 2e\u207B \u2192 H\u2082 + 2OH\u207B',
anodeEq: '2Cl\u207B \u2212 2e\u207B \u2192 Cl\u2082',
},
CuSO4: {
name: 'CuSO\u2084', displayName: 'CuSO\u2084 (водный р-р)',
cation: 'Cu\u00B2\u207A', anion: 'SO\u2084\u00B2\u207B',
M: 63.546, n: 2, R: 12,
solColor: [55, 120, 210],
cathodeProduct: 'Cu\u2193', anodeProduct: 'O\u2082',
depositColor: '#b87333',
cathodeBubColor: null,
anodeBubColor: 'rgba(200,210,255,0.50)',
cathodeEq: 'Cu\u00B2\u207A + 2e\u207B \u2192 Cu\u2193',
anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A',
},
H2SO4: {
name: 'H\u2082SO\u2084', displayName: 'H\u2082SO\u2084 (водный р-р)',
cation: 'H\u207A', anion: 'SO\u2084\u00B2\u207B',
M: 2, n: 2, R: 6,
solColor: [200, 200, 215],
cathodeProduct: 'H\u2082', anodeProduct: 'O\u2082',
depositColor: null,
cathodeBubColor: 'rgba(160,210,255,0.55)',
anodeBubColor: 'rgba(200,210,255,0.50)',
cathodeEq: '2H\u207A + 2e\u207B \u2192 H\u2082',
anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.voltage = 6;
this.electrolyte = 'NaCl';
this.speed = 1;
this._time = 0;
this._massDeposit = 0;
this._gasVolume = 0;
this._depositH = 0;
this._ions = [];
this._bubbles = [];
this._electronPhase = 0;
this.playing = false;
this._raf = null;
this._lastTs = null;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
// ── public API ────────────────────────────────────────────────
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 640;
const h = this.canvas.offsetHeight || 420;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
this._initIons();
}
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
setParams({ voltage, electrolyte } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (electrolyte !== undefined) {
// accept both 'nacl' (from lab.html) and 'NaCl' (canonical)
const keyMap = { nacl: 'NaCl', cuso4: 'CuSO4', h2so4: 'H2SO4' };
const key = keyMap[String(electrolyte).toLowerCase()] || electrolyte;
if (ElectrolysisSim.ELECTROLYTES[key] && this.electrolyte !== key) {
this.electrolyte = key;
this.reset(); return;
}
}
this.draw(); this._emit();
}
preset(name) {
const map = { nacl: ['NaCl', 6], cuso4: ['CuSO4', 4], h2so4: ['H2SO4', 3] };
const [el, v] = map[name] || map.nacl;
this.voltage = v; this.electrolyte = el;
this.reset();
}
reset() {
this.pause();
this._time = 0; this._massDeposit = 0;
this._gasVolume = 0; this._depositH = 0;
this._bubbles = []; this._electronPhase = 0;
this._initIons();
this.draw(); this._emit();
}
play() { if (this.playing) return; this.playing = true; this._lastTs = null; this._tick(); }
pause() { this.playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
start() { this.play(); }
stop() { this.pause(); }
info() {
return {
voltage: this.voltage,
current: +this._current().toFixed(3),
electrolyte: this.electrolyte,
massDeposited: +this._massDeposit.toFixed(4),
gasVolume: +this._gasVolume.toFixed(2),
time: +this._time.toFixed(1),
};
}
// ── internals ─────────────────────────────────────────────────
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_el() { return ElectrolysisSim.ELECTROLYTES[this.electrolyte]; }
_current() { return this.voltage / this._el().R; }
_cell() {
const { W, H } = this;
const cw = Math.min(W * 0.50, 300);
const ch = Math.min(H * 0.48, 210);
return { cx: (W - cw) / 2, cy: H * 0.28, cw, ch };
}
_electrodes() {
const { cx, cy, cw, ch } = this._cell();
const ew = 13, eh = ch * 0.70, gap = cw * 0.12;
const ey = cy + ch - eh - 8;
return {
cathode: { x: cx + gap, y: ey, w: ew, h: eh },
anode: { x: cx + cw - gap - ew, y: ey, w: ew, h: eh },
};
}
_initIons() {
this._ions = [];
const { cx, cy, cw, ch } = this._cell();
if (!cw || !ch) return;
const el = this._el();
for (let i = 0; i < 30; i++) {
const isCat = i < 15;
this._ions.push({
x: cx + 18 + Math.random() * (cw - 36),
y: cy + 12 + Math.random() * (ch - 24),
vx: (Math.random() - 0.5) * 0.7,
vy: (Math.random() - 0.5) * 0.5,
charge: isCat ? 1 : -1,
label: isCat ? el.cation : el.anion,
color: isCat ? '#EF476F' : '#06D6E0',
});
}
}
_spawnIon(charge) {
const { cx, cy, cw, ch } = this._cell();
const el = this._el();
this._ions.push({
x: charge > 0 ? cx + 8 : cx + cw - 8,
y: cy + 12 + Math.random() * (ch - 24),
vx: charge > 0 ? 0.55 : -0.55,
vy: (Math.random() - 0.5) * 0.4,
charge,
label: charge > 0 ? el.cation : el.anion,
color: charge > 0 ? '#EF476F' : '#06D6E0',
});
}
_spawnBubble(x, y, color) {
this._bubbles.push({
x, y,
r: 1.5 + Math.random() * 2.5,
vx: (Math.random() - 0.5) * 0.3,
vy: -(0.5 + Math.random() * 0.9),
life: 1,
decay: 0.005 + Math.random() * 0.007,
color,
});
}
// ── simulation tick ────────────────────────────────────────────
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this._lastTs) this._lastTs = ts;
const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
this._lastTs = ts;
this._step(dt); this.draw(); this._emit(); this._tick();
});
}
_step(dt) {
const el = this._el(), I = this._current();
const { cx, cy, cw, ch } = this._cell();
const elec = this._electrodes();
this._time += dt;
this._electronPhase = (this._electronPhase + dt * I * 1.2) % 1;
// Faraday's law
const molesPS = I / (el.n * ElectrolysisSim.F);
if (el.depositColor) {
this._massDeposit += el.M * molesPS * dt;
this._depositH = Math.min(elec.cathode.h * 0.72, this._depositH + dt * 0.14 * I);
}
this._gasVolume += molesPS * 22400 * dt;
// Ion drift + thermal jitter
const drift = I * 0.45;
for (const ion of this._ions) {
ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.18;
ion.vy += (Math.random() - 0.5) * 0.14;
ion.vx = Math.max(-3.5, Math.min(3.5, ion.vx * 0.96));
ion.vy = Math.max(-3.5, Math.min(3.5, ion.vy * 0.96));
ion.x += ion.vx; ion.y += ion.vy;
ion.x = Math.max(cx + 4, Math.min(cx + cw - 4, ion.x));
ion.y = Math.max(cy + 4, Math.min(cy + ch - 4, ion.y));
}
// Ions reaching electrodes → discharge + bubbles
const rm = new Set();
for (let i = 0; i < this._ions.length; i++) {
const ion = this._ions[i];
if (ion.charge > 0 && ion.x <= elec.cathode.x + elec.cathode.w + 5) {
rm.add(i);
if (el.cathodeBubColor) {
for (let b = 0; b < 2; b++)
this._spawnBubble(
elec.cathode.x + elec.cathode.w + 2 + Math.random() * 4,
elec.cathode.y + Math.random() * elec.cathode.h,
el.cathodeBubColor);
}
}
if (ion.charge < 0 && ion.x >= elec.anode.x - 5) {
rm.add(i);
if (el.anodeBubColor) {
for (let b = 0; b < 2; b++)
this._spawnBubble(
elec.anode.x - 2 - Math.random() * 4,
elec.anode.y + Math.random() * elec.anode.h,
el.anodeBubColor);
}
}
}
this._ions = this._ions.filter((_, i) => !rm.has(i));
// Replenish ions to keep count ~15 each
let cat = 0, an = 0;
for (const ion of this._ions) ion.charge > 0 ? cat++ : an++;
while (cat < 15) { this._spawnIon(1); cat++; }
while (an < 15) { this._spawnIon(-1); an++; }
// Bubble physics
this._bubbles = this._bubbles.filter(b => {
b.x += b.vx + Math.sin(b.life * 22) * 0.12;
b.y += b.vy;
b.life -= b.decay;
return b.life > 0 && b.y > cy + 2;
});
}
// ── draw ──────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
// Background
ctx.fillStyle = ElectrolysisSim.BG; ctx.fillRect(0, 0, W, H);
// Dot grid
ctx.fillStyle = 'rgba(255,255,255,0.018)';
for (let x = 22; x < W; x += 22)
for (let y = 22; y < H; y += 22) {
ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
}
this._drawWiresAndBattery();
this._drawCellBody();
this._drawSolution();
this._drawDeposit();
this._drawElectrodes();
this._drawBubbles();
this._drawIons();
this._drawLabels();
this._drawInfoPanel();
}
_drawCellBody() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke();
// glass shimmer
const gg = ctx.createLinearGradient(cx, cy, cx + 14, cy);
gg.addColorStop(0, 'rgba(255,255,255,0.05)');
gg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gg; ctx.fillRect(cx + 1, cy + 1, 14, ch - 2);
ctx.restore();
}
_drawSolution() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
const [r, g, b] = this._el().solColor;
ctx.save();
ctx.beginPath(); ctx.roundRect(cx + 2, cy + 2, cw - 4, ch - 4, 4); ctx.clip();
const sg = ctx.createLinearGradient(cx, cy, cx, cy + ch);
sg.addColorStop(0, `rgba(${r},${g},${b},0.06)`);
sg.addColorStop(1, `rgba(${r},${g},${b},0.22)`);
ctx.fillStyle = sg; ctx.fillRect(cx + 2, cy + 2, cw - 4, ch - 4);
ctx.restore();
}
_drawElectrodes() {
const { ctx } = this;
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
ctx.fillStyle = '#42425a';
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.stroke();
ctx.fillStyle = '#525268';
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.stroke();
ctx.font = `bold 16px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillStyle = '#06D6E0';
ctx.fillText('\u2212', e.cathode.x + e.cathode.w / 2, e.cathode.y - 3);
ctx.fillStyle = '#EF476F';
ctx.fillText('+', e.anode.x + e.anode.w / 2, e.anode.y - 3);
}
_drawDeposit() {
const el = this._el();
if (!el.depositColor || this._depositH < 1) return;
const { ctx } = this;
const c = this._electrodes().cathode;
const dh = Math.min(this._depositH, c.h * 0.72);
ctx.save();
const dg = ctx.createLinearGradient(c.x + c.w, c.y + c.h - dh, c.x + c.w + 10, c.y + c.h);
dg.addColorStop(0, 'rgba(184,115,51,0.35)');
dg.addColorStop(1, 'rgba(184,115,51,0.85)');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, dh, [2, 2, 0, 0]); ctx.fill();
ctx.shadowColor = '#b87333'; ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(210,150,80,0.5)';
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, 3, [2, 2, 0, 0]); ctx.fill();
ctx.restore();
}
_drawIons() {
const { ctx } = this;
const FN = ElectrolysisSim.FONT;
ctx.save();
for (const ion of this._ions) {
const g = ctx.createRadialGradient(ion.x, ion.y, 0, ion.x, ion.y, 11);
g.addColorStop(0, ion.color + '2a'); g.addColorStop(1, ion.color + '00');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = ion.color;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.68)';
ctx.font = `8px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(ion.label, ion.x, ion.y - 5);
}
ctx.restore();
}
_drawBubbles() {
const { ctx } = this;
ctx.save();
for (const b of this._bubbles) {
ctx.globalAlpha = b.life * 0.65;
ctx.strokeStyle = b.color; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${b.life * 0.18})`;
ctx.beginPath(); ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
_drawWiresAndBattery() {
const { ctx } = this;
const { cx, cy, cw } = this._cell();
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
const cXt = e.cathode.x + e.cathode.w / 2; // cathode top center
const aXt = e.anode.x + e.anode.w / 2; // anode top center
const bx = cx + cw / 2; // battery center x
const by = cy - Math.max(42, this.H * 0.09); // battery y
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5;
// Cathode wire: up then right to battery
ctx.beginPath();
ctx.moveTo(cXt, e.cathode.y); ctx.lineTo(cXt, by); ctx.lineTo(bx - 22, by);
ctx.stroke();
// Anode wire: up then left to battery +
ctx.beginPath();
ctx.moveTo(aXt, e.anode.y); ctx.lineTo(aXt, by); ctx.lineTo(bx + 22, by);
ctx.stroke();
// Electron flow dots (cathode side: from battery toward cathode)
const dist = (bx - 22) - cXt;
for (let i = 0; i < 4; i++) {
const t = ((this._electronPhase + i / 4) % 1);
const ex = (bx - 22) - t * dist;
const ey = by;
if (ex >= cXt - 1 && ex <= bx - 22 + 1) {
ctx.fillStyle = '#4CC9F0'; ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 5;
ctx.beginPath(); ctx.arc(ex, ey, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
// Battery symbol — two plates
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx + 22, by - 14); ctx.lineTo(bx + 22, by + 14); ctx.stroke();
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo(bx - 22, by - 8); ctx.lineTo(bx - 22, by + 8); ctx.stroke();
// Connecting wire between battery plates
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(bx - 22, by); ctx.lineTo(bx + 22, by); ctx.stroke();
// Voltage label
ctx.fillStyle = '#FFD166';
ctx.font = `bold 12px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(this.voltage.toFixed(1) + ' V', bx, by - 18);
// +/ labels on battery
ctx.fillStyle = '#EF476F'; ctx.font = `bold 10px ${FN}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('+', bx + 26, by);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('\u2212', bx - 26, by);
ctx.restore();
}
_drawLabels() {
const { ctx } = this;
const el = this._el(), e = this._electrodes();
const { cx, cy, cw, ch } = this._cell();
const FN = ElectrolysisSim.FONT;
ctx.save();
ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = '#06D6E0';
ctx.fillText('\u041A\u0430\u0442\u043E\u0434 (\u2212)', e.cathode.x + e.cathode.w / 2, cy + ch + 6);
ctx.fillStyle = '#EF476F';
ctx.fillText('\u0410\u043D\u043E\u0434 (+)', e.anode.x + e.anode.w / 2, cy + ch + 6);
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.fillText(el.cathodeProduct, e.cathode.x + e.cathode.w / 2, cy + ch + 20);
ctx.fillText(el.anodeProduct, e.anode.x + e.anode.w / 2, cy + ch + 20);
ctx.fillStyle = '#9B5DE5'; ctx.font = `bold 11px ${FN}`;
ctx.fillText(el.displayName, cx + cw / 2, cy + ch + 36);
ctx.font = `8px ${FN}`;
ctx.fillStyle = 'rgba(6,214,224,0.48)';
ctx.fillText(el.cathodeEq, e.cathode.x + e.cathode.w / 2, cy + ch + 52);
ctx.fillStyle = 'rgba(239,71,111,0.48)';
ctx.fillText(el.anodeEq, e.anode.x + e.anode.w / 2, cy + ch + 52);
ctx.restore();
}
_drawInfoPanel() {
const { ctx } = this;
const inf = this.info(), el = this._el();
const FN = ElectrolysisSim.FONT;
const px = 12, py = 10, pw = 170, lh = 17;
const rows = [
['U', inf.voltage.toFixed(1) + ' \u0412'],
['I', this._current().toFixed(3) + ' \u0410'],
['\u0422\u0432\u0440\u0435\u043C\u044F', this._fmtTime(inf.time)],
];
if (el.depositColor) rows.push(['m(Cu)', inf.massDeposited.toFixed(4) + ' \u0433']);
rows.push(['V(\u0433\u0430\u0437)', inf.gasVolume.toFixed(2) + ' \u043C\u043B']);
const ph = 12 + rows.length * lh + 8;
ctx.save();
ctx.fillStyle = 'rgba(5,5,20,0.86)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.stroke();
ctx.font = `10px ${FN}`; ctx.textBaseline = 'middle';
rows.forEach(([k, v], i) => {
const ry = py + 10 + i * lh + lh / 2;
ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, px + 10, ry);
ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.textAlign = 'right'; ctx.fillText(v, px + pw - 10, ry);
});
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = `italic 8px ${FN}`; ctx.textAlign = 'left';
ctx.fillText('m = M\u00B7I\u00B7t / (n\u00B7F)', px + 10, py + ph + 10);
ctx.restore();
}
_fmtTime(s) {
if (s < 60) return s.toFixed(1) + ' \u0441';
return Math.floor(s / 60) + ' \u043C\u0438\u043D ' + (s % 60).toFixed(0) + ' \u0441';
}
}
if (typeof module !== 'undefined') module.exports = ElectrolysisSim;