fd29acbbdd
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>
659 lines
26 KiB
JavaScript
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;
|