ae31e4c4e8
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only) Each sim's _open*() + UI helpers moved to its engine file: graph.js, projectile.js, collision.js, magnetic.js, triangle.js, geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js, reactions.js (chemistry), newton.js (dynamics), chemsandbox.js, celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js, normaldist.js, graphtransform.js, pendulum.js, equilibrium.js, thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js, probability.js, bohratom.js, electrolysis.js, waves.js, crystal.js, orbitals.js, stereo.js, hydrostatics.js All 34 engine files syntax-checked OK.
1467 lines
61 KiB
JavaScript
1467 lines
61 KiB
JavaScript
'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 {} }
|
||
}
|
||
|
||
/* ─── lab UI init ─────────────────────────────────── */
|
||
var hydroSim = null;
|
||
let _hydroValveOpen = true;
|
||
|
||
function _openHydro(preset) {
|
||
document.getElementById('sim-topbar-title').textContent = 'Гидростатика';
|
||
_simShow('sim-hydro');
|
||
document.getElementById('ctrl-hydro').style.display = '';
|
||
_registerSimState('hydrostatics',
|
||
() => ({ mode: hydroSim?.mode, liq: hydroSim?.liquidKey }),
|
||
st => { if (st?.mode && hydroSim) hydroMode(st.mode); });
|
||
if (_embedMode) _startStateEmit('hydrostatics');
|
||
window.addEventListener('load', () => {}, { once: true });
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
const canvas = document.getElementById('hydro-canvas');
|
||
const mode = preset || 'pressure';
|
||
if (!hydroSim) {
|
||
hydroSim = new HydroSim(canvas, mode);
|
||
hydroSim.onUpdate = _hydroUpdateUI;
|
||
} else {
|
||
hydroSim.fit();
|
||
hydroSim.play();
|
||
}
|
||
hydroMode(mode);
|
||
}));
|
||
}
|
||
|
||
function hydroMode(mode) {
|
||
if (!hydroSim) return;
|
||
hydroSim.setMode(mode);
|
||
const sel = document.getElementById('hydro-mode-sel');
|
||
if (sel) sel.value = mode;
|
||
// show/hide sub-controls
|
||
['arch','comm','surf','mat'].forEach(k => {
|
||
const el = document.getElementById('hydro-panel-' + k);
|
||
const el2 = document.getElementById('hydro-' + k + '-ctrl');
|
||
if (el) el.style.display = 'none';
|
||
if (el2) el2.style.display = 'none';
|
||
});
|
||
if (mode === 'archimedes') {
|
||
const a = document.getElementById('hydro-panel-mat');
|
||
const b = document.getElementById('hydro-arch-ctrl');
|
||
if (a) a.style.display = '';
|
||
if (b) b.style.display = 'flex';
|
||
}
|
||
if (mode === 'surface') {
|
||
const a = document.getElementById('hydro-panel-theta');
|
||
const b = document.getElementById('hydro-surf-ctrl');
|
||
if (a) a.style.display = '';
|
||
if (b) b.style.display = 'flex';
|
||
}
|
||
if (mode === 'communicating') {
|
||
const a = document.getElementById('hydro-panel-comm');
|
||
const b = document.getElementById('hydro-comm-ctrl');
|
||
if (a) a.style.display = '';
|
||
if (b) b.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function hydroToggleSurface() {
|
||
if (!hydroSim) return;
|
||
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
|
||
hydroSim._stMode = next;
|
||
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
|
||
['hydro-surf-toggle','hydro-surf-toggle-panel'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = label;
|
||
});
|
||
}
|
||
|
||
function hydroToggleValve() {
|
||
if (!hydroSim) return;
|
||
_hydroValveOpen = !_hydroValveOpen;
|
||
hydroSim.setValve(_hydroValveOpen);
|
||
const label = _hydroValveOpen ? 'Кран: открыт' : 'Кран: закрыт';
|
||
const color = _hydroValveOpen ? '#06D6A0' : '#F15BB5';
|
||
['hydro-valve-btn','hydro-valve-panel-btn'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) { el.textContent = label; el.style.color = color; el.style.borderColor = _hydroValveOpen ? 'rgba(6,214,160,.3)' : 'rgba(241,91,181,.3)'; }
|
||
});
|
||
}
|
||
|
||
function hydroSetVessels(n, btn) {
|
||
if (hydroSim) hydroSim.setNumVessels(n);
|
||
document.querySelectorAll('.hydro-nv').forEach(b => b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
}
|
||
|
||
function _hydroUpdateUI(info) {
|
||
if (!info) return;
|
||
const el = document.getElementById('hydro-formulas');
|
||
if (!el) return;
|
||
const lines = [];
|
||
if (info.formula) lines.push(`<span style="color:#FFD166">${info.formula}</span>`);
|
||
if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
|
||
if (info.matName) lines.push(`Материал: ${info.matName}`);
|
||
if (info.FA) lines.push(`<span style="color:#06D6E0">F_A = ${info.FA} Н</span>`);
|
||
if (info.mg) lines.push(`<span style="color:#F15BB5">mg = ${info.mg} Н</span>`);
|
||
if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
|
||
if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
|
||
el.innerHTML = lines.join('<br>');
|
||
// result badge
|
||
const rb = document.getElementById('hydro-result');
|
||
if (rb && info.state) {
|
||
const colors = { 'ВСПЛЫВАЕТ': '#06D6A0', 'ТОНЕТ': '#F15BB5', 'ВЗВЕШЕНО': '#FFD166' };
|
||
rb.style.display = '';
|
||
rb.style.color = colors[info.state] || '#fff';
|
||
rb.style.background = (colors[info.state] || '#9B5DE5') + '18';
|
||
rb.style.border = '1px solid ' + (colors[info.state] || '#9B5DE5') + '44';
|
||
rb.textContent = info.state;
|
||
} else if (rb) {
|
||
rb.style.display = 'none';
|
||
}
|
||
}
|
||
|