'use strict'; /* ═══════════════════════════════════════════════════════════════════ HydroSim v2 — Гидростатика Модули: давление · поверхностное натяжение · сообщающиеся сосуды · Архимед Canvas 2D, pure-JS physics (no external libraries) ═══════════════════════════════════════════════════════════════════ */ class HydroSim { static G = 9.81; static LIQUIDS = { water: { name: 'Вода', rho: 1000, color: '#2979FF', sigma: 0.073 }, saltwater: { name: 'Солёная вода', rho: 1030, color: '#1565C0', sigma: 0.074 }, oil: { name: 'Масло', rho: 900, color: '#FFA000', sigma: 0.033 }, alcohol: { name: 'Спирт', rho: 790, color: '#CE93D8', sigma: 0.022 }, glycerin: { name: 'Глицерин', rho: 1260, color: '#FFCC02', sigma: 0.063 }, mercury: { name: 'Ртуть', rho: 13600, color: '#B0BEC5', sigma: 0.500 }, }; static MATERIALS = { styrofoam: { name: 'Пенопласт', rho: 30, color: '#F5F5F5' }, cork: { name: 'Пробка', rho: 120, color: '#8D6E63' }, wood: { name: 'Дерево', rho: 500, color: '#A1887F' }, ice: { name: 'Лёд', rho: 900, color: '#B3E5FC' }, plastic: { name: 'Пластик', rho: 1100, color: '#EF5350' }, glass: { name: 'Стекло', rho: 2500, color: '#90CAF9' }, aluminum: { name: 'Алюминий', rho: 2700, color: '#CFD8DC' }, iron: { name: 'Железо', rho: 7800, color: '#78909C' }, gold: { name: 'Золото', rho: 19300, color: '#FFD700' }, }; constructor(canvas, mode = 'pressure') { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.mode = mode; this._raf = null; this._running = false; this.onUpdate = null; this._lastNotify = 0; this._t = 0; this.liquidKey = 'water'; this.materialKey = 'wood'; this.g = HydroSim.G; /* pressure */ this._probe = { vy: 0.5, dragging: false }; /* surface */ this._stMode = 'capillary'; this._contactAngle = 20; /* communicating */ this._numVessels = 2; this._liquidFrac = 0.45; this._valveOpen = true; this._vesselShapes = ['rect', 'wide']; this._animLevel = null; this._targetLevel = null; this._recalcVessels(); /* archimedes */ this._archReady = false; this._bodies = []; this._waterLevel = 0.60; this._bodyShape = 'rect'; this._bindEvents(); this._ro = new ResizeObserver(() => this.fit()); this._ro.observe(canvas.parentElement || canvas); requestAnimationFrame(() => { this.fit(); this.play(); }); } /* ── Public API ── */ setMode(m) { this.mode = m; if (m === 'archimedes') this._initArch(); else this._destroyArch(); this._notify(); } setLiquid(key) { this.liquidKey = key; this._notify(); } setMaterial(key) { this.materialKey = key; if (this.mode === 'archimedes') this._archReset(); this._notify(); } setContactAngle(deg) { this._contactAngle = deg; } setNumVessels(n) { this._numVessels = n; this._vesselShapes = Array.from({ length: n }, (_, i) => ['rect','wide','narrow','trapezoid'][i % 4]); this._recalcVessels(); this._notify(); } setValve(open) { this._valveOpen = open; this._recalcVessels(); this._notify(); } addLiquid() { this._liquidFrac = Math.min(0.85, this._liquidFrac + 0.05); this._recalcVessels(); } removeLiquid() { this._liquidFrac = Math.max(0.05, this._liquidFrac - 0.05); this._recalcVessels(); } setBodyShape(s){ this._bodyShape = s; if (this.mode === 'archimedes') this._archReset(); } addBody() { this._archAddBody(); } clearBodies() { this._archClear(); this._notify(); } getInfo() { const liq = HydroSim.LIQUIDS[this.liquidKey]; const mat = HydroSim.MATERIALS[this.materialKey]; switch (this.mode) { case 'pressure': { const h = this._probe.vy * this._tankH_m(); const P = liq.rho * this.g * h; return { h: h.toFixed(3), rho: liq.rho, P: Math.round(P), liqName: liq.name, formula: `P = ρ·g·h = ${liq.rho}·${this.g.toFixed(1)}·${h.toFixed(3)} = ${Math.round(P)} Па` }; } case 'surface': { const r = 0.001, theta = this._contactAngle * Math.PI / 180; const h = 2 * liq.sigma * Math.cos(theta) / (liq.rho * this.g * r); return { sigma: liq.sigma, theta: this._contactAngle, h: (h * 1000).toFixed(1), liqName: liq.name, formula: `h = 2σ·cos(θ)/(ρgr) = ${(h*1000).toFixed(1)} мм (r=1мм)` }; } case 'communicating': { const lvl = this._animLevel ?? this._targetLevel ?? this._liquidFrac * 0.8; const h = lvl * this._tankH_m(), P = liq.rho * this.g * h; return { h: h.toFixed(3), rho: liq.rho, P: Math.round(P), liqName: liq.name, vessels: this._numVessels, valve: this._valveOpen, formula: `h₁ = h₂ = ${h.toFixed(3)} м; P = ${Math.round(P)} Па` }; } case 'archimedes': { if (!this._bodies.length) return { liqName: liq.name, matName: mat.name }; const b = this._bodies[0]; const Vs = b.submergedFrac * b.volume; const FA = liq.rho * this.g * Vs, mg = mat.rho * b.volume * this.g; const state = mat.rho < liq.rho * 0.99 ? 'ВСПЛЫВАЕТ' : mat.rho > liq.rho * 1.01 ? 'ТОНЕТ' : 'ВЗВЕШЕНО'; return { FA: FA.toFixed(4), mg: mg.toFixed(4), state, liqName: liq.name, matName: mat.name, rhoMat: mat.rho, rhoLiq: liq.rho, formula: `Fₐ = ρж·g·Vпог = ${liq.rho}·${this.g.toFixed(1)}·${Vs.toFixed(5)} = ${FA.toFixed(4)} Н` }; } } return {}; } fit() { const el = this.canvas.parentElement || this.canvas; const w = el.clientWidth || 600, h = el.clientHeight || 400; if (w < 50 || h < 50) return; this.canvas.width = w; this.canvas.height = h; this.W = w; this.H = h; /* archimedes: no walls to rebuild — pure JS physics */ } play() { if (!this._running) { this._running = true; requestAnimationFrame(t => this._loop(t)); } } stop() { this._running = false; if (this._raf) cancelAnimationFrame(this._raf); } destroy() { this.stop(); this._destroyArch(); this._ro?.disconnect(); this.canvas.removeEventListener('pointerdown', this._onPD); this.canvas.removeEventListener('pointermove', this._onPM); window.removeEventListener('pointerup', this._onPU); } /* ── Loop ── */ _loop(t) { if (!this._running) return; this._raf = requestAnimationFrame(ts => this._loop(ts)); this._t = t; this._update(t); this._draw(t); if (t - this._lastNotify > 120) { this._lastNotify = t; this._notify(); } } _update(t) { if (this.mode === 'communicating' && this._targetLevel !== null) { if (this._animLevel === null) this._animLevel = this._targetLevel; const d = this._targetLevel - this._animLevel; this._animLevel += d * 0.07; if (Math.abs(d) < 0.001) this._animLevel = this._targetLevel; } this._waveT = ((this._waveT || 0) + 0.025); if (this.mode === 'archimedes' && this._archReady) this._archPhysStep(); } _draw(t) { if (!this.W || !this.H) return; this.ctx.clearRect(0, 0, this.W, this.H); /* opaque background — prevents page bg colour bleeding through transparent canvas */ this.ctx.fillStyle = '#0d0920'; this.ctx.fillRect(0, 0, this.W, this.H); switch (this.mode) { case 'pressure': this._drawPressure(t); break; case 'surface': this._drawSurface(t); break; case 'communicating': this._drawCommunicating(t); break; case 'archimedes': this._drawArchimedes(t); break; } } /* ═══════════════════════════════════════════════════ МОДУЛЬ 1 — ДАВЛЕНИЕ ═══════════════════════════════════════════════════ */ _drawPressure(t) { const ctx = this.ctx, W = this.W, H = this.H; const liq = HydroSim.LIQUIDS[this.liquidKey]; /* tank */ const tw = Math.min(W * 0.30, 200), th = H * 0.72; const tx = W * 0.12, ty = H * 0.10; /* pressure at probe */ const h = this._probe.vy * this._tankH_m(); const P = liq.rho * this.g * h; const maxP = liq.rho * this.g * this._tankH_m(); /* draw pressure-gradient zones inside tank */ this._drawPressureZones(ctx, tx, ty, tw, th, maxP, liq.color); /* vessel over zones */ this._drawGlassVessel(ctx, tx, ty, tw, th); /* wave surface */ const liqY = ty; this._drawWaveSurface(ctx, tx + 3, liqY, tw - 6, liq.color, this._waveT); /* depth ruler */ this._drawRuler(ctx, tx - 36, ty, th, '0 м', '1 м'); /* probe */ const pX = tx + tw * 0.5, pY = ty + this._probe.vy * th; /* depth dashed line */ ctx.save(); ctx.setLineDash([5, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(tx - 36, pY); ctx.lineTo(tx + tw + 80, pY); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); /* depth label */ ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText('h = ' + h.toFixed(2) + ' м', tx - 40, pY + 4); ctx.restore(); /* pressure arrows */ const maxLen = Math.min(60, tw * 0.42); const arLen = maxP > 0 ? (P / maxP) * maxLen : 0; const arCol = this._lerpColor('#FFD166', '#F15BB5', P / Math.max(maxP, 1)); const dirs = [{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }]; for (const d of dirs) this._drawArrow(ctx, pX, pY, d.dx * arLen, d.dy * arLen, arCol, 4); /* pressure value label */ if (P > 5) { ctx.save(); ctx.fillStyle = arCol; ctx.font = 'bold 14px "JetBrains Mono",monospace'; ctx.textAlign = 'center'; ctx.shadowColor = arCol; ctx.shadowBlur = 8; ctx.fillText(Math.round(P) + ' Па', pX, pY - 22); ctx.shadowBlur = 0; ctx.restore(); } /* probe dot */ ctx.save(); ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = this._probe.dragging ? 24 : 14; ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.arc(pX, pY, 12, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2.5; ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('P', pX, pY); ctx.textBaseline = 'alphabetic'; ctx.restore(); /* drag hint */ ctx.fillStyle = 'rgba(6,214,224,0.4)'; ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText('↕ тяни', pX, pY + 28); /* gauge */ const gR = Math.min(48, H * 0.11); const gCx = tx + tw + 22 + gR, gCy = ty + th * 0.50; this._drawGauge(ctx, gCx, gCy, P, maxP, 'Па', gR); /* right-side info panel */ this._drawInfoPanel(ctx, tx + tw + 22 + gR * 2 + 12, ty + 4, W - (tx + tw + 22 + gR * 2 + 16), th - 8, [ { label: 'Жидкость', value: liq.name }, { label: 'ρ', value: liq.rho + ' кг/м³' }, { label: 'h', value: h.toFixed(3) + ' м' }, { label: 'P', value: Math.round(P) + ' Па', color: arCol }, ]); /* formula strip */ this._drawFormula(ctx, W / 2, H - 12, `P = ρ·g·h = ${liq.rho}·${this.g.toFixed(1)}·${h.toFixed(2)} = ${Math.round(P)} Па`, '#FFD166'); } _drawPressureZones(ctx, x, y, w, h, maxP, color) { ctx.save(); ctx.beginPath(); ctx.rect(x + 3, y, w - 6, h); ctx.clip(); /* deep-color gradient */ const g = ctx.createLinearGradient(0, y, 0, y + h); g.addColorStop(0, color + '22'); g.addColorStop(0.4, color + '55'); g.addColorStop(1, color + 'CC'); ctx.fillStyle = g; ctx.fillRect(x + 3, y, w - 6, h); /* isobar lines */ ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; ctx.setLineDash([4, 6]); for (let i = 1; i < 10; i++) { const iy = y + (i / 10) * h; ctx.beginPath(); ctx.moveTo(x + 3, iy); ctx.lineTo(x + w - 3, iy); ctx.stroke(); } ctx.setLineDash([]); /* caustic shimmer */ ctx.globalAlpha = 0.06; for (let i = 0; i < 6; i++) { const cx2 = x + 10 + ((i * 47 + (this._waveT || 0) * 15) % (w - 20)); const cy2 = y + h * 0.3 + Math.sin(i + (this._waveT || 0) * 0.5) * h * 0.15; ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.ellipse(cx2, cy2, 12, 5, 0.4, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; ctx.restore(); } /* ═══════════════════════════════════════════════════ МОДУЛЬ 2 — ПОВЕРХНОСТНОЕ НАТЯЖЕНИЕ ═══════════════════════════════════════════════════ */ _drawSurface(t) { if (this._stMode === 'capillary') this._drawCapillaries(t); else this._drawDrop(t); } _drawCapillaries(t) { const ctx = this.ctx, W = this.W, H = this.H; const liq = HydroSim.LIQUIDS[this.liquidKey]; const diameters = [0.5, 1.0, 2.0, 4.0]; const n = diameters.length; const tW = W * 0.76, tX = W * 0.12; const tY = H * 0.55, tH = H * 0.28; /* wide container */ this._fillLiquidRect(ctx, tX + 3, tY, tW - 6, tH, liq.color); this._drawGlassVessel(ctx, tX, tY, tW, tH); this._drawWaveSurface(ctx, tX + 3, tY, tW - 6, liq.color, this._waveT); const capTubeW = Math.max(26, Math.min(W * 0.055, 40)); const step = tW / (n + 1); const theta = this._contactAngle * Math.PI / 180; const cosT = Math.cos(theta); const capH = H * 0.52; for (let i = 0; i < n; i++) { const r_m = diameters[i] * 0.5 * 0.001; const h_m = 2 * liq.sigma * cosT / (liq.rho * this.g * r_m); const cx = tX + step * (i + 1); const capBot = tY; const capTop = tY - capH; const inner = capTubeW - 10; /* tube walls — rendered as filled rects with gradient */ const wallG = ctx.createLinearGradient(cx - capTubeW / 2, 0, cx + capTubeW / 2, 0); wallG.addColorStop(0, 'rgba(140,180,255,0.55)'); wallG.addColorStop(0.18, 'rgba(200,225,255,0.25)'); wallG.addColorStop(0.82, 'rgba(200,225,255,0.12)'); wallG.addColorStop(1, 'rgba(140,180,255,0.45)'); ctx.save(); ctx.fillStyle = wallG; ctx.fillRect(cx - capTubeW / 2, capTop, capTubeW, capH); /* hollow out centre */ ctx.clearRect(cx - inner / 2, capTop, inner, capH); ctx.restore(); /* glass highlight on left wall interior */ ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.fillRect(cx - inner / 2, capTop, 2, capH); ctx.restore(); /* liquid rise height (visual scale: 5cm = full tube) */ const scaledH = (h_m / 0.05) * capH * 0.9; const liqH = Math.min(Math.max(scaledH, 0), capH); const liqTop = capBot - liqH; if (liqH > 1) { /* cylindrical-look radial gradient */ const rg = ctx.createRadialGradient(cx, liqTop + liqH / 2, 0, cx, liqTop + liqH / 2, inner / 2); rg.addColorStop(0, liq.color + 'EE'); rg.addColorStop(0.6, liq.color + 'BB'); rg.addColorStop(1, liq.color + '55'); ctx.save(); ctx.beginPath(); ctx.rect(cx - inner / 2, liqTop, inner, liqH); ctx.clip(); ctx.fillStyle = rg; ctx.fillRect(cx - inner / 2, liqTop, inner, liqH); ctx.restore(); } /* meniscus */ this._drawMeniscus(ctx, cx, liqTop, inner, this._contactAngle, liq.color); /* meniscus specular highlight */ if (this._contactAngle < 90 && liqH > 3) { const dip = (inner / 2) * Math.cos(theta) * 1.1; ctx.save(); ctx.globalAlpha = 0.28; ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.ellipse(cx - inner * 0.15, liqTop - dip * 0.3, inner * 0.22, Math.max(2, dip * 0.35), 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } /* tube top cap */ ctx.save(); ctx.strokeStyle = 'rgba(140,180,255,0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx - capTubeW / 2, capTop); ctx.lineTo(cx + capTubeW / 2, capTop); ctx.stroke(); ctx.restore(); /* diameter label */ ctx.fillStyle = 'rgba(255,255,255,0.60)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText('d=' + diameters[i] + ' мм', cx, capTop - 22); /* rise height label */ if (liqH > 14) { ctx.fillStyle = liq.color; ctx.font = 'bold 13px monospace'; ctx.textAlign = 'center'; ctx.fillText((h_m * 1000).toFixed(1) + ' мм', cx, liqTop - 10); } /* vertical bracket showing rise */ if (liqH > 28) { ctx.save(); ctx.strokeStyle = liq.color + '88'; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.moveTo(cx + capTubeW / 2 + 6, capBot); ctx.lineTo(cx + capTubeW / 2 + 6, liqTop); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } } /* contact angle status */ const isH = this._contactAngle < 90; ctx.fillStyle = isH ? '#06D6E0' : '#F15BB5'; ctx.font = '13px Manrope,sans-serif'; ctx.textAlign = 'left'; ctx.fillText('θ = ' + this._contactAngle + '° — ' + (isH ? 'смачивание ↑' : 'несмачивание ↓'), tX, H - 34); this._drawFormula(ctx, W / 2, H - 12, 'h = 2σ·cos(θ) / (ρgr) σ=' + liq.sigma + ' Н/м θ=' + this._contactAngle + '°', '#06D6E0'); } _drawMeniscus(ctx, cx, topY, innerW, angleDeg, color) { const r = innerW / 2; ctx.save(); if (angleDeg < 90) { const dip = r * Math.cos(angleDeg * Math.PI / 180) * 1.1; ctx.beginPath(); ctx.moveTo(cx - r, topY); ctx.quadraticCurveTo(cx, topY - dip, cx + r, topY); } else { const bulge = r * 0.5; ctx.beginPath(); ctx.moveTo(cx - r, topY); ctx.quadraticCurveTo(cx, topY + bulge, cx + r, topY); } ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.stroke(); ctx.restore(); } _drawDrop(t) { const ctx = this.ctx, W = this.W, H = this.H; const liq = HydroSim.LIQUIDS[this.liquidKey]; const theta = this._contactAngle * Math.PI / 180; const isHydrophobic = this._contactAngle >= 90; const surfY = H * 0.60; /* surface plane */ ctx.save(); const sg = ctx.createLinearGradient(0, surfY - 4, 0, surfY + H * 0.18); sg.addColorStop(0, isHydrophobic ? 'rgba(155,93,229,0.55)' : 'rgba(6,214,224,0.55)'); sg.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = sg; ctx.fillRect(W * 0.05, surfY, W * 0.90, H * 0.18); ctx.strokeStyle = isHydrophobic ? '#9B5DE5' : '#06D6E0'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(W * 0.05, surfY); ctx.lineTo(W * 0.95, surfY); ctx.stroke(); ctx.restore(); /* surface label */ ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(isHydrophobic ? 'Гидрофобная поверхность' : 'Гидрофильная поверхность', W / 2, surfY + 20); /* drop shape */ const dropVol = 0.0002; const cos3 = Math.cos(theta); const Rpow = 2 - 3 * cos3 + cos3 * cos3 * cos3; const R = Math.pow((3 * dropVol) / (Math.PI * Math.max(Rpow, 0.01)), 1 / 3); const Rpx = Math.min(R * 2200, W * 0.24); const contactR = Rpx * Math.sin(theta); const centerY = surfY - Rpx * Math.cos(theta); const wobble = 1 + Math.sin(t * 0.002) * 0.012; /* slight oscillation */ ctx.save(); ctx.shadowColor = liq.color; ctx.shadowBlur = 22; ctx.beginPath(); ctx.rect(0, 0, W, surfY + 1); ctx.clip(); const dg = ctx.createRadialGradient( W / 2 - Rpx * 0.28, centerY - Rpx * 0.28, Rpx * 0.04, W / 2, centerY, Rpx * wobble ); dg.addColorStop(0, liq.color + 'EE'); dg.addColorStop(0.5, liq.color + 'BB'); dg.addColorStop(1, liq.color + '44'); ctx.fillStyle = dg; ctx.beginPath(); ctx.ellipse(W / 2, centerY, Rpx * wobble, Rpx / wobble, 0, 0, Math.PI * 2); ctx.fill(); /* specular highlight */ ctx.globalAlpha = 0.50; ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.ellipse(W / 2 - Rpx * 0.28, centerY - Rpx * 0.28, Rpx * 0.20, Rpx * 0.10, -0.5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); /* contact angle arc */ ctx.save(); ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5; const arcR = 34, tangAngle = Math.PI - theta; ctx.beginPath(); ctx.arc(W / 2 - contactR, surfY, arcR, -Math.PI * 0.5, tangAngle - Math.PI * 0.5); ctx.stroke(); ctx.fillStyle = '#FFD166'; ctx.font = 'bold 13px monospace'; ctx.textAlign = 'left'; ctx.fillText('θ = ' + this._contactAngle + '°', W / 2 - contactR + arcR + 6, surfY - 8); ctx.restore(); this._drawFormula(ctx, W / 2, H - 12, 'σ = ' + liq.sigma + ' Н/м ΔP = 2σ/R ≈ ' + (2 * liq.sigma / (R || 0.001)).toFixed(1) + ' Па', '#F15BB5'); } /* ═══════════════════════════════════════════════════ МОДУЛЬ 3 — СООБЩАЮЩИЕСЯ СОСУДЫ ═══════════════════════════════════════════════════ */ _drawCommunicating(t) { const ctx = this.ctx, W = this.W, H = this.H; const liq = HydroSim.LIQUIDS[this.liquidKey]; const n = this._numVessels; /* ── Geometry ──────────────────────────────────────── One unified container, internal partitions stop PIPE_H above the floor → shared bottom pipe zone. Vessels = sections between outer walls + partitions. */ const CX = W * 0.09, CW = W * 0.82; // container left + width const CY = H * 0.09, CH = H * 0.73; // container top + height const PIPE_H = Math.max(22, CH * 0.07); // connecting pipe zone height const PIPE_Y = CY + CH - PIPE_H; // pipe zone top Y const FLOOR = CY + CH; // container floor Y const PART_W = 5; // partition thickness (px) const SEC_W = (CW - (n - 1) * PART_W) / n; // each section width const isOpen = this._valveOpen; const liqFrac = isOpen ? (this._animLevel ?? this._targetLevel ?? this._liquidFrac * 0.8) : this._liquidFrac * 0.8; /* usable height for liquid level display (above pipe zone) */ const USABLE = CH - PIPE_H; const liqTopY = PIPE_Y - liqFrac * USABLE; // liquid surface Y const liqH = FLOOR - liqTopY; // total liquid column height /* ── 1. Liquid fill in each section ─────────────────── */ for (let i = 0; i < n; i++) { const sx = CX + i * (SEC_W + PART_W); const lg = ctx.createLinearGradient(0, liqTopY, 0, FLOOR); lg.addColorStop(0, liq.color + '42'); lg.addColorStop(0.4, liq.color + '88'); lg.addColorStop(1, liq.color + 'CC'); ctx.fillStyle = lg; ctx.fillRect(sx, liqTopY, SEC_W, liqH); } /* ── 2. Pipe zone fill (open = liquid, closed = dark) ── */ for (let i = 0; i < n - 1; i++) { const px = CX + i * (SEC_W + PART_W) + SEC_W; if (isOpen) { const pg = ctx.createLinearGradient(0, PIPE_Y, 0, FLOOR); pg.addColorStop(0, liq.color + '88'); pg.addColorStop(1, liq.color + 'CC'); ctx.fillStyle = pg; } else { ctx.fillStyle = 'rgba(8,4,18,0.95)'; } ctx.fillRect(px, PIPE_Y, PART_W, PIPE_H); } /* ── 3. Wave on each liquid surface ─────────────────── */ for (let i = 0; i < n; i++) { const sx = CX + i * (SEC_W + PART_W); this._drawWaveSurface(ctx, sx + 2, liqTopY, SEC_W - 4, liq.color, this._waveT + i * 1.3); } /* ── 4. Outer container walls (U-shape, open at top) ── */ ctx.save(); ctx.strokeStyle = 'rgba(200,225,255,0.55)'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.shadowColor = 'rgba(180,210,255,0.22)'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.moveTo(CX, CY); // left wall top ctx.lineTo(CX, FLOOR); // left wall ctx.lineTo(CX + CW, FLOOR); // bottom floor ctx.lineTo(CX + CW, CY); // right wall ctx.stroke(); /* glass reflection strip */ ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(CX + 5, CY); ctx.lineTo(CX + 5, FLOOR); ctx.stroke(); ctx.restore(); /* ── 5. Internal partitions (stop at pipe zone top) ─── */ ctx.save(); ctx.strokeStyle = 'rgba(200,225,255,0.42)'; ctx.lineWidth = PART_W; ctx.lineCap = 'butt'; ctx.shadowColor = 'rgba(180,210,255,0.15)'; ctx.shadowBlur = 4; for (let i = 0; i < n - 1; i++) { const px = CX + i * (SEC_W + PART_W) + SEC_W + PART_W / 2; ctx.beginPath(); ctx.moveTo(px, CY); ctx.lineTo(px, PIPE_Y); // stop above pipe zone ctx.stroke(); } ctx.restore(); /* ── 6. Valve icon in each pipe gap ─────────────────── */ for (let i = 0; i < n - 1; i++) { const px = CX + i * (SEC_W + PART_W) + SEC_W; const vm = px + PART_W / 2; const vc = PIPE_Y + PIPE_H / 2; if (isOpen) { /* flow arrows ← → */ this._drawArrow(ctx, vm - 16, vc, 12, 0, '#06D6A0', 1.8); this._drawArrow(ctx, vm + 16, vc, -12, 0, '#06D6A0', 1.8); } else { ctx.save(); ctx.translate(vm, vc); ctx.rotate(Math.PI / 4); ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 2.5; ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 10; ctx.strokeRect(-6, -6, 12, 12); ctx.restore(); } } /* ── 7. Height labels + dashed brackets ─────────────── */ for (let i = 0; i < n; i++) { const sx = CX + i * (SEC_W + PART_W); if (liqFrac > 0.02) { const h_m = liqFrac * this._tankH_m(); ctx.fillStyle = liq.color; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; ctx.fillText('h = ' + h_m.toFixed(2) + ' м', sx + SEC_W / 2, liqTopY - 10); /* dashed bracket */ ctx.save(); ctx.strokeStyle = liq.color + '55'; ctx.lineWidth = 1; ctx.setLineDash([2, 4]); ctx.beginPath(); ctx.moveTo(sx + SEC_W - 6, PIPE_Y); ctx.lineTo(sx + SEC_W - 6, liqTopY); ctx.stroke(); ctx.setLineDash([]); ctx.restore(); } } /* ── 8. Equal-level dashed line ─────────────────────── */ if (isOpen && liqFrac > 0.02) { ctx.save(); ctx.setLineDash([12, 6]); ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.55; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 4; ctx.beginPath(); ctx.moveTo(CX - 14, liqTopY); ctx.lineTo(CX + CW + 14, liqTopY); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.shadowBlur = 0; this._labelPill(ctx, CX + CW + 18, liqTopY, 'h₁ = h₂', '#06D6E0'); ctx.restore(); } /* ── 9. Status + formula ─────────────────────────────── */ ctx.fillStyle = isOpen ? '#06D6A0' : '#F15BB5'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText('Кран: ' + (isOpen ? 'открыт' : 'закрыт'), W - 12, H - 34); const h_m = liqFrac * this._tankH_m(); this._drawFormula(ctx, W / 2, H - 12, 'h₁ = h₂ = ' + h_m.toFixed(2) + ' м P = ρgh = ' + Math.round(liq.rho * this.g * h_m) + ' Па', '#9B5DE5'); } /* ═══════════════════════════════════════════════════ МОДУЛЬ 4 — АРХИМЕД ═══════════════════════════════════════════════════ */ _archTank() { const W = this.W || 600, H = this.H || 400; const tx = W * 0.03, ty = H * 0.06; const tw = W * 0.60, th = H * 0.80; const waterlineY = ty + th * (1 - this._waterLevel); return { tx, ty, tw, th, waterlineY }; } _initArch() { if (this._archReady) return; this._bodies = []; this._archReady = true; this._archAddBody(); } _destroyArch() { this._archReady = false; this._bodies = []; } /* pure-JS physics helpers ─────────────────────────── */ _archAddBody() { if (!this._archReady && this.mode === 'archimedes') { this._initArch(); return; } if (!this._archReady) return; const mat = HydroSim.MATERIALS[this.materialKey]; const liq = HydroSim.LIQUIDS[this.liquidKey]; const { tx, ty, tw, th, waterlineY } = this._archTank(); const size = Math.max(46, Math.min(tw * 0.20, 76)); const eqFrac = Math.min(1.0, mat.rho / liq.rho); const sinks = eqFrac >= 1.0; /* spawn at equilibrium → zero entry velocity, no overshooting */ let y = sinks ? waterlineY + (ty + th - waterlineY) * 0.32 : waterlineY + size * (eqFrac - 0.5); y = Math.max(ty + size * 0.55, Math.min(ty + th - size * 0.55, y)); const spread = Math.min(tw * 0.26, size + 20); const x = tx + tw * 0.25 + this._bodies.length * spread; const s_m = size * 0.0018; const volume = this._bodyShape === 'circle' ? Math.PI * (s_m / 2) ** 2 * 0.08 : s_m * s_m * 0.08; this._bodies.push({ x, y, vy: 0, size, shape: this._bodyShape, mat: this.materialKey, submergedFrac: sinks ? 1 : eqFrac, wobble: 0, volume, }); } _archClear() { this._bodies = []; } _archReset() { this._bodies = []; this._archAddBody(); } _archPhysStep() { const liq = HydroSim.LIQUIDS[this.liquidKey]; const { ty, th, waterlineY } = this._archTank(); const botY = ty + th - 3, topY = ty + 3; const G = 0.38; /* px/frame² — gravitational accel in canvas units */ const DAMP = 0.22; /* viscous damping fraction/frame at full submersion */ const VMAX = 8; /* speed cap px/frame */ for (const b of this._bodies) { const mat = HydroSim.MATERIALS[b.mat]; const half = b.size / 2; const bot = b.y + half, top = b.y - half; /* submerged fraction */ let frac = 0; if (bot > waterlineY) { frac = top >= waterlineY ? 1.0 : (bot - waterlineY) / b.size; frac = Math.max(0, Math.min(1, frac)); } b.submergedFrac = frac; /* net downward acceleration: G·(1 − ρж/ρт·frac) positive = downward (canvas Y), negative = floats up */ const rhoRatio = Math.min(liq.rho / mat.rho, 10.0); b.vy += G * (1.0 - rhoRatio * frac); /* viscous drag — heavier when deeper */ if (frac > 0) b.vy *= (1.0 - DAMP * frac); b.vy = Math.max(-VMAX, Math.min(VMAX, b.vy)); b.y += b.vy; /* soft collisions */ if (b.y + half >= botY) { b.y = botY - half; b.vy *= -0.12; } if (b.y - half <= topY) { b.y = topY + half; b.vy = Math.abs(b.vy) * 0.08; } /* visual wobble driven by vertical speed */ b.wobble = (b.wobble || 0) * 0.86 + b.vy * 0.004; } } /* ── Draw ─────────────────────────────────────────── */ _drawArchimedes(t) { const ctx = this.ctx, W = this.W, H = this.H; const liq = HydroSim.LIQUIDS[this.liquidKey]; const { tx, ty, tw, th, waterlineY } = this._archTank(); /* ── air zone (above waterline) */ const airH = Math.max(0, waterlineY - ty); if (airH > 1) { ctx.save(); const ag = ctx.createLinearGradient(0, ty, 0, waterlineY); ag.addColorStop(0, 'rgba(18,12,38,0.0)'); ag.addColorStop(1, 'rgba(18,12,38,0.28)'); ctx.fillStyle = ag; ctx.fillRect(tx + 4, ty, tw - 8, airH); ctx.restore(); } /* ── liquid zone (below waterline) */ const liqH = ty + th - waterlineY; if (liqH > 1) { this._fillLiquidRect(ctx, tx + 4, waterlineY, tw - 8, liqH, liq.color); } /* ── glass vessel + wave */ this._drawGlassVessel(ctx, tx, ty, tw, th); this._drawWaveSurface(ctx, tx + 4, waterlineY, tw - 8, liq.color, this._waveT); /* ── waterline dashed rule */ ctx.save(); ctx.setLineDash([10, 7]); ctx.strokeStyle = liq.color + 'BB'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(tx + 4, waterlineY); ctx.lineTo(tx + tw - 4, waterlineY); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = liq.color + 'CC'; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'right'; ctx.fillText('← ' + liq.name, tx + tw - 6, waterlineY - 7); ctx.restore(); /* ── bodies (clip to tank interior) */ ctx.save(); ctx.beginPath(); ctx.rect(tx + 4, ty + 1, tw - 8, th - 2); ctx.clip(); for (const b of this._bodies) this._archDrawBody(ctx, b, waterlineY, liq); ctx.restore(); /* ── force arrows — clipped to tank band so mg↓ never bleeds below the glass floor */ ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, ty + th); ctx.clip(); for (const b of this._bodies) this._archDrawForces(ctx, b, liq); ctx.restore(); /* ── info panel (right side) */ const panX = tx + tw + 16, panW = W - panX - 8; if (panW > 80) this._archDrawPanel(ctx, panX, ty, panW, th, liq); /* ── bottom formula */ if (this._bodies.length > 0) { const b = this._bodies[0]; const FA = liq.rho * this.g * b.volume * b.submergedFrac; const mg = HydroSim.MATERIALS[b.mat].rho * b.volume * this.g; this._drawFormula(ctx, tx + tw * 0.5, H - 12, `Fₐ = ${FA.toFixed(4)} Н mg = ${mg.toFixed(4)} Н ρж/ρт = ${(liq.rho / HydroSim.MATERIALS[b.mat].rho).toFixed(2)}`, '#06D6E0'); } else { this._drawFormula(ctx, tx + tw * 0.5, H - 12, 'Fₐ = ρж · g · Vпогружённый — закон Архимеда', '#06D6E0'); } } _archDrawBody(ctx, b, waterlineY, liq) { const mat = HydroSim.MATERIALS[b.mat]; const half = b.size / 2; /* submerged liquid tint on body */ if (b.submergedFrac > 0.01) { ctx.save(); const subTop = b.y - half; ctx.beginPath(); ctx.rect(b.x - half - 1, waterlineY, b.size + 2, b.y + half - waterlineY + 1); ctx.clip(); ctx.globalAlpha = 0.22; ctx.fillStyle = liq.color; if (b.shape === 'circle') { ctx.beginPath(); ctx.arc(b.x, b.y, half, 0, Math.PI * 2); ctx.fill(); } else { ctx.fillRect(b.x - half, b.y - half, b.size, b.size); } ctx.restore(); } /* main body */ ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.wobble || 0); ctx.shadowColor = mat.color + 'BB'; ctx.shadowBlur = 18; ctx.fillStyle = mat.color; if (b.shape === 'circle') { ctx.beginPath(); ctx.arc(0, 0, half, 0, Math.PI * 2); ctx.fill(); } else { const r = Math.min(10, half * 0.25); if (ctx.roundRect) ctx.roundRect(-half, -half, b.size, b.size, r); else ctx.rect(-half, -half, b.size, b.size); ctx.fill(); } ctx.shadowBlur = 0; /* material texture */ this._archMatTex(ctx, b.mat, half, b.shape); /* outline */ ctx.strokeStyle = 'rgba(255,255,255,0.38)'; ctx.lineWidth = 2; if (b.shape === 'circle') { ctx.beginPath(); ctx.arc(0, 0, half, 0, Math.PI * 2); ctx.stroke(); } else { const r = Math.min(10, half * 0.25); if (ctx.roundRect) ctx.roundRect(-half, -half, b.size, b.size, r); else ctx.rect(-half, -half, b.size, b.size); ctx.stroke(); } ctx.restore(); /* name tag below body */ ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.72)'; ctx.font = 'bold 11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(mat.name, b.x, b.y + half + 16); ctx.restore(); } _archMatTex(ctx, matKey, half, shape) { ctx.save(); ctx.globalAlpha = 0.60; if (matKey === 'wood' || matKey === 'cork') { ctx.strokeStyle = 'rgba(0,0,0,0.18)'; ctx.lineWidth = 1.5; if (shape === 'circle') { for (let r = half * 0.35; r < half; r += half * 0.30) { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.stroke(); } } else { for (let y2 = -half + 10; y2 < half; y2 += 10) { ctx.beginPath(); ctx.moveTo(-half + 3, y2); ctx.lineTo(half - 3, y2); ctx.stroke(); } } } else if (['iron','gold','aluminum','glass','plastic'].includes(matKey)) { const g = ctx.createRadialGradient(-half * 0.3, -half * 0.3, 0, 0, 0, half * 1.1); g.addColorStop(0, 'rgba(255,255,255,0.55)'); g.addColorStop(0.45, 'rgba(255,255,255,0.12)'); g.addColorStop(1, 'rgba(0,0,0,0.28)'); ctx.fillStyle = g; if (shape === 'circle') { ctx.beginPath(); ctx.arc(0, 0, half, 0, Math.PI * 2); ctx.fill(); } else ctx.fillRect(-half, -half, half * 2, half * 2); } else if (matKey === 'ice') { ctx.strokeStyle = 'rgba(180,240,255,0.65)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-half * 0.5, -half * 0.5); ctx.lineTo(half * 0.5, half * 0.5); ctx.stroke(); ctx.beginPath(); ctx.moveTo(half * 0.5, -half * 0.5); ctx.lineTo(-half * 0.5, half * 0.5); ctx.stroke(); } else if (matKey === 'styrofoam') { ctx.fillStyle = 'rgba(255,255,255,0.40)'; for (let i = 0; i < 6; i++) { const ax = Math.cos(i * 1.047) * half * 0.52, ay = Math.sin(i * 1.047) * half * 0.52; ctx.beginPath(); ctx.arc(ax, ay, half * 0.09, 0, Math.PI * 2); ctx.fill(); } } ctx.restore(); } _archDrawForces(ctx, b, liq) { const mat = HydroSim.MATERIALS[b.mat]; const half = b.size / 2; /* buoyancy arrows only when partly submerged */ const FA_rel = (liq.rho / mat.rho) * b.submergedFrac; /* relative to mg */ const showFA = b.submergedFrac > 0.01; const BASE = 54; const maxRel = Math.max(FA_rel, 1.0, 0.001); const Fa_px = showFA ? Math.max(22, Math.min(90, (FA_rel / maxRel) * BASE)) : 0; const mg_px = Math.max(22, Math.min(90, (1.0 / maxRel) * BASE)); const lx = b.x - half - 20, rx = b.x + half + 20; if (showFA) this._drawArrow(ctx, lx, b.y, 0, -Fa_px, '#06D6E0', 3.5, 'Fₐ'); this._drawArrow(ctx, rx, b.y, 0, mg_px, '#F15BB5', 3.5, 'mg'); } _archDrawPanel(ctx, x, y, w, h, liq) { /* background */ ctx.save(); ctx.fillStyle = 'rgba(6,4,20,0.78)'; ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; if (ctx.roundRect) ctx.roundRect(x, y, w, h, 10); else ctx.rect(x, y, w, h); ctx.fill(); ctx.stroke(); ctx.restore(); if (!this._bodies.length) { ctx.fillStyle = 'rgba(255,255,255,0.20)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText('+ Добавьте тело', x + w / 2, y + h / 2); return; } const b = this._bodies[0]; const mat = HydroSim.MATERIALS[b.mat]; const FA = liq.rho * this.g * b.volume * b.submergedFrac; const mg = mat.rho * b.volume * this.g; const state = mat.rho < liq.rho * 0.99 ? 'ВСПЛЫВАЕТ' : mat.rho > liq.rho * 1.01 ? 'ТОНЕТ' : 'ВЗВЕШЕНО'; const stC = state === 'ВСПЛЫВАЕТ' ? '#06D6A0' : state === 'ТОНЕТ' ? '#F15BB5' : '#FFD166'; const pulse = state !== 'ВЗВЕШЕНО' ? 0.7 + Math.sin(this._t * 0.004) * 0.3 : 1.0; const pad = 11, lh = 20; let cy = y + 20; /* ── state badge ── */ ctx.save(); ctx.fillStyle = stC + '1E'; ctx.strokeStyle = stC + '66'; ctx.lineWidth = 1.5; if (ctx.roundRect) ctx.roundRect(x + pad, cy - 15, w - pad * 2, 30, 7); else ctx.rect(x + pad, cy - 15, w - pad * 2, 30); ctx.fill(); ctx.stroke(); ctx.globalAlpha = pulse; ctx.fillStyle = stC; ctx.font = 'bold 14px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.shadowColor = stC; ctx.shadowBlur = 10; ctx.fillText(state, x + w / 2, cy + 4); ctx.globalAlpha = 1; ctx.shadowBlur = 0; ctx.restore(); cy += 38; /* ── separator ── */ ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + pad, cy); ctx.lineTo(x + w - pad, cy); ctx.stroke(); ctx.restore(); cy += 13; /* ── material + liquid ── */ ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText('МАТЕРИАЛ', x + pad, cy); cy += lh * 0.8; ctx.fillStyle = mat.color; ctx.fillRect(x + pad, cy - 11, 12, 12); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 0.5; ctx.strokeRect(x + pad, cy - 11, 12, 12); ctx.fillStyle = '#fff'; ctx.font = '12px Manrope,sans-serif'; ctx.fillText(mat.name, x + pad + 16, cy); cy += lh * 0.7; ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = '11px "JetBrains Mono",monospace'; ctx.fillText('ρт = ' + mat.rho + ' кг/м³', x + pad, cy); cy += lh * 0.9; ctx.fillText('ρж = ' + liq.rho + ' кг/м³', x + pad, cy); cy += lh * 1.2; ctx.restore(); /* ── force bars ── */ ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText('СИЛЫ', x + pad, cy); cy += lh * 0.85; const barW = w - pad * 2; const maxF = Math.max(FA, mg, 1e-14); const faW = Math.max(3, (FA / maxF) * barW); const mgW = Math.max(3, (mg / maxF) * barW); /* Fа row */ ctx.fillStyle = 'rgba(6,214,224,0.12)'; ctx.fillRect(x + pad, cy - 12, barW, 17); ctx.fillStyle = '#06D6E0'; ctx.fillRect(x + pad, cy - 12, faW, 17); ctx.fillStyle = '#fff'; ctx.font = '10px "JetBrains Mono",monospace'; ctx.fillText('Fа ↑', x + pad + 3, cy); ctx.textAlign = 'right'; ctx.fillText(FA.toFixed(5), x + pad + barW - 2, cy); ctx.textAlign = 'left'; cy += lh * 1.05; /* mg row */ ctx.fillStyle = 'rgba(241,91,181,0.12)'; ctx.fillRect(x + pad, cy - 12, barW, 17); ctx.fillStyle = '#F15BB5'; ctx.fillRect(x + pad, cy - 12, mgW, 17); ctx.fillStyle = '#fff'; ctx.fillText('mg ↓', x + pad + 3, cy); ctx.textAlign = 'right'; ctx.fillText(mg.toFixed(5), x + pad + barW - 2, cy); ctx.textAlign = 'left'; cy += lh * 1.35; ctx.restore(); /* ── density ratio bar ── */ ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.32)'; const ratio = (liq.rho / mat.rho); ctx.fillText('ρж / ρт = ' + (ratio).toFixed(2), x + pad, cy); cy += lh * 0.85; const rbW = w - pad * 2; /* bar covers ratio 0..4; mark at ratio=1 */ const normR = Math.min(ratio / 4, 1.0); const mark1 = x + pad + rbW * 0.25; /* ratio = 1 position */ ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fillRect(x + pad, cy - 10, rbW, 13); const rg = ctx.createLinearGradient(x + pad, 0, x + pad + rbW, 0); rg.addColorStop(0, '#F15BB5'); rg.addColorStop(0.25, '#FFD166'); rg.addColorStop(1, '#06D6A0'); ctx.fillStyle = rg; ctx.fillRect(x + pad, cy - 10, normR * rbW, 13); /* cursor on bar */ const curX = x + pad + normR * rbW; ctx.fillStyle = '#fff'; ctx.fillRect(curX - 2, cy - 13, 4, 19); /* neutral marker */ ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillRect(mark1 - 1, cy - 13, 2, 19); ctx.fillStyle = 'rgba(255,255,255,0.30)'; ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillText('=1', mark1, cy + 14); ctx.restore(); } /* ═══════════════════════════════════════════════════ HELPERS — RENDERING ═══════════════════════════════════════════════════ */ _drawGlassVessel(ctx, x, y, w, h) { ctx.save(); /* thick wall gradient — left */ const lG = ctx.createLinearGradient(x, 0, x + 14, 0); lG.addColorStop(0, 'rgba(160,200,255,0.65)'); lG.addColorStop(0.5, 'rgba(160,200,255,0.30)'); lG.addColorStop(1, 'rgba(160,200,255,0.05)'); ctx.fillStyle = lG; ctx.fillRect(x, y, 14, h + 14); /* right */ const rG = ctx.createLinearGradient(x + w - 14, 0, x + w, 0); rG.addColorStop(0, 'rgba(160,200,255,0.05)'); rG.addColorStop(0.5, 'rgba(160,200,255,0.25)'); rG.addColorStop(1, 'rgba(160,200,255,0.60)'); ctx.fillStyle = rG; ctx.fillRect(x + w - 14, y, 14, h + 14); /* bottom */ const bG = ctx.createLinearGradient(0, y + h, 0, y + h + 14); bG.addColorStop(0, 'rgba(160,200,255,0.35)'); bG.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = bG; ctx.fillRect(x, y + h, w, 14); /* outline */ ctx.strokeStyle = 'rgba(200,225,255,0.55)'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x + 2, y); ctx.lineTo(x + 2, y + h + 12); ctx.lineTo(x + w - 2, y + h + 12); ctx.lineTo(x + w - 2, y); ctx.stroke(); /* inner reflection */ ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + 6, y); ctx.lineTo(x + 6, y + h); ctx.stroke(); ctx.restore(); } _fillLiquidRect(ctx, x, y, w, h, color) { ctx.save(); const g = ctx.createLinearGradient(0, y, 0, y + h); g.addColorStop(0, color + '33'); g.addColorStop(0.25, color + '66'); g.addColorStop(0.75, color + 'AA'); g.addColorStop(1, color + 'CC'); ctx.fillStyle = g; ctx.fillRect(x, y, w, h); /* deep shadow at bottom */ const dG = ctx.createLinearGradient(0, y + h * 0.75, 0, y + h); dG.addColorStop(0, 'rgba(0,0,30,0)'); dG.addColorStop(1, 'rgba(0,0,30,0.22)'); ctx.fillStyle = dG; ctx.fillRect(x, y + h * 0.75, w, h * 0.25); ctx.restore(); } _drawWaveSurface(ctx, x, y, w, color, wt) { ctx.save(); ctx.strokeStyle = color + 'CC'; ctx.lineWidth = 1.8; ctx.beginPath(); for (let px = x; px <= x + w; px += 2) { const wy = y + Math.sin(px * 0.055 + wt) * 2.5 + Math.sin(px * 0.13 + wt * 1.3) * 1.0; px === x ? ctx.moveTo(px, wy) : ctx.lineTo(px, wy); } ctx.stroke(); /* surface sheen */ ctx.strokeStyle = 'rgba(255,255,255,0.14)'; ctx.lineWidth = 1; ctx.beginPath(); for (let px = x; px <= x + w; px += 2) { const wy = y + Math.sin(px * 0.055 + wt + 0.4) * 2.5; px === x ? ctx.moveTo(px, wy - 2) : ctx.lineTo(px, wy - 2); } ctx.stroke(); ctx.restore(); } _drawLiquidShaped(ctx, x, y, w, h, frac, color, shape, wt) { const liqH = h * Math.max(0, Math.min(frac, 1)); const liqY = y + h - liqH; if (liqH < 2) return; ctx.save(); /* shape clip */ ctx.beginPath(); switch (shape) { case 'wide': ctx.rect(x - w*0.15, y, w*1.3, h); break; case 'narrow': ctx.rect(x + w*0.2, y, w*0.6, h); break; case 'trapezoid': { const bl = x - w*0.12, br = x+w+w*0.12, tl = x, tr = x+w; ctx.moveTo(tl, y); ctx.lineTo(bl, y+h); ctx.lineTo(br, y+h); ctx.lineTo(tr, y); ctx.closePath(); break; } default: ctx.rect(x, y, w, h); } ctx.clip(); const g = ctx.createLinearGradient(0, liqY, 0, y + h); g.addColorStop(0, color + '44'); g.addColorStop(0.4, color + '88'); g.addColorStop(1, color + 'BB'); ctx.fillStyle = g; ctx.fillRect(x - w*0.2, liqY, w*1.4, liqH); ctx.restore(); } _drawVesselShaped(ctx, x, y, w, h, shape) { ctx.save(); ctx.strokeStyle = 'rgba(200,225,255,0.55)'; ctx.lineWidth = 2.5; ctx.shadowColor = 'rgba(180,210,255,0.20)'; ctx.shadowBlur = 6; ctx.beginPath(); switch (shape) { case 'wide': ctx.moveTo(x - w*0.15, y); ctx.lineTo(x - w*0.15, y+h); ctx.lineTo(x+w+w*0.15, y+h); ctx.lineTo(x+w+w*0.15, y); break; case 'narrow': ctx.moveTo(x + w*0.2, y); ctx.lineTo(x+w*0.2, y+h); ctx.lineTo(x+w*0.8, y+h); ctx.lineTo(x+w*0.8, y); break; case 'trapezoid': ctx.moveTo(x, y); ctx.lineTo(x-w*0.12, y+h); ctx.lineTo(x+w+w*0.12, y+h); ctx.lineTo(x+w, y); break; default: ctx.moveTo(x, y); ctx.lineTo(x, y+h); ctx.lineTo(x+w, y+h); ctx.lineTo(x+w, y); } ctx.stroke(); /* inner reflection strip */ ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.14)'; ctx.lineWidth = 1; const lx = shape === 'wide' ? x - w*0.15 + 5 : shape === 'narrow' ? x+w*0.2+5 : x+5; ctx.beginPath(); ctx.moveTo(lx, y); ctx.lineTo(lx, y+h); ctx.stroke(); ctx.restore(); } _shapeRect(x, w, shape) { if (shape === 'wide') return [x - w*0.15, w*1.3]; if (shape === 'narrow') return [x + w*0.2, w*0.6]; return [x, w]; } _drawArrow(ctx, x, y, dx, dy, color, lw = 3, label) { const len = Math.hypot(dx, dy); if (len < 2) return; ctx.save(); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw; ctx.shadowColor = color; ctx.shadowBlur = 7; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x+dx, y+dy); ctx.stroke(); const a = Math.atan2(dy, dx), ar = 11; ctx.beginPath(); ctx.moveTo(x+dx, y+dy); ctx.lineTo(x+dx - ar*Math.cos(a-0.35), y+dy - ar*Math.sin(a-0.35)); ctx.lineTo(x+dx - ar*Math.cos(a+0.35), y+dy - ar*Math.sin(a+0.35)); ctx.closePath(); ctx.fill(); if (label) { ctx.shadowBlur = 0; const lx = x + dx * 0.5 + (dy === 0 ? 0 : 16); const ly = y + dy * 0.5 + (dx === 0 ? -10 : -6); /* pill background */ ctx.font = 'bold 12px monospace'; const tw2 = ctx.measureText(label).width + 8; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(lx - tw2/2, ly - 9, tw2, 16, 4); else ctx.rect(lx - tw2/2, ly - 9, tw2, 16); ctx.fill(); ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, lx, ly - 1); ctx.textBaseline = 'alphabetic'; } ctx.restore(); } _drawRuler(ctx, x, y, h, labelTop, labelBot) { ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x+8, y); ctx.lineTo(x+8, y+h); ctx.stroke(); for (let i = 0; i <= 10; i++) { const ty2 = y + (i/10)*h, tw2 = i%5===0 ? 10 : 5; ctx.beginPath(); ctx.moveTo(x+8-tw2, ty2); ctx.lineTo(x+8, ty2); ctx.stroke(); if (i % 5 === 0) { ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '10px monospace'; ctx.textAlign = 'right'; ctx.fillText(i === 0 ? labelTop : (i === 10 ? labelBot : ''), x+5, ty2+4); } } ctx.restore(); } _drawGauge(ctx, cx, cy, val, maxVal, unit, r = 44) { const frac = Math.min(val / Math.max(maxVal, 1), 1); const startA = Math.PI * 0.75, endA = Math.PI * 2.25; const valA = startA + (endA - startA) * frac; const color = this._lerpColor('#06D6E0', '#F15BB5', frac); ctx.save(); /* outer ring */ ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = r * 0.22 + 4; ctx.beginPath(); ctx.arc(cx, cy, r, startA, endA); ctx.stroke(); /* track */ ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = r * 0.22; ctx.beginPath(); ctx.arc(cx, cy, r, startA, endA); ctx.stroke(); /* value arc */ ctx.strokeStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 12; ctx.beginPath(); ctx.arc(cx, cy, r, startA, valA); ctx.stroke(); ctx.shadowBlur = 0; /* tick marks */ ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const a = startA + (endA - startA) * (i / 4); ctx.beginPath(); ctx.moveTo(cx + (r - 8)*Math.cos(a), cy + (r - 8)*Math.sin(a)); ctx.lineTo(cx + (r + 3)*Math.cos(a), cy + (r + 3)*Math.sin(a)); ctx.stroke(); } /* needle */ ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + (r - 12)*Math.cos(valA), cy + (r - 12)*Math.sin(valA)); ctx.stroke(); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI*2); ctx.fill(); /* value */ ctx.fillStyle = color; ctx.font = 'bold 13px monospace'; ctx.textAlign = 'center'; ctx.fillText(Math.round(val) + ' ' + unit, cx, cy + r + 20); ctx.restore(); } _drawInfoPanel(ctx, x, y, w, h, rows) { if (w < 50) return; ctx.save(); ctx.fillStyle = 'rgba(8,6,20,0.55)'; ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, w, Math.min(h, rows.length * 22 + 20), 8); else ctx.rect(x, y, w, rows.length * 22 + 20); ctx.fill(); ctx.stroke(); ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'left'; rows.forEach((r, i) => { const ry = y + 16 + i * 22; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillText(r.label, x + 10, ry); ctx.fillStyle = r.color || 'rgba(255,255,255,0.85)'; ctx.textAlign = 'right'; ctx.fillText(r.value, x + w - 10, ry); ctx.textAlign = 'left'; }); ctx.restore(); } _labelPill(ctx, x, y, text, color) { ctx.save(); ctx.font = 'bold 12px Manrope,sans-serif'; const tw2 = ctx.measureText(text).width + 16; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y - 11, tw2, 20, 5); else ctx.rect(x, y - 11, tw2, 20); ctx.fill(); ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, x + 8, y - 1); ctx.textBaseline = 'alphabetic'; ctx.restore(); } _drawFormula(ctx, x, y, text, color) { ctx.save(); ctx.font = '12px "JetBrains Mono",monospace'; const tw2 = ctx.measureText(text).width; const pad = 12, bh = 28; let bx = x - (tw2 + pad*2) / 2; bx = Math.max(4, Math.min((this.W||600) - tw2 - pad*2 - 4, bx)); const by = y - bh; ctx.fillStyle = 'rgba(6,4,18,0.90)'; ctx.strokeStyle = color; ctx.lineWidth = 1.3; ctx.shadowColor = color; ctx.shadowBlur = 6; const rr = 7; ctx.beginPath(); ctx.moveTo(bx+rr, by); ctx.lineTo(bx+tw2+pad*2-rr, by); ctx.quadraticCurveTo(bx+tw2+pad*2, by, bx+tw2+pad*2, by+rr); ctx.lineTo(bx+tw2+pad*2, by+bh-rr); ctx.quadraticCurveTo(bx+tw2+pad*2, by+bh, bx+tw2+pad*2-rr, by+bh); ctx.lineTo(bx+rr, by+bh); ctx.quadraticCurveTo(bx, by+bh, bx, by+bh-rr); ctx.lineTo(bx, by+rr); ctx.quadraticCurveTo(bx, by, bx+rr, by); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, bx+pad, by+bh/2); ctx.textBaseline = 'alphabetic'; ctx.restore(); } /* ═══ Physics helpers ═══ */ _tankH_m() { return 1.0; } _recalcVessels() { this._targetLevel = this._valveOpen ? this._liquidFrac * 0.82 : null; if (!this._valveOpen) this._animLevel = null; } /* ═══ Events ═══ */ _bindEvents() { this._onPD = e => this._pointerDown(e); this._onPM = e => this._pointerMove(e); this._onPU = () => { this._probe.dragging = false; }; this.canvas.addEventListener('pointerdown', this._onPD); this.canvas.addEventListener('pointermove', this._onPM); window.addEventListener('pointerup', this._onPU); } _cp(e) { const r = this.canvas.getBoundingClientRect(); return { x: (e.clientX-r.left)*(this.W/r.width), y: (e.clientY-r.top)*(this.H/r.height) }; } _pointerDown(e) { if (this.mode !== 'pressure') return; const { x, y } = this._cp(e); const tw = Math.min(this.W*0.30, 200), tx = this.W*0.12, ty = this.H*0.10, th = this.H*0.72; const pX = tx + tw*0.5, pY = ty + this._probe.vy * th; if (Math.hypot(x-pX, y-pY) < 22) { this._probe.dragging = true; this.canvas.setPointerCapture(e.pointerId); } } _pointerMove(e) { if (!this._probe.dragging) return; const { y } = this._cp(e); const ty = this.H*0.10, th = this.H*0.72; this._probe.vy = Math.max(0.02, Math.min(0.98, (y-ty)/th)); } /* ═══ Utils ═══ */ _lerpColor(c1, c2, t) { const h = s => parseInt(s.slice(1), 16); const p = (a, b, t2) => Math.round(Math.max(0, Math.min(255, a + (b-a)*t2))); const v1 = h(c1), v2 = h(c2); return '#' + [[(v1>>16)&255,(v2>>16)&255],[(v1>>8)&255,(v2>>8)&255],[v1&255,v2&255]] .map(([a,b]) => p(a,b,t).toString(16).padStart(2,'0')).join(''); } _notify() { if (this.onUpdate) try { this.onUpdate(this.getInfo()); } catch {} } }