'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;