Files
Learn_System/frontend/js/labs/hydrostatics.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

1351 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 {} }
}