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

659 lines
26 KiB
JavaScript

'use strict';
/* ══════════════════════════════════════════════════════════════
TitrationSim — acid-base titration simulation
Strong acid (HCl) / weak acid (CH₃COOH) + strong base (NaOH)
Left 60%: burette + Erlenmeyer flask with indicator colour
Right 40%: real-time pH vs V(base) titration curve
Henderson-Hasselbalch for weak acid buffer region
══════════════════════════════════════════════════════════════ */
class TitrationSim {
static PINK = '#EF476F';
static VIOLET = '#9B5DE5';
static CYAN = '#06D6E0';
static GREEN = '#7BF5A4';
static YELLOW = '#FFD166';
static BG = '#0D0D1A';
static FONT = "Manrope, system-ui, sans-serif";
/* ── Constructor ────────────────────────────────────────── */
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* chemistry */
this.acidConc = 0.1; // mol/L
this.baseConc = 0.1; // mol/L
this.acidVol = 50; // mL
this.acidType = 'strong'; // 'strong' | 'weak'
this.indicator = 'phenolphthalein'; // 'phenolphthalein' | 'methyl_orange' | 'litmus'
this.Ka = 1.8e-5; // CH₃COOH dissociation constant
/* state */
this.baseAdded = 0; // mL of base added
this._curve = []; // [{v, pH}]
this._drops = []; // [{x, y, vy, r}]
this._splashes = []; // [{x, y, vx, vy, r, life}]
this._ripples = []; // [{x, y, radius, life}]
this._dropAccum = 0;
this._wave = 0;
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
this.speed = 1;
this.onUpdate = null;
this._recordPoint();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── Geometry ───────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
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;
}
/* ── Public API ─────────────────────────────────────────── */
getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
if (acidVol !== undefined) this.acidVol = Math.max(25, Math.min(100, +acidVol));
if (indicator !== undefined) this.indicator = indicator;
if (acidType !== undefined) this.acidType = acidType;
this.reset();
}
preset(name) {
const presets = {
strong_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'strong', indicator: 'phenolphthalein' },
weak_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'weak', indicator: 'phenolphthalein' },
concentrated: { acidConc: 0.5, baseConc: 0.5, acidVol: 25, acidType: 'strong', indicator: 'methyl_orange' },
};
const p = presets[name] || presets.strong_strong;
Object.assign(this, p);
this.reset();
}
reset() {
this.pause();
this.baseAdded = 0;
this._curve = [];
this._drops = [];
this._splashes = [];
this._ripples = [];
this._dropAccum = 0;
this._wave = 0;
this._recordPoint();
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() {
const eqVol = this._eqVolume();
return {
pH: +this._calcPH(this.baseAdded).toFixed(2),
baseAdded: +this.baseAdded.toFixed(2),
eqPoint: +eqVol.toFixed(2),
indicator: this.indicator,
acidType: this.acidType,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── Chemistry ──────────────────────────────────────────── */
_eqVolume() { return (this.acidConc * this.acidVol) / this.baseConc; }
_maxVolume() { return this._eqVolume() * 1.5; }
_calcPH(vBase) {
const nAcid = this.acidConc * this.acidVol / 1000;
const nBase = this.baseConc * vBase / 1000;
const vTotal = (this.acidVol + vBase) / 1000;
return this.acidType === 'strong'
? this._strongPH(nAcid, nBase, vTotal)
: this._weakPH(nAcid, nBase, vTotal);
}
_strongPH(nA, nB, vT) {
const d = nA - nB;
if (Math.abs(d) < 1e-10) return 7.0;
if (d > 0) return -Math.log10(d / vT);
return 14 + Math.log10(-d / vT);
}
_weakPH(nA, nB, vT) {
const Ka = this.Ka;
const d = nA - nB;
if (d < -1e-10) return 14 + Math.log10(-d / vT); // excess base
if (Math.abs(d) < 1e-10) { // equivalence — hydrolysis
const Kb = 1e-14 / Ka;
return 14 + Math.log10(Math.sqrt(Kb * (nB / vT)));
}
if (nB < 1e-10) { // pure weak acid
const c = nA / vT;
const cH = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * c)) / 2;
return -Math.log10(cH);
}
return -Math.log10(Ka) + Math.log10((nB / vT) / (d / vT)); // Henderson-Hasselbalch
}
_recordPoint() {
this._curve.push({ v: this.baseAdded, pH: this._calcPH(this.baseAdded) });
}
/* ── Indicator colour ───────────────────────────────────── */
_indicatorColor(pH) {
if (this.indicator === 'phenolphthalein') {
if (pH < 8.2) return 'rgba(255,255,255,0.04)';
if (pH > 10) return 'rgba(220,20,120,0.60)';
const t = (pH - 8.2) / 1.8;
return `rgba(220,${200 - Math.round(180 * t)},${255 - Math.round(135 * t)},${(0.04 + 0.56 * t).toFixed(2)})`;
}
if (this.indicator === 'methyl_orange') {
if (pH < 3.1) return 'rgba(220,40,40,0.50)';
if (pH > 4.4) return 'rgba(240,210,60,0.35)';
const t = (pH - 3.1) / 1.3;
return `rgba(${220 + Math.round(20 * t)},${40 + Math.round(170 * t)},${40 + Math.round(20 * t)},${(0.50 - 0.15 * t).toFixed(2)})`;
}
/* litmus */
if (pH < 5) return 'rgba(220,50,60,0.55)';
if (pH > 8) return 'rgba(60,80,210,0.55)';
const t = (pH - 5) / 3;
return `rgba(${220 - Math.round(160 * t)},${50 + Math.round(30 * t)},${60 + Math.round(150 * t)},0.55)`;
}
_liquidRGB(pH) {
if (this.indicator === 'phenolphthalein') {
if (pH < 8.2) return [180, 210, 255];
const t = Math.min(1, (pH - 8.2) / 1.8);
return [180 + t * 40, 210 - t * 140, 255 - t * 135];
}
if (this.indicator === 'methyl_orange') {
if (pH < 3.1) return [220, 80, 80];
if (pH > 4.4) return [240, 210, 80];
const t = (pH - 3.1) / 1.3;
return [220 + t * 20, 80 + t * 130, 80];
}
/* litmus */
if (pH < 5) return [220, 70, 70];
if (pH > 8) return [80, 100, 220];
const t = (pH - 5) / 3;
return [220 - 140 * t, 70 + 30 * t, 70 + 150 * t];
}
_phColor(pH) {
if (pH < 3) return TitrationSim.PINK;
if (pH < 5) return TitrationSim.YELLOW;
if (pH < 9) return TitrationSim.GREEN;
if (pH < 11) return TitrationSim.CYAN;
return TitrationSim.VIOLET;
}
/* ── Animation loop ─────────────────────────────────────── */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
this._wave += rawDt * 2.0;
const maxV = this._maxVolume();
if (this.baseAdded < maxV) {
this.baseAdded = Math.min(this.baseAdded + (maxV / 14) * dt, maxV);
this._recordPoint();
if (this._curve.length > 600) this._curve.shift();
this._spawnDrops(dt);
} else {
this.pause();
}
/* move drops */
for (const d of this._drops) { d.vy += 480 * dt; d.y += d.vy * dt; }
const surfY = this.H * 0.72;
/* spawn splashes when drops hit surface */
for (const d of this._drops) {
if (d.y >= surfY && !d.hit) {
d.hit = true;
const bx = d.x;
for (let i = 0; i < 3; i++) {
const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI;
const s = 15 + Math.random() * 25;
this._splashes.push({ x: bx, y: surfY, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 8, r: 1 + Math.random(), life: 1 });
}
this._ripples.push({ x: bx, y: surfY, radius: 2, life: 1 });
}
}
this._drops = this._drops.filter(d => d.y < surfY + 4);
/* animate splashes */
for (const s of this._splashes) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 160 * dt; s.life -= dt * 3.5; }
this._splashes = this._splashes.filter(s => s.life > 0);
for (const r of this._ripples) { r.radius += dt * 30; r.life -= dt * 2.2; }
this._ripples = this._ripples.filter(r => r.life > 0);
this.draw();
this._emit();
if (this.playing) this._tick();
});
}
_spawnDrops(dt) {
this._dropAccum += dt;
const interval = 0.18 / Math.max(0.5, this.speed);
while (this._dropAccum >= interval) {
this._dropAccum -= interval;
const simW = this.W * 0.6;
const bx = simW * 0.42;
this._drops.push({
x: bx + (Math.random() - 0.5) * 3,
y: this.H * 0.38 + 14,
vy: 10 + Math.random() * 8,
r: 2.2 + Math.random() * 1.4,
hit: false,
});
}
}
/* ═══════════════════════ Rendering ═══════════════════════ */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const simW = W * 0.6;
ctx.fillStyle = TitrationSim.BG;
ctx.fillRect(0, 0, W, H);
/* dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.04)';
for (let x = 0; x < W; x += 28) for (let y = 0; y < H; y += 28) {
ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
}
/* divider */
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(simW, 16); ctx.lineTo(simW, H - 16); ctx.stroke();
this._drawStand(ctx, simW);
this._drawBurette(ctx, simW);
this._drawFlask(ctx, simW);
this._drawParticles(ctx);
this._drawOverlay(ctx);
this._drawPHCurve(ctx, simW, W, H);
}
/* ── Lab stand ──────────────────────────────────────────── */
_drawStand(ctx, simW) {
const H = this.H, sx = simW * 0.2;
const g = ctx.createLinearGradient(sx - 3, 0, sx + 3, 0);
g.addColorStop(0, 'rgba(120,130,160,0.5)');
g.addColorStop(0.5, 'rgba(180,190,210,0.7)');
g.addColorStop(1, 'rgba(100,110,140,0.4)');
ctx.fillStyle = g;
ctx.fillRect(sx - 3, H * 0.06, 6, H * 0.84);
ctx.fillStyle = 'rgba(150,160,190,0.40)';
ctx.beginPath(); ctx.roundRect(sx - 36, H * 0.90, 72, 7, 3); ctx.fill();
ctx.fillStyle = 'rgba(140,150,180,0.55)';
ctx.fillRect(sx - 1, H * 0.12, simW * 0.22 + 2, 5);
}
/* ── Burette ────────────────────────────────────────────── */
_drawBurette(ctx, simW) {
const H = this.H, FNT = TitrationSim.FONT;
const bx = simW * 0.42, bT = H * 0.06, bB = H * 0.38, bW = 12;
const maxV = this._maxVolume();
const frac = Math.max(0, 1 - this.baseAdded / maxV);
/* glass tube */
const gg = ctx.createLinearGradient(bx - bW, 0, bx + bW, 0);
gg.addColorStop(0, 'rgba(120,170,255,0.18)');
gg.addColorStop(0.4, 'rgba(160,200,255,0.08)');
gg.addColorStop(0.6, 'rgba(160,200,255,0.08)');
gg.addColorStop(1, 'rgba(100,150,240,0.15)');
ctx.fillStyle = gg;
ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.fill();
/* liquid level */
if (frac > 0.01) {
const lt = bT + (bB - bT) * (1 - frac) + 4;
const lg = ctx.createLinearGradient(0, lt, 0, bB);
lg.addColorStop(0, 'rgba(100,160,255,0.25)');
lg.addColorStop(1, 'rgba(80,140,240,0.40)');
ctx.fillStyle = lg;
ctx.beginPath(); ctx.roundRect(bx - bW + 2, lt, bW * 2 - 4, bB - lt - 4, 3); ctx.fill();
}
/* glass outline + highlight */
ctx.strokeStyle = 'rgba(120,175,255,0.50)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.stroke();
ctx.strokeStyle = 'rgba(200,225,255,0.25)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(bx - bW + 3, bT + 8); ctx.lineTo(bx - bW + 3, bB - 8); ctx.stroke();
/* graduations */
ctx.strokeStyle = 'rgba(180,210,255,0.30)'; ctx.lineWidth = 0.8;
ctx.font = `8px ${FNT}`; ctx.fillStyle = 'rgba(180,210,255,0.45)'; ctx.textAlign = 'right';
for (let i = 0; i <= 10; i++) {
const y = bT + 6 + (bB - bT - 12) * (i / 10), maj = i % 2 === 0;
ctx.beginPath(); ctx.moveTo(bx + bW, y); ctx.lineTo(bx + bW + (maj ? 8 : 4), y); ctx.stroke();
if (maj) ctx.fillText(((i / 10) * maxV).toFixed(0), bx + bW + 22, y + 3);
}
/* stopcock + nozzle */
ctx.fillStyle = 'rgba(180,190,220,0.55)'; ctx.fillRect(bx - 4, bB - 2, 8, 8);
ctx.fillStyle = 'rgba(140,165,210,0.50)';
ctx.beginPath();
ctx.moveTo(bx - 3, bB + 6); ctx.lineTo(bx + 3, bB + 6);
ctx.lineTo(bx + 1.5, bB + 14); ctx.lineTo(bx - 1.5, bB + 14);
ctx.closePath(); ctx.fill();
/* forming drip */
if (this.playing && this.baseAdded < maxV) {
const pulse = 0.5 + 0.5 * Math.sin(this._wave * 4);
const dr = 2.5 + pulse * 1.5;
ctx.save(); ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(100,180,255,0.65)';
ctx.beginPath(); ctx.arc(bx, bB + 14 + dr, dr, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
/* labels */
ctx.fillStyle = 'rgba(180,210,255,0.60)'; ctx.font = `bold 10px ${FNT}`; ctx.textAlign = 'center';
ctx.fillText('NaOH', bx, bT - 6);
ctx.fillText(`${this.baseConc} M`, bx, bT - 18);
}
/* ── Erlenmeyer flask ───────────────────────────────────── */
_drawFlask(ctx, simW) {
const H = this.H, cx = simW * 0.42, pH = this._calcPH(this.baseAdded);
const fB = H * 0.88, fNT = H * 0.58, fNW = 10, fBW = simW * 0.22;
const flaskP = () => {
ctx.beginPath();
ctx.moveTo(cx - fNW, fNT); ctx.lineTo(cx - fNW, fNT + 16);
ctx.bezierCurveTo(cx - fNW, fNT + 40, cx - fBW, fB - 30, cx - fBW, fB);
ctx.lineTo(cx + fBW, fB);
ctx.bezierCurveTo(cx + fBW, fB - 30, cx + fNW, fNT + 40, cx + fNW, fNT + 16);
ctx.lineTo(cx + fNW, fNT); ctx.closePath();
};
const lY = H * 0.72, [lr, lg, lb] = this._liquidRGB(pH);
const amp = 2 + (this.playing ? 1.5 : 0);
const wY = x => lY + Math.sin((x - cx) * 0.08 + this._wave) * amp
+ Math.sin((x - cx) * 0.15 - this._wave * 1.4) * amp * 0.3;
/* liquid clipped to flask */
ctx.save(); flaskP(); ctx.clip();
ctx.beginPath();
for (let x = cx - fBW - 2; x <= cx + fBW + 2; x += 2) {
x === cx - fBW - 2 ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x));
}
ctx.lineTo(cx + fBW + 2, fB + 4); ctx.lineTo(cx - fBW - 2, fB + 4); ctx.closePath();
const lGrad = ctx.createLinearGradient(0, lY, 0, fB);
lGrad.addColorStop(0, `rgba(${lr},${lg},${lb},0.30)`);
lGrad.addColorStop(0.5, `rgba(${lr},${lg},${lb},0.45)`);
lGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0.55)`);
ctx.fillStyle = lGrad; ctx.fill();
ctx.fillStyle = this._indicatorColor(pH); ctx.fill();
/* surface shimmer */
ctx.beginPath();
for (let x = cx - fBW; x <= cx + fBW; x += 2) {
x === cx - fBW ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x));
}
ctx.strokeStyle = `rgba(${Math.min(255, lr + 80)},${Math.min(255, lg + 80)},${Math.min(255, lb + 80)},0.45)`;
ctx.lineWidth = 1.2; ctx.stroke();
ctx.restore();
/* glass outline */
ctx.strokeStyle = 'rgba(120,175,255,0.55)'; ctx.lineWidth = 2; flaskP(); ctx.stroke();
/* left highlight */
ctx.save(); ctx.beginPath();
ctx.moveTo(cx - fNW + 2, fNT + 4); ctx.lineTo(cx - fNW + 2, fNT + 18);
ctx.bezierCurveTo(cx - fNW + 2, fNT + 42, cx - fBW + 8, fB - 32, cx - fBW + 6, fB - 4);
const hg = ctx.createLinearGradient(cx - fBW, fNT, cx - fBW, fB);
hg.addColorStop(0, 'rgba(200,230,255,0.30)'); hg.addColorStop(1, 'rgba(200,230,255,0.03)');
ctx.strokeStyle = hg; ctx.lineWidth = 3; ctx.stroke(); ctx.restore();
/* neck rim */
ctx.strokeStyle = 'rgba(140,185,255,0.60)'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(cx - fNW - 4, fNT); ctx.lineTo(cx + fNW + 4, fNT); ctx.stroke();
/* acid label */
const label = this.acidType === 'strong' ? 'HCl' : 'CH\u2083COOH';
ctx.fillStyle = 'rgba(180,210,255,0.55)'; ctx.font = `9px ${TitrationSim.FONT}`; ctx.textAlign = 'center';
ctx.fillText(`${label} ${this.acidConc} M, ${this.acidVol} mL`, cx, fB + 14);
/* pH value */
ctx.font = `bold 14px ${TitrationSim.FONT}`;
ctx.fillStyle = this._phColor(pH);
ctx.fillText(`pH ${pH.toFixed(2)}`, cx, fB + 32);
}
/* ── Drops / splashes / ripples ─────────────────────────── */
_drawParticles(ctx) {
for (const d of this._drops) {
ctx.save(); ctx.globalAlpha = 0.85;
ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 8;
const st = Math.min(2.5, 1 + d.vy * 0.003);
ctx.beginPath(); ctx.ellipse(d.x, d.y, d.r, d.r * st, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(100,180,255,0.70)'; ctx.fill();
ctx.fillStyle = 'rgba(220,240,255,0.55)';
ctx.beginPath(); ctx.arc(d.x - d.r * 0.3, d.y - d.r * 0.4, d.r * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
const [sr, sg, sb] = this._liquidRGB(this._calcPH(this.baseAdded));
for (const s of this._splashes) {
ctx.save(); ctx.globalAlpha = s.life * 0.7;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${Math.min(255, sr + 60)},${Math.min(255, sg + 60)},${Math.min(255, sb + 60)})`; ctx.fill();
ctx.restore();
}
for (const r of this._ripples) {
ctx.save(); ctx.globalAlpha = r.life * 0.4;
ctx.strokeStyle = 'rgba(180,220,255,0.6)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
/* ── Stats overlay ──────────────────────────────────────── */
_drawOverlay(ctx) {
const pH = this._calcPH(this.baseAdded);
const eqV = this._eqVolume();
const bx = 10, by = 10, bw = 150, bh = 78;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const lh = 16;
ctx.font = `bold 12px ${TitrationSim.FONT}`;
ctx.fillStyle = TitrationSim.CYAN;
ctx.fillText(`pH = ${pH.toFixed(2)}`, bx + 10, by + 8);
ctx.font = `10px ${TitrationSim.FONT}`;
ctx.fillStyle = TitrationSim.YELLOW;
ctx.fillText(`V(NaOH) = ${this.baseAdded.toFixed(1)} mL`, bx + 10, by + 8 + lh);
ctx.fillStyle = TitrationSim.GREEN;
ctx.fillText(`V\u044D\u043A\u0432 = ${eqV.toFixed(1)} mL`, bx + 10, by + 8 + lh * 2);
const names = { phenolphthalein: '\u0424\u0435\u043D\u043E\u043B\u0444\u0442.', methyl_orange: '\u041C\u0435\u0442.\u043E\u0440.', litmus: '\u041B\u0430\u043A\u043C\u0443\u0441' };
ctx.fillStyle = 'rgba(255,255,255,0.40)';
ctx.fillText(names[this.indicator] || this.indicator, bx + 10, by + 8 + lh * 3);
}
/* ── pH titration curve (right 40%) ─────────────────────── */
_drawPHCurve(ctx, x0, W, H) {
const gW = W - x0;
const pad = { l: 36, r: 12, t: 30, b: 32 };
const px = x0 + pad.l, py = pad.t;
const pw = gW - pad.l - pad.r, ph = H - pad.t - pad.b;
const maxV = this._maxVolume();
const eqV = this._eqVolume();
const FNT = TitrationSim.FONT;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, gW, H);
/* title */
ctx.fillStyle = 'rgba(200,220,255,0.65)';
ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'center';
ctx.fillText('\u041A\u0440\u0438\u0432\u0430\u044F \u0442\u0438\u0442\u0440\u043E\u0432\u0430\u043D\u0438\u044F', x0 + gW / 2, 14);
/* grid + y labels */
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.5;
ctx.fillStyle = 'rgba(180,210,255,0.35)';
ctx.font = `9px ${FNT}`; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let p = 0; p <= 14; p += 2) {
const yl = py + ph - (p / 14) * ph;
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
ctx.fillText(p.toString(), px - 5, yl);
}
/* x labels */
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
const vs = maxV > 60 ? 20 : maxV > 30 ? 10 : 5;
for (let v = 0; v <= maxV; v += vs) {
const xl = px + (v / maxV) * pw;
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.beginPath(); ctx.moveTo(xl, py); ctx.lineTo(xl, py + ph); ctx.stroke();
ctx.fillText(v.toFixed(0), xl, py + ph + 6);
}
ctx.fillStyle = 'rgba(180,210,255,0.50)'; ctx.font = `bold 10px ${FNT}`;
ctx.fillText('V (mL)', x0 + gW / 2, py + ph + 22);
/* y-axis label */
ctx.save();
ctx.translate(x0 + 10, py + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('pH', 0, 0);
ctx.restore();
/* dashed pH=7 */
const y7 = py + ph * (1 - 7 / 14);
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(123,245,164,0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, y7); ctx.lineTo(px + pw, y7); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(123,245,164,0.40)'; ctx.font = `9px ${FNT}`;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('pH 7', px + pw + 4, y7);
/* axes */
ctx.strokeStyle = 'rgba(160,200,255,0.40)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py + ph); ctx.lineTo(px + pw, py + ph); ctx.stroke();
/* curve */
if (this._curve.length > 1) {
ctx.strokeStyle = TitrationSim.CYAN; ctx.lineWidth = 2.5;
ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6;
ctx.beginPath();
for (let i = 0; i < this._curve.length; i++) {
const pt = this._curve[i];
const lx = px + (pt.v / maxV) * pw;
const ly = py + ph * (1 - Math.max(0, Math.min(14, pt.pH)) / 14);
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke(); ctx.shadowBlur = 0;
/* current point dot + tooltip */
const last = this._curve[this._curve.length - 1];
const dx = px + (last.v / maxV) * pw;
const dy = py + ph * (1 - Math.max(0, Math.min(14, last.pH)) / 14);
ctx.save();
ctx.shadowColor = '#FFF'; ctx.shadowBlur = 10; ctx.fillStyle = '#FFF';
ctx.beginPath(); ctx.arc(dx, dy, 4.5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
const tw = 72, th = 30;
const tx = Math.min(dx + 8, px + pw - tw - 4);
const ty = Math.max(dy - th - 8, py + 2);
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 5); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = this._phColor(last.pH);
ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(`pH ${last.pH.toFixed(2)}`, tx + 6, ty + 5);
ctx.fillStyle = 'rgba(200,220,255,0.60)'; ctx.font = `9px ${FNT}`;
ctx.fillText(`${last.v.toFixed(1)} mL`, tx + 6, ty + 18);
ctx.restore();
}
/* equivalence point markers */
const eqX = px + (eqV / maxV) * pw;
const eqPH = this._calcPH(eqV);
const eqY = py + ph * (1 - Math.max(0, Math.min(14, eqPH)) / 14);
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(155,93,229,0.45)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(eqX, py); ctx.lineTo(eqX, py + ph); ctx.stroke();
ctx.setLineDash([]);
/* equivalence diamond */
ctx.save();
ctx.shadowColor = TitrationSim.VIOLET; ctx.shadowBlur = 10;
ctx.fillStyle = TitrationSim.VIOLET;
ctx.beginPath();
ctx.moveTo(eqX, eqY - 6); ctx.lineTo(eqX + 5, eqY);
ctx.lineTo(eqX, eqY + 6); ctx.lineTo(eqX - 5, eqY);
ctx.closePath(); ctx.fill();
ctx.shadowBlur = 0; ctx.restore();
ctx.fillStyle = 'rgba(155,93,229,0.70)';
ctx.font = `bold 9px ${FNT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('\u044D\u043A\u0432', eqX, eqY - 10);
ctx.fillStyle = 'rgba(155,93,229,0.50)'; ctx.font = `8px ${FNT}`;
ctx.fillText(`${eqV.toFixed(1)} mL`, eqX, eqY - 20);
}
}
if (typeof module !== 'undefined') module.exports = TitrationSim;