'use strict'; /** * _chem_visuals.js — shared lab-glassware drawing helpers * Used by: chemsandbox, flask, titration, electrolysis, ionexchange, redox * * Public API: * ChemVisuals.drawErlenmeyer(ctx, x, y, w, h, fillColor) * ChemVisuals.drawBeaker(ctx, x, y, w, h, fillColor, opts) * ChemVisuals.drawBurette(ctx, x, y, h, fillColor, valveOpen) * ChemVisuals.drawTube(ctx, x, y, h, fillColor) * ChemVisuals.drawSpiritLamp(ctx, x, y, flameOn, t) * ChemVisuals.animateGasBubbles(ctx, x, y, color, t) * ChemVisuals.animatePrecipitateFall(ctx, x, y, color, t) * ChemVisuals.drawProductLabel(ctx, x, y, text, type, age) * ChemVisuals.drawEduTooltip(ctx, x, y, w, lines, age) * ChemVisuals.drawDeskBackground(ctx, W, H, tableY) * ChemVisuals.drawPHStrip(ctx, x, y, pH) */ window.ChemVisuals = (() => { /* ── internal helpers ─────────────────────────────────────────── */ function _rrect(ctx, x, y, w, h, r) { r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } function _glassHighlight(ctx, x, y, w, h) { /* left-edge specular stripe */ const hl = ctx.createLinearGradient(x, y, x + w * 0.28, y); hl.addColorStop(0, 'rgba(255,255,255,0.22)'); hl.addColorStop(0.45, 'rgba(255,255,255,0.07)'); hl.addColorStop(1, 'rgba(255,255,255,0.00)'); ctx.save(); ctx.fillStyle = hl; ctx.fillRect(x + 2, y + 4, w * 0.26, h - 8); ctx.restore(); } /* ── Erlenmeyer flask (Коническая колба) ─────────────────────── */ /* x,y = top-center of neck; w = body width; h = total height */ function drawErlenmeyer(ctx, x, y, w, h, fillColor) { const nw = w * 0.14; /* half-neck-width */ const nb = y + h * 0.28; /* neck-bottom y */ const bHalf = w * 0.48; /* half-body-width */ const bot = y + h; /* erlenmeyer path: straight neck → shoulder curve → flat bottom */ const erlPath = () => { ctx.beginPath(); ctx.moveTo(x - nw, y); ctx.lineTo(x - nw, nb); /* left shoulder */ ctx.bezierCurveTo(x - nw, nb + h * 0.18, x - bHalf, bot - h * 0.10, x - bHalf, bot); ctx.lineTo(x + bHalf, bot); /* right shoulder */ ctx.bezierCurveTo(x + bHalf, bot - h * 0.10, x + nw, nb + h * 0.18, x + nw, nb); ctx.lineTo(x + nw, y); ctx.closePath(); }; /* liquid fill */ if (fillColor) { ctx.save(); erlPath(); ctx.clip(); const fillTop = nb + h * 0.22; const fg = ctx.createLinearGradient(0, fillTop, 0, bot); fg.addColorStop(0, _alpha(fillColor, 0.35)); fg.addColorStop(1, _alpha(fillColor, 0.65)); ctx.fillStyle = fg; ctx.fillRect(x - bHalf - 2, fillTop, bHalf * 2 + 4, bot - fillTop + 2); ctx.restore(); } /* glass tint fill */ ctx.save(); erlPath(); const gf = ctx.createLinearGradient(x - bHalf, y, x + bHalf, bot); gf.addColorStop(0, 'rgba(220,238,255,0.07)'); gf.addColorStop(1, 'rgba(180,210,255,0.03)'); ctx.fillStyle = gf; ctx.fill(); /* outline */ ctx.strokeStyle = 'rgba(140,190,255,0.70)'; ctx.lineWidth = 1.8; ctx.stroke(); ctx.restore(); /* neck rim ellipse */ ctx.save(); ctx.beginPath(); ctx.ellipse(x, y, nw + 2, 3, 0, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(180,215,255,0.55)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); /* specular left stripe */ ctx.save(); ctx.beginPath(); ctx.moveTo(x - nw + 2, y + 6); ctx.lineTo(x - nw + 2, nb + 4); ctx.bezierCurveTo(x - nw + 2, nb + h * 0.22, x - bHalf * 0.68, bot - h * 0.18, x - bHalf * 0.72, bot - h * 0.10); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 2.5; ctx.stroke(); ctx.restore(); /* graduation marks on body */ ctx.save(); ctx.strokeStyle = 'rgba(180,210,255,0.25)'; ctx.lineWidth = 0.8; for (let i = 1; i <= 3; i++) { const gy = nb + (bot - nb) * (i / 4); const hw = nw + (bHalf - nw) * (i / 4) - 4; ctx.beginPath(); ctx.moveTo(x - hw, gy); ctx.lineTo(x - hw + 8, gy); ctx.stroke(); } ctx.restore(); } /* ── Beaker (Химический стакан) ─────────────────────────────── */ /* x,y = top-left corner; w,h = dimensions */ function drawBeaker(ctx, x, y, w, h, fillColor, opts) { opts = opts || {}; const hasPour = opts.spout !== false; /* small spout notch top-right */ const r = 5; /* beaker outline path — open top, straight walls, rounded bottom */ const bPath = () => { ctx.beginPath(); ctx.moveTo(x, y); if (hasPour) { ctx.lineTo(x + w - 14, y); ctx.lineTo(x + w - 10, y - 7); ctx.lineTo(x + w + 2, y - 7); ctx.lineTo(x + w, y); } else { ctx.lineTo(x + w, y); } ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y); }; /* liquid fill clipped */ if (fillColor) { ctx.save(); bPath(); ctx.clip(); const liqTop = y + h * 0.25; const lg = ctx.createLinearGradient(0, liqTop, 0, y + h); lg.addColorStop(0, _alpha(fillColor, 0.30)); lg.addColorStop(1, _alpha(fillColor, 0.60)); ctx.fillStyle = lg; ctx.fillRect(x + 1, liqTop, w - 2, h); ctx.restore(); } /* glass tint */ ctx.save(); bPath(); const gf = ctx.createLinearGradient(x, y, x + w, y + h); gf.addColorStop(0, 'rgba(220,238,255,0.06)'); gf.addColorStop(1, 'rgba(180,210,255,0.02)'); ctx.fillStyle = gf; ctx.fill(); ctx.strokeStyle = 'rgba(130,185,255,0.60)'; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); /* graduation scale on right wall */ ctx.save(); ctx.strokeStyle = 'rgba(170,205,255,0.28)'; ctx.lineWidth = 0.8; ctx.font = '7px monospace'; ctx.fillStyle = 'rgba(170,205,255,0.40)'; ctx.textAlign = 'right'; for (let i = 1; i <= 4; i++) { const gy = y + h * 0.85 - (h * 0.55 * i / 4); ctx.beginPath(); ctx.moveTo(x + w, gy); ctx.lineTo(x + w - (i % 2 === 0 ? 8 : 4), gy); ctx.stroke(); if (i % 2 === 0) ctx.fillText((i * 25).toString(), x + w - 10, gy + 3); } ctx.restore(); /* specular highlight */ _glassHighlight(ctx, x, y, w, h); } /* ── Burette (Бюретка) ──────────────────────────────────────── */ /* x,y = top-center; h = length; frac = fill fraction 0..1 */ function drawBurette(ctx, x, y, h, fillColor, valveOpen) { const bw = 10; /* half-width */ const valveY = y + h - 12; /* glass tube */ ctx.save(); const gg = ctx.createLinearGradient(x - bw, 0, x + bw, 0); gg.addColorStop(0, 'rgba(140,185,255,0.22)'); gg.addColorStop(0.38, 'rgba(170,210,255,0.08)'); gg.addColorStop(0.62, 'rgba(170,210,255,0.08)'); gg.addColorStop(1, 'rgba(120,165,245,0.18)'); ctx.fillStyle = gg; _rrect(ctx, x - bw, y, bw * 2, h - 10, 4); ctx.fill(); ctx.strokeStyle = 'rgba(130,185,255,0.55)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); /* liquid inside */ if (fillColor) { const lh = h * 0.85; ctx.save(); _rrect(ctx, x - bw + 2, y + 4, bw * 2 - 4, lh - 4, 3); ctx.clip(); const lg = ctx.createLinearGradient(0, y, 0, y + lh); lg.addColorStop(0, _alpha(fillColor, 0.25)); lg.addColorStop(1, _alpha(fillColor, 0.45)); ctx.fillStyle = lg; ctx.fillRect(x - bw + 2, y + 4, bw * 2 - 4, lh - 4); ctx.restore(); } /* graduation lines */ ctx.save(); ctx.strokeStyle = 'rgba(170,210,255,0.32)'; ctx.lineWidth = 0.7; ctx.font = '7px monospace'; ctx.fillStyle = 'rgba(170,210,255,0.45)'; ctx.textAlign = 'left'; for (let i = 0; i <= 10; i++) { const gy = y + 6 + (h - 18) * (i / 10); const maj = i % 2 === 0; ctx.beginPath(); ctx.moveTo(x + bw, gy); ctx.lineTo(x + bw + (maj ? 7 : 3), gy); ctx.stroke(); if (maj) ctx.fillText((i * 5).toString(), x + bw + 9, gy + 3); } ctx.restore(); /* stopcock */ ctx.save(); ctx.fillStyle = valveOpen ? 'rgba(100,180,120,0.70)' : 'rgba(190,170,140,0.65)'; _rrect(ctx, x - bw - 3, valveY, bw * 2 + 6, 8, 3); ctx.fill(); ctx.strokeStyle = 'rgba(200,200,200,0.35)'; ctx.lineWidth = 1; ctx.stroke(); ctx.restore(); /* nozzle */ ctx.save(); ctx.fillStyle = 'rgba(150,175,220,0.55)'; ctx.beginPath(); ctx.moveTo(x - 3, valveY + 8); ctx.lineTo(x + 3, valveY + 8); ctx.lineTo(x + 1.5, valveY + 16); ctx.lineTo(x - 1.5, valveY + 16); ctx.closePath(); ctx.fill(); ctx.restore(); /* specular */ ctx.save(); ctx.strokeStyle = 'rgba(220,240,255,0.25)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - bw + 3, y + 8); ctx.lineTo(x - bw + 3, valveY - 6); ctx.stroke(); ctx.restore(); } /* ── Test tube (Пробирка) ────────────────────────────────────── */ /* x,y = top-center; h = length; w = half-width */ function drawTube(ctx, x, y, h, fillColor) { const w = 9; const tubeBot = y + h; /* tube path: open top, rounded bottom */ const tubePath = () => { ctx.beginPath(); ctx.moveTo(x - w, y); ctx.lineTo(x - w, tubeBot - w); ctx.arc(x, tubeBot - w, w, Math.PI, 0, true); ctx.lineTo(x + w, y); }; /* fill */ if (fillColor) { ctx.save(); tubePath(); ctx.clip(); const liqTop = y + h * 0.35; const lg = ctx.createLinearGradient(0, liqTop, 0, tubeBot); lg.addColorStop(0, _alpha(fillColor, 0.35)); lg.addColorStop(1, _alpha(fillColor, 0.65)); ctx.fillStyle = lg; ctx.fillRect(x - w, liqTop, w * 2, tubeBot - liqTop + 2); ctx.restore(); } /* glass */ ctx.save(); tubePath(); const gf = ctx.createLinearGradient(x - w, y, x + w, tubeBot); gf.addColorStop(0, 'rgba(210,235,255,0.08)'); gf.addColorStop(1, 'rgba(180,215,255,0.02)'); ctx.fillStyle = gf; ctx.fill(); ctx.strokeStyle = 'rgba(140,190,255,0.65)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); /* highlight */ ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - w + 2.5, y + 6); ctx.lineTo(x - w + 2.5, tubeBot - w * 1.4); ctx.stroke(); ctx.restore(); } /* ── Spirit lamp (Спиртовка) ─────────────────────────────────── */ /* x,y = bottom-center of lamp base; flameOn = bool; t = anim time */ function drawSpiritLamp(ctx, x, y, flameOn, t) { t = t || 0; /* base / reservoir */ const rw = 22, rh = 18; ctx.save(); const bg = ctx.createLinearGradient(x - rw, y - rh, x + rw, y); bg.addColorStop(0, 'rgba(180,210,255,0.25)'); bg.addColorStop(1, 'rgba(140,170,220,0.15)'); ctx.fillStyle = bg; _rrect(ctx, x - rw, y - rh, rw * 2, rh, 5); ctx.fill(); ctx.strokeStyle = 'rgba(140,185,255,0.55)'; ctx.lineWidth = 1.5; ctx.stroke(); /* alcohol inside (tinted) */ ctx.save(); _rrect(ctx, x - rw + 2, y - rh + 3, rw * 2 - 4, rh - 5, 4); ctx.clip(); ctx.fillStyle = 'rgba(160,200,255,0.20)'; ctx.fillRect(x - rw + 2, y - rh * 0.5, rw * 2 - 4, rh); ctx.restore(); /* specular on reservoir */ ctx.strokeStyle = 'rgba(220,240,255,0.25)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - rw + 4, y - rh + 5); ctx.lineTo(x - rw + 4, y - 5); ctx.stroke(); ctx.restore(); /* wick holder + wick */ ctx.save(); ctx.fillStyle = 'rgba(180,175,165,0.70)'; _rrect(ctx, x - 4, y - rh - 8, 8, 10, 2); ctx.fill(); /* wick */ ctx.fillStyle = 'rgba(240,235,215,0.85)'; ctx.fillRect(x - 1.5, y - rh - 6, 3, 7); ctx.restore(); /* flame */ if (flameOn) { const flickX = Math.sin(t * 7.3) * 2.0 + Math.sin(t * 13.1) * 0.8; const flickY = Math.abs(Math.sin(t * 5.7)) * 2.0; const fTop = y - rh - 22 - flickY; const fMid = y - rh - 14; const fBase = y - rh - 8; /* outer flame (orange) */ ctx.save(); ctx.beginPath(); ctx.moveTo(x + flickX - 6, fBase); ctx.bezierCurveTo(x + flickX - 8, fMid, x + flickX - 3, fTop + 6, x + flickX, fTop); ctx.bezierCurveTo(x + flickX + 3, fTop + 6, x + flickX + 8, fMid, x + flickX + 6, fBase); ctx.closePath(); const fg = ctx.createLinearGradient(x, fBase, x, fTop); fg.addColorStop(0, 'rgba(255,120,10,0.90)'); fg.addColorStop(0.5, 'rgba(255,185,40,0.75)'); fg.addColorStop(1, 'rgba(255,240,140,0.35)'); ctx.fillStyle = fg; ctx.shadowColor = 'rgba(255,140,20,0.55)'; ctx.shadowBlur = 14; ctx.fill(); ctx.restore(); /* inner blue core */ ctx.save(); ctx.beginPath(); const cTop = fMid + (fBase - fMid) * 0.15; ctx.moveTo(x - 2.5, fBase); ctx.bezierCurveTo(x - 3, fMid + 4, x - 1.5, cTop + 3, x, cTop); ctx.bezierCurveTo(x + 1.5, cTop + 3, x + 3, fMid + 4, x + 2.5, fBase); ctx.closePath(); const cf = ctx.createLinearGradient(x, fBase, x, cTop); cf.addColorStop(0, 'rgba(80,160,255,0.80)'); cf.addColorStop(1, 'rgba(120,200,255,0.20)'); ctx.fillStyle = cf; ctx.fill(); ctx.restore(); /* warm glow on table beneath lamp */ ctx.save(); const gl = ctx.createRadialGradient(x, y, 0, x, y, rw * 1.8); gl.addColorStop(0, 'rgba(255,130,20,0.12)'); gl.addColorStop(1, 'rgba(255,130,20,0.00)'); ctx.fillStyle = gl; ctx.scale(1, 0.25); ctx.beginPath(); ctx.arc(x, y / 0.25, rw * 1.8, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } /* ── Gas bubbles animation ───────────────────────────────────── */ /* Draws 3 ascending bubbles at staggered offsets */ function animateGasBubbles(ctx, x, y, color, t) { t = t || 0; for (let i = 0; i < 3; i++) { const phase = (t * 1.4 + i * 0.8) % 2.5; if (phase > 2.0) continue; /* fade in/out cycle */ const bx = x + (i - 1) * 6 + Math.sin(phase * 3.1 + i) * 3; const by = y - phase * 18; const br = 2.8 + Math.sin(phase * 2) * 0.6; const alp = Math.min(1, phase < 1.6 ? phase / 0.4 : (2.0 - phase) / 0.4); ctx.save(); ctx.globalAlpha = alp * 0.60; ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2); ctx.strokeStyle = color || 'rgba(200,230,255,0.9)'; ctx.lineWidth = 0.9; ctx.stroke(); /* glint */ ctx.beginPath(); ctx.arc(bx - br * 0.3, by - br * 0.3, br * 0.22, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fill(); ctx.restore(); } } /* ── Animated up-arrow for gas product ─────────────────────── */ function _drawArrowUp(ctx, x, y, color, alpha) { ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 1.8; /* shaft */ ctx.beginPath(); ctx.moveTo(x, y + 10); ctx.lineTo(x, y + 2); ctx.stroke(); /* head */ ctx.beginPath(); ctx.moveTo(x - 5, y + 6); ctx.lineTo(x, y); ctx.lineTo(x + 5, y + 6); ctx.closePath(); ctx.fill(); ctx.restore(); } /* ── Animated down-arrow for precipitate ────────────────────── */ function _drawArrowDown(ctx, x, y, color, alpha) { ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 1.8; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + 8); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - 5, y + 4); ctx.lineTo(x, y + 10); ctx.lineTo(x + 5, y + 4); ctx.closePath(); ctx.fill(); ctx.restore(); } /* ── Precipitate falling particles ──────────────────────────── */ function animatePrecipitateFall(ctx, x, y, color, t) { t = t || 0; for (let i = 0; i < 4; i++) { const phase = (t * 0.9 + i * 0.7) % 2.8; if (phase > 2.2) continue; const px = x + (i - 1.5) * 7 + Math.sin(phase * 2.5 + i * 1.1) * 3; const py = y + phase * 14; const pr = 2.2 + Math.sin(i) * 0.6; const alp = Math.min(1, phase < 1.8 ? 1 : (2.2 - phase) / 0.4); ctx.save(); ctx.globalAlpha = alp * 0.65; ctx.beginPath(); ctx.arc(px, py, pr, 0, Math.PI * 2); ctx.fillStyle = color || '#CCCCCC'; ctx.fill(); ctx.restore(); } } /* ── Product label (gas or precipitate) ────────────────────── */ /* age: 0..1, 0=appear, 1=disappear */ function drawProductLabel(ctx, x, y, text, type, age) { /* type: 'gas' | 'precip' | 'liquid' */ age = Math.max(0, Math.min(1, age === undefined ? 0 : age)); const alpha = age < 0.15 ? age / 0.15 : age > 0.75 ? (1 - age) / 0.25 : 1.0; if (alpha <= 0) return; const isGas = type === 'gas'; const isPrec = type === 'precip'; const color = isGas ? '#A8E6FF' : isPrec ? '#FFD580' : '#9FF0B0'; const arrowY = isGas ? y - 18 : y + 8; const textY = isGas ? y - 34 : y + 28; const offsetX = isGas ? 0 : 0; /* bouncing offset for gas (rises) */ const drift = isGas ? -age * 8 : age * 6; if (isGas) { _drawArrowUp(ctx, x + offsetX, arrowY + drift, color, alpha * 0.9); } else if (isPrec) { _drawArrowDown(ctx, x + offsetX, arrowY + drift, color, alpha * 0.9); } ctx.save(); ctx.globalAlpha = alpha; ctx.font = 'bold 10px "Courier New", monospace'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.fillText(text, x + offsetX, textY + drift); ctx.shadowBlur = 0; ctx.restore(); } /* ── Educational tooltip bubble ─────────────────────────────── */ /* x,y = pointer tip; lines = string[]; age = 0..1 */ function drawEduTooltip(ctx, x, y, w, lines, age) { age = Math.max(0, Math.min(1, age === undefined ? 0 : age)); const alpha = age < 0.12 ? age / 0.12 : age > 0.75 ? (1 - age) / 0.25 : 1.0; if (alpha <= 0.02) return; const lineH = 14; const pad = 10; const bh = lines.length * lineH + pad * 2; const bx = x - w / 2; const by = y - bh - 14; ctx.save(); ctx.globalAlpha = alpha; /* shadow */ ctx.shadowColor = 'rgba(0,80,180,0.35)'; ctx.shadowBlur = 12; /* background */ _rrect(ctx, bx, by, w, bh, 7); const bg = ctx.createLinearGradient(bx, by, bx, by + bh); bg.addColorStop(0, 'rgba(10,25,60,0.94)'); bg.addColorStop(1, 'rgba(6,14,40,0.96)'); ctx.fillStyle = bg; ctx.fill(); ctx.shadowBlur = 0; /* border */ ctx.strokeStyle = 'rgba(80,145,255,0.50)'; ctx.lineWidth = 1.2; ctx.stroke(); /* pointer triangle */ const ptx = x; ctx.beginPath(); ctx.moveTo(ptx - 7, by + bh); ctx.lineTo(ptx, by + bh + 11); ctx.lineTo(ptx + 7, by + bh); ctx.closePath(); ctx.fillStyle = 'rgba(10,25,60,0.94)'; ctx.fill(); ctx.strokeStyle = 'rgba(80,145,255,0.50)'; ctx.lineWidth = 1.2; ctx.stroke(); /* left accent bar */ ctx.fillStyle = 'rgba(80,145,255,0.60)'; ctx.fillRect(bx + 5, by + pad, 2.5, bh - pad * 2); /* text lines */ ctx.font = '10.5px "Manrope", sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; lines.forEach((line, i) => { ctx.fillStyle = i === 0 ? 'rgba(140,195,255,0.95)' : 'rgba(200,220,255,0.80)'; ctx.fillText(line, bx + 15, by + pad + lineH * i + lineH / 2); }); ctx.restore(); } /* ── Laboratory desk background ─────────────────────────────── */ /* Draws warm desk surface + subtle shadow under vessel */ function drawDeskBackground(ctx, W, H, tableY) { tableY = tableY || H * 0.78; /* wooden desk stripe */ ctx.save(); const dg = ctx.createLinearGradient(0, tableY, 0, H); dg.addColorStop(0, '#1e1610'); dg.addColorStop(0.15, '#261c12'); dg.addColorStop(1, '#0e0b08'); ctx.fillStyle = dg; ctx.fillRect(0, tableY, W, H - tableY); /* wood grain lines */ ctx.strokeStyle = 'rgba(255,200,120,0.04)'; ctx.lineWidth = 1; for (let i = 0; i < 5; i++) { const gy = tableY + 8 + i * 12; ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy + (Math.random() * 4 - 2)); ctx.stroke(); } /* edge highlight */ ctx.strokeStyle = 'rgba(200,160,100,0.18)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, tableY); ctx.lineTo(W, tableY); ctx.stroke(); ctx.restore(); } /* ── Vessel shadow on desk ───────────────────────────────────── */ function drawVesselShadow(ctx, cx, tableY, radius) { ctx.save(); ctx.scale(1, 0.22); const sg = ctx.createRadialGradient(cx, tableY / 0.22, 0, cx, tableY / 0.22, radius * 1.3); sg.addColorStop(0, 'rgba(0,0,0,0.35)'); sg.addColorStop(0.5, 'rgba(0,0,0,0.14)'); sg.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = sg; ctx.beginPath(); ctx.arc(cx, tableY / 0.22, radius * 1.3, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } /* ── pH strip indicator ─────────────────────────────────────── */ /* x,y = left edge top; pH = current value */ function drawPHStrip(ctx, x, y, pH) { const sw = 14, sh = 90; const strip = [ { ph: 0, rgb: [180, 0, 0] }, { ph: 3, rgb: [220, 40, 40] }, { ph: 4, rgb: [255, 130, 50] }, { ph: 5, rgb: [255, 215, 60] }, { ph: 6, rgb: [170, 220, 80] }, { ph: 7, rgb: [60, 185, 100] }, { ph: 8, rgb: [40, 155, 180] }, { ph: 9, rgb: [50, 100, 210] }, { ph: 11, rgb: [100, 60, 180] }, { ph: 14, rgb: [80, 20, 140] }, ]; /* rainbow gradient */ const gr = ctx.createLinearGradient(0, y, 0, y + sh); strip.forEach(s => { gr.addColorStop(s.ph / 14, `rgb(${s.rgb[0]},${s.rgb[1]},${s.rgb[2]})`); }); ctx.save(); _rrect(ctx, x, y, sw, sh, 3); ctx.fillStyle = gr; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.20)'; ctx.lineWidth = 1; ctx.stroke(); /* pH value labels */ ctx.font = '6.5px monospace'; ctx.fillStyle = 'rgba(255,255,255,0.40)'; ctx.textAlign = 'left'; [0, 4, 7, 10, 14].forEach(v => { const gy = y + (v / 14) * sh; ctx.fillText(v.toString(), x + sw + 3, gy + 2); }); /* marker for current pH */ const markerY = y + (Math.min(14, Math.max(0, pH)) / 14) * sh; ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x - 4, markerY); ctx.lineTo(x + sw + 2, markerY); ctx.stroke(); /* marker arrowhead */ ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.beginPath(); ctx.moveTo(x - 4, markerY - 4); ctx.lineTo(x - 4, markerY + 4); ctx.lineTo(x - 10, markerY); ctx.closePath(); ctx.fill(); ctx.restore(); ctx.restore(); } /* ── Color utility ──────────────────────────────────────────── */ function _alpha(hex, a) { if (hex.startsWith('#')) { const n = parseInt(hex.slice(1), 16); return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; } /* already rgb/rgba — just return with rough alpha inject */ if (hex.startsWith('rgba')) return hex.replace(/[\d.]+\)$/, a + ')'); if (hex.startsWith('rgb(')) return hex.replace('rgb(', 'rgba(').replace(')', `,${a})`); return hex; } /* ── Public API ─────────────────────────────────────────────── */ return { drawErlenmeyer, drawBeaker, drawBurette, drawTube, drawSpiritLamp, animateGasBubbles, animatePrecipitateFall, drawProductLabel, drawEduTooltip, drawDeskBackground, drawVesselShadow, drawPHStrip, }; })();