LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
SimUtil — shared utility functions for lab simulations.
Loaded once before all sim scripts.
══════════════════════════════════════════════════════════════ */
const SimUtil = (() => {
/**
* DPR-aware canvas resize. Sets canvas pixel size and applies
* `setTransform` so subsequent drawing is in CSS-pixel coords.
* Returns { W, H, dpr }.
*/
function fitCanvas(canvas, ctx) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || canvas.parentElement?.offsetWidth || 600;
const h = canvas.offsetHeight || canvas.parentElement?.offsetHeight || 400;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { W: w, H: h, dpr };
}
/**
* Compute a "nice" grid step for the given pixel range and target ~n divisions.
*/
function niceStep(rangePx, scale, n) {
if (!n) n = 8;
const raw = rangePx / scale / n;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
/**
* Format number for grid/axis labels.
*/
function fmt(n, step) {
if (n === 0) return '0';
if (step >= 1 && Number.isInteger(n)) return String(n);
if (step < 0.001) return n.toExponential(1);
const dec = Math.max(0, -Math.floor(Math.log10(step)));
return n.toFixed(dec);
}
/**
* Draw an arrow from (x1,y1) to (x2,y2).
*/
function arrow(ctx, x1, y1, x2, y2, color, lw) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) return;
const ux = dx / len, uy = dy / len;
const hs = Math.min(10, len * 0.3); // head size
ctx.save();
ctx.strokeStyle = color || '#fff';
ctx.fillStyle = color || '#fff';
ctx.lineWidth = lw || 2;
ctx.lineCap = 'round';
// shaft
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2 - ux * hs * 0.5, y2 - uy * hs * 0.5);
ctx.stroke();
// head
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - ux * hs - uy * hs * 0.4, y2 - uy * hs + ux * hs * 0.4);
ctx.lineTo(x2 - ux * hs + uy * hs * 0.4, y2 - uy * hs - ux * hs * 0.4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
/**
* Draw a coordinate grid with labels.
* @param {object} opts — { ox, oy, scl, font, gridColor, labelColor }
*/
function drawGrid(ctx, W, H, opts) {
const { ox = 0, oy = 0, scl = 50, font, gridColor, labelColor } = opts || {};
const step = niceStep(W, scl);
// math <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> pixel helpers
const toPx = (mx, my) => [W / 2 + (mx - ox) * scl, H / 2 - (my - oy) * scl];
const toMx = (px) => (px - W / 2) / scl + ox;
const toMy = (py) => -(py - H / 2) / scl + oy;
const x0 = toMx(0), x1 = toMx(W);
const y0 = toMy(H), y1 = toMy(0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
// grid lines
ctx.strokeStyle = gridColor || 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = toPx(x, 0);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = toPx(0, y);
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
// labels
ctx.font = font || '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = labelColor || 'rgba(255,255,255,0.3)';
const [axX, axY] = toPx(0, 0);
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = toPx(x, 0);
if (px < 18 || px > W - 18) continue;
ctx.fillText(fmt(x, step), px, lblY);
}
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = toPx(0, y);
if (py < 12 || py > H - 12) continue;
ctx.fillText(fmt(y, step), lblX, py);
}
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(0, axY); ctx.lineTo(W - 10, axY); ctx.stroke();
ctx.beginPath(); ctx.moveTo(axX, H); ctx.lineTo(axX, 8); ctx.stroke();
// arrowheads
ctx.fillStyle = 'rgba(255,255,255,0.4)';
_arrowHead(ctx, W - 8, axY, 0);
_arrowHead(ctx, axX, 6, -Math.PI / 2);
// axis labels
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
ctx.fillText('x', W - 10, axY - 13);
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('y', axX + 7, 4);
}
function _arrowHead(ctx, x, y, angle) {
const s = 5;
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
/**
* Draw a tooltip panel at (x, y).
* @param {string[]} rows — lines of text
* @param {object} opts — { bg, fg, font, padding, radius }
*/
function tooltip(ctx, x, y, rows, opts) {
const { bg = 'rgba(22,22,38,0.92)', fg = '#ddd', font = '12px Manrope, sans-serif',
padding = 8, radius = 8 } = opts || {};
ctx.save();
ctx.font = font;
let maxW = 0;
for (const r of rows) maxW = Math.max(maxW, ctx.measureText(r).width);
const w = maxW + padding * 2;
const lineH = 17;
const h = rows.length * lineH + padding * 2;
const tx = x + 14, ty = y - h / 2;
ctx.fillStyle = bg;
ctx.beginPath();
ctx.roundRect(tx, ty, w, h, radius);
ctx.fill();
ctx.fillStyle = fg;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
for (let i = 0; i < rows.length; i++) {
ctx.fillText(rows[i], tx + padding, ty + padding + i * lineH);
}
ctx.restore();
}
return { fitCanvas, niceStep, fmt, arrow, drawGrid, tooltip };
})();
+855
View File
@@ -0,0 +1,855 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════
AngryBirdsSim — Angry Birds Physics
Real projectile physics: RK4, drag, wind, gravity by planet.
Blocks: AABB with impulse collisions and destruction.
Pigs: circle targets with HP system.
6 levels with increasing difficulty.
═══════════════════════════════════════════════════════════════════ */
const AB_PLANETS = {
earth: { g: 9.81, label: 'Земля <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>', sky1: '#0f1923', sky2: '#1a3a2a', ground: '#3d6b47', g_label: '9.81' },
moon: { g: 1.62, label: 'Луна <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg>', sky1: '#000008', sky2: '#0a0a18', ground: '#7a7a66', g_label: '1.62' },
mars: { g: 3.71, label: 'Марс <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg>', sky1: '#2a0800', sky2: '#5c1e00', ground: '#7a3010', g_label: '3.71' },
jupiter: { g: 24.79, label: 'Юпитер <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg>', sky1: '#1a0a00', sky2: '#3d1e00', ground: '#5c3220', g_label: '24.79' },
};
const AB_MATS = {
wood: { color: '#b5651d', border: '#7a3f0a', hpMax: 120, mass: 2.0, debris: '#8B4513' },
stone: { color: '#7a7a7a', border: '#444', hpMax: 320, mass: 6.0, debris: '#555' },
glass: { color: '#a8d8ea', border: '#5badd4', hpMax: 45, mass: 0.8, debris: '#d4eef8' },
};
const AB_BIRDS = {
normal: { color: '#e63946', r: 18, mass: 1.0, Cd: 0.28, label: 'Красная' },
heavy: { color: '#888', r: 23, mass: 4.2, Cd: 0.45, label: 'Тяжёлая' },
fast: { color: '#ffd166', r: 13, mass: 0.55, Cd: 0.08, label: 'Жёлтая' },
};
const _PX_M = 42; // pixels per metre (base scale, adjusted in _layout)
/* ── Level definitions ── */
/* rx = metres right of slingshot, gy = metres above ground (bottom of block) */
function _buildLevels() {
return [
/* 1 — Tutorial: one wooden column, one pig */
{ planet: 'earth', wind: 0, birdType: 'normal', birds: 3,
blocks: [
{ mat: 'wood', rx: 7.2, gy: 0, w: 0.55, h: 1.9 },
{ mat: 'wood', rx: 6.8, gy: 1.9, w: 1.2, h: 0.38 },
],
pigs: [{ rx: 7.2, gy: 2.35 }] },
/* 2 — Glass tower, 2 pigs */
{ planet: 'earth', wind: 0, birdType: 'normal', birds: 4,
blocks: [
{ mat: 'glass', rx: 6.6, gy: 0, w: 0.45, h: 1.6 },
{ mat: 'glass', rx: 8.2, gy: 0, w: 0.45, h: 1.6 },
{ mat: 'glass', rx: 6.3, gy: 1.6, w: 2.5, h: 0.4 },
{ mat: 'glass', rx: 7.3, gy: 2.0, w: 0.55, h: 1.3 },
],
pigs: [{ rx: 6.8, gy: 2.05 }, { rx: 7.3, gy: 3.4 }] },
/* 3 — Wind, wooden house */
{ planet: 'earth', wind: 5, birdType: 'normal', birds: 4,
blocks: [
{ mat: 'wood', rx: 6.5, gy: 0, w: 0.5, h: 2.1 },
{ mat: 'wood', rx: 9.1, gy: 0, w: 0.5, h: 2.1 },
{ mat: 'wood', rx: 6.2, gy: 2.1, w: 3.4, h: 0.45 },
{ mat: 'wood', rx: 7.1, gy: 2.55, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 8.5, gy: 2.55, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 6.8, gy: 4.15, w: 2.5, h: 0.4 },
],
pigs: [{ rx: 7.8, gy: 2.6 }, { rx: 7.8, gy: 4.6 }] },
/* 4 — Moon, stone fortress, heavy bird */
{ planet: 'moon', wind: 0, birdType: 'heavy', birds: 3,
blocks: [
{ mat: 'stone', rx: 7.0, gy: 0, w: 0.6, h: 2.6 },
{ mat: 'stone', rx: 9.6, gy: 0, w: 0.6, h: 2.6 },
{ mat: 'stone', rx: 6.7, gy: 2.6, w: 3.5, h: 0.6 },
{ mat: 'stone', rx: 8.2, gy: 3.2, w: 0.6, h: 1.6 },
],
pigs: [{ rx: 7.5, gy: 3.2 }, { rx: 8.5, gy: 4.8 }] },
/* 5 — Mars, headwind, mixed materials, fast bird */
{ planet: 'mars', wind: -4, birdType: 'fast', birds: 4,
blocks: [
{ mat: 'stone', rx: 6.5, gy: 0, w: 0.5, h: 1.3 },
{ mat: 'wood', rx: 6.5, gy: 1.3, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 8.2, gy: 0, w: 0.5, h: 1.3 },
{ mat: 'wood', rx: 8.2, gy: 1.3, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 6.2, gy: 2.9, w: 2.9, h: 0.5 },
{ mat: 'wood', rx: 7.4, gy: 3.4, w: 0.7, h: 1.1 },
],
pigs: [{ rx: 6.8, gy: 3.4 }, { rx: 8.8, gy: 3.4 }] },
/* 6 — Jupiter, strong wind, multi-level, 3 pigs */
{ planet: 'jupiter', wind: 7, birdType: 'normal', birds: 5,
blocks: [
{ mat: 'stone', rx: 6.2, gy: 0, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 7.7, gy: 0, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 9.2, gy: 0, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 5.9, gy: 1.6, w: 1.2, h: 0.5 },
{ mat: 'wood', rx: 7.4, gy: 1.6, w: 2.3, h: 0.5 },
{ mat: 'stone', rx: 8.9, gy: 1.6, w: 1.2, h: 0.5 },
{ mat: 'stone', rx: 6.7, gy: 2.1, w: 0.5, h: 1.6 },
{ mat: 'stone', rx: 8.7, gy: 2.1, w: 0.5, h: 1.6 },
{ mat: 'wood', rx: 6.4, gy: 3.7, w: 3.1, h: 0.45 },
],
pigs: [{ rx: 6.4, gy: 2.15 }, { rx: 7.7, gy: 2.15 }, { rx: 8.9, gy: 2.15 }] },
];
}
/* ══════════════════════════════════════════════════════════════════ */
class AngryBirdsSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.levelIdx = 0;
this.state = 'aim'; // 'aim' | 'flying' | 'settling' | 'win' | 'lose'
this.bird = null;
this.birdsLeft = [];
this.blocks = [];
this.pigs = [];
this.score = 0;
this._particles = [];
this._raf = null;
this._lastTs = null;
this._settleTimer = 0;
this._previewPath = [];
this._drag = { pulling: false, mx: 0, my: 0 }; // mx/my updated in _layout()
/* layout (computed in _layout) */
this._gY = 0; // ground Y (canvas px)
this._sX = 0; // sling X
this._sY = 0; // sling Y (bird rest position)
this._sc = _PX_M; // px per metre
this._levels = _buildLevels();
this._stars = Array.from({ length: 70 }, () => ({
x: Math.random(), y: Math.random() * 0.7, r: 0.5 + Math.random() * 1.5, a: 0.3 + Math.random() * 0.7,
}));
this.onUpdate = null;
this._ready = false; // true after first _initLevel
new ResizeObserver(() => { this.fit(); if (!this._raf) this.draw(); })
.observe(canvas.parentElement || canvas);
}
/* ── PUBLIC API ─────────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const el = this.canvas.parentElement || this.canvas;
const W = el.clientWidth || 800;
const H = el.clientHeight || 480;
this.canvas.width = W * dpr;
this.canvas.height = H * dpr;
this.canvas.style.width = W + 'px';
this.canvas.style.height = H + 'px';
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._layout();
/* Only init on first open — resize must NOT reset the game */
if (!this._ready) { this._ready = true; this._initLevel(); }
}
loadLevel(n) {
this.levelIdx = Math.max(0, Math.min(n, this._levels.length - 1));
this._ready = true; // ensure _initLevel runs even before first fit
this._initLevel();
}
restart() { this._ready = true; this._initLevel(); }
start() {
if (this._raf) return;
this._lastTs = null;
const tick = (ts) => {
const dt = this._lastTs ? Math.min((ts - this._lastTs) / 1000, 0.05) : 0.016;
this._lastTs = ts;
this._update(dt);
this.draw();
this._raf = requestAnimationFrame(tick);
};
this._raf = requestAnimationFrame(tick);
}
stop() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
draw() {
if (!this.W) return;
this._drawBackground();
this._drawSlingshot();
this._drawBlocks();
this._drawPigs();
if (this.bird) this._drawBird(this.bird);
this._drawParticles();
if (this.state === 'aim' && this._drag.pulling) this._drawPreview();
this._drawSlingshotRubber();
this._drawQueue();
this._drawHUD();
if (this.state === 'win' || this.state === 'lose') this._drawOverlay();
}
info() {
const lvl = this._levels[this.levelIdx];
const planet = lvl ? AB_PLANETS[lvl.planet] : AB_PLANETS.earth;
return {
level: this.levelIdx + 1,
birds: this.birdsLeft.length + (this.bird ? 1 : 0),
pigs: this.pigs.filter(p => !p.destroyed).length,
score: this.score,
planet: planet ? planet.label : '—',
g: planet ? planet.g_label : '—',
};
}
/* ── MOUSE HANDLERS ─────────────────────────────────────────── */
handleMouseDown(e) {
if (this.state !== 'aim' || !this.bird) return;
const { x, y } = this._evt(e);
if (Math.hypot(x - this._sX, y - this._sY) < 54) {
this._drag.pulling = true;
this._clampDrag(x, y);
this._updatePreview();
}
}
handleMouseMove(e) {
if (!this._drag.pulling) return;
const { x, y } = this._evt(e);
this._clampDrag(x, y);
this._updatePreview();
this.draw();
}
handleMouseUp(e) {
if (!this._drag.pulling) return;
this._drag.pulling = false;
const dx = this._drag.mx - this._sX;
const dy = this._drag.my - this._sY;
if (Math.hypot(dx, dy) < 12) { this.draw(); return; }
const K = 5.8; // px stretch <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> px/s velocity
this._launch(-dx * K, -dy * K);
}
/* ── PRIVATE ────────────────────────────────────────────────── */
_layout() {
this._gY = Math.round(this.H * 0.80);
this._sX = Math.round(this.W * 0.17);
this._sY = this._gY - 68;
this._sc = Math.max(34, Math.min(50, this.H / 11));
/* keep drag position at sling so rubber band doesn't snap to (0,0) */
if (!this._drag.pulling) { this._drag.mx = this._sX; this._drag.my = this._sY; }
}
_initLevel() {
if (!this.W) return;
const lvl = this._levels[this.levelIdx];
if (!lvl) return;
this.state = 'aim';
this.score = 0;
this._particles = [];
this._previewPath = [];
this._settleTimer = 0;
this._drag.pulling = false;
this.birdsLeft = Array(lvl.birds - 1).fill(lvl.birdType);
this.bird = this._spawnBird(lvl.birdType);
this._placeBirdAtSling(this.bird);
const sc = this._sc;
this.blocks = lvl.blocks.map((b, i) => {
const mat = AB_MATS[b.mat] || AB_MATS.wood;
return {
id: i, mat: b.mat,
x: this._sX + b.rx * sc,
y: this._gY - b.gy * sc - b.h * sc,
w: b.w * sc, h: b.h * sc,
vx: 0, vy: 0, angle: 0, angVel: 0,
hp: mat.hpMax, maxHp: mat.hpMax,
mass: mat.mass * b.w * b.h,
destroyed: false, onGround: true, // start stationary; set false on impulse
};
});
this.pigs = lvl.pigs.map((p, i) => ({
id: i,
x: this._sX + p.rx * sc,
y: this._gY - p.gy * sc - 20,
vx: 0, vy: 0,
r: 20, hp: 100, maxHp: 100,
destroyed: false, flash: 0, onGround: true, // start stationary
}));
if (this.onUpdate) this.onUpdate(this.info());
}
_spawnBird(type) {
const def = AB_BIRDS[type] || AB_BIRDS.normal;
return {
type, color: def.color,
x: 0, y: 0, vx: 0, vy: 0,
r: def.r, mass: def.mass, Cd: def.Cd,
trail: [], launched: false, destroyed: false,
};
}
_placeBirdAtSling(bird) {
bird.x = this._sX; bird.y = this._sY;
bird.vx = 0; bird.vy = 0;
bird.launched = false; bird.trail = [];
}
_launch(vx, vy) {
if (!this.bird) return;
this.bird.vx = vx; this.bird.vy = vy;
this.bird.launched = true;
this.state = 'flying';
this._emit('launch', this.bird.x, this.bird.y, this.bird.color, 8);
if (this.onUpdate) this.onUpdate(this.info());
}
_clampDrag(mx, my) {
let dx = mx - this._sX;
let dy = my - this._sY;
if (dx > 5) dx = 5; // mostly left only
const dist = Math.hypot(dx, dy);
const maxPull = 80;
if (dist > maxPull) { dx = dx / dist * maxPull; dy = dy / dist * maxPull; }
this._drag.mx = this._sX + dx;
this._drag.my = this._sY + dy;
}
/* ── UPDATE LOOP ─────────────────────────────────────────────── */
_update(dt) {
if (this.state === 'flying') {
this._stepBird(dt);
this._collisions();
if (this.bird && (this.bird.destroyed || this._offScreen(this.bird))) {
this.bird = null;
this.state = 'settling';
this._settleTimer = 1.5;
}
}
if (this.state === 'flying' || this.state === 'settling') {
this._stepBlocks(dt);
this._stepPigs(dt);
}
if (this.state === 'settling') {
this._settleTimer -= dt;
if (this._settleTimer <= 0) {
if (this.pigs.every(p => p.destroyed)) {
this._win();
} else if (!this.birdsLeft.length) {
this.state = 'lose';
if (this.onUpdate) this.onUpdate(this.info());
} else {
this.bird = this._spawnBird(this.birdsLeft.shift());
this._placeBirdAtSling(this.bird);
this.state = 'aim';
if (this.onUpdate) this.onUpdate(this.info());
}
}
}
this._stepParticles(dt);
}
_planet() {
const lvl = this._levels[this.levelIdx];
return AB_PLANETS[lvl?.planet] || AB_PLANETS.earth;
}
_stepBird(dt) {
const bird = this.bird;
if (!bird?.launched) return;
const g = this._planet().g * this._sc; // px/s²
const lvl = this._levels[this.levelIdx];
const wind = (lvl.wind || 0) * this._sc * 0.22; // px/s² wind accel
/* Quadratic drag in px-space (empirical, looks right) */
const spd = Math.hypot(bird.vx, bird.vy);
const kD = 2.8e-5 * bird.Cd;
const ax = (wind - kD * spd * bird.vx) / bird.mass;
const ay = (g - kD * spd * bird.vy) / bird.mass;
/* Simple Euler (fast enough for game) */
bird.vx += ax * dt;
bird.vy += ay * dt;
bird.x += bird.vx * dt;
bird.y += bird.vy * dt;
/* Trail */
bird.trail.push({ x: bird.x, y: bird.y });
if (bird.trail.length > 22) bird.trail.shift();
/* Ground bounce / stop */
if (bird.y + bird.r >= this._gY) {
bird.y = this._gY - bird.r;
bird.vy *= -0.32;
bird.vx *= 0.72;
this._emit('impact', bird.x, this._gY, '#a0d080', 5);
if (Math.abs(bird.vy) < 22) bird.destroyed = true;
}
}
_stepBlocks(dt) {
const g = this._planet().g * this._sc;
for (const b of this.blocks) {
if (b.destroyed) continue;
if (!b.onGround) {
b.vy += g * dt;
b.x += b.vx * dt; b.y += b.vy * dt;
b.angle += b.angVel * dt;
b.vx *= 0.992;
if (b.y + b.h >= this._gY) {
b.y = this._gY - b.h;
b.vy *= -0.22; b.vx *= 0.65; b.angVel *= 0.45;
if (Math.abs(b.vy) < 18) { b.vy = 0; b.onGround = true; }
}
} else {
// sleeping on ground: slide + check if kicked into air
if (Math.abs(b.vx) > 0.5 || Math.abs(b.vy) > 0.5) b.onGround = false;
b.vx *= 0.82; b.x += b.vx * dt;
}
}
}
_stepPigs(dt) {
const g = this._planet().g * this._sc;
for (const p of this.pigs) {
if (p.destroyed) continue;
if (p.flash > 0) p.flash -= dt;
if (p.onGround) {
// wake up if kicked
if (Math.abs(p.vx) > 0.5 || Math.abs(p.vy) > 0.5) p.onGround = false;
p.vx *= 0.82; p.x += p.vx * dt;
continue;
}
p.vy += g * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.y + p.r >= this._gY) {
p.y = this._gY - p.r; p.vy *= -0.18; p.vx *= 0.65;
if (Math.abs(p.vy) < 10) { p.vy = 0; p.onGround = true; }
}
}
}
_collisions() {
const bird = this.bird;
if (!bird?.launched) return;
/* Bird vs Blocks */
for (const b of this.blocks) {
if (b.destroyed) continue;
const cx = Math.max(b.x, Math.min(bird.x, b.x + b.w));
const cy = Math.max(b.y, Math.min(bird.y, b.y + b.h));
const dx = bird.x - cx, dy = bird.y - cy;
const dist = Math.hypot(dx, dy);
if (dist >= bird.r) continue;
const nx = dist > 0.5 ? dx / dist : 0;
const ny = dist > 0.5 ? dy / dist : -1;
const vRel = (bird.vx - b.vx) * nx + (bird.vy - b.vy) * ny;
if (vRel >= 0) continue;
const e = 0.28;
const j = -(1 + e) * vRel / (1 / bird.mass + 1 / b.mass);
bird.vx += j / bird.mass * nx;
bird.vy += j / bird.mass * ny;
b.vx -= j / b.mass * nx;
b.vy -= j / b.mass * ny;
b.angVel += (dx * (-j / b.mass * ny) - dy * (-j / b.mass * nx)) * 0.015;
b.onGround = false; // wake up block — now subject to gravity
const dmg = Math.abs(j) * 0.18;
b.hp -= dmg;
this.score += Math.max(0, Math.floor(dmg * 3));
this._emit('hit', cx, cy, AB_MATS[b.mat]?.debris || '#888', 5);
if (b.hp <= 0) {
b.destroyed = true;
this.score += 500;
this._emit('destroy', b.x + b.w / 2, b.y + b.h / 2, AB_MATS[b.mat]?.debris || '#888', 15);
}
bird.x += nx * (bird.r - dist + 1);
bird.y += ny * (bird.r - dist + 1);
}
/* Bird vs Pigs */
for (const p of this.pigs) {
if (p.destroyed) continue;
const dx = bird.x - p.x, dy = bird.y - p.y;
const dist = Math.hypot(dx, dy);
const minD = bird.r + p.r;
if (dist >= minD) continue;
const nx = dist > 0.5 ? dx / dist : 0;
const ny = dist > 0.5 ? dy / dist : -1;
const pigMass = 2.2;
const vRel = (bird.vx - p.vx) * nx + (bird.vy - p.vy) * ny;
if (vRel >= 0) continue;
const e = 0.15;
const j = -(1 + e) * vRel / (1 / bird.mass + 1 / pigMass);
bird.vx += j / bird.mass * nx;
bird.vy += j / bird.mass * ny;
p.vx -= j / pigMass * nx;
p.vy -= j / pigMass * ny;
p.onGround = false; // wake up pig
const dmg = Math.abs(j) * 0.28;
p.hp -= dmg; p.flash = 0.35;
if (p.hp <= 0) {
p.destroyed = true;
this.score += 5000;
this._emit('destroy', p.x, p.y, '#4ade80', 20);
} else {
this._emit('hit', p.x, p.y, '#86efac', 5);
}
bird.x += nx * (minD - dist + 1);
bird.y += ny * (minD - dist + 1);
}
}
_win() {
this.state = 'win';
this.score += this.birdsLeft.length * 3000;
this._emit('confetti', this.W / 2, this.H * 0.35, '#ffd166', 50);
if (this.onUpdate) this.onUpdate(this.info());
}
_offScreen(b) {
return b.x > this.W + 60 || b.x < -60 || b.y > this.H + 60;
}
/* ── PARTICLES ───────────────────────────────────────────────── */
_emit(type, x, y, color, n) {
const confetti = ['#ffd166', '#ef476f', '#06d6e0', '#7bf5a4', '#9b5de5'];
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const spd = type === 'confetti' ? 80 + Math.random() * 200
: type === 'destroy' ? 90 + Math.random() * 220
: 45 + Math.random() * 100;
this._particles.push({
x, y,
vx: Math.cos(a) * spd,
vy: Math.sin(a) * spd - (type === 'destroy' || type === 'confetti' ? 100 : 20),
r: type === 'confetti' ? 4 + Math.random() * 5 : 2 + Math.random() * 4,
color: type === 'confetti' ? confetti[i % confetti.length] : color,
gravity: type === 'confetti' ? 180 : 300,
life: 1, maxLife: 0.5 + Math.random() * 0.9,
});
}
}
_stepParticles(dt) {
for (const p of this._particles) {
p.x += p.vx * dt; p.y += p.vy * dt;
p.vy += p.gravity * dt; p.vx *= 0.97;
p.life -= dt / p.maxLife;
}
this._particles = this._particles.filter(p => p.life > 0);
}
/* ── PREVIEW PATH ────────────────────────────────────────────── */
_updatePreview() {
const dx = this._drag.mx - this._sX;
const dy = this._drag.my - this._sY;
const K = 5.8;
const vx0 = -dx * K, vy0 = -dy * K;
const g = this._planet().g * this._sc;
const lvl = this._levels[this.levelIdx];
const wind = (lvl?.wind || 0) * this._sc * 0.22;
this._previewPath = [];
let x = this._sX, y = this._sY, vx = vx0, vy = vy0;
const dt = 0.028;
for (let i = 0; i < 38; i++) {
vx += wind * dt; vy += g * dt;
x += vx * dt; y += vy * dt;
if (y > this._gY || x > this.W) break;
this._previewPath.push({ x, y, a: 1 - i / 38 });
}
}
/* ── DRAWING ─────────────────────────────────────────────────── */
_drawBackground() {
const ctx = this.ctx;
const pl = this._planet();
ctx.clearRect(0, 0, this.W, this.H);
/* Sky */
const sky = ctx.createLinearGradient(0, 0, 0, this._gY);
sky.addColorStop(0, pl.sky1); sky.addColorStop(1, pl.sky2);
ctx.fillStyle = sky; ctx.fillRect(0, 0, this.W, this._gY);
/* Stars */
for (const s of this._stars) {
ctx.beginPath(); ctx.arc(s.x * this.W, s.y * this._gY, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${s.a})`; ctx.fill();
}
/* Ground */
const gnd = ctx.createLinearGradient(0, this._gY, 0, this.H);
gnd.addColorStop(0, pl.ground);
gnd.addColorStop(1, this._shade(pl.ground, 0.45));
ctx.fillStyle = gnd; ctx.fillRect(0, this._gY, this.W, this.H - this._gY);
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, this._gY); ctx.lineTo(this.W, this._gY); ctx.stroke();
/* Wind arrow (visual, centred top) */
const lvl = this._levels[this.levelIdx];
if (lvl?.wind) {
const dir = lvl.wind > 0 ? 1 : -1;
const len = Math.min(Math.abs(lvl.wind) * 7, 70);
const wx = this.W * 0.5, wy = 22;
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2.5;
ctx.setLineDash([5, 3]);
ctx.beginPath(); ctx.moveTo(wx - dir * len / 2, wy); ctx.lineTo(wx + dir * len / 2, wy); ctx.stroke();
ctx.setLineDash([]);
const ax = wx + dir * len / 2;
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.moveTo(ax, wy); ctx.lineTo(ax - dir*9, wy-5); ctx.lineTo(ax - dir*9, wy+5); ctx.closePath(); ctx.fill();
}
}
_drawSlingshot() {
const ctx = this.ctx;
const sx = this._sX, sy = this._sY, gY = this._gY;
ctx.strokeStyle = '#6b3a1f'; ctx.lineWidth = 9; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(sx, gY); ctx.lineTo(sx - 2, sy + 14); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx - 12, gY + 4); ctx.lineTo(sx - 18, sy - 12); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + 12, gY + 4); ctx.lineTo(sx + 18, sy - 12); ctx.stroke();
ctx.fillStyle = '#4a2510';
ctx.beginPath(); ctx.arc(sx - 18, sy - 12, 5.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(sx + 18, sy - 12, 5.5, 0, Math.PI * 2); ctx.fill();
}
_drawSlingshotRubber() {
const ctx = this.ctx;
const sx = this._sX, sy = this._sY;
const bx = (this._drag.pulling && this.bird) ? this._drag.mx : (this.bird ? this.bird.x : sx);
const by = (this._drag.pulling && this.bird) ? this._drag.my : (this.bird ? this.bird.y : sy);
ctx.strokeStyle = '#7a4020'; ctx.lineWidth = 3; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(sx - 18, sy - 12); ctx.lineTo(bx, by); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + 18, sy - 12); ctx.lineTo(bx, by); ctx.stroke();
}
_drawQueue() {
const birds = this.birdsLeft;
if (!birds.length) return;
const qy = this._gY + 26;
const gap = 28;
const startX = this._sX + 30; // right of sling handle
for (let i = 0; i < Math.min(birds.length, 8); i++) {
const def = AB_BIRDS[birds[i]] || AB_BIRDS.normal;
this._drawBirdShape(startX + i * gap, qy, def.r * 0.6, def.color, 0.65);
}
}
_drawBird(bird) {
const ctx = this.ctx;
/* trail */
for (let i = 0; i < bird.trail.length; i++) {
const t = bird.trail[i];
ctx.beginPath(); ctx.arc(t.x, t.y, bird.r * 0.38, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${(i / bird.trail.length) * 0.35})`; ctx.fill();
}
this._drawBirdShape(bird.x, bird.y, bird.r, bird.color, 1);
}
_drawBirdShape(x, y, r, color, alpha) {
const ctx = this.ctx;
ctx.save(); ctx.globalAlpha = alpha;
const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.1, x, y, r);
g.addColorStop(0, this._lighten(color, 60)); g.addColorStop(1, color);
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = this._shade(color, 0.6); ctx.lineWidth = 1.5; ctx.stroke();
/* eyes */
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x + r * 0.26, y - r * 0.18, r * 0.24, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#1a1a1a';
ctx.beginPath(); ctx.arc(x + r * 0.33, y - r * 0.15, r * 0.11, 0, Math.PI * 2); ctx.fill();
/* eyebrow (angry) */
ctx.strokeStyle = '#333'; ctx.lineWidth = Math.max(1.5, r * 0.13); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x + r * 0.08, y - r * 0.4); ctx.lineTo(x + r * 0.52, y - r * 0.28); ctx.stroke();
ctx.restore();
}
_drawBlocks() {
const ctx = this.ctx;
for (const b of this.blocks) {
if (b.destroyed) continue;
const mat = AB_MATS[b.mat] || AB_MATS.wood;
const hp = b.hp / b.maxHp;
ctx.save();
ctx.translate(b.x + b.w / 2, b.y + b.h / 2); ctx.rotate(b.angle);
ctx.fillStyle = mat.color; ctx.globalAlpha = 0.45 + hp * 0.55;
ctx.fillRect(-b.w / 2, -b.h / 2, b.w, b.h);
ctx.globalAlpha = 1;
ctx.strokeStyle = mat.border; ctx.lineWidth = 1.5;
ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h);
if (hp < 0.55) {
ctx.strokeStyle = `rgba(0,0,0,${(1 - hp) * 0.55})`; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-b.w * 0.12, -b.h * 0.35); ctx.lineTo(b.w * 0.18, b.h * 0.12);
ctx.moveTo(b.w * 0.08, -b.h * 0.22); ctx.lineTo(-b.w * 0.2, b.h * 0.3);
ctx.stroke();
}
ctx.restore();
}
}
_drawPigs() {
const ctx = this.ctx;
for (const p of this.pigs) {
if (p.destroyed) continue;
const flash = p.flash > 0;
ctx.save();
const grd = ctx.createRadialGradient(p.x - p.r * 0.3, p.y - p.r * 0.3, p.r * 0.1, p.x, p.y, p.r);
grd.addColorStop(0, flash ? '#ffcc44' : '#7bf5a4');
grd.addColorStop(1, flash ? '#ff6600' : '#22c55e');
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = grd; ctx.fill();
ctx.strokeStyle = '#166534'; ctx.lineWidth = 2; ctx.stroke();
/* snout */
ctx.beginPath(); ctx.ellipse(p.x, p.y + p.r * 0.28, p.r * 0.38, p.r * 0.26, 0, 0, Math.PI * 2);
ctx.fillStyle = flash ? '#ffdd88' : '#4ade80'; ctx.fill();
ctx.fillStyle = '#166534';
ctx.beginPath(); ctx.arc(p.x - p.r * 0.13, p.y + p.r * 0.25, p.r * 0.07, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x + p.r * 0.13, p.y + p.r * 0.25, p.r * 0.07, 0, Math.PI * 2); ctx.fill();
/* eyes */
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(p.x - p.r * 0.26, p.y - p.r * 0.1, p.r * 0.23, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x + p.r * 0.26, p.y - p.r * 0.1, p.r * 0.23, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#111';
ctx.beginPath(); ctx.arc(p.x - p.r * 0.22, p.y - p.r * 0.08, p.r * 0.1, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(p.x + p.r * 0.3, p.y - p.r * 0.08, p.r * 0.1, 0, Math.PI * 2); ctx.fill();
/* HP bar */
if (p.hp < p.maxHp) {
const bw = p.r * 2.2, bh = 5, bx = p.x - bw / 2, by = p.y - p.r - 11;
ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = `hsl(${120 * p.hp / p.maxHp},88%,42%)`;
ctx.fillRect(bx, by, bw * p.hp / p.maxHp, bh);
}
ctx.restore();
}
}
_drawParticles() {
const ctx = this.ctx;
for (const p of this._particles) {
ctx.save(); ctx.globalAlpha = Math.max(0, p.life) * 0.88;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color; ctx.fill(); ctx.restore();
}
}
_drawPreview() {
const ctx = this.ctx;
ctx.save();
for (const pt of this._previewPath) {
ctx.beginPath(); ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${pt.a * 0.55})`; ctx.fill();
}
ctx.restore();
}
_drawHUD() {
const ctx = this.ctx;
const pl = this._planet();
const lvl = this._levels[this.levelIdx];
/* Score — top right */
ctx.font = 'bold 20px Manrope,sans-serif'; ctx.textAlign = 'right';
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(this.score.toLocaleString('ru') + ' очков', this.W - 14, 30);
/* Level — top left */
ctx.textAlign = 'left'; ctx.font = 'bold 15px Manrope,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillText(`Уровень ${this.levelIdx + 1} / ${this._levels.length}`, 14, 30);
/* Planet + g — second line, readable */
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillText(`${pl.label} g = ${pl.g} м/с²`, 14, 50);
/* Wind reminder */
if (lvl?.wind) {
ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.fillText(`Ветер ${lvl.wind > 0 ? '→' : '←'} ${Math.abs(lvl.wind)} м/с`, 14, 66);
}
/* Active bird type label near sling */
if (this.bird && this.state === 'aim') {
const def = AB_BIRDS[this.bird.type] || AB_BIRDS.normal;
ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(def.label, this._sX, this._gY + 50);
}
}
_drawOverlay() {
const ctx = this.ctx;
const win = this.state === 'win';
ctx.fillStyle = win ? 'rgba(0,30,8,0.78)' : 'rgba(30,0,0,0.78)';
ctx.fillRect(0, 0, this.W, this.H);
const cx = this.W / 2, cy = this.H / 2;
ctx.textAlign = 'center';
ctx.font = 'bold 34px Manrope,sans-serif';
ctx.fillStyle = win ? '#7bf5a4' : '#ef476f';
ctx.fillText(win ? '✦ Уровень пройден!' : '✦ Попробуй ещё раз', cx, cy - 18);
ctx.font = '17px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(win ? `Очки: ${this.score.toLocaleString('ru')}` : 'Свиньи выжили!', cx, cy + 18);
ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.fillText('Выбери уровень или нажми «Сначала»', cx, cy + 48);
}
/* ── UTILS ───────────────────────────────────────────────────── */
_evt(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
_expandHex(hex) {
// Expand 3-digit (#abc <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> #aabbcc)
if (/^#[0-9a-fA-F]{3}$/.test(hex))
hex = '#' + hex[1]+hex[1] + hex[2]+hex[2] + hex[3]+hex[3];
return hex;
}
_shade(hex, f) {
hex = this._expandHex(hex);
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
return `rgb(${Math.floor(r*f)},${Math.floor(g*f)},${Math.floor(b*f)})`;
}
_lighten(hex, add) {
hex = this._expandHex(hex);
const r = Math.min(255, parseInt(hex.slice(1,3),16)+add);
const g = Math.min(255, parseInt(hex.slice(3,5),16)+add);
const b = Math.min(255, parseInt(hex.slice(5,7),16)+add);
return `rgb(${r},${g},${b})`;
}
}
+639
View File
@@ -0,0 +1,639 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
BohrAtomSim — Bohr atomic model simulation (hydrogen)
E_n = 13.6 / n² eV λ = 1240 / ΔE nm
Orbital animation · energy diagram · spectrum bar
══════════════════════════════════════════════════════════════ */
class BohrAtomSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.level = 2; // current energy level n (16)
this._angle = 0; // electron orbital angle
this._lastTransition = null; // { from, to, deltaE, wavelength, series }
this._emittedPhotons = []; // wavelengths emitted so far
/* transition animation */
this._trans = null; // { from, to, t, dur, photon }
this._photons = []; // flying photon particles [{x,y,vx,vy,color,t,maxT}]
/* spectrum marks */
this._specMarks = []; // wavelengths (nm)
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
/* interaction */
this._hoverLevel = null;
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ level } = {}) {
if (level !== undefined) {
const n = Math.max(1, Math.min(6, Math.round(+level)));
if (n !== this.level) this.transition(this.level, n);
}
this.draw();
this._emit();
}
transition(from, to) {
from = Math.max(1, Math.min(6, Math.round(+from)));
to = Math.max(1, Math.min(6, Math.round(+to)));
if (from === to) return;
const eFrom = -13.6 / (from * from);
const eTo = -13.6 / (to * to);
const deltaE = Math.abs(eTo - eFrom);
const wl = 1240 / deltaE;
const color = this._wavelengthToColor(wl);
const series = this._seriesName(from, to);
this._lastTransition = { from, to, deltaE, wavelength: wl, series };
const isEmission = from > to;
if (isEmission) this._emittedPhotons.push(wl);
/* push spectrum mark */
if (!this._specMarks.includes(Math.round(wl))) {
this._specMarks.push(Math.round(wl));
}
/* start animation */
this._trans = {
from, to, t: 0, dur: 0.5,
color, wavelength: wl,
isEmission,
};
if (!this.playing) { this.playing = true; this._lastTs = null; this._tick(); }
this._emit();
}
preset(name) {
const presets = {
lyman_alpha: { from: 2, to: 1 },
balmer_alpha: { from: 3, to: 2 },
balmer_beta: { from: 4, to: 2 },
paschen: { from: 4, to: 3 },
};
const p = presets[name];
if (!p) return;
this.level = p.from;
this.transition(p.from, p.to);
}
reset() {
this.pause();
this.level = 2;
this._angle = 0;
this._lastTransition = null;
this._emittedPhotons = [];
this._specMarks = [];
this._trans = null;
this._photons = [];
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const n = this.level;
const en = -13.6 / (n * n);
return {
level: n,
energy: +en.toFixed(4),
lastTransition: this._lastTransition ? { ...this._lastTransition } : null,
emittedPhotons: this._emittedPhotons.slice(),
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_energyOf(n) { return -13.6 / (n * n); }
_seriesName(from, to) {
const lo = Math.min(from, to);
if (lo === 1) return 'Lyman';
if (lo === 2) return 'Balmer';
if (lo === 3) return 'Paschen';
if (lo === 4) return 'Brackett';
if (lo === 5) return 'Pfund';
return '';
}
_wavelengthToColor(nm) {
if (nm < 380) return '#9B5DE5';
if (nm > 780) return '#EF476F';
/* approximate visible spectrum */
let r = 0, g = 0, b = 0;
if (nm < 450) {
const t = (nm - 380) / 70;
r = (1 - t) * 0.6; g = 0; b = 1;
} else if (nm < 495) {
const t = (nm - 450) / 45;
r = 0; g = t; b = 1;
} else if (nm < 570) {
const t = (nm - 495) / 75;
r = t; g = 1; b = 1 - t;
} else if (nm < 590) {
const t = (nm - 570) / 20;
r = 1; g = 1 - t * 0.5; b = 0;
} else if (nm < 620) {
const t = (nm - 590) / 30;
r = 1; g = 0.5 - t * 0.5; b = 0;
} else {
const t = Math.min((nm - 620) / 160, 1);
r = 1; g = 0; b = 0;
}
const clamp = v => Math.max(0, Math.min(255, Math.round(v * 255)));
return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`;
}
/* ── tick / animate ────────────────────────── */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (this._lastTs === null) this._lastTs = ts;
const dt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
/* orbital motion — angular speed inversely proportional to n */
const omega = (2.5 / this.level);
this._angle += omega * dt * 2 * Math.PI;
if (this._angle > Math.PI * 2) this._angle -= Math.PI * 2;
/* transition animation */
if (this._trans) {
this._trans.t += dt;
if (this._trans.t >= this._trans.dur) {
this.level = this._trans.to;
/* spawn photon */
if (this._trans.isEmission) {
const cx = this.W * 0.325;
const cy = (this.H - 44) * 0.5;
const a = this._angle;
const r = this._orbitRadius(this._trans.to);
const ex = cx + r * Math.cos(a);
const ey = cy + r * Math.sin(a);
const pa = Math.random() * Math.PI * 2;
this._photons.push({
x: ex, y: ey,
vx: Math.cos(pa) * 120, vy: Math.sin(pa) * 120,
color: this._trans.color, t: 0, maxT: 1.2,
});
}
this._trans = null;
this._emit();
}
}
/* update photons */
for (const p of this._photons) {
p.x += p.vx * dt;
p.y += p.vy * dt;
p.t += dt;
}
this._photons = this._photons.filter(p => p.t < p.maxT);
this.draw();
this._tick();
});
}
/* ── geometry helpers ──────────────────────── */
_orbitRadius(n) {
const maxR = Math.min(this.W * 0.325, (this.H - 44) * 0.5) * 0.85;
return 18 + (n - 1) * (maxR - 18) / 5;
}
_diagramLevelY(n) {
/* energy diagram in right panel; map energy <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> y */
const panelTop = 30;
const panelBot = this.H - 74;
const eMin = -13.6; // n=1
const eMax = -0.378; // n=6
const en = this._energyOf(n);
const t = (en - eMin) / (eMax - eMin);
return panelBot - t * (panelBot - panelTop);
}
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const atomW = W * 0.65;
const panelX = atomW;
const specH = 44; // spectrum bar height at bottom
/* divider */
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(atomW - 1, 0, 2, H - specH);
this._drawAtom(ctx, atomW, H - specH);
this._drawEnergyDiagram(ctx, panelX, W, H - specH);
this._drawSpectrumBar(ctx, W, H, specH);
this._drawPhotons(ctx);
}
/* ── atom (left 65%) ───────────────────────── */
_drawAtom(ctx, aW, aH) {
const cx = aW * 0.5;
const cy = aH * 0.5;
/* nucleus glow */
const ng = ctx.createRadialGradient(cx, cy, 0, cx, cy, 20);
ng.addColorStop(0, 'rgba(255,220,80,0.9)');
ng.addColorStop(0.3, 'rgba(255,200,60,0.3)');
ng.addColorStop(1, 'rgba(255,200,60,0)');
ctx.fillStyle = ng;
ctx.beginPath(); ctx.arc(cx, cy, 20, 0, Math.PI * 2); ctx.fill();
/* nucleus dot */
ctx.fillStyle = '#FFD166';
ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fill();
/* orbitals */
for (let n = 1; n <= 6; n++) {
const r = this._orbitRadius(n);
const isCurrent = n === this._currentDisplayLevel();
const alpha = isCurrent ? 0.6 : 0.15;
ctx.strokeStyle = isCurrent ? '#06D6E0' : `rgba(255,255,255,${alpha})`;
ctx.lineWidth = isCurrent ? 2 : 1;
ctx.setLineDash(isCurrent ? [] : [4, 4]);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
/* label */
const en = this._energyOf(n);
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.35)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(`n=${n} ${en.toFixed(2)} eV`, cx + r + 6, cy - 4);
}
/* electron */
const eLevel = this._currentDisplayLevel();
let eAngle = this._angle;
let eR = this._orbitRadius(eLevel);
/* during transition: interpolate radius */
if (this._trans) {
const prog = Math.min(this._trans.t / this._trans.dur, 1);
const ease = prog * prog * (3 - 2 * prog); // smoothstep
const rFrom = this._orbitRadius(this._trans.from);
const rTo = this._orbitRadius(this._trans.to);
eR = rFrom + (rTo - rFrom) * ease;
}
const ex = cx + eR * Math.cos(eAngle);
const ey = cy + eR * Math.sin(eAngle);
/* electron glow */
const eg = ctx.createRadialGradient(ex, ey, 0, ex, ey, 14);
eg.addColorStop(0, 'rgba(6,214,224,0.8)');
eg.addColorStop(0.4, 'rgba(6,214,224,0.2)');
eg.addColorStop(1, 'rgba(6,214,224,0)');
ctx.fillStyle = eg;
ctx.beginPath(); ctx.arc(ex, ey, 14, 0, Math.PI * 2); ctx.fill();
/* electron dot */
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.arc(ex, ey, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(ex - 1.5, ey - 1.5, 1.5, 0, Math.PI * 2); ctx.fill();
/* title */
ctx.font = "bold 13px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('Модель атома Бора (водород)', aW * 0.5, 10);
}
_currentDisplayLevel() {
if (this._trans) return this._trans.from;
return this.level;
}
/* ── energy diagram (right 35%) ────────────── */
_drawEnergyDiagram(ctx, x0, W, pH) {
const pW = W - x0;
const pad = { l: 52, r: 16, t: 30, b: 20 };
const lineX0 = x0 + pad.l;
const lineX1 = W - pad.r;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, pW, pH);
/* title */
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Энергетические уровни', x0 + 10, 10);
/* draw each level */
for (let n = 1; n <= 6; n++) {
const y = this._diagramLevelY(n);
const en = this._energyOf(n);
const isCurrent = n === this.level && !this._trans;
/* line */
ctx.strokeStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.3)';
ctx.lineWidth = isCurrent ? 2.5 : 1.5;
ctx.beginPath(); ctx.moveTo(lineX0, y); ctx.lineTo(lineX1, y); ctx.stroke();
/* hover highlight */
if (this._hoverLevel === n && n !== this.level) {
ctx.strokeStyle = 'rgba(155,93,229,0.5)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(lineX0, y); ctx.lineTo(lineX1, y); ctx.stroke();
}
/* n label (right) */
ctx.font = "11px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.6)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(`n=${n}`, lineX0 - 4, y);
/* energy label (left of n) */
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.textAlign = 'right';
ctx.fillText(`${en.toFixed(2)}`, lineX0 - 30, y);
/* dot on current level */
if (isCurrent) {
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.arc(lineX0 + 8, y, 4, 0, Math.PI * 2); ctx.fill();
}
}
/* transition arrow */
if (this._lastTransition) {
const lt = this._lastTransition;
const y1 = this._diagramLevelY(lt.from);
const y2 = this._diagramLevelY(lt.to);
const ax = (lineX0 + lineX1) * 0.5 + 10;
const col = this._wavelengthToColor(lt.wavelength);
ctx.strokeStyle = col;
ctx.lineWidth = 2;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(ax, y1); ctx.lineTo(ax, y2); ctx.stroke();
/* arrowhead */
const dir = y2 > y1 ? 1 : -1;
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(ax, y2);
ctx.lineTo(ax - 5, y2 - dir * 8);
ctx.lineTo(ax + 5, y2 - dir * 8);
ctx.closePath(); ctx.fill();
/* ΔE and λ labels */
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = col;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const midY = (y1 + y2) / 2;
ctx.fillText(`ΔE=${lt.deltaE.toFixed(2)} eV`, ax + 8, midY - 8);
ctx.fillText(`λ=${lt.wavelength.toFixed(1)} nm`, ax + 8, midY + 8);
/* series name */
if (lt.series) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.fillText(lt.series, ax + 8, midY + 22);
}
}
}
/* ── spectrum bar (bottom) ─────────────────── */
_drawSpectrumBar(ctx, W, H, barH) {
const y0 = H - barH;
/* background strip */
ctx.fillStyle = 'rgba(5,5,20,0.9)';
ctx.fillRect(0, y0, W, barH);
/* label */
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Спектр', 6, y0 + 2);
/* visible spectrum gradient */
const gradX0 = 50, gradX1 = W - 16;
const gradW = gradX1 - gradX0;
const gradY = y0 + 14, gradH = 16;
const nmMin = 380, nmMax = 780;
for (let px = 0; px < gradW; px++) {
const nm = nmMin + (px / gradW) * (nmMax - nmMin);
ctx.fillStyle = this._wavelengthToColor(nm);
ctx.globalAlpha = 0.6;
ctx.fillRect(gradX0 + px, gradY, 1, gradH);
}
ctx.globalAlpha = 1;
/* border */
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.strokeRect(gradX0, gradY, gradW, gradH);
/* nm tick labels */
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let nm = 400; nm <= 750; nm += 50) {
const px = gradX0 + ((nm - nmMin) / (nmMax - nmMin)) * gradW;
ctx.fillText(nm, px, gradY + gradH + 2);
}
/* UV / IR labels */
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'right';
ctx.fillText('UV', gradX0 - 4, gradY + 4);
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left';
ctx.fillText('IR', gradX1 + 4, gradY + 4);
/* emission marks */
for (const wl of this._specMarks) {
let px;
if (wl < nmMin) {
px = gradX0 - 6;
} else if (wl > nmMax) {
px = gradX1 + 6;
} else {
px = gradX0 + ((wl - nmMin) / (nmMax - nmMin)) * gradW;
}
const col = this._wavelengthToColor(wl);
ctx.strokeStyle = col;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, gradY - 3); ctx.lineTo(px, gradY + gradH + 3); ctx.stroke();
/* tiny wavelength label above */
ctx.font = "7px 'Manrope', system-ui, sans-serif";
ctx.fillStyle = col;
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(wl, px, gradY - 4);
}
}
/* ── flying photons ────────────────────────── */
_drawPhotons(ctx) {
for (const p of this._photons) {
const alpha = 1 - p.t / p.maxT;
const r = 4 + p.t * 6;
/* glow */
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 2);
g.addColorStop(0, p.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = alpha * 0.5;
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p.x, p.y, r * 2, 0, Math.PI * 2); ctx.fill();
/* core */
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, r * 0.5, 0, Math.PI * 2); ctx.fill();
/* wavy trail */
ctx.strokeStyle = p.color;
ctx.lineWidth = 1;
ctx.globalAlpha = alpha * 0.4;
ctx.beginPath();
const len = 30;
const vMag = Math.hypot(p.vx, p.vy) || 1;
const dx = -p.vx / vMag, dy = -p.vy / vMag;
const nx = -dy, ny = dx;
for (let i = 0; i <= len; i++) {
const t = i / len;
const wx = p.x + dx * i * 1.5 + nx * Math.sin(t * 8 + p.t * 12) * 3;
const wy = p.y + dy * i * 1.5 + ny * Math.sin(t * 8 + p.t * 12) * 3;
i === 0 ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
mx: (t.clientX - r.left) * (this.W / r.width),
my: (t.clientY - r.top) * (this.H / r.height),
};
};
const hitLevel = (mx, my) => {
/* check energy diagram area */
const panelX = this.W * 0.65;
if (mx < panelX) return null;
const pH = this.H - 44;
const pad = { l: 52, r: 16 };
const lineX0 = panelX + pad.l;
const lineX1 = this.W - pad.r;
for (let n = 1; n <= 6; n++) {
const y = this._diagramLevelY(n);
if (mx >= lineX0 - 10 && mx <= lineX1 + 10 && Math.abs(my - y) < 10) {
return n;
}
}
return null;
};
/* click on level <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> transition */
cv.addEventListener('click', e => {
const { mx, my } = getPos(e);
const n = hitLevel(mx, my);
if (n !== null && n !== this.level && !this._trans) {
this.transition(this.level, n);
}
});
/* hover cursor */
cv.addEventListener('mousemove', e => {
const { mx, my } = getPos(e);
const n = hitLevel(mx, my);
this._hoverLevel = n;
cv.style.cursor = (n !== null && n !== this.level) ? 'pointer' : 'default';
});
cv.addEventListener('mouseleave', () => {
this._hoverLevel = null;
});
/* touch tap */
cv.addEventListener('touchend', e => {
if (e.changedTouches.length !== 1) return;
const r = cv.getBoundingClientRect();
const mx = (e.changedTouches[0].clientX - r.left) * (this.W / r.width);
const my = (e.changedTouches[0].clientY - r.top) * (this.H / r.height);
const n = hitLevel(mx, my);
if (n !== null && n !== this.level && !this._trans) {
this.transition(this.level, n);
}
});
}
}
+406
View File
@@ -0,0 +1,406 @@
'use strict';
/**
* BrownianSim v2 — Brownian Motion simulation.
* v2: age-gradient trail, MSD history chart, hover tooltip on big particle,
* resetOrigin() method.
*/
class BrownianSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.big = { x: 0, y: 0, vx: 0, vy: 0, r: 22 };
this.small = [];
this.N = 120;
this.T = 1.0;
this.trail = [];
this._origin = { x: 0, y: 0 };
this._steps = 0;
this._raf = null;
this.onUpdate = null;
this._dpr = 1;
// v2
this._msdHistory = []; // [{step, msd}]
this._hover = false;
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hover = false; });
}
_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),
};
}
_onMouseMove(e) {
const { x, y } = this._cp(e);
this._hover = Math.hypot(x - this.big.x, y - this.big.y) < this.big.r + 22;
}
// ── public API ──────────────────────────────────────────────────────────────
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
const w = this.canvas.offsetWidth, h = this.canvas.offsetHeight;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.W = w; this.H = h;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.reset();
}
reset() {
const { W, H } = this;
this.big = { x: W / 2, y: H / 2, vx: 0, vy: 0, r: 22 };
this._origin = { x: W / 2, y: H / 2 };
this.trail = [{ x: W / 2, y: H / 2 }];
this._steps = 0;
this._msdHistory = [];
const small = [];
let att = 0;
while (small.length < this.N && att < this.N * 20) {
att++;
const r = 4;
const x = r + Math.random() * (W - 2 * r);
const y = r + Math.random() * (H - 2 * r);
if (Math.hypot(x - W / 2, y - H / 2) < this.big.r + r + 8) continue;
const a = Math.random() * Math.PI * 2, s = this.T * 4.5;
small.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r });
}
this.small = small;
}
resetOrigin() {
this._origin = { x: this.big.x, y: this.big.y };
this._msdHistory = [];
}
setN(n) { this.N = Math.max(10, Math.min(300, n)); this.reset(); }
setT(t) {
const f = Math.sqrt(t / this.T);
for (const s of this.small) { s.vx *= f; s.vy *= f; }
this.T = t;
}
start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop.bind(this)); }
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
// ── simulation ──────────────────────────────────────────────────────────────
_loop() {
this._step(); this._step(); this._step();
this.draw();
this._raf = requestAnimationFrame(this._loop.bind(this));
}
_step() {
const { W, H, big, small } = this;
big.x += big.vx; big.y += big.vy;
if (big.x < big.r) { big.x = big.r; big.vx = Math.abs(big.vx); }
if (big.x > W - big.r) { big.x = W - big.r; big.vx = -Math.abs(big.vx); }
if (big.y < big.r) { big.y = big.r; big.vy = Math.abs(big.vy); }
if (big.y > H - big.r) { big.y = H - big.r; big.vy = -Math.abs(big.vy); }
for (const s of small) {
s.x += s.vx; s.y += s.vy;
if (s.x < s.r) { s.x = s.r; s.vx = Math.abs(s.vx); }
if (s.x > W - s.r) { s.x = W - s.r; s.vx = -Math.abs(s.vx); }
if (s.y < s.r) { s.y = s.r; s.vy = Math.abs(s.vy); }
if (s.y > H - s.r) { s.y = H - s.r; s.vy = -Math.abs(s.vy); }
}
// big vs small
const m1 = big.r * big.r;
for (const s of small) {
const dx = s.x - big.x, dy = s.y - big.y;
const dist = Math.hypot(dx, dy), md = big.r + s.r;
if (dist < md && dist > 0.001) {
const nx = dx / dist, ny = dy / dist;
const dvn = (big.vx - s.vx) * nx + (big.vy - s.vy) * ny;
if (dvn > 0) continue;
const m2 = s.r * s.r, imp = (2 * dvn) / (m1 + m2);
big.vx -= imp * m2 * nx; big.vy -= imp * m2 * ny;
s.vx += imp * m1 * nx; s.vy += imp * m1 * ny;
const ov = md - dist, f1 = m2 / (m1 + m2), f2 = m1 / (m1 + m2);
big.x -= nx * ov * f1; big.y -= ny * ov * f1;
s.x += nx * ov * f2; s.y += ny * ov * f2;
}
}
// small vs small — spatial grid
const cs = 10, cols = Math.ceil(W / cs) + 1;
const grid = new Map();
for (let i = 0; i < small.length; i++) {
const s = small[i];
const k = Math.floor(s.x / cs) + Math.floor(s.y / cs) * cols;
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
const checked = new Set();
for (let i = 0; i < small.length; i++) {
const s1 = small[i];
const cx = Math.floor(s1.x / cs), cy = Math.floor(s1.y / cs);
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
if (!cell) continue;
for (const j of cell) {
if (j <= i) continue;
const pk = i * 10000 + j;
if (checked.has(pk)) continue;
checked.add(pk);
const s2 = small[j];
const dx = s2.x - s1.x, dy = s2.y - s1.y;
const d = Math.hypot(dx, dy), md = s1.r + s2.r;
if (d < md && d > 0.001) {
const nx = dx / d, ny = dy / d;
const dvn = (s1.vx - s2.vx) * nx + (s1.vy - s2.vy) * ny;
if (dvn < 0) continue;
s1.vx -= dvn * nx; s1.vy -= dvn * ny;
s2.vx += dvn * nx; s2.vy += dvn * ny;
const ov = (md - d) / 2;
s1.x -= nx * ov; s1.y -= ny * ov;
s2.x += nx * ov; s2.y += ny * ov;
}
}
}
}
// Trail
if (this._steps % 2 === 0) {
this.trail.push({ x: big.x, y: big.y });
if (this.trail.length > 600) this.trail.shift();
}
// MSD history
if (this._steps % 6 === 0) {
const dx = big.x - this._origin.x, dy = big.y - this._origin.y;
this._msdHistory.push({ step: this._steps, msd: dx * dx + dy * dy });
if (this._msdHistory.length > 250) this._msdHistory.shift();
}
this._steps++;
if (this._steps % 40 === 0 && this.onUpdate) this.onUpdate(this.info());
}
info() {
const dx = this.big.x - this._origin.x, dy = this.big.y - this._origin.y;
return {
steps: this._steps,
displacement: Math.hypot(dx, dy).toFixed(1),
msd: (dx * dx + dy * dy).toFixed(0),
speed: Math.hypot(this.big.vx, this.big.vy).toFixed(2),
N: this.N, T: this.T,
};
}
// ── drawing ─────────────────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
const TAU = Math.PI * 2;
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7);
bg.addColorStop(0, '#080818'); bg.addColorStop(1, '#03030C');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
// Dot grid
ctx.fillStyle = 'rgba(255,255,255,0.025)';
for (let x = 0; x < W; x += 30) for (let y = 0; y < H; y += 30) {
ctx.beginPath(); ctx.arc(x, y, 1, 0, TAU); ctx.fill();
}
// MSD history chart (bottom-left)
this._drawMsdChart(ctx, W, H);
// Age-gradient trail
const trail = this.trail;
for (let i = 1; i < trail.length; i++) {
const frac = i / trail.length;
// young <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> gold (#FFD166), old <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> dark indigo
const hue = 220 + (1 - frac) * 20; // 220..240 — indigo <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> blue
const sat = 60 + frac * 40;
const lit = 20 + frac * 50;
ctx.beginPath();
ctx.arc(trail[i].x, trail[i].y, 1.5, 0, TAU);
ctx.fillStyle = `hsla(${hue},${sat}%,${lit}%,${frac * 0.75})`;
ctx.fill();
}
// Newest segment in gold
if (trail.length > 2) {
const t0 = trail[trail.length - 2], t1 = trail[trail.length - 1];
ctx.strokeStyle = 'rgba(255,209,102,0.9)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(t0.x, t0.y); ctx.lineTo(t1.x, t1.y); ctx.stroke();
}
// Displacement vector
const ox = this._origin.x, oy = this._origin.y;
const bx = this.big.x, by = this.big.y;
const vlen = Math.hypot(bx - ox, by - oy);
if (vlen > 2) {
ctx.save();
ctx.strokeStyle = 'rgba(255,100,100,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(bx, by); ctx.stroke();
const ang = Math.atan2(by - oy, bx - ox), hl = 8;
ctx.fillStyle = 'rgba(255,100,100,0.6)';
ctx.beginPath(); ctx.moveTo(bx, by);
ctx.lineTo(bx - hl * Math.cos(ang - 0.4), by - hl * Math.sin(ang - 0.4));
ctx.lineTo(bx - hl * Math.cos(ang + 0.4), by - hl * Math.sin(ang + 0.4));
ctx.closePath(); ctx.fill();
const mx = (ox + bx) / 2, my = (oy + by) / 2;
ctx.fillStyle = 'rgba(255,140,140,0.85)';
ctx.font = "10px 'Manrope', sans-serif";
ctx.fillText(`|Δr| = ${vlen.toFixed(1)}`, mx + 6, my - 4);
ctx.restore();
}
// Origin marker
ctx.save();
ctx.strokeStyle = 'rgba(255,100,100,0.35)'; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.arc(ox, oy, 6, 0, TAU); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
// Small particles
ctx.save();
ctx.shadowBlur = 4; ctx.shadowColor = '#4CC9F0'; ctx.fillStyle = '#4CC9F0';
for (const s of this.small) {
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, TAU); ctx.fill();
}
ctx.restore();
// Big particle
const big = this.big;
ctx.save();
ctx.shadowBlur = 32; ctx.shadowColor = 'rgba(255,214,0,0.65)';
const grad = ctx.createRadialGradient(
big.x - big.r * 0.3, big.y - big.r * 0.3, 2,
big.x, big.y, big.r
);
grad.addColorStop(0, '#FFD166'); grad.addColorStop(1, '#9B5DE5');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(big.x, big.y, big.r, 0, TAU); ctx.fill();
// Hover ring
if (this._hover) {
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(big.x, big.y, big.r + 4, 0, TAU); ctx.stroke();
}
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(big.x, big.y, big.r, 0, TAU); ctx.stroke();
ctx.fillStyle = 'white'; ctx.font = "bold 12px 'Manrope', sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('B', big.x, big.y);
ctx.restore();
// Hover tooltip
if (this._hover) this._drawBigTooltip(ctx, W, H);
}
_drawMsdChart(ctx, W, H) {
const hist = this._msdHistory;
const chartW = 190, chartH = 90;
const cx = 14, cy = H - chartH - 14;
ctx.save();
ctx.fillStyle = 'rgba(0,0,10,0.72)';
ctx.beginPath(); ctx.roundRect(cx, cy, chartW, chartH, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = "9px 'Manrope', sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('MSD vs Шагов', cx + 8, cy + 7);
if (hist.length > 2) {
const padL = 8, padR = 10, padT = 20, padB = 8;
const pw = chartW - padL - padR;
const ph = chartH - padT - padB;
const maxMsd = Math.max(...hist.map(h => h.msd), 1);
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < hist.length; i++) {
const hx = cx + padL + (i / (hist.length - 1)) * pw;
const hy = cy + padT + ph - (hist[i].msd / maxMsd) * ph;
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
}
ctx.stroke();
// Theoretical linear MSD ~ D*t line (straight reference)
const lastMsd = hist[hist.length - 1].msd;
const firstMsd = hist[0].msd;
ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(cx + padL, cy + padT + ph - (firstMsd / maxMsd) * ph);
ctx.lineTo(cx + padL + pw, cy + padT + ph - (lastMsd / maxMsd) * ph);
ctx.stroke();
ctx.setLineDash([]);
// Current value
const last = hist[hist.length - 1];
ctx.fillStyle = '#9B5DE5'; ctx.font = "bold 10px 'Manrope', sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(last.msd.toFixed(0), cx + chartW - padR - 2,
cy + padT + ph - (last.msd / maxMsd) * ph);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = "10px 'Manrope', sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('Накапливается…', cx + chartW / 2, cy + chartH / 2);
}
ctx.restore();
}
_drawBigTooltip(ctx, W, H) {
const big = this.big;
const spd = Math.hypot(big.vx, big.vy);
const ke = 0.5 * big.r * big.r * spd * spd; // prop to mass (r²)
const dx = big.x - this._origin.x, dy = big.y - this._origin.y;
const disp = Math.hypot(dx, dy);
const msd = dx * dx + dy * dy;
const rows = [
['|v|', spd.toFixed(2) + ' у.е.'],
['KE', ke.toFixed(0) + ' у.е.'],
['|Δr|', disp.toFixed(1) + ' px'],
['MSD', msd.toFixed(0) + ' px²'],
];
const tw = 142, th = 18 + rows.length * 17 + 8;
let tx = big.x + big.r + 12, ty = big.y - th / 2;
if (tx + tw > W - 10) tx = big.x - big.r - tw - 12;
ty = Math.max(8, Math.min(H - th - 8, ty));
ctx.save();
ctx.fillStyle = 'rgba(6,8,28,0.93)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill();
ctx.fillStyle = '#FFD166';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8, 8, 0, 0]); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.font = "11px 'Manrope', monospace"; ctx.textBaseline = 'middle';
for (let i = 0; i < rows.length; i++) {
const ry = ty + 18 + i * 17;
ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.textAlign = 'left';
ctx.fillText(rows[i][0], tx + 10, ry);
ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.textAlign = 'right';
ctx.fillText(rows[i][1], tx + tw - 10, ry);
}
ctx.restore();
}
}
if (typeof module !== 'undefined') module.exports = BrownianSim;
+815
View File
@@ -0,0 +1,815 @@
'use strict';
/* ════════════════════════════════════════════════════════════════
CellDivisionSim v2 — интерактивное деление клетки
Митоз и мейоз · анимация · частицы · скрабинг · клик
════════════════════════════════════════════════════════════════ */
class CellDivisionSim {
static MITOSIS_PHASES = [
{ id: 'interphase', label: 'Интерфаза', chromN: '2n = 46', dna: '2C → 4C', dur: 6000,
desc: 'G1+S+G2: клетка растёт, ДНК удваивается в S-периоде' },
{ id: 'prophase', label: 'Профаза', chromN: '2n = 46', dna: '4C', dur: 4500,
desc: 'Хромосомы конденсируются · ядерная оболочка разрушается · формируется веретено' },
{ id: 'metaphase', label: 'Метафаза', chromN: '2n = 46', dna: '4C', dur: 3500,
desc: 'Хромосомы на метафазной пластинке · нити веретена у кинетохор' },
{ id: 'anaphase', label: 'Анафаза', chromN: '4n = 92', dna: '4C', dur: 3000,
desc: 'Хроматиды расходятся к полюсам · клетка вытягивается' },
{ id: 'telophase', label: 'Телофаза', chromN: '2n = 46', dna: '4C', dur: 3000,
desc: 'Два ядра восстанавливаются · хромосомы деконденсируются' },
{ id: 'cytokinesis', label: 'Цитокинез', chromN: '2n = 46', dna: '2C', dur: 3500,
desc: 'Цитоплазма делится · 2 дочерних диплоидных клетки (2n = 46)' },
];
static MEIOSIS_PHASES = [
{ id: 'interphase', label: 'Интерфаза', chromN: '2n = 46', dna: '2C → 4C', dur: 4000,
desc: 'Репликация ДНК перед делением' },
{ id: 'prophase1', label: 'Профаза I', chromN: '2n = 46', dna: '4C', dur: 5000,
desc: 'Конъюгация гомологов · кроссинговер — рекомбинация генов' },
{ id: 'metaphase1', label: 'Метафаза I', chromN: '2n = 46', dna: '4C', dur: 3000,
desc: 'Биваленты (пары гомологов) выстраиваются по экватору' },
{ id: 'anaphase1', label: 'Анафаза I', chromN: '2n = 46', dna: '4C', dur: 3000,
desc: 'Гомологичные хромосомы расходятся к полюсам' },
{ id: 'telophase1', label: 'Телофаза I', chromN: 'n = 23', dna: '2C', dur: 2500,
desc: 'Два гаплоидных ядра · хромосомы ещё с сестринскими хроматидами' },
{ id: 'prophase2', label: 'Профаза II', chromN: 'n = 23', dna: '2C', dur: 2000,
desc: 'Без репликации ДНК · начало второго деления' },
{ id: 'metaphase2', label: 'Метафаза II', chromN: 'n = 23', dna: '2C', dur: 2500,
desc: 'Хромосомы на экваторе · нити веретена к хроматидам' },
{ id: 'anaphase2', label: 'Анафаза II', chromN: 'n = 23', dna: '2C', dur: 2500,
desc: 'Хроматиды расходятся к полюсам' },
{ id: 'telophase2', label: 'Телофаза II', chromN: 'n = 23', dna: 'C', dur: 2500,
desc: 'Четыре гаплоидных ядра формируются' },
{ id: 'cytokinesis', label: 'Цитокинез', chromN: 'n = 23', dna: 'C', dur: 3000,
desc: '4 гаплоидные клетки — гаметы (n = 23)' },
];
static C = {
bg: '#070711',
cell: 'rgba(34,211,153,0.055)',
cellStr: '#22d399',
nucFill: 'rgba(122,77,210,0.09)',
nucStr: '#9B5DE5',
chromatin:'#06D6E0',
ch: ['#EF476F','#FF9F1C','#9B5DE5','#06D6E0','#F15BB5','#7BF5A4'],
spindle: 'rgba(255,214,0,0.55)',
pole: '#FFD166',
furrow: '#22d399',
crossing: '#FFD166',
progress: '#22d399',
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.mode = 'mitosis';
this._phaseIdx = 0;
this._phaseT = 0;
this._autoPlay = true;
this._speed = 1.0;
this._raf = null;
this._last = 0;
this._time = 0;
this.W = 0; this.H = 0;
this.onUpdate = null;
this._chromatinDots = [];
this._particles = [];
this._draggingBar = false;
this._bindEvents();
this.fit();
}
/* ── Lifecycle ──────────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 700;
const H = this.canvas.offsetHeight || 440;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._genChromatinDots();
if (!this._raf) this._draw();
}
start() {
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
reset() {
this._phaseIdx = 0;
this._phaseT = 0;
this._particles = [];
this._emitUpdate();
if (!this._raf) this._draw();
}
setMode(mode) { this.mode = mode; this.reset(); }
setSpeed(s) { this._speed = s; }
nextPhase() {
const phases = this._phases();
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
this._phaseT = 0;
this._particles = [];
this._emitUpdate();
if (!this._raf) this._draw();
}
prevPhase() {
const phases = this._phases();
this._phaseIdx = (this._phaseIdx - 1 + phases.length) % phases.length;
this._phaseT = 0;
this._particles = [];
this._emitUpdate();
if (!this._raf) this._draw();
}
jumpToPhase(idx) {
const phases = this._phases();
this._phaseIdx = Math.max(0, Math.min(phases.length - 1, idx));
this._phaseT = 0;
this._particles = [];
this._emitUpdate();
if (!this._raf) this._draw();
}
toggleAutoPlay() {
this._autoPlay = !this._autoPlay;
if (this._autoPlay && !this._raf) this.start();
return this._autoPlay;
}
info() {
const phases = this._phases();
const p = phases[this._phaseIdx];
return { phase: p.label, chromN: p.chromN, dna: p.dna,
index: this._phaseIdx, total: phases.length,
progress: this._phaseT, mode: this.mode };
}
/* ── Events ─────────────────────────────────────────────────── */
_bindEvents() {
const c = this.canvas;
const getBarT = e => {
const rect = c.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - rect.left - 14) / (this.W - 28)));
};
c.addEventListener('click', e => {
const rect = c.getBoundingClientRect();
if (e.clientY - rect.top < this.H - 28) this.nextPhase();
});
c.addEventListener('mousedown', e => {
const rect = c.getBoundingClientRect();
if (e.clientY - rect.top >= this.H - 28) {
this._draggingBar = true;
this._phaseT = getBarT(e);
if (!this._raf) this._draw();
}
});
c.addEventListener('mousemove', e => {
const rect = c.getBoundingClientRect();
c.style.cursor = (e.clientY - rect.top >= this.H - 28) ? 'col-resize' : 'pointer';
if (this._draggingBar) { this._phaseT = getBarT(e); if (!this._raf) this._draw(); }
});
c.addEventListener('mouseup', () => { this._draggingBar = false; });
c.addEventListener('mouseleave', () => { this._draggingBar = false; });
c.setAttribute('tabindex', '0');
c.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); this.toggleAutoPlay(); }
if (e.key === 'ArrowRight') { e.preventDefault(); this.nextPhase(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); this.prevPhase(); }
});
}
/* ── Internals ──────────────────────────────────────────────── */
_phases() {
return this.mode === 'meiosis'
? CellDivisionSim.MEIOSIS_PHASES
: CellDivisionSim.MITOSIS_PHASES;
}
_emitUpdate() {
if (this.onUpdate) this.onUpdate(this.info());
}
_ease(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
_genChromatinDots() {
const { W, H } = this;
const cx = W / 2, cy = H / 2;
const nr = Math.min(W, H) * 0.17;
this._chromatinDots = Array.from({ length: 52 }, (_, i) => {
// stable seeded positions
const seed = i * 1337 + 42;
const a = ((seed * 9301 + 49297) % 233280) / 233280 * Math.PI * 2;
const r = ((seed * 4321 + 12345) % 233280) / 233280 * nr * 0.88;
const sz = 1.4 + ((seed * 2341 + 7777) % 233280) / 233280 * 2.5;
const ph = ((seed * 6543 + 3210) % 233280) / 233280 * Math.PI * 2;
return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * 0.85, size: sz, phase: ph };
});
}
_spawnEnvelopeParticles(cx, cy, nucR) {
for (let i = 0; i < 32; i++) {
const a = Math.random() * Math.PI * 2;
const r = nucR * (0.88 + Math.random() * 0.18);
this._particles.push({
x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * 0.9,
vx: Math.cos(a) * (0.8 + Math.random() * 1.8),
vy: Math.sin(a) * (0.8 + Math.random() * 1.8),
life: 1, decay: 0.016 + Math.random() * 0.018,
size: 2 + Math.random() * 3, color: '#9B5DE5',
});
}
}
/* ── Tick ───────────────────────────────────────────────────── */
_tick(t) {
const dt = Math.min(t - this._last, 80);
this._last = t;
this._time += dt;
if (this._autoPlay && !this._draggingBar) {
const phases = this._phases();
const phase = phases[this._phaseIdx];
this._phaseT += (dt / phase.dur) * this._speed;
// nuclear envelope breakdown particles
if ((phase.id === 'prophase' || phase.id === 'prophase1') &&
this._phaseT > 0.34 && this._phaseT < 0.38 && this._particles.length < 5) {
const cellR = Math.min(this.W, this.H) * 0.37;
this._spawnEnvelopeParticles(this.W / 2, this.H / 2, cellR * 0.46);
}
if (this._phaseT >= 1) {
this._phaseT = 0;
this._phaseIdx = (this._phaseIdx + 1) % phases.length;
this._particles = [];
this._emitUpdate();
}
}
this._particles = this._particles.filter(p => {
p.x += p.vx; p.y += p.vy; p.vx *= 0.94; p.vy *= 0.94;
p.life -= p.decay; return p.life > 0;
});
this._emitUpdate();
this._draw();
}
/* ── Drawing ────────────────────────────────────────────────── */
_draw() {
const { ctx, W, H } = this;
const C = CellDivisionSim.C;
const t = this._phaseT;
const phases = this._phases();
const phase = phases[this._phaseIdx];
const cx = W / 2, cy = H / 2;
const cellR = Math.min(W, H) * 0.37;
const nucR = cellR * 0.46;
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, W, H);
// subtle radial bg
const bg2 = ctx.createRadialGradient(cx, cy, 0, cx, cy, cellR * 1.5);
bg2.addColorStop(0, 'rgba(34,211,153,0.022)');
bg2.addColorStop(1, 'transparent');
ctx.fillStyle = bg2;
ctx.fillRect(0, 0, W, H);
switch (phase.id) {
case 'interphase': this._drawInterphase(cx, cy, cellR, nucR, t); break;
case 'prophase':
case 'prophase1': this._drawProphase(cx, cy, cellR, nucR, t, phase.id === 'prophase1'); break;
case 'metaphase':
case 'metaphase1': this._drawMetaphase(cx, cy, cellR, t, phase.id === 'metaphase1', false); break;
case 'prophase2': this._drawProphase2(cx, cy, cellR, nucR, t); break;
case 'metaphase2': this._drawMetaphase(cx, cy, cellR, t, false, true); break;
case 'anaphase': this._drawAnaphase(cx, cy, cellR, t, false, false); break;
case 'anaphase1': this._drawAnaphase(cx, cy, cellR, t, true, false); break;
case 'anaphase2': this._drawAnaphase(cx, cy, cellR, t, false, true); break;
case 'telophase': this._drawTelophase(cx, cy, cellR, nucR, t, false, false); break;
case 'telophase1': this._drawTelophase(cx, cy, cellR, nucR, t, true, false); break;
case 'telophase2': this._drawTelophase(cx, cy, cellR, nucR, t, false, true); break;
case 'cytokinesis': this._drawCytokinesis(cx, cy, cellR, nucR, t); break;
}
this._drawParticles();
this._drawOverlay(phase);
this._drawProgressBar();
this._drawHint();
}
/* ── Cell / nucleus ─────────────────────────────────────────── */
_cellPath(cx, cy, rx, ry, wobble) {
const ctx = this.ctx, N = 48;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const a = (i / N) * Math.PI * 2;
const w = (wobble || 0) * (Math.sin(a * 3 + this._time * 0.00075) * 0.6 +
Math.sin(a * 5 + this._time * 0.00055) * 0.4);
ctx.lineTo(cx + Math.cos(a) * (rx + rx * w),
cy + Math.sin(a) * ((ry || rx * 0.88) + (ry || rx * 0.88) * w));
}
ctx.closePath();
}
_drawCell(cx, cy, rx, ry, wobble, alpha) {
const ctx = this.ctx, C = CellDivisionSim.C;
ctx.save();
ctx.globalAlpha = alpha !== undefined ? alpha : 1;
this._cellPath(cx, cy, rx, ry, wobble !== undefined ? wobble : 0.013);
ctx.shadowColor = C.cellStr; ctx.shadowBlur = 20;
ctx.fillStyle = C.cell; ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = C.cellStr; ctx.lineWidth = 2.2;
ctx.globalAlpha *= 0.65; ctx.stroke();
ctx.restore();
}
_drawNucleus(cx, cy, rx, ry, alpha) {
const ctx = this.ctx, C = CellDivisionSim.C;
ctx.save(); ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry || rx * 0.9, 0, 0, Math.PI * 2);
ctx.shadowColor = C.nucStr; ctx.shadowBlur = 16;
ctx.fillStyle = C.nucFill; ctx.fill();
ctx.shadowBlur = 0; ctx.setLineDash([3, 3]);
ctx.strokeStyle = C.nucStr; ctx.lineWidth = 1.6; ctx.stroke();
ctx.setLineDash([]); ctx.restore();
}
/* ── Chromosome ─────────────────────────────────────────────── */
_drawChromosome(x, y, size, angle, color, sister, alpha) {
const ctx = this.ctx;
ctx.save();
if (alpha !== undefined) ctx.globalAlpha = alpha;
ctx.translate(x, y); ctx.rotate(angle);
const aw = size * 0.20, ah = size * 0.50, gap = size * 0.11;
const offsets = sister ? [-gap, gap] : [0];
ctx.shadowColor = color; ctx.shadowBlur = 10;
for (const ox of offsets) {
ctx.save(); ctx.translate(ox, 0);
ctx.beginPath();
ctx.moveTo(0, -gap * 0.5);
ctx.bezierCurveTo(-aw * 1.1, -ah * 0.35, -aw * 1.3, -ah * 0.75, 0, -ah);
ctx.bezierCurveTo( aw * 1.3, -ah * 0.75, aw * 1.1, -ah * 0.35, 0, -gap * 0.5);
ctx.moveTo(0, gap * 0.5);
ctx.bezierCurveTo(-aw * 1.1, ah * 0.35, -aw * 1.3, ah * 0.75, 0, ah);
ctx.bezierCurveTo( aw * 1.3, ah * 0.75, aw * 1.1, ah * 0.35, 0, gap * 0.5);
ctx.fillStyle = color; ctx.fill();
ctx.restore();
}
ctx.shadowColor = '#fff'; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.arc(0, 0, size * 0.12, 0, Math.PI * 2);
ctx.fillStyle = '#fff'; ctx.fill();
ctx.restore();
}
_chrPairs(cx, cy, r) {
const ch = CellDivisionSim.C.ch;
return [
{ x: cx - r * 0.50, y: cy - r * 0.28, angle: -0.20, color: ch[0] },
{ x: cx - r * 0.15, y: cy - r * 0.35, angle: 0.15, color: ch[1] },
{ x: cx + r * 0.28, y: cy - r * 0.20, angle: 0.28, color: ch[2] },
{ x: cx - r * 0.38, y: cy + r * 0.22, angle: 0.18, color: ch[3] },
{ x: cx + r * 0.12, y: cy + r * 0.32, angle: -0.22, color: ch[4] },
{ x: cx + r * 0.48, y: cy + r * 0.18, angle: -0.10, color: ch[5] },
];
}
_chrPairsHaploid(cx, cy, r) {
const ch = CellDivisionSim.C.ch;
return [
{ x: cx - r * 0.36, y: cy - r * 0.24, angle: -0.18, color: ch[0] },
{ x: cx + r * 0.08, y: cy - r * 0.08, angle: 0.12, color: ch[2] },
{ x: cx + r * 0.32, y: cy + r * 0.26, angle: 0.28, color: ch[4] },
];
}
/* ── Spindle ────────────────────────────────────────────────── */
_drawSpindle(cx, cy, cellR, alpha, chrs) {
const ctx = this.ctx, C = CellDivisionSim.C;
ctx.save(); ctx.globalAlpha = alpha;
const poleY = cellR * 0.72;
const poles = [{ x: cx, y: cy - poleY }, { x: cx, y: cy + poleY }];
// aster rays
for (const pole of poles) {
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2;
ctx.beginPath(); ctx.moveTo(pole.x, pole.y);
ctx.lineTo(pole.x + Math.cos(a) * 16, pole.y + Math.sin(a) * 16);
ctx.strokeStyle = 'rgba(255,214,0,0.28)'; ctx.lineWidth = 0.8; ctx.stroke();
}
}
// fibers
if (chrs) {
for (const ch of chrs) {
for (const pole of poles) {
ctx.beginPath(); ctx.moveTo(pole.x, pole.y);
ctx.quadraticCurveTo(cx + (ch.x - cx) * 0.18, (pole.y + ch.y) / 2, ch.x, ch.y);
ctx.strokeStyle = C.spindle; ctx.lineWidth = 0.9; ctx.stroke();
}
}
}
// pole dots
for (const p of poles) {
ctx.shadowColor = C.pole; ctx.shadowBlur = 14;
ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
ctx.fillStyle = C.pole; ctx.fill();
}
ctx.restore();
}
/* ── Phase renderers ────────────────────────────────────────── */
_drawInterphase(cx, cy, cellR, nucR, t) {
const ctx = this.ctx, C = CellDivisionSim.C;
const te = this._ease(t);
this._drawCell(cx, cy, cellR);
this._drawNucleus(cx, cy, nucR, nucR * 0.9, 1);
const dots = this._chromatinDots;
ctx.save();
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const pulse = 0.35 + 0.25 * Math.sin(d.phase + this._time * 0.0015);
ctx.globalAlpha = pulse;
ctx.beginPath(); ctx.arc(d.x, d.y, d.size * 0.72, 0, Math.PI * 2);
ctx.shadowColor = C.chromatin; ctx.shadowBlur = 5;
ctx.fillStyle = C.chromatin; ctx.fill();
// replication copies
if (te > 0.45 && i < Math.floor(((te - 0.45) / 0.55) * dots.length)) {
ctx.globalAlpha = ((te - 0.45) / 0.55) * 0.5;
ctx.beginPath(); ctx.arc(d.x + 3.5, d.y + 2, d.size * 0.62, 0, Math.PI * 2);
ctx.shadowColor = '#FFD166'; ctx.fillStyle = '#FFD166'; ctx.fill();
}
}
ctx.restore();
if (t > 0.42) {
const a = Math.min(1, (t - 0.42) * 7);
ctx.save(); ctx.globalAlpha = a;
ctx.font = 'bold 11px Manrope,sans-serif';
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
ctx.fillText('S-период: репликация ДНК', cx, cy + nucR + 26);
ctx.restore();
}
}
_drawProphase(cx, cy, cellR, nucR, t, isMeiosis1) {
const ctx = this.ctx, C = CellDivisionSim.C;
const te = this._ease(t);
this._drawCell(cx, cy, cellR);
this._drawNucleus(cx, cy, nucR, nucR * 0.9, 1 - te * 0.95);
if (te > 0.28) {
this._drawSpindle(cx, cy, cellR, (te - 0.28) / 0.72 * 0.6);
}
const chrs = this._chrPairs(cx, cy, cellR * 0.27);
const size = 11 + te * 15;
if (isMeiosis1) {
for (let i = 0; i < chrs.length; i++) {
const ch = chrs[i];
const off = (1 - te) * 9 * (i % 2 === 0 ? 1 : -1);
this._drawChromosome(ch.x + off, ch.y, size, ch.angle, ch.color, true);
if (te > 0.52) {
const ca = (te - 0.52) / 0.48;
ctx.save(); ctx.globalAlpha = ca * 0.88;
ctx.shadowColor = C.crossing; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(ch.x, ch.y, size * 0.55, 0, Math.PI * 2);
ctx.strokeStyle = C.crossing; ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
}
}
if (te > 0.52) {
const ca = (te - 0.52) / 0.48;
ctx.save(); ctx.globalAlpha = ca;
ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
ctx.font = 'bold 11px Manrope,sans-serif';
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center';
ctx.fillText('Кроссинговер — рекомбинация', cx, cy + cellR * 0.72);
ctx.restore();
}
} else {
for (const ch of chrs) this._drawChromosome(ch.x, ch.y, size, ch.angle, ch.color, true);
}
}
_drawProphase2(cx, cy, cellR, nucR, t) {
const te = this._ease(t);
this._drawCell(cx, cy, cellR * 0.9);
this._drawNucleus(cx, cy, nucR * 0.75, nucR * 0.67, 1 - te * 0.85);
const chrs = this._chrPairsHaploid(cx, cy, cellR * 0.22);
const size = 14 + te * 9;
if (te > 0.3) this._drawSpindle(cx, cy, cellR * 0.9, (te - 0.3) / 0.7 * 0.55);
for (const ch of chrs) this._drawChromosome(ch.x, ch.y, size, ch.angle, ch.color, true);
}
_drawMetaphase(cx, cy, cellR, t, isMeiosis1, isHaploid) {
const ctx = this.ctx, C = CellDivisionSim.C;
const te = this._ease(t);
this._drawCell(cx, cy, cellR);
ctx.save();
ctx.setLineDash([5, 5]);
ctx.strokeStyle = `rgba(255,255,255,${0.14 + te * 0.12})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx - cellR * 0.82, cy); ctx.lineTo(cx + cellR * 0.82, cy);
ctx.stroke(); ctx.setLineDash([]); ctx.restore();
const ch = C.ch;
const sp = cellR * 0.55;
if (isHaploid) {
const pos = [
{ x: cx - sp * 0.42, y: cy, angle: -0.05 },
{ x: cx, y: cy, angle: 0.0 },
{ x: cx + sp * 0.42, y: cy, angle: 0.05 },
];
this._drawSpindle(cx, cy, cellR, 0.75 + te * 0.25, pos);
for (let i = 0; i < pos.length; i++)
this._drawChromosome(pos[i].x, pos[i].y, 21, pos[i].angle, ch[i * 2], true);
} else if (isMeiosis1) {
const pos = [
{ x: cx - sp * 0.54, y: cy, angle: -0.08 }, { x: cx - sp * 0.17, y: cy, angle: 0.04 },
{ x: cx + sp * 0.17, y: cy, angle: -0.04 }, { x: cx + sp * 0.54, y: cy, angle: 0.08 },
{ x: cx - sp * 0.35, y: cy, angle: 0.12 }, { x: cx + sp * 0.35, y: cy, angle: -0.12 },
];
this._drawSpindle(cx, cy, cellR, 0.8 + te * 0.2, pos);
for (let i = 0; i < pos.length; i++) {
this._drawChromosome(pos[i].x - 5, pos[i].y, 19, pos[i].angle, ch[i % 6], true);
this._drawChromosome(pos[i].x + 5, pos[i].y, 19, pos[i].angle + 0.25, ch[(i + 3) % 6], true);
}
} else {
const pos = [
{ x: cx - sp * 0.54, y: cy, angle: -0.08 }, { x: cx - sp * 0.28, y: cy, angle: 0.04 },
{ x: cx - sp * 0.04, y: cy, angle: -0.02 }, { x: cx + sp * 0.04, y: cy, angle: 0.02 },
{ x: cx + sp * 0.28, y: cy, angle: -0.04 }, { x: cx + sp * 0.54, y: cy, angle: 0.08 },
];
this._drawSpindle(cx, cy, cellR, 0.8 + te * 0.2, pos);
for (let i = 0; i < 6; i++)
this._drawChromosome(pos[i].x, pos[i].y, 22, pos[i].angle, ch[i], true);
}
}
_drawAnaphase(cx, cy, cellR, t, isMeiosis1, isHaploid) {
const ctx = this.ctx, C = CellDivisionSim.C;
const te = this._ease(t);
const stretchY = 1 + te * 0.38;
ctx.save();
ctx.translate(cx, cy); ctx.scale(1, stretchY); ctx.translate(-cx, -cy);
this._drawCell(cx, cy, cellR * (1 - te * 0.04));
ctx.restore();
const poleOffset = cellR * 0.7 * te;
const topY = cy - poleOffset, botY = cy + poleOffset;
this._drawSpindle(cx, cy, cellR, 1 - te * 0.3);
const ch = C.ch, sp = cellR * (isHaploid ? 0.32 : 0.44);
const n = isHaploid ? 3 : 6;
for (let i = 0; i < n; i++) {
const ox = (i - (n - 1) / 2) * sp * (isHaploid ? 0.5 : 0.34);
const ang = (i - (n - 1) / 2) * 0.07;
if (isMeiosis1) {
this._drawChromosome(cx + ox, topY, 20, ang, ch[i], true);
this._drawChromosome(cx + ox, botY, 20, ang, ch[(i + 3) % 6], true);
} else {
const ci = isHaploid ? i * 2 : i;
this._drawChromosome(cx + ox, topY, isHaploid ? 17 : 18, ang, ch[ci % 6], false);
this._drawChromosome(cx + ox, botY, isHaploid ? 17 : 18, ang, ch[ci % 6], false);
}
}
}
_drawTelophase(cx, cy, cellR, nucR, t, isMeiosis1, isHaploid) {
const ctx = this.ctx, C = CellDivisionSim.C;
const te = this._ease(t);
const sep = cellR * 0.44;
ctx.save();
ctx.translate(cx, cy); ctx.scale(1, 1.30 - te * 0.12); ctx.translate(-cx, -cy);
this._drawCell(cx, cy, cellR);
ctx.restore();
for (const s of [-1, 1])
this._drawNucleus(cx, cy + s * sep, nucR * 0.72, nucR * 0.65, te);
const ch = C.ch, size = 20 * (1 - te * 0.78);
const alpha = 1 - te * 0.88, sp = cellR * (isHaploid ? 0.22 : 0.34);
const n = isHaploid ? 3 : 6;
for (let i = 0; i < n; i++) {
const ox = (i - (n - 1) / 2) * sp * (isHaploid ? 0.5 : 0.26);
const ci = isHaploid ? i * 2 : i;
ctx.save(); ctx.globalAlpha = alpha;
if (isMeiosis1) {
this._drawChromosome(cx + ox, cy - sep, size, 0, ch[ci % 6], true);
this._drawChromosome(cx + ox, cy + sep, size, 0, ch[(ci + 3) % 6], true);
} else {
this._drawChromosome(cx + ox, cy - sep, size, 0, ch[ci % 6], false);
this._drawChromosome(cx + ox, cy + sep, size, 0, ch[ci % 6], false);
}
ctx.restore();
}
if (te > 0.45) {
const fa = (te - 0.45) / 0.55;
ctx.save(); ctx.globalAlpha = fa * 0.6;
ctx.setLineDash([7, 5]);
ctx.shadowColor = C.furrow; ctx.shadowBlur = 10;
ctx.strokeStyle = C.furrow; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx - cellR * 0.7, cy); ctx.lineTo(cx + cellR * 0.7, cy);
ctx.stroke(); ctx.setLineDash([]); ctx.restore();
}
}
_drawCytokinesis(cx, cy, cellR, nucR, t) {
const ctx = this.ctx, C = CellDivisionSim.C;
const te = this._ease(t);
if (te < 0.80) {
const pinch = te;
ctx.save();
ctx.beginPath();
for (let i = 0; i <= 48; i++) {
const a = (i / 48) * Math.PI * 2;
const s2 = Math.abs(Math.cos(a));
const waist = 1 - pinch * Math.max(0, 1 - s2 * 2.2) * 0.90;
ctx.lineTo(cx + Math.cos(a) * cellR * waist,
cy + Math.sin(a) * cellR * 0.88 * (1 + pinch * 0.09));
}
ctx.closePath();
ctx.shadowColor = C.cellStr; ctx.shadowBlur = 16;
ctx.fillStyle = C.cell; ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = C.cellStr; ctx.lineWidth = 2.2;
ctx.globalAlpha = 0.68; ctx.stroke(); ctx.restore();
ctx.save(); ctx.globalAlpha = pinch * 0.85;
ctx.shadowColor = C.furrow; ctx.shadowBlur = 14;
ctx.strokeStyle = C.furrow; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(cx - cellR * (1 - pinch * 0.92), cy);
ctx.lineTo(cx + cellR * (1 - pinch * 0.92), cy);
ctx.stroke(); ctx.restore();
} else {
const appear = (te - 0.80) / 0.20;
const sepY = cellR * 0.52;
for (const s of [-1, 1])
this._drawCell(cx, cy + s * sepY * 0.54, cellR * 0.65, cellR * 0.57, 0.01, 0.5 + appear * 0.5);
}
const sep = cellR * 0.50;
for (const s of [-1, 1])
this._drawNucleus(cx, cy + s * sep * 0.52, nucR * 0.68, nucR * 0.61, Math.min(1, te * 1.5));
if (te > 0.72) {
const a = (te - 0.72) / 0.28;
ctx.save(); ctx.globalAlpha = a;
ctx.shadowColor = '#22d399'; ctx.shadowBlur = 12;
ctx.font = 'bold 12px Manrope,sans-serif';
ctx.fillStyle = '#22d399'; ctx.textAlign = 'center';
ctx.fillText(
this.mode === 'meiosis' ? '4 гаплоидные клетки (n = 23)' : '2 диплоидные клетки (2n = 46)',
cx, cy + cellR * 0.88);
ctx.restore();
}
}
/* ── Particles ──────────────────────────────────────────────── */
_drawParticles() {
const ctx = this.ctx;
for (const p of this._particles) {
ctx.save();
ctx.globalAlpha = p.life * 0.82;
ctx.shadowColor = p.color; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = p.color; ctx.fill();
ctx.restore();
}
}
/* ── UI overlays ────────────────────────────────────────────── */
_drawOverlay(phase) {
const ctx = this.ctx;
const { W, H } = this;
// phase name pill — top right
ctx.save();
ctx.font = 'bold 14px Manrope,sans-serif';
const tw = ctx.measureText(phase.label).width;
_cdRRect(ctx, W - tw - 30, 12, tw + 22, 28, 8);
ctx.shadowColor = '#22d399'; ctx.shadowBlur = 14;
ctx.fillStyle = 'rgba(34,211,153,0.12)'; ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(34,211,153,0.38)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = '#22d399'; ctx.textAlign = 'left';
ctx.fillText(phase.label, W - tw - 19, 30);
ctx.restore();
// chromN + DNA — bottom left
ctx.save();
ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(6,214,224,0.78)';
ctx.fillText('n: ' + phase.chromN, 14, H - 46);
ctx.fillStyle = 'rgba(255,214,0,0.78)';
ctx.fillText('ДНК: ' + phase.dna, 14, H - 32);
ctx.restore();
// description — bottom right
ctx.save();
ctx.font = '10.5px Manrope,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.36)';
ctx.textAlign = 'right';
const maxW = W * 0.5;
const words = phase.desc.split(' ');
let line = '', lines = [];
for (const w of words) {
const test = line + (line ? ' ' : '') + w;
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = w; }
else line = test;
}
if (line) lines.push(line);
lines.forEach((l, i) => ctx.fillText(l, W - 14, H - 46 + i * 14));
ctx.restore();
}
_drawProgressBar() {
const ctx = this.ctx;
const { W, H } = this;
const C = CellDivisionSim.C;
const phases = this._phases();
const bx = 14, bw = W - 28, by = H - 14, bh = 4;
const total = (this._phaseIdx + this._phaseT) / phases.length;
_cdRRect(ctx, bx, by - bh / 2, bw, bh, 2);
ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill();
if (total > 0) {
_cdRRect(ctx, bx, by - bh / 2, bw * total, bh, 2);
ctx.shadowColor = C.progress; ctx.shadowBlur = 8;
ctx.fillStyle = C.progress; ctx.fill(); ctx.shadowBlur = 0;
}
// phase tick marks
for (let i = 1; i < phases.length; i++) {
const tx = bx + bw * (i / phases.length);
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.fillRect(tx - 0.5, by - bh, 1, bh * 2);
}
// status
ctx.save();
ctx.font = '10px Manrope,sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.textAlign = 'left';
ctx.fillText(this._autoPlay ? '> авто' : '|| пауза', bx, H - 22);
ctx.restore();
}
_drawHint() {
if (this._time >= 4500) return;
const a = Math.min(1, this._time / 600) * Math.max(0, 1 - (this._time - 3200) / 1300);
const ctx = this.ctx;
ctx.save();
ctx.globalAlpha = a * 0.42;
ctx.font = '10.5px Manrope,sans-serif';
ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
ctx.fillText('Клик — следующая фаза · тяни полосу внизу · Space — пауза', this.W / 2, this.H - 26);
ctx.restore();
}
}
function _cdRRect(ctx, x, y, w, h, r) {
if (w <= 0 || h <= 0) return;
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+748
View File
@@ -0,0 +1,748 @@
'use strict';
/* ══════════════════════════════════════════════════════════
CoulombSim — Coulomb's Law interactive simulation
• Click canvas to place charge (+ or )
• Drag to reposition, double-click / right-click to remove
• Layers: colormap, field lines, vector arrows,
equipotentials, force arrows
Electric field of point charge q at (cx,cy):
Ex = K·q·(x-cx)/r³, Ey = K·q·(y-cy)/r³
Potential: V = K·q/r
══════════════════════════════════════════════════════════ */
class CoulombSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.charges = []; // [{x, y, q, id}]
this._nextId = 0;
this.addSign = +1;
/* layers */
this.layers = {
colormap: true,
fieldlines: true,
vectors: false,
equipotentials: true,
forces: false,
};
/* interaction */
this._drag = null; // charge index being dragged
this._hovered = null; // charge index under mouse
this._downPos = null; // mousedown position for click vs drag detection
this._mousePos = null; // {x, y}
/* colormap cache */
this._cmDirty = true;
this._cmCache = null; // ImageData
/* cursor reading */
this._cursorE = null; // {ex, ey, mag, v}
/* visual Coulomb constant */
this.K = 60000;
/* dimensions */
this.W = 0;
this.H = 0;
/* callback */
this.onUpdate = null;
this._bindEvents();
}
/* ── Resize ─────────────────────────────────────────────── */
fit() {
this.W = this.canvas.offsetWidth;
this.H = this.canvas.offsetHeight;
this.canvas.width = this.W * devicePixelRatio;
this.canvas.height = this.H * devicePixelRatio;
this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
this._cmDirty = true;
this._cmCache = null;
this.draw();
}
/* ── Reset ──────────────────────────────────────────────── */
reset() {
this.charges = [];
this._nextId = 0;
this._cmDirty = true;
this._cmCache = null;
this._drag = null;
this._hovered = null;
}
/* ── Charge management ──────────────────────────────────── */
addCharge(x, y, q) {
this.charges.push({ x, y, q, id: this._nextId++ });
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
removeCharge(i) {
if (i < 0 || i >= this.charges.length) return;
this.charges.splice(i, 1);
this._cmDirty = true;
this._drag = null;
this._hovered = null;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
toggleLayer(name) {
if (name in this.layers) {
this.layers[name] = !this.layers[name];
this.draw();
}
}
setSign(s) {
this.addSign = s >= 0 ? +1 : -1;
}
/* ── Presets ────────────────────────────────────────────── */
preset(name) {
this.reset();
const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2;
if (name === 'dipole') {
this.addCharge(cx - d, cy, 1);
this.addCharge(cx + d, cy, -1);
} else if (name === 'equal') {
this.addCharge(cx - d, cy, 1);
this.addCharge(cx + d, cy, 1);
} else if (name === 'quadrupole') {
this.addCharge(cx - d, cy - d, 1);
this.addCharge(cx + d, cy - d, -1);
this.addCharge(cx + d, cy + d, 1);
this.addCharge(cx - d, cy + d, -1);
} else if (name === 'ring') {
for (let i = 0; i < 6; i++) {
const a = i * Math.PI / 3;
this.addCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1);
}
}
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── Info ───────────────────────────────────────────────── */
info() {
const pos = this.charges.filter(c => c.q > 0).length;
const neg = this.charges.filter(c => c.q < 0).length;
let maxE = 0;
for (let x = 20; x < this.W; x += 40)
for (let y = 20; y < this.H; y += 40) {
const f = this._fieldAt(x, y);
if (f.mag > maxE) maxE = f.mag;
}
const ce = this._cursorE;
return {
total: this.charges.length,
positive: pos,
negative: neg,
maxE: maxE.toFixed(0),
cursorE: ce ? ce.mag.toFixed(0) : '—',
cursorV: ce ? ce.v.toFixed(0) : '—',
};
}
/* ── Physics ────────────────────────────────────────────── */
_fieldAt(x, y) {
let ex = 0, ey = 0, v = 0;
for (const c of this.charges) {
const dx = x - c.x, dy = y - c.y;
const r2 = dx * dx + dy * dy;
if (r2 < 1) continue;
const r = Math.sqrt(r2);
const r3 = r2 * r;
ex += this.K * c.q * dx / r3;
ey += this.K * c.q * dy / r3;
v += this.K * c.q / r;
}
return { ex, ey, mag: Math.hypot(ex, ey), v };
}
/* ── HSL <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> RGB helper ───────────────────────────────────── */
_hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
s /= 100; l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
}
/* ── Colormap ───────────────────────────────────────────── */
_drawColormap(ctx) {
const W = this.W, H = this.H;
const STEP = 3;
if (this._cmDirty || !this._cmCache) {
const imgW = Math.ceil(W / STEP);
const imgH = Math.ceil(H / STEP);
const img = ctx.createImageData(imgW, imgH);
const d = img.data;
for (let py = 0; py < imgH; py++) {
for (let px = 0; px < imgW; px++) {
const x = px * STEP + STEP / 2;
const y = py * STEP + STEP / 2;
const { mag, v } = this._fieldAt(x, y);
/* hue based on potential sign */
let hue;
if (v > 0) hue = 0 + (v / (v + 30000)) * 30; // 030 red-orange
else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220240 blue
else hue = 0;
const sat = 80;
const lit = Math.tanh(mag / 3000) * 40
+ Math.tanh(Math.abs(v) / 50000) * 25;
const [r, g, b] = this._hslToRgb(hue, sat, lit);
const idx = (py * imgW + px) * 4;
d[idx] = r;
d[idx + 1] = g;
d[idx + 2] = b;
d[idx + 3] = 200;
}
}
this._cmCache = { img, imgW, imgH, STEP };
this._cmDirty = false;
}
const { img, imgW, imgH } = this._cmCache;
/* draw scaled up */
const oc = document.createElement('canvas');
oc.width = imgW;
oc.height = imgH;
oc.getContext('2d').putImageData(img, 0, 0);
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'medium';
ctx.drawImage(oc, 0, 0, W, H);
ctx.restore();
}
/* ── Equipotentials ─────────────────────────────────────── */
_drawEquipotentials(ctx) {
const W = this.W, H = this.H;
const GRID = 8;
const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000];
const cols = Math.ceil(W / GRID) + 1;
const rows = Math.ceil(H / GRID) + 1;
/* build V grid */
const vGrid = new Float64Array(cols * rows);
for (let r = 0; r < rows; r++)
for (let c = 0; c < cols; c++)
vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.lineWidth = 0.8;
ctx.setLineDash([4, 4]);
ctx.beginPath();
for (const level of LEVELS) {
for (let r = 0; r < rows - 1; r++) {
for (let c = 0; c < cols - 1; c++) {
const v00 = vGrid[ r * cols + c ];
const v10 = vGrid[ r * cols + c + 1];
const v01 = vGrid[(r + 1) * cols + c ];
const v11 = vGrid[(r + 1) * cols + c + 1];
/* crossed edges: top, right, bottom, left */
const pts = [];
const interp = (va, vb, xa, ya, xb, yb) => {
const t = (level - va) / (vb - va);
return [xa + t * (xb - xa), ya + t * (yb - ya)];
};
if ((v00 - level) * (v10 - level) < 0)
pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID));
if ((v10 - level) * (v11 - level) < 0)
pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID));
if ((v01 - level) * (v11 - level) < 0)
pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID));
if ((v00 - level) * (v01 - level) < 0)
pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID));
if (pts.length >= 2) {
ctx.moveTo(pts[0][0], pts[0][1]);
ctx.lineTo(pts[1][0], pts[1][1]);
}
}
}
}
ctx.stroke();
ctx.restore();
}
/* ── Vector arrows ──────────────────────────────────────── */
_drawVectors(ctx) {
const GRID = 45;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
for (let x = GRID / 2; x < this.W; x += GRID) {
for (let y = GRID / 2; y < this.H; y += GRID) {
const { ex, ey, mag } = this._fieldAt(x, y);
if (mag < 1e-6) continue;
const len = Math.tanh(mag / 8000) * 18;
const nx = ex / mag, ny = ey / mag;
const x2 = x + nx * len, y2 = y + ny * len;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
/* arrowhead */
const ax = -ny * 3, ay = nx * 3;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx * 6 + ax, y2 - ny * 6 + ay);
ctx.lineTo(x2 - nx * 6 - ax, y2 - ny * 6 - ay);
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
}
/* ── Field lines ────────────────────────────────────────── */
_drawFieldLines(ctx) {
const W = this.W, H = this.H, sim = this;
const RAYS = 12;
const STEP = 2.5;
const MAX = 2500;
const MARGIN = 5;
const HIT_R = 12;
const START_R = 18;
function rkStep(x, y, h) {
const f = (px, py) => {
const e = sim._fieldAt(px, py);
const m = Math.hypot(e.ex, e.ey) || 1e-10;
return [e.ex / m, e.ey / m];
};
const [k1x, k1y] = f(x, y);
const [k2x, k2y] = f(x + h * k1x / 2, y + h * k1y / 2);
const [k3x, k3y] = f(x + h * k2x / 2, y + h * k2y / 2);
const [k4x, k4y] = f(x + h * k3x, y + h * k3y);
return [
x + h * (k1x + 2 * k2x + 2 * k3x + k4x) / 6,
y + h * (k1y + 2 * k2y + 2 * k3y + k4y) / 6,
];
}
const traceLine = (startX, startY, dir) => {
const pts = [[startX, startY]];
let px = startX, py = startY;
for (let s = 0; s < MAX; s++) {
const [nx, ny] = rkStep(px, py, dir * STEP);
if (nx < -MARGIN || nx > W + MARGIN || ny < -MARGIN || ny > H + MARGIN) break;
/* stop near negative charges */
let hitNeg = false;
for (const c of sim.charges) {
if (c.q < 0 && Math.hypot(nx - c.x, ny - c.y) < HIT_R) { hitNeg = true; break; }
}
if (hitNeg) break;
pts.push([nx, ny]);
px = nx; py = ny;
}
return pts;
};
ctx.save();
ctx.lineWidth = 1.2;
for (const charge of this.charges) {
const dir = charge.q > 0 ? 1 : -1;
for (let i = 0; i < RAYS; i++) {
const angle = (i / RAYS) * Math.PI * 2;
const sx = charge.x + START_R * Math.cos(angle);
const sy = charge.y + START_R * Math.sin(angle);
const pts = traceLine(sx, sy, dir);
if (pts.length < 2) continue;
const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length - 1][0], pts[pts.length - 1][1]);
grad.addColorStop(0, 'rgba(255,255,255,0.75)');
grad.addColorStop(0.5, 'rgba(255,255,255,0.35)');
grad.addColorStop(1, 'rgba(255,255,255,0.0)');
ctx.strokeStyle = grad;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.stroke();
}
}
ctx.restore();
}
/* ── Force arrows ───────────────────────────────────────── */
_drawForceArrows(ctx) {
ctx.save();
for (let i = 0; i < this.charges.length; i++) {
const ci = this.charges[i];
let fx = 0, fy = 0;
for (let j = 0; j < this.charges.length; j++) {
if (i === j) continue;
const cj = this.charges[j];
const dx = ci.x - cj.x, dy = ci.y - cj.y;
const r2 = dx * dx + dy * dy;
if (r2 < 1) continue;
const r3 = r2 * Math.sqrt(r2);
const F = this.K * ci.q * cj.q;
fx += F * dx / r3;
fy += F * dy / r3;
}
const mag = Math.hypot(fx, fy);
if (mag < 1e-6) continue;
const len = Math.tanh(mag / 50000) * 55;
const nx = fx / mag, ny = fy / mag;
const x2 = ci.x + nx * len, y2 = ci.y + ny * len;
ctx.strokeStyle = '#FFD166';
ctx.fillStyle = '#FFD166';
ctx.lineWidth = 2;
ctx.shadowBlur = 10;
ctx.shadowColor = '#FFD166';
ctx.beginPath();
ctx.moveTo(ci.x, ci.y);
ctx.lineTo(x2, y2);
ctx.stroke();
/* arrowhead */
const ax = -ny * 5, ay = nx * 5;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx * 10 + ax, y2 - ny * 10 + ay);
ctx.lineTo(x2 - nx * 10 - ax, y2 - ny * 10 - ay);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.restore();
}
/* ── Draw charges ───────────────────────────────────────── */
_drawCharges(ctx) {
for (let i = 0; i < this.charges.length; i++) {
const c = this.charges[i];
const r = 14 + Math.tanh(Math.abs(c.q) / 5) * 4;
const pos = c.q > 0;
ctx.save();
ctx.shadowBlur = 18;
ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0';
/* hovered outer ring */
if (this._hovered === i) {
ctx.beginPath();
ctx.arc(c.x, c.y, r + 6, 0, Math.PI * 2);
ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)';
ctx.lineWidth = 2;
ctx.stroke();
}
/* body gradient */
const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.y, r);
if (pos) {
grd.addColorStop(0, '#FF7FA3');
grd.addColorStop(1, '#EF476F');
} else {
grd.addColorStop(0, '#90E0FF');
grd.addColorStop(1, '#4CC9F0');
}
ctx.beginPath();
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.fill();
/* label */
ctx.shadowBlur = 0;
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(pos ? '+' : '', c.x, c.y + 1);
ctx.restore();
}
}
/* ── Cursor E display ───────────────────────────────────── */
_drawCursorE(ctx) {
const { ex, ey, mag, v } = this._cursorE;
const { x, y } = this._mousePos;
if (mag < 1e-6) return;
const nx = ex / mag, ny = ey / mag;
const len = 20;
const x2 = x + nx * len, y2 = y + ny * len;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.8)';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
const ax = -ny * 4, ay = nx * 4;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - nx * 8 + ax, y2 - ny * 8 + ay);
ctx.lineTo(x2 - nx * 8 - ax, y2 - ny * 8 - ay);
ctx.closePath();
ctx.fill();
/* text */
const eStr = mag >= 1000 ? (mag / 1000).toFixed(1) + 'k' : mag.toFixed(0);
const vStr = Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0);
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.shadowBlur = 4;
ctx.shadowColor = '#000';
ctx.fillText(`|E| = ${eStr}`, x + 6, y - 14);
ctx.fillText(`V = ${vStr}`, x + 6, y - 2);
ctx.restore();
}
/* ── Hint ───────────────────────────────────────────────── */
_drawHint(ctx) {
const W = this.W, H = this.H;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '16px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30);
/* simple circle icon */
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2);
ctx.stroke();
ctx.font = 'bold 22px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.fillText('+', W / 2, H / 2 - 13);
ctx.restore();
}
/* ── Main draw ──────────────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.clearRect(0, 0, W, H);
/* 1. background radial gradient */
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2);
bg.addColorStop(0, '#0D0D1A');
bg.addColorStop(1, '#050508');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
/* 2. subtle grid */
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.025)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
ctx.restore();
if (this.charges.length > 0) {
/* 3. colormap */
if (this.layers.colormap) this._drawColormap(ctx);
/* 4. equipotentials */
if (this.layers.equipotentials) this._drawEquipotentials(ctx);
/* 5. vectors */
if (this.layers.vectors) this._drawVectors(ctx);
/* 6. field lines */
if (this.layers.fieldlines) this._drawFieldLines(ctx);
/* 7. force arrows */
if (this.layers.forces) this._drawForceArrows(ctx);
}
/* 8. charges */
this._drawCharges(ctx);
/* 9. cursor E */
if (this._cursorE && this._mousePos && this.charges.length > 0)
this._drawCursorE(ctx);
/* 10. hint if empty */
if (this.charges.length === 0) this._drawHint(ctx);
}
/* ── Events ─────────────────────────────────────────────── */
_bindEvents() {
const canvas = this.canvas;
const pos = e => {
const r = canvas.getBoundingClientRect();
const s = e.touches ? e.touches[0] : e;
return { x: s.clientX - r.left, y: s.clientY - r.top };
};
const hitIdx = p => {
for (let i = this.charges.length - 1; i >= 0; i--)
if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i;
return -1;
};
/* ── mousedown ── */
canvas.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const p = pos(e);
const hi = hitIdx(p);
this._downPos = p;
if (hi >= 0) this._drag = hi;
});
/* ── mousemove ── */
canvas.addEventListener('mousemove', e => {
const p = pos(e);
this._mousePos = p;
if (this._drag !== null) {
this.charges[this._drag].x = p.x;
this.charges[this._drag].y = p.y;
this._cmDirty = true;
this._cursorE = this._fieldAt(p.x, p.y);
this.draw();
} else {
this._hovered = hitIdx(p);
this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null;
this.draw();
}
});
/* ── mouseup ── */
canvas.addEventListener('mouseup', e => {
if (e.button !== 0) return;
const p = pos(e);
const wasDragging = this._drag !== null;
if (wasDragging) {
this._drag = null;
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
return;
}
/* click (no drag) */
const dp = this._downPos || p;
const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
if (dist < 5) {
const hi = hitIdx(p);
if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
}
if (this.onUpdate) this.onUpdate(this.info());
});
/* ── contextmenu (remove) ── */
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const p = pos(e);
const hi = hitIdx(p);
if (hi >= 0) this.removeCharge(hi);
});
/* ── dblclick (remove) ── */
canvas.addEventListener('dblclick', e => {
const p = pos(e);
const hi = hitIdx(p);
if (hi >= 0) this.removeCharge(hi);
});
/* ── mouseleave ── */
canvas.addEventListener('mouseleave', () => {
this._cursorE = null;
this._mousePos = null;
this._hovered = null;
this.draw();
});
/* ── touch support ── */
canvas.addEventListener('touchstart', e => {
e.preventDefault();
const p = pos(e);
const hi = hitIdx(p);
this._downPos = p;
if (hi >= 0) this._drag = hi;
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const p = pos(e);
this._mousePos = p;
if (this._drag !== null) {
this.charges[this._drag].x = p.x;
this.charges[this._drag].y = p.y;
this._cmDirty = true;
this._cursorE = this._fieldAt(p.x, p.y);
this.draw();
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
const wasDragging = this._drag !== null;
if (wasDragging) {
this._drag = null;
this._cmDirty = true;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
return;
}
const p = pos({ touches: e.changedTouches });
const dp = this._downPos || p;
const dist = Math.hypot(p.x - dp.x, p.y - dp.y);
if (dist < 10) {
const hi = hitIdx(p);
if (hi < 0) this.addCharge(p.x, p.y, this.addSign);
}
if (this.onUpdate) this.onUpdate(this.info());
}, { passive: false });
}
}
+315
View File
@@ -0,0 +1,315 @@
'use strict';
/* ═══════════════════════════════════════════════
CrystalSim — 3D crystal lattice (Three.js)
NaCl, Diamond, BCC metal, FCC metal
═══════════════════════════════════════════════ */
class CrystalSim {
constructor(container) {
this.container = container;
this._running = false;
/* Three.js */
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x0D0D1A, 1);
container.appendChild(this.renderer.domElement);
/* lighting */
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(5, 8, 6);
this.scene.add(dir);
const pt = new THREE.PointLight(0x9B5DE5, 0.4, 50);
pt.position.set(-4, 3, 5);
this.scene.add(pt);
this.camera.position.set(8, 6, 8);
this.camera.lookAt(0, 0, 0);
/* orbit-like manual controls */
this._drag = false;
this._prevX = 0;
this._prevY = 0;
this._rotY = 0.6;
this._rotX = 0.4;
this._dist = 12;
this._autoSpin = true;
const el = this.renderer.domElement;
el.style.cursor = 'grab';
el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; });
window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; });
window.addEventListener('pointermove', e => {
if (!this._drag) return;
this._rotY += (e.clientX - this._prevX) * 0.008;
this._rotX += (e.clientY - this._prevY) * 0.008;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._prevX = e.clientX; this._prevY = e.clientY;
});
el.addEventListener('wheel', e => {
e.preventDefault();
this._dist = Math.max(5, Math.min(30, this._dist + e.deltaY * 0.02));
}, { passive: false });
/* touch */
el.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false;
}
}, { passive: true });
el.addEventListener('touchmove', e => {
if (!this._drag || e.touches.length !== 1) return;
const t = e.touches[0];
this._rotY += (t.clientX - this._prevX) * 0.008;
this._rotX += (t.clientY - this._prevY) * 0.008;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._prevX = t.clientX; this._prevY = t.clientY;
}, { passive: true });
el.addEventListener('touchend', () => { this._drag = false; });
/* resize */
this._ro = new ResizeObserver(() => this.fit());
this._ro.observe(container);
/* state */
this._lattice = 'nacl';
this._group = new THREE.Group();
this.scene.add(this._group);
this._buildLattice('nacl');
this.fit();
this.play();
}
/* ── public ── */
setLattice(type) {
this._lattice = type;
this._buildLattice(type);
}
fit() {
const w = this.container.clientWidth || 600;
const h = this.container.clientHeight || 400;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
play() { if (!this._running) { this._running = true; this._loop(); } }
stop() { this._running = false; }
pause() { this._running = false; }
/* ── lattice builders ── */
_buildLattice(type) {
// clear
while (this._group.children.length) {
const c = this._group.children[0];
c.geometry?.dispose(); c.material?.dispose();
this._group.remove(c);
}
const builders = {
nacl: () => this._buildNaCl(),
diamond: () => this._buildDiamond(),
bcc: () => this._buildBCC(),
fcc: () => this._buildFCC(),
};
(builders[type] || builders.nacl)();
}
_sphere(r, color) {
const geo = new THREE.SphereGeometry(r, 24, 24);
const mat = new THREE.MeshPhysicalMaterial({
color, metalness: 0.1, roughness: 0.3,
clearcoat: 0.6, clearcoatRoughness: 0.2,
});
return new THREE.Mesh(geo, mat);
}
_bond(from, to, color = 0x555555) {
const dir = new THREE.Vector3().subVectors(to, from);
const len = dir.length();
const geo = new THREE.CylinderGeometry(0.04, 0.04, len, 8);
const mat = new THREE.MeshStandardMaterial({ color, opacity: 0.5, transparent: true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(from).add(dir.multiplyScalar(0.5));
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
return mesh;
}
_buildNaCl() {
const n = 3; // 3×3×3 unit cells
const a = 2.0; // lattice constant
const offset = -(n - 1) * a / 2;
const positions = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
for (let k = 0; k < n; k++) {
const x = offset + i * a, y = offset + j * a, z = offset + k * a;
const isNa = (i + j + k) % 2 === 0;
const s = this._sphere(isNa ? 0.28 : 0.38, isNa ? 0x9B5DE5 : 0x06D6E0);
s.position.set(x, y, z);
this._group.add(s);
positions.push({ x, y, z });
}
// bonds to nearest neighbors
for (let i = 0; i < positions.length; i++)
for (let j = i + 1; j < positions.length; j++) {
const dx = positions[i].x - positions[j].x;
const dy = positions[i].y - positions[j].y;
const dz = positions[i].z - positions[j].z;
const d2 = dx * dx + dy * dy + dz * dz;
if (Math.abs(d2 - a * a) < 0.01) {
const b = this._bond(
new THREE.Vector3(positions[i].x, positions[i].y, positions[i].z),
new THREE.Vector3(positions[j].x, positions[j].y, positions[j].z),
0x444466
);
this._group.add(b);
}
}
}
_buildDiamond() {
const a = 2.5;
const basis = [
[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5],
[0.25, 0.25, 0.25], [0.75, 0.75, 0.25], [0.75, 0.25, 0.75], [0.25, 0.75, 0.75],
];
const n = 2;
const offset = -(n * a) / 2;
const allPos = [];
for (let ci = 0; ci < n; ci++)
for (let cj = 0; cj < n; cj++)
for (let ck = 0; ck < n; ck++)
for (const [bx, by, bz] of basis) {
const x = offset + (ci + bx) * a;
const y = offset + (cj + by) * a;
const z = offset + (ck + bz) * a;
const s = this._sphere(0.22, 0x34d399);
s.position.set(x, y, z);
this._group.add(s);
allPos.push(new THREE.Vector3(x, y, z));
}
// bonds
const bondLen = a * Math.sqrt(3) / 4;
const tol = bondLen * 0.15;
for (let i = 0; i < allPos.length; i++)
for (let j = i + 1; j < allPos.length; j++) {
const d = allPos[i].distanceTo(allPos[j]);
if (Math.abs(d - bondLen) < tol) {
this._group.add(this._bond(allPos[i], allPos[j], 0x228866));
}
}
}
_buildBCC() {
const a = 2.2, n = 3;
const offset = -(n - 1) * a / 2;
const allPos = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
for (let k = 0; k < n; k++) {
// corner atoms
const x1 = offset + i * a, y1 = offset + j * a, z1 = offset + k * a;
const s1 = this._sphere(0.3, 0xF15BB5);
s1.position.set(x1, y1, z1);
this._group.add(s1);
allPos.push(new THREE.Vector3(x1, y1, z1));
// body center (except last cell in each dimension)
if (i < n - 1 && j < n - 1 && k < n - 1) {
const cx = x1 + a / 2, cy = y1 + a / 2, cz = z1 + a / 2;
const s2 = this._sphere(0.3, 0xF59E0B);
s2.position.set(cx, cy, cz);
this._group.add(s2);
allPos.push(new THREE.Vector3(cx, cy, cz));
}
}
// bonds
const bondLen = a * Math.sqrt(3) / 2;
const tol = bondLen * 0.1;
for (let i = 0; i < allPos.length; i++)
for (let j = i + 1; j < allPos.length; j++) {
const d = allPos[i].distanceTo(allPos[j]);
if (Math.abs(d - bondLen) < tol) {
this._group.add(this._bond(allPos[i], allPos[j], 0x664444));
}
}
}
_buildFCC() {
const a = 2.4, n = 3;
const offset = -(n - 1) * a / 2;
const allPos = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
for (let k = 0; k < n; k++) {
const x = offset + i * a, y = offset + j * a, z = offset + k * a;
// corner
const s = this._sphere(0.25, 0x60a5fa);
s.position.set(x, y, z);
this._group.add(s);
allPos.push(new THREE.Vector3(x, y, z));
// face centers (only for cell interiors)
if (i < n - 1 && j < n - 1) {
const f1 = this._sphere(0.25, 0x60a5fa);
f1.position.set(x + a / 2, y + a / 2, z);
this._group.add(f1);
allPos.push(new THREE.Vector3(x + a / 2, y + a / 2, z));
}
if (i < n - 1 && k < n - 1) {
const f2 = this._sphere(0.25, 0x60a5fa);
f2.position.set(x + a / 2, y, z + a / 2);
this._group.add(f2);
allPos.push(new THREE.Vector3(x + a / 2, y, z + a / 2));
}
if (j < n - 1 && k < n - 1) {
const f3 = this._sphere(0.25, 0x60a5fa);
f3.position.set(x, y + a / 2, z + a / 2);
this._group.add(f3);
allPos.push(new THREE.Vector3(x, y + a / 2, z + a / 2));
}
}
// bonds to nearest neighbors (a/√2)
const bondLen = a / Math.SQRT2;
const tol = bondLen * 0.1;
for (let i = 0; i < allPos.length; i++)
for (let j = i + 1; j < allPos.length; j++) {
const d = allPos[i].distanceTo(allPos[j]);
if (Math.abs(d - bondLen) < tol) {
this._group.add(this._bond(allPos[i], allPos[j], 0x334466));
}
}
}
/* ── animation ── */
_loop() {
if (!this._running) return;
requestAnimationFrame(() => this._loop());
if (this._autoSpin) this._rotY += 0.003;
this.camera.position.set(
this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
this._dist * Math.sin(this._rotX),
this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
);
this.camera.lookAt(0, 0, 0);
this.renderer.render(this.scene, this.camera);
}
}
+465
View File
@@ -0,0 +1,465 @@
'use strict';
/**
* DiffusionSim v2 — Diffusion simulation (two gases mixing).
* v2: entropy timeline on history chart, pore mode (gap in partition), density heatmap.
*/
class DiffusionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.particles = [];
this.N = 60;
this.T = 1.0;
this.partitionOn = true;
this._history = []; // {step, fracA_left, entropy}
this._steps = 0;
this._raf = null;
this.onUpdate = null;
this._dpr = 1;
// v2
this._poreMode = false; // partition has a gap in the center
this._poreH = 40; // gap height in pixels
this._heatmap = null; // cached density heatmap
this._hmTick = 0;
}
// ── public API ──────────────────────────────────────────────────────────────
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
const w = this.canvas.offsetWidth, h = this.canvas.offsetHeight;
this.canvas.width = w * dpr; this.canvas.height = h * dpr;
this.W = w; this.H = h;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.reset();
}
reset() {
const { W, H } = this;
this.partitionOn = true;
this._poreMode = false;
this._steps = 0;
this._history = [{ step: 0, fracA_left: 1.0, entropy: 0 }];
this._heatmap = null;
const particles = [];
const r = 5;
let attA = 0;
while (particles.filter(p => p.type === 'A').length < this.N && attA < this.N * 30) {
attA++;
const x = r + Math.random() * (W / 2 - 2 * r);
const y = r + Math.random() * (H - 2 * r);
const a = Math.random() * Math.PI * 2, s = this.T * 3.5;
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r, type: 'A' });
}
let attB = 0;
while (particles.filter(p => p.type === 'B').length < this.N && attB < this.N * 30) {
attB++;
const x = W / 2 + r + Math.random() * (W / 2 - 2 * r);
const y = r + Math.random() * (H - 2 * r);
const a = Math.random() * Math.PI * 2, s = this.T * 3.5;
particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r, type: 'B' });
}
this.particles = particles;
}
togglePartition() {
if (this._poreMode) {
// If pore is on, full toggle removes pore first
this._poreMode = false;
this.partitionOn = true;
} else {
this.partitionOn = !this.partitionOn;
}
}
togglePore() {
if (!this.partitionOn && !this._poreMode) {
// Partition is fully off — re-enable with pore
this.partitionOn = true;
this._poreMode = true;
} else if (this.partitionOn && !this._poreMode) {
this._poreMode = true; // add pore to full partition
} else if (this._poreMode) {
this._poreMode = false; // remove pore, keep partition
}
}
setN(n) { this.N = Math.max(10, Math.min(200, n)); this.reset(); }
setT(t) {
const f = Math.sqrt(t / this.T);
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
this.T = t;
}
start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop.bind(this)); }
stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
// ── simulation ──────────────────────────────────────────────────────────────
_loop() {
this._step(); this._step();
this.draw();
this._raf = requestAnimationFrame(this._loop.bind(this));
}
_step() {
const { W, H, particles } = this;
for (const p of particles) {
p.x += p.vx; p.y += p.vy;
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); }
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
// Partition logic
if (this.partitionOn) {
const mid = W / 2, hw = 3;
const inPore = this._poreMode
&& p.y > H / 2 - this._poreH / 2
&& p.y < H / 2 + this._poreH / 2;
if (!inPore) {
if (p.vx > 0 && p.x + p.r > mid - hw && p.x < mid) {
p.x = mid - hw - p.r; p.vx = -Math.abs(p.vx);
} else if (p.vx < 0 && p.x - p.r < mid + hw && p.x > mid) {
p.x = mid + hw + p.r; p.vx = Math.abs(p.vx);
}
}
}
}
// Spatial grid collisions
const cs = 14, cols = Math.ceil(W / cs) + 1;
const grid = new Map();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols;
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
for (let i = 0; i < particles.length; i++) {
const p1 = particles[i];
const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs);
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
if (!cell) continue;
for (const j of cell) {
if (j <= i) continue;
const p2 = particles[j];
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const d = Math.hypot(dx, dy), md = p1.r + p2.r;
if (d < md && d > 0.001) {
const nx = dx / d, ny = dy / d;
const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny;
if (dvn < 0) continue;
p1.vx -= dvn * nx; p1.vy -= dvn * ny;
p2.vx += dvn * nx; p2.vy += dvn * ny;
const ov = (md - d) / 2;
p1.x -= nx * ov; p1.y -= ny * ov;
p2.x += nx * ov; p2.y += ny * ov;
}
}
}
}
// History (with entropy)
if (this._steps % 60 === 0) {
const left = particles.filter(p => p.x < W / 2);
const fracA_left = left.length > 0
? left.filter(p => p.type === 'A').length / left.length
: 0;
const f = fracA_left;
const entropy = -(f * Math.log(f + 1e-9) + (1 - f) * Math.log(1 - f + 1e-9));
this._history.push({ step: this._steps, fracA_left, entropy });
if (this._history.length > 200) this._history.shift();
}
// Heatmap update (every 30 steps)
if (this._steps % 30 === 0) this._updateHeatmap();
this._steps++;
if (this._steps % 30 === 0 && this.onUpdate) this.onUpdate(this.info());
}
_updateHeatmap() {
const { W, H, particles } = this;
const cols = 20, rows = 14;
const cw = W / cols, ch = H / rows;
const grid = [];
for (let r = 0; r < rows; r++) {
grid[r] = [];
for (let c = 0; c < cols; c++) grid[r][c] = { A: 0, B: 0 };
}
for (const p of particles) {
const c = Math.min(cols - 1, Math.floor(p.x / cw));
const r = Math.min(rows - 1, Math.floor(p.y / ch));
grid[r][c][p.type]++;
}
const maxCount = Math.max(...grid.flat().map(c => c.A + c.B), 1);
this._heatmap = { grid, cols, rows, cw, ch, maxCount };
}
info() {
const { particles, W, N } = this;
const leftA = particles.filter(p => p.x < W / 2 && p.type === 'A').length;
const leftB = particles.filter(p => p.x < W / 2 && p.type === 'B').length;
const rightA = particles.filter(p => p.x >= W / 2 && p.type === 'A').length;
const rightB = particles.filter(p => p.x >= W / 2 && p.type === 'B').length;
const mixed = (leftB + rightA) / (2 * N);
const fracAL = leftA / ((leftA + leftB) || 1);
const entropy = -(fracAL * Math.log(fracAL + 1e-9) + (1 - fracAL) * Math.log(1 - fracAL + 1e-9));
return {
leftA, leftB, rightA, rightB,
mixed: (mixed * 100).toFixed(0),
entropy: entropy.toFixed(3),
partitionOn: this.partitionOn,
poreMode: this._poreMode,
steps: this._steps,
};
}
// ── drawing ─────────────────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
const TAU = Math.PI * 2;
ctx.fillStyle = '#080818'; ctx.fillRect(0, 0, W, H);
// Background tints
ctx.fillStyle = 'rgba(6,214,224,0.04)'; ctx.fillRect(0, 0, W / 2, H);
ctx.fillStyle = 'rgba(241,91,181,0.04)'; ctx.fillRect(W / 2, 0, W / 2, H);
// Density heatmap (subtle)
this._drawHeatmap(ctx);
// Partition
if (this.partitionOn) this._drawPartition(ctx, W, H);
// Particles
ctx.save();
for (const p of this.particles) {
const color = p.type === 'A' ? '#06D6E0' : '#F15BB5';
ctx.shadowColor = color; ctx.shadowBlur = 6;
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.fill();
}
ctx.restore();
// Concentration bar (right side)
this._drawConcBar(ctx, W, H);
// History chart with entropy (bottom)
this._drawHistoryChart(ctx, W, H);
// Stats overlay (top-left)
this._drawStats(ctx);
}
_drawHeatmap(ctx) {
const hm = this._heatmap;
if (!hm) return;
for (let r = 0; r < hm.rows; r++) for (let c = 0; c < hm.cols; c++) {
const cell = hm.grid[r][c];
const total = cell.A + cell.B;
if (total === 0) continue;
const frac = total / hm.maxCount;
// Color based on A vs B ratio
const fracA = cell.A / total;
// Mix cyan and pink by composition
const rr = Math.round(6 + (241 - 6) * (1 - fracA));
const rg = Math.round(214 + (91 - 214) * (1 - fracA));
const rb = Math.round(224 + (181 - 224) * (1 - fracA));
ctx.fillStyle = `rgba(${rr},${rg},${rb},${frac * 0.08})`;
ctx.fillRect(c * hm.cw, r * hm.ch, hm.cw, hm.ch);
}
}
_drawPartition(ctx, W, H) {
const mid = W / 2, pw = 6;
const poreOn = this._poreMode;
const poreY1 = H / 2 - this._poreH / 2;
const poreY2 = H / 2 + this._poreH / 2;
ctx.save();
ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(255,255,255,0.5)';
const grad = ctx.createLinearGradient(mid - pw / 2, 0, mid + pw / 2, 0);
grad.addColorStop(0, 'rgba(255,255,255,0.15)');
grad.addColorStop(1, 'rgba(255,255,255,0.05)');
ctx.fillStyle = grad;
if (!poreOn) {
ctx.fillRect(mid - pw / 2, 0, pw, H);
} else {
// Two segments (above and below pore)
ctx.fillRect(mid - pw / 2, 0, pw, poreY1);
ctx.fillRect(mid - pw / 2, poreY2, pw, H - poreY2);
// Pore opening highlight
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(mid - pw / 2, poreY1); ctx.lineTo(mid + pw / 2, poreY1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mid - pw / 2, poreY2); ctx.lineTo(mid + pw / 2, poreY2); ctx.stroke();
ctx.setLineDash([]);
// Pore gap arrows (showing flow direction)
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = "bold 10px monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('⇌', mid, H / 2);
}
if (!poreOn) {
// Door handle
const hx = mid - 10, hy = H / 2 - 14, hw = 20, hh = 28;
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 4); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = "bold 10px monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('||', mid, H / 2);
}
ctx.restore();
}
_drawConcBar(ctx, W, H) {
const barX = W - 20, barHalf = H / 2;
const { particles } = this;
const lA = particles.filter(p => p.x < W / 2 && p.type === 'A').length;
const lT = particles.filter(p => p.x < W / 2).length || 1;
const rA = particles.filter(p => p.x >= W / 2 && p.type === 'A').length;
const rT = particles.filter(p => p.x >= W / 2).length || 1;
const fAL = lA / lT, fAR = rA / rT;
ctx.fillStyle = '#06D6E0'; ctx.fillRect(barX, 0, 20, barHalf * fAL);
ctx.fillStyle = '#F15BB5'; ctx.fillRect(barX, barHalf * fAL, 20, barHalf * (1 - fAL));
ctx.fillStyle = '#06D6E0'; ctx.fillRect(barX, barHalf, 20, barHalf * fAR);
ctx.fillStyle = '#F15BB5'; ctx.fillRect(barX, barHalf + barHalf * fAR, 20, barHalf * (1 - fAR));
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillRect(barX, barHalf - 1, 20, 2);
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.strokeRect(barX, 0, 20, H);
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = "9px 'Manrope', sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.translate(barX + 10, H / 2); ctx.rotate(-Math.PI / 2);
ctx.fillText('Концентрация', 0, 0);
ctx.restore();
}
_drawHistoryChart(ctx, W, H) {
const graphH = 100, graphY = H - graphH, graphW = W - 24;
ctx.save();
ctx.fillStyle = 'rgba(0,0,10,0.76)';
ctx.beginPath(); ctx.roundRect(0, graphY, graphW, graphH, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = "10px 'Manrope', sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Доля A в левой половине', 10, graphY + 6);
// Y-axis labels
ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = "9px 'Manrope', sans-serif";
ctx.fillText('1.0', 4, graphY + 18);
ctx.fillText('0.0', 4, graphY + graphH - 10);
const refY = graphY + graphH * 0.5 - 2;
ctx.setLineDash([4, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(24, refY); ctx.lineTo(graphW - 10, refY); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = "9px 'Manrope', sans-serif";
ctx.textAlign = 'left'; ctx.fillText('равновесие', 28, refY - 10);
const hist = this._history;
if (hist.length > 1) {
const plotX0 = 28, plotW = graphW - 38;
const plotY0 = graphY + 18, plotH2 = graphH - 28;
// Concentration line (cyan)
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < hist.length; i++) {
const hx = plotX0 + (i / (hist.length - 1)) * plotW;
const hy = plotY0 + plotH2 * (1 - hist[i].fracA_left);
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
}
ctx.stroke();
// Entropy line (orange, dashed, scaled to 0..ln(2) ≈ 0.693)
const maxEnt = Math.log(2);
ctx.strokeStyle = '#FFB347'; ctx.lineWidth = 1.2;
ctx.setLineDash([4, 3]);
ctx.beginPath();
for (let i = 0; i < hist.length; i++) {
const hx = plotX0 + (i / (hist.length - 1)) * plotW;
const hy = plotY0 + plotH2 * (1 - hist[i].entropy / maxEnt);
if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
}
ctx.stroke();
ctx.setLineDash([]);
// Legend
ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.arc(plotX0 + plotW - 50, graphY + 8, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = "8px sans-serif"; ctx.textBaseline = 'middle';
ctx.textAlign = 'left'; ctx.fillText('X(A)', plotX0 + plotW - 44, graphY + 8);
ctx.fillStyle = '#FFB347'; ctx.beginPath(); ctx.arc(plotX0 + plotW - 22, graphY + 8, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillText('S', plotX0 + plotW - 16, graphY + 8);
// Current value
const last = hist[hist.length - 1];
const endX = plotX0 + plotW;
const endY = plotY0 + plotH2 * (1 - last.fracA_left);
ctx.fillStyle = '#06D6E0'; ctx.font = "bold 10px 'Manrope', sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText((last.fracA_left * 100).toFixed(0) + '%', endX - 2, endY);
}
ctx.restore();
}
_drawStats(ctx) {
const info = this.info();
const pad = 10, panelW = 180, panelH = 90, px = 14, py = 14;
ctx.save();
ctx.fillStyle = 'rgba(0,0,10,0.72)';
ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
const lineH = 18;
ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.font = "11px 'Manrope', sans-serif";
ctx.fillStyle = '#06D6E0';
ctx.fillText(`Лево: A=${info.leftA} B=${info.leftB}`, px + pad, py + pad);
ctx.fillStyle = '#F15BB5';
ctx.fillText(`Право: A=${info.rightA} B=${info.rightB}`, px + pad, py + pad + lineH);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fillText(`Смешивание: ${info.mixed}%`, px + pad, py + pad + lineH * 2);
const stateLabel = !info.partitionOn ? 'Снята' : info.poreMode ? 'С порой' : 'Вкл';
const stateColor = !info.partitionOn ? '#F15BB5' : info.poreMode ? '#FFB347' : '#06D6E0';
ctx.fillStyle = stateColor;
ctx.fillText(`Раздел: ${stateLabel}`, px + pad, py + pad + lineH * 3);
ctx.restore();
}
}
if (typeof module !== 'undefined') module.exports = DiffusionSim;
+540
View File
@@ -0,0 +1,540 @@
'use strict';
/**
* ElectrolysisSim v2 — Электролиз водных растворов
* Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль
* Чистый рерайт: стабильная физика, ионная анимация, пузырьки, осадок.
*/
class ElectrolysisSim {
static F = 96485;
static BG = '#0b0b1a';
static FONT = 'Manrope, system-ui, sans-serif';
static ELECTROLYTES = {
NaCl: {
name: 'NaCl', displayName: 'NaCl (водный р-р)',
cation: 'Na\u207A', anion: 'Cl\u207B',
M: 2, n: 2, R: 8,
solColor: [160, 200, 230],
cathodeProduct: 'H\u2082', anodeProduct: 'Cl\u2082',
depositColor: null,
cathodeBubColor: 'rgba(160,210,255,0.55)',
anodeBubColor: 'rgba(180,255,140,0.50)',
cathodeEq: '2H\u2082O + 2e\u207B \u2192 H\u2082 + 2OH\u207B',
anodeEq: '2Cl\u207B \u2212 2e\u207B \u2192 Cl\u2082',
},
CuSO4: {
name: 'CuSO\u2084', displayName: 'CuSO\u2084 (водный р-р)',
cation: 'Cu\u00B2\u207A', anion: 'SO\u2084\u00B2\u207B',
M: 63.546, n: 2, R: 12,
solColor: [55, 120, 210],
cathodeProduct: 'Cu\u2193', anodeProduct: 'O\u2082',
depositColor: '#b87333',
cathodeBubColor: null,
anodeBubColor: 'rgba(200,210,255,0.50)',
cathodeEq: 'Cu\u00B2\u207A + 2e\u207B \u2192 Cu\u2193',
anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A',
},
H2SO4: {
name: 'H\u2082SO\u2084', displayName: 'H\u2082SO\u2084 (водный р-р)',
cation: 'H\u207A', anion: 'SO\u2084\u00B2\u207B',
M: 2, n: 2, R: 6,
solColor: [200, 200, 215],
cathodeProduct: 'H\u2082', anodeProduct: 'O\u2082',
depositColor: null,
cathodeBubColor: 'rgba(160,210,255,0.55)',
anodeBubColor: 'rgba(200,210,255,0.50)',
cathodeEq: '2H\u207A + 2e\u207B \u2192 H\u2082',
anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.voltage = 6;
this.electrolyte = 'NaCl';
this.speed = 1;
this._time = 0;
this._massDeposit = 0;
this._gasVolume = 0;
this._depositH = 0;
this._ions = [];
this._bubbles = [];
this._electronPhase = 0;
this.playing = false;
this._raf = null;
this._lastTs = null;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
// ── public API ────────────────────────────────────────────────
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 640;
const h = this.canvas.offsetHeight || 420;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
this._initIons();
}
setParams({ voltage, electrolyte } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (electrolyte !== undefined) {
// accept both 'nacl' (from lab.html) and 'NaCl' (canonical)
const keyMap = { nacl: 'NaCl', cuso4: 'CuSO4', h2so4: 'H2SO4' };
const key = keyMap[String(electrolyte).toLowerCase()] || electrolyte;
if (ElectrolysisSim.ELECTROLYTES[key] && this.electrolyte !== key) {
this.electrolyte = key;
this.reset(); return;
}
}
this.draw(); this._emit();
}
preset(name) {
const map = { nacl: ['NaCl', 6], cuso4: ['CuSO4', 4], h2so4: ['H2SO4', 3] };
const [el, v] = map[name] || map.nacl;
this.voltage = v; this.electrolyte = el;
this.reset();
}
reset() {
this.pause();
this._time = 0; this._massDeposit = 0;
this._gasVolume = 0; this._depositH = 0;
this._bubbles = []; this._electronPhase = 0;
this._initIons();
this.draw(); this._emit();
}
play() { if (this.playing) return; this.playing = true; this._lastTs = null; this._tick(); }
pause() { this.playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } }
start() { this.play(); }
stop() { this.pause(); }
info() {
return {
voltage: this.voltage,
current: +this._current().toFixed(3),
electrolyte: this.electrolyte,
massDeposited: +this._massDeposit.toFixed(4),
gasVolume: +this._gasVolume.toFixed(2),
time: +this._time.toFixed(1),
};
}
// ── internals ─────────────────────────────────────────────────
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_el() { return ElectrolysisSim.ELECTROLYTES[this.electrolyte]; }
_current() { return this.voltage / this._el().R; }
_cell() {
const { W, H } = this;
const cw = Math.min(W * 0.50, 300);
const ch = Math.min(H * 0.48, 210);
return { cx: (W - cw) / 2, cy: H * 0.28, cw, ch };
}
_electrodes() {
const { cx, cy, cw, ch } = this._cell();
const ew = 13, eh = ch * 0.70, gap = cw * 0.12;
const ey = cy + ch - eh - 8;
return {
cathode: { x: cx + gap, y: ey, w: ew, h: eh },
anode: { x: cx + cw - gap - ew, y: ey, w: ew, h: eh },
};
}
_initIons() {
this._ions = [];
const { cx, cy, cw, ch } = this._cell();
if (!cw || !ch) return;
const el = this._el();
for (let i = 0; i < 30; i++) {
const isCat = i < 15;
this._ions.push({
x: cx + 18 + Math.random() * (cw - 36),
y: cy + 12 + Math.random() * (ch - 24),
vx: (Math.random() - 0.5) * 0.7,
vy: (Math.random() - 0.5) * 0.5,
charge: isCat ? 1 : -1,
label: isCat ? el.cation : el.anion,
color: isCat ? '#EF476F' : '#06D6E0',
});
}
}
_spawnIon(charge) {
const { cx, cy, cw, ch } = this._cell();
const el = this._el();
this._ions.push({
x: charge > 0 ? cx + 8 : cx + cw - 8,
y: cy + 12 + Math.random() * (ch - 24),
vx: charge > 0 ? 0.55 : -0.55,
vy: (Math.random() - 0.5) * 0.4,
charge,
label: charge > 0 ? el.cation : el.anion,
color: charge > 0 ? '#EF476F' : '#06D6E0',
});
}
_spawnBubble(x, y, color) {
this._bubbles.push({
x, y,
r: 1.5 + Math.random() * 2.5,
vx: (Math.random() - 0.5) * 0.3,
vy: -(0.5 + Math.random() * 0.9),
life: 1,
decay: 0.005 + Math.random() * 0.007,
color,
});
}
// ── simulation tick ────────────────────────────────────────────
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (!this._lastTs) this._lastTs = ts;
const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed;
this._lastTs = ts;
this._step(dt); this.draw(); this._emit(); this._tick();
});
}
_step(dt) {
const el = this._el(), I = this._current();
const { cx, cy, cw, ch } = this._cell();
const elec = this._electrodes();
this._time += dt;
this._electronPhase = (this._electronPhase + dt * I * 1.2) % 1;
// Faraday's law
const molesPS = I / (el.n * ElectrolysisSim.F);
if (el.depositColor) {
this._massDeposit += el.M * molesPS * dt;
this._depositH = Math.min(elec.cathode.h * 0.72, this._depositH + dt * 0.14 * I);
}
this._gasVolume += molesPS * 22400 * dt;
// Ion drift + thermal jitter
const drift = I * 0.45;
for (const ion of this._ions) {
ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.18;
ion.vy += (Math.random() - 0.5) * 0.14;
ion.vx = Math.max(-3.5, Math.min(3.5, ion.vx * 0.96));
ion.vy = Math.max(-3.5, Math.min(3.5, ion.vy * 0.96));
ion.x += ion.vx; ion.y += ion.vy;
ion.x = Math.max(cx + 4, Math.min(cx + cw - 4, ion.x));
ion.y = Math.max(cy + 4, Math.min(cy + ch - 4, ion.y));
}
// Ions reaching electrodes → discharge + bubbles
const rm = new Set();
for (let i = 0; i < this._ions.length; i++) {
const ion = this._ions[i];
if (ion.charge > 0 && ion.x <= elec.cathode.x + elec.cathode.w + 5) {
rm.add(i);
if (el.cathodeBubColor) {
for (let b = 0; b < 2; b++)
this._spawnBubble(
elec.cathode.x + elec.cathode.w + 2 + Math.random() * 4,
elec.cathode.y + Math.random() * elec.cathode.h,
el.cathodeBubColor);
}
}
if (ion.charge < 0 && ion.x >= elec.anode.x - 5) {
rm.add(i);
if (el.anodeBubColor) {
for (let b = 0; b < 2; b++)
this._spawnBubble(
elec.anode.x - 2 - Math.random() * 4,
elec.anode.y + Math.random() * elec.anode.h,
el.anodeBubColor);
}
}
}
this._ions = this._ions.filter((_, i) => !rm.has(i));
// Replenish ions to keep count ~15 each
let cat = 0, an = 0;
for (const ion of this._ions) ion.charge > 0 ? cat++ : an++;
while (cat < 15) { this._spawnIon(1); cat++; }
while (an < 15) { this._spawnIon(-1); an++; }
// Bubble physics
this._bubbles = this._bubbles.filter(b => {
b.x += b.vx + Math.sin(b.life * 22) * 0.12;
b.y += b.vy;
b.life -= b.decay;
return b.life > 0 && b.y > cy + 2;
});
}
// ── draw ──────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
// Background
ctx.fillStyle = ElectrolysisSim.BG; ctx.fillRect(0, 0, W, H);
// Dot grid
ctx.fillStyle = 'rgba(255,255,255,0.018)';
for (let x = 22; x < W; x += 22)
for (let y = 22; y < H; y += 22) {
ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
}
this._drawWiresAndBattery();
this._drawCellBody();
this._drawSolution();
this._drawDeposit();
this._drawElectrodes();
this._drawBubbles();
this._drawIons();
this._drawLabels();
this._drawInfoPanel();
}
_drawCellBody() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke();
// glass shimmer
const gg = ctx.createLinearGradient(cx, cy, cx + 14, cy);
gg.addColorStop(0, 'rgba(255,255,255,0.05)');
gg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gg; ctx.fillRect(cx + 1, cy + 1, 14, ch - 2);
ctx.restore();
}
_drawSolution() {
const { ctx } = this;
const { cx, cy, cw, ch } = this._cell();
const [r, g, b] = this._el().solColor;
ctx.save();
ctx.beginPath(); ctx.roundRect(cx + 2, cy + 2, cw - 4, ch - 4, 4); ctx.clip();
const sg = ctx.createLinearGradient(cx, cy, cx, cy + ch);
sg.addColorStop(0, `rgba(${r},${g},${b},0.06)`);
sg.addColorStop(1, `rgba(${r},${g},${b},0.22)`);
ctx.fillStyle = sg; ctx.fillRect(cx + 2, cy + 2, cw - 4, ch - 4);
ctx.restore();
}
_drawElectrodes() {
const { ctx } = this;
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
ctx.fillStyle = '#42425a';
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.fill();
ctx.strokeStyle = 'rgba(6,214,224,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.stroke();
ctx.fillStyle = '#525268';
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.fill();
ctx.strokeStyle = 'rgba(239,71,111,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.stroke();
ctx.font = `bold 16px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillStyle = '#06D6E0';
ctx.fillText('\u2212', e.cathode.x + e.cathode.w / 2, e.cathode.y - 3);
ctx.fillStyle = '#EF476F';
ctx.fillText('+', e.anode.x + e.anode.w / 2, e.anode.y - 3);
}
_drawDeposit() {
const el = this._el();
if (!el.depositColor || this._depositH < 1) return;
const { ctx } = this;
const c = this._electrodes().cathode;
const dh = Math.min(this._depositH, c.h * 0.72);
ctx.save();
const dg = ctx.createLinearGradient(c.x + c.w, c.y + c.h - dh, c.x + c.w + 10, c.y + c.h);
dg.addColorStop(0, 'rgba(184,115,51,0.35)');
dg.addColorStop(1, 'rgba(184,115,51,0.85)');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, dh, [2, 2, 0, 0]); ctx.fill();
ctx.shadowColor = '#b87333'; ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(210,150,80,0.5)';
ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, 3, [2, 2, 0, 0]); ctx.fill();
ctx.restore();
}
_drawIons() {
const { ctx } = this;
const FN = ElectrolysisSim.FONT;
ctx.save();
for (const ion of this._ions) {
const g = ctx.createRadialGradient(ion.x, ion.y, 0, ion.x, ion.y, 11);
g.addColorStop(0, ion.color + '2a'); g.addColorStop(1, ion.color + '00');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 11, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = ion.color;
ctx.beginPath(); ctx.arc(ion.x, ion.y, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.68)';
ctx.font = `8px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(ion.label, ion.x, ion.y - 5);
}
ctx.restore();
}
_drawBubbles() {
const { ctx } = this;
ctx.save();
for (const b of this._bubbles) {
ctx.globalAlpha = b.life * 0.65;
ctx.strokeStyle = b.color; ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${b.life * 0.18})`;
ctx.beginPath(); ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.3, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
_drawWiresAndBattery() {
const { ctx } = this;
const { cx, cy, cw } = this._cell();
const e = this._electrodes();
const FN = ElectrolysisSim.FONT;
const cXt = e.cathode.x + e.cathode.w / 2; // cathode top center
const aXt = e.anode.x + e.anode.w / 2; // anode top center
const bx = cx + cw / 2; // battery center x
const by = cy - Math.max(42, this.H * 0.09); // battery y
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5;
// Cathode wire: up then right to battery
ctx.beginPath();
ctx.moveTo(cXt, e.cathode.y); ctx.lineTo(cXt, by); ctx.lineTo(bx - 22, by);
ctx.stroke();
// Anode wire: up then left to battery +
ctx.beginPath();
ctx.moveTo(aXt, e.anode.y); ctx.lineTo(aXt, by); ctx.lineTo(bx + 22, by);
ctx.stroke();
// Electron flow dots (cathode side: from battery toward cathode)
const dist = (bx - 22) - cXt;
for (let i = 0; i < 4; i++) {
const t = ((this._electronPhase + i / 4) % 1);
const ex = (bx - 22) - t * dist;
const ey = by;
if (ex >= cXt - 1 && ex <= bx - 22 + 1) {
ctx.fillStyle = '#4CC9F0'; ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 5;
ctx.beginPath(); ctx.arc(ex, ey, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
// Battery symbol — two plates
ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(bx + 22, by - 14); ctx.lineTo(bx + 22, by + 14); ctx.stroke();
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.moveTo(bx - 22, by - 8); ctx.lineTo(bx - 22, by + 8); ctx.stroke();
// Connecting wire between battery plates
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(bx - 22, by); ctx.lineTo(bx + 22, by); ctx.stroke();
// Voltage label
ctx.fillStyle = '#FFD166';
ctx.font = `bold 12px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(this.voltage.toFixed(1) + ' V', bx, by - 18);
// +/ labels on battery
ctx.fillStyle = '#EF476F'; ctx.font = `bold 10px ${FN}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('+', bx + 26, by);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('\u2212', bx - 26, by);
ctx.restore();
}
_drawLabels() {
const { ctx } = this;
const el = this._el(), e = this._electrodes();
const { cx, cy, cw, ch } = this._cell();
const FN = ElectrolysisSim.FONT;
ctx.save();
ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = '#06D6E0';
ctx.fillText('\u041A\u0430\u0442\u043E\u0434 (\u2212)', e.cathode.x + e.cathode.w / 2, cy + ch + 6);
ctx.fillStyle = '#EF476F';
ctx.fillText('\u0410\u043D\u043E\u0434 (+)', e.anode.x + e.anode.w / 2, cy + ch + 6);
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.fillText(el.cathodeProduct, e.cathode.x + e.cathode.w / 2, cy + ch + 20);
ctx.fillText(el.anodeProduct, e.anode.x + e.anode.w / 2, cy + ch + 20);
ctx.fillStyle = '#9B5DE5'; ctx.font = `bold 11px ${FN}`;
ctx.fillText(el.displayName, cx + cw / 2, cy + ch + 36);
ctx.font = `8px ${FN}`;
ctx.fillStyle = 'rgba(6,214,224,0.48)';
ctx.fillText(el.cathodeEq, e.cathode.x + e.cathode.w / 2, cy + ch + 52);
ctx.fillStyle = 'rgba(239,71,111,0.48)';
ctx.fillText(el.anodeEq, e.anode.x + e.anode.w / 2, cy + ch + 52);
ctx.restore();
}
_drawInfoPanel() {
const { ctx } = this;
const inf = this.info(), el = this._el();
const FN = ElectrolysisSim.FONT;
const px = 12, py = 10, pw = 170, lh = 17;
const rows = [
['U', inf.voltage.toFixed(1) + ' \u0412'],
['I', this._current().toFixed(3) + ' \u0410'],
['\u0422\u0432\u0440\u0435\u043C\u044F', this._fmtTime(inf.time)],
];
if (el.depositColor) rows.push(['m(Cu)', inf.massDeposited.toFixed(4) + ' \u0433']);
rows.push(['V(\u0433\u0430\u0437)', inf.gasVolume.toFixed(2) + ' \u043C\u043B']);
const ph = 12 + rows.length * lh + 8;
ctx.save();
ctx.fillStyle = 'rgba(5,5,20,0.86)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.stroke();
ctx.font = `10px ${FN}`; ctx.textBaseline = 'middle';
rows.forEach(([k, v], i) => {
const ry = py + 10 + i * lh + lh / 2;
ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, px + 10, ry);
ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.textAlign = 'right'; ctx.fillText(v, px + pw - 10, ry);
});
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.font = `italic 8px ${FN}`; ctx.textAlign = 'left';
ctx.fillText('m = M\u00B7I\u00B7t / (n\u00B7F)', px + 10, py + ph + 10);
ctx.restore();
}
_fmtTime(s) {
if (s < 60) return s.toFixed(1) + ' \u0441';
return Math.floor(s / 60) + ' \u043C\u0438\u043D ' + (s % 60).toFixed(0) + ' \u0441';
}
}
if (typeof module !== 'undefined') module.exports = ElectrolysisSim;
+475
View File
@@ -0,0 +1,475 @@
'use strict';
/**
* EquilibriumSim — Chemical equilibrium simulation.
* A + B ⇌ C + D with Arrhenius kinetics, Le Chatelier principle.
* Left: particle animation with collisions & reactions.
* Right (30%): live concentration graph over time.
*/
class EquilibriumSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.particles = [];
this.flashes = []; // [{x, y, t, maxT, color}]
this._history = []; // [{step, nA, nB, nC, nD}]
this._nextId = 0;
/* parameters */
this.T = 300; // temperature K
this.nA = 20; // initial A count
this.nB = 20; // initial B count
this.Ea_f = 50; // forward activation energy
this.Ea_r = 55; // reverse activation energy
/* runtime */
this._steps = 0;
this._raf = null;
this._dpr = 1;
this.playing = false;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ═══════════════════════ public API ═══════════════════════ */
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
this.reset();
}
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
let needReset = false;
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
if (Ea_f !== undefined) this.Ea_f = +Ea_f;
if (Ea_r !== undefined) this.Ea_r = +Ea_r;
if (nA !== undefined) { this.nA = Math.max(10, Math.min(40, +nA)); needReset = true; }
if (nB !== undefined) { this.nB = Math.max(10, Math.min(40, +nB)); needReset = true; }
if (needReset) this.reset();
this.draw();
this._emit();
}
preset(name) {
const presets = {
default: { T: 300, nA: 20, nB: 20, Ea_f: 50, Ea_r: 55 },
exothermic: { T: 280, nA: 20, nB: 20, Ea_f: 35, Ea_r: 65 },
endothermic: { T: 350, nA: 20, nB: 20, Ea_f: 65, Ea_r: 35 },
excess_A: { T: 300, nA: 35, nB: 15, Ea_f: 50, Ea_r: 55 },
};
const p = presets[name] || presets.default;
Object.assign(this, p);
this.reset();
}
reset() {
this.pause();
const { W, H } = this;
if (!W || !H) return;
this.particles = [];
this.flashes = [];
this._history = [];
this._steps = 0;
this._nextId = 0;
const simW = W * 0.7;
this._spawnType('A', this.nA, simW);
this._spawnType('B', this.nB, simW);
this._recordHistory();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
let nA = 0, nB = 0, nC = 0, nD = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else if (p.type === 'C') nC++;
else nD++;
}
const cA = nA || 0.001, cB = nB || 0.001;
const cC = nC || 0.001, cD = nD || 0.001;
const Q = (cC * cD) / (cA * cB);
const keq = Math.exp((this.Ea_f - this.Ea_r) / (this.T * 0.05));
const direction = Q < keq * 0.95 ? '\u2192' : Q > keq * 1.05 ? '\u2190' : '\u21CC';
return { keq: +keq.toFixed(3), Q: +Q.toFixed(3), direction, nA, nB, nC, nD };
}
/* ═══════════════════════ internals ═══════════════════════ */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(() => {
for (let i = 0; i < 3; i++) this._step();
this.draw();
this._tick();
});
}
_color(type) {
return { A: '#EF476F', B: '#9B5DE5', C: '#7BF5A4', D: '#FFD166' }[type] || '#aaa';
}
_radius() { return 5; }
_spawnType(type, count, maxX) {
const { H } = this;
const r = this._radius();
const margin = 10;
let placed = 0, att = 0;
while (placed < count && att < count * 60) {
att++;
const x = margin + r + Math.random() * (maxX - 2 * r - margin * 2);
const y = margin + r + Math.random() * (H - 2 * r - margin * 2);
let overlap = false;
for (const p of this.particles) {
if ((p.x - x) ** 2 + (p.y - y) ** 2 < (p.r + r + 1) ** 2) { overlap = true; break; }
}
if (overlap) continue;
const a = Math.random() * Math.PI * 2;
const spd = 1.5 + Math.random() * 1.5;
this.particles.push({ x, y, vx: Math.cos(a) * spd, vy: Math.sin(a) * spd, r, type, id: this._nextId++ });
placed++;
}
}
_step() {
const { W, H } = this;
const simW = W * 0.7;
const dt = 0.6;
/* move + walls */
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
if (p.x > simW - p.r) { p.x = simW - p.r; p.vx = -Math.abs(p.vx); }
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
}
/* spatial grid */
const cs = 18;
const cols = Math.ceil(simW / cs) + 1;
const grid = new Map();
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols;
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
const toRemove = new Set();
const toAdd = [];
/* collisions + reactions */
for (let i = 0; i < this.particles.length; i++) {
const p1 = this.particles[i];
if (toRemove.has(p1.id)) continue;
const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs);
for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + (cy + dcy) * cols);
if (!cell) continue;
for (const j of cell) {
if (j <= i) continue;
const p2 = this.particles[j];
if (toRemove.has(p2.id)) continue;
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const dist2 = dx * dx + dy * dy;
const minD = p1.r + p2.r;
if (dist2 >= minD * minD) continue;
const dist = Math.sqrt(dist2);
/* forward: A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> C + D */
const isAB = (p1.type === 'A' && p2.type === 'B') || (p1.type === 'B' && p2.type === 'A');
if (isAB) {
const kf = Math.exp(-this.Ea_f / (this.T * 0.08)) * 0.35;
if (Math.random() < kf) {
toRemove.add(p1.id); toRemove.add(p2.id);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const a1 = Math.random() * Math.PI * 2;
const spd = 1.2 + Math.random();
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'C', id: this._nextId++ });
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'D', id: this._nextId++ });
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '123,245,164' });
continue;
}
}
/* reverse: C + D <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> A + B */
const isCD = (p1.type === 'C' && p2.type === 'D') || (p1.type === 'D' && p2.type === 'C');
if (isCD) {
const kr = Math.exp(-this.Ea_r / (this.T * 0.08)) * 0.35;
if (Math.random() < kr) {
toRemove.add(p1.id); toRemove.add(p2.id);
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
const a1 = Math.random() * Math.PI * 2;
const spd = 1.2 + Math.random();
toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'A', id: this._nextId++ });
toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'B', id: this._nextId++ });
this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '239,71,111' });
continue;
}
}
/* elastic bounce */
if (dist > 0.001) {
const nx = dx / dist, ny = dy / dist;
const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny;
if (dvn > 0) {
p1.vx -= dvn * nx; p1.vy -= dvn * ny;
p2.vx += dvn * nx; p2.vy += dvn * ny;
}
const ov = (minD - dist) * 0.5;
p1.x -= nx * ov; p1.y -= ny * ov;
p2.x += nx * ov; p2.y += ny * ov;
}
}
}
}
if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id));
for (const p of toAdd) this.particles.push(p);
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
this._steps++;
if (this._steps % 20 === 0) {
this._recordHistory();
this._emit();
}
}
_recordHistory() {
let nA = 0, nB = 0, nC = 0, nD = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else if (p.type === 'C') nC++;
else nD++;
}
this._history.push({ step: this._steps, nA, nB, nC, nD });
if (this._history.length > 300) this._history.shift();
}
/* ═══════════════════════ rendering ═══════════════════════ */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const simW = W * 0.7;
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.025)';
for (let x = 30; x < simW; x += 30)
for (let y = 30; y < H; y += 30) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
/* divider */
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(simW - 1, 0, 2, H);
/* flashes */
for (const f of this.flashes) {
const prog = f.t / f.maxT;
const radius = prog * 38 + 4;
const alpha = (1 - prog) * 0.55;
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius);
g.addColorStop(0, `rgba(${f.color},${alpha * 1.5})`);
g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.4})`);
g.addColorStop(1, `rgba(${f.color},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(f.x, f.y, radius, 0, Math.PI * 2); ctx.fill();
}
/* particles */
for (const p of this.particles) this._drawParticle(ctx, p);
/* right panel: concentration graph */
this._drawGraph(ctx, simW, W, H);
/* stats overlay */
this._drawStats(ctx);
/* equation label */
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = "bold 11px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center';
ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12);
}
_drawParticle(ctx, p) {
const col = this._color(p.type);
const { x, y, r } = p;
/* outer glow */
const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3.2);
glow.addColorStop(0, col + '44');
glow.addColorStop(1, col + '00');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(x, y, r * 3.2, 0, Math.PI * 2); ctx.fill();
/* body gradient */
const body = ctx.createRadialGradient(x - r * 0.25, y - r * 0.25, r * 0.05, x, y, r);
body.addColorStop(0, col + 'ff');
body.addColorStop(0.6, col + 'cc');
body.addColorStop(1, col + '88');
ctx.fillStyle = body;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
/* specular */
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.beginPath(); ctx.arc(x - r * 0.28, y - r * 0.28, r * 0.28, 0, Math.PI * 2); ctx.fill();
/* label */
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.font = `bold ${Math.round(r * 1.1)}px sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(p.type, x, y + 0.5);
ctx.textBaseline = 'alphabetic';
}
_drawGraph(ctx, x0, W, H) {
const gW = W - x0, pad = { l: 36, r: 10, t: 32, b: 28 };
const px = x0 + pad.l, py = pad.t;
const pw = gW - pad.l - pad.r;
const ph = H - pad.t - pad.b;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, gW, H);
/* title */
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left';
ctx.fillText('\u041A\u043E\u043D\u0446\u0435\u043D\u0442\u0440\u0430\u0446\u0438\u044F', x0 + 10, 16);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const yl = py + ph * (i / 4);
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
}
/* y-axis labels */
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right';
const maxN = Math.max(this.nA, this.nB) * 1.2 + 2;
for (let i = 0; i <= 4; i++) {
const v = Math.round(maxN * (4 - i) / 4);
ctx.fillText(v, px - 4, py + ph * (i / 4) + 3);
}
if (this._history.length < 2) return;
const n = this._history.length;
const lines = [
{ key: 'nA', color: '#EF476F', label: 'A' },
{ key: 'nB', color: '#9B5DE5', label: 'B' },
{ key: 'nC', color: '#7BF5A4', label: 'C' },
{ key: 'nD', color: '#FFD166', label: 'D' },
];
for (const { key, color } of lines) {
ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = 1.6;
for (let i = 0; i < n; i++) {
const lx = px + (i / Math.max(n - 1, 1)) * pw;
const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph;
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke();
}
/* legend */
lines.forEach(({ color, label }, i) => {
const lx = x0 + 10 + i * 38;
const ly = H - 14;
ctx.fillStyle = color;
ctx.fillRect(lx, ly, 10, 2.5);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left';
ctx.fillText(label, lx + 13, ly + 3);
});
/* current values */
const last = this._history[n - 1];
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "8px monospace";
ctx.textAlign = 'right';
ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC} D:${last.nD}`, x0 + gW - 8, H - 14);
}
_drawStats(ctx) {
const info = this.info();
const px = 10, py = 10, pw = 160, ph = 82;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
const lh = 16;
ctx.fillStyle = '#7BF5A4';
ctx.fillText(`K\u2091\u2071 = ${info.keq}`, px + 10, py + 8);
ctx.fillStyle = '#FFD166';
ctx.fillText(`Q = ${info.Q}`, px + 10, py + 8 + lh);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`\u041D\u0430\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435: ${info.direction}`, px + 10, py + 8 + lh * 2);
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.fillText(`T = ${this.T} K`, px + 10, py + 8 + lh * 3);
}
/* ═══════════════════════ utility ═══════════════════════ */
_rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
if (typeof module !== 'undefined') module.exports = EquilibriumSim;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
/**
* GasSim v2 — Ideal Gas simulation (PV=nRT, Maxwell-Boltzmann distribution)
* v2: hover inspector, velocity vectors, movable piston, v_mp/v_rms markers.
*/
class GasSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0;
this.H = 0;
this.particles = [];
this.N = 80;
this.T = 1.0;
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._raf = null;
this._updateTick = 0;
this.onUpdate = null;
this._loop = this._loop.bind(this);
// v2
this._showVectors = false;
this._pistonFrac = 1.0; // fraction of W — right wall position
this._hover = null; // hovered particle
this._pistonDrag = false;
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hover = null; this._pistonDrag = false; });
canvas.addEventListener('mousedown', e => this._onMouseDown(e));
canvas.addEventListener('mouseup', () => { this._pistonDrag = false; });
}
// ── canvas coordinate helper ────────────────────────────────────────────────
_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),
};
}
_onMouseDown(e) {
const { x } = this._cp(e);
const px = this.W * this._pistonFrac;
if (Math.abs(x - px) < 16) this._pistonDrag = true;
}
_onMouseMove(e) {
const { x, y } = this._cp(e);
if (this._pistonDrag) {
this.setPiston(x / this.W);
return;
}
// nearest particle within 28px
let best = null, bestD = 28;
for (const p of this.particles) {
const d = Math.hypot(p.x - x, p.y - y);
if (d < bestD) { bestD = d; best = p; }
}
this._hover = best;
}
// ── public API ──────────────────────────────────────────────────────────────
fit() {
this.W = this.canvas.offsetWidth;
this.H = this.canvas.offsetHeight;
this.canvas.width = this.W * devicePixelRatio;
this.canvas.height = this.H * devicePixelRatio;
this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
this.reset();
}
reset() {
this.particles = [];
const px = this.W * this._pistonFrac;
for (let i = 0; i < this.N; i++) {
const a = Math.random() * Math.PI * 2;
const s = this._maxwellSpeed();
this.particles.push({
x: 20 + Math.random() * (px - 40),
y: 20 + Math.random() * (this.H - 40),
vx: s * Math.cos(a),
vy: s * Math.sin(a),
r: 5,
});
}
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._updateTick = 0;
this._hover = null;
}
setN(n) { this.N = n; this.reset(); }
setT(t) {
const oldT = this.T;
if (oldT <= 0) { this.T = t; this.reset(); return; }
const f = Math.sqrt(t / oldT);
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
this.T = t;
}
setPiston(frac) {
this._pistonFrac = Math.max(0.3, Math.min(1.0, frac));
const px = this.W * this._pistonFrac;
for (const p of this.particles) {
if (p.x + p.r > px) { p.x = px - p.r; if (p.vx > 0) p.vx = -p.vx; }
}
}
toggleVectors() { this._showVectors = !this._showVectors; }
start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop); }
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
// ── simulation ──────────────────────────────────────────────────────────────
_loop() {
this._step();
this._step();
this.draw();
this._raf = requestAnimationFrame(this._loop);
}
_maxwellSpeed() {
const u1 = Math.max(1e-10, Math.random());
const sigma = this.T * 60;
return Math.abs(Math.sqrt(-2 * Math.log(u1)) * Math.cos(Math.PI * 2 * Math.random()) * sigma + sigma);
}
_step() {
const { W, H, particles } = this;
const px = W * this._pistonFrac;
for (const p of particles) { p.x += p.vx; p.y += p.vy; }
for (const p of particles) {
if (p.x < p.r) {
p.x = p.r; p.vx = Math.abs(p.vx);
this._wallImpulse += 2 * Math.abs(p.vx);
} else if (p.x > px - p.r) {
p.x = px - p.r; p.vx = -Math.abs(p.vx);
this._wallImpulse += 2 * Math.abs(p.vx);
}
if (p.y < p.r) {
p.y = p.r; p.vy = Math.abs(p.vy);
this._wallImpulse += 2 * Math.abs(p.vy);
} else if (p.y > H - p.r) {
p.y = H - p.r; p.vy = -Math.abs(p.vy);
this._wallImpulse += 2 * Math.abs(p.vy);
}
}
// Spatial grid collision
const cell = 14, cols = Math.ceil(W / cell), rows = Math.ceil(H / cell);
const grid = new Map();
const key = (cx, cy) => cy * cols + cx;
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const k = key(Math.floor(p.x / cell), Math.floor(p.y / cell));
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
const checked = new Set();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const cx = Math.floor(p.x / cell);
const cy = Math.floor(p.y / cell);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
const cell2 = grid.get(key(nx, ny));
if (!cell2) continue;
for (const j of cell2) {
if (j <= i) continue;
const pk = i * 100000 + j;
if (checked.has(pk)) continue;
checked.add(pk);
const q = particles[j];
const ddx = q.x - p.x, ddy = q.y - p.y;
const d2 = ddx * ddx + ddy * ddy;
const md = p.r + q.r;
if (d2 < md * md && d2 > 0) {
const d = Math.sqrt(d2), nx2 = ddx / d, ny2 = ddy / d;
const dvn = (q.vx - p.vx) * nx2 + (q.vy - p.vy) * ny2;
if (dvn >= 0) continue;
p.vx += dvn * nx2; p.vy += dvn * ny2;
q.vx -= dvn * nx2; q.vy -= dvn * ny2;
const ov = (md - d) / 2;
p.x -= ov * nx2; p.y -= ov * ny2;
q.x += ov * nx2; q.y += ov * ny2;
}
}
}
}
this._pressureSmooth = this._pressureSmooth * 0.92 + this._wallImpulse * 0.08;
this._wallImpulse = 0;
if (++this._updateTick % 30 === 0 && this.onUpdate) this.onUpdate(this.info());
}
info() {
const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy));
const avgSpeed = speeds.length ? speeds.reduce((a, b) => a + b) / speeds.length : 0;
const pf = this._pistonFrac;
const P = this._pressureSmooth / (2 * (this.W * pf + this.H)) * 100;
const V = (this.W * pf * this.H) / 10000;
return {
N: this.N, T: this.T,
P: P.toFixed(1), V: V.toFixed(1), PV: (P * V).toFixed(1),
avgSpeed: avgSpeed.toFixed(0),
speedData: this._speedHistogram(speeds),
};
}
_speedHistogram(speeds) {
const maxSpeed = this.T * 200;
const numBins = 12;
const binWidth = maxSpeed / numBins;
const bins = new Array(numBins).fill(0);
for (const s of speeds) {
const idx = Math.floor(s / binWidth);
if (idx >= 0 && idx < numBins) bins[idx]++;
}
return { bins, max: Math.max(...bins, 1), binWidth };
}
_mbCurve(v) {
const sigma = this.T * 60;
return (v / (sigma * sigma)) * Math.exp(-v * v / (2 * sigma * sigma));
}
// ── drawing ─────────────────────────────────────────────────────────────────
draw() {
const { ctx, W, H } = this;
const pistonX = W * this._pistonFrac;
// Background
const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7);
bg.addColorStop(0, '#080818'); bg.addColorStop(1, '#030308');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
// Grid
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= W; x += 20) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = 0; y <= H; y += 20) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
// Dead zone beyond piston
if (this._pistonFrac < 0.99) {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(pistonX, 0, W - pistonX, H);
}
// Pressure wall glow
const P = parseFloat(this.info().P);
const wi = Math.min(1, P / 50);
if (wi > 0) {
const a = wi * 0.3, gd = 30;
const glows = [
[ctx.createLinearGradient(0, 0, gd, 0), 0, 0, gd, H],
[ctx.createLinearGradient(pistonX, 0, pistonX - gd, 0), pistonX - gd, 0, gd, H],
[ctx.createLinearGradient(0, 0, 0, gd), 0, 0, W, gd],
[ctx.createLinearGradient(0, H, 0, H - gd), 0, H - gd, W, gd],
];
for (const [g, rx, ry, rw, rh] of glows) {
g.addColorStop(0, `rgba(155,93,229,${a})`);
g.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = g; ctx.fillRect(rx, ry, rw, rh);
}
}
// Velocity vectors
if (this._showVectors) {
ctx.save();
for (const p of this.particles) {
const scale = 3;
const ex = p.x + p.vx * scale, ey = p.y + p.vy * scale;
const ang = Math.atan2(p.vy, p.vx);
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke();
const hl = 4;
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - hl * Math.cos(ang - 0.4), ey - hl * Math.sin(ang - 0.4));
ctx.lineTo(ex - hl * Math.cos(ang + 0.4), ey - hl * Math.sin(ang + 0.4));
ctx.closePath(); ctx.fill();
}
ctx.restore();
}
// Particles
for (const p of this.particles) {
const spd = Math.hypot(p.vx, p.vy);
const T = this.T;
const color = spd < T * 40 ? '#4CC9F0' : spd < T * 80 ? '#7BF5A4' : spd < T * 120 ? '#FFD166' : '#EF476F';
const isH = this._hover === p;
ctx.save();
ctx.shadowBlur = isH ? 20 : 8;
ctx.shadowColor = color;
ctx.beginPath(); ctx.arc(p.x, p.y, isH ? p.r + 2 : p.r, 0, Math.PI * 2);
ctx.fillStyle = color; ctx.fill();
if (isH) { ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); }
ctx.restore();
}
// Piston
this._drawPiston(ctx, pistonX, H);
// Hover inspector
if (this._hover) this._drawInspector(ctx, this._hover, W, H);
// Histogram
this._drawHistogram(ctx, W, H);
}
_drawPiston(ctx, pistonX, H) {
if (this._pistonFrac >= 0.99) return;
ctx.save();
const pw = 8;
ctx.shadowBlur = 16; ctx.shadowColor = 'rgba(255,209,102,0.5)';
const g = ctx.createLinearGradient(pistonX - pw, 0, pistonX + pw, 0);
g.addColorStop(0, 'rgba(255,209,102,0.4)');
g.addColorStop(0.5, 'rgba(255,209,102,0.9)');
g.addColorStop(1, 'rgba(255,209,102,0.3)');
ctx.fillStyle = g; ctx.fillRect(pistonX - pw / 2, 0, pw, H);
// Handle
const hh = 44, hw = 18, hx = pistonX - hw / 2, hy = H / 2 - hh / 2;
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,209,102,0.88)';
ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 4); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1.5;
for (let i = 0; i < 3; i++) {
const gy = hy + 10 + i * 10;
ctx.beginPath(); ctx.moveTo(hx + 4, gy); ctx.lineTo(hx + hw - 4, gy); ctx.stroke();
}
ctx.fillStyle = 'rgba(255,209,102,0.7)';
ctx.font = "bold 9px 'Manrope', sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('⇌', pistonX, hy - 12);
ctx.restore();
}
_drawInspector(ctx, p, W, H) {
const spd = Math.hypot(p.vx, p.vy);
const ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
const ke = 0.5 * spd * spd;
const T = this.T;
const clr = spd < T * 40 ? '#4CC9F0' : spd < T * 80 ? '#7BF5A4' : spd < T * 120 ? '#FFD166' : '#EF476F';
const rows = [
['|v|', spd.toFixed(1) + ' у.е.'],
['vx', p.vx.toFixed(1)],
['vy', p.vy.toFixed(1)],
['KE', ke.toFixed(0) + ' у.е.'],
['угол', ang.toFixed(1) + '°'],
];
const tw = 132, th = 18 + rows.length * 17 + 8;
let tx = p.x + 14, ty = p.y - th / 2;
if (tx + tw > W - 10) tx = p.x - tw - 14;
ty = Math.max(8, Math.min(H - th - 8, ty));
ctx.save();
ctx.fillStyle = 'rgba(6,8,28,0.92)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill();
ctx.fillStyle = clr;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8, 8, 0, 0]); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
ctx.font = "11px 'Manrope', monospace"; ctx.textBaseline = 'middle';
for (let i = 0; i < rows.length; i++) {
const ry = ty + 18 + i * 17;
ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.textAlign = 'left';
ctx.fillText(rows[i][0], tx + 10, ry);
ctx.fillStyle = 'rgba(255,255,255,0.92)'; ctx.textAlign = 'right';
ctx.fillText(rows[i][1], tx + tw - 10, ry);
}
ctx.restore();
}
_drawHistogram(ctx, W, H) {
const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy));
const hist = this._speedHistogram(speeds);
const hw = 204, hh = 102;
const hx = W - hw - 12, hy = H - hh - 12;
const pad = { l: 8, r: 8, t: 20, b: 18 };
const barW = (hw - pad.l - pad.r) / hist.bins.length;
const barAreaH = hh - pad.t - pad.b;
const maxV = this.T * 200;
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.58)';
ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 6); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.72)'; ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Распределение скоростей', hx + hw / 2, hy + 11);
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '8px sans-serif';
ctx.fillText('v (у.е.)', hx + hw / 2, hy + hh - 2);
// Bars
for (let i = 0; i < hist.bins.length; i++) {
const ratio = hist.bins[i] / hist.max;
const bh = ratio * barAreaH;
const bx = hx + pad.l + i * barW;
const by = hy + pad.t + barAreaH - bh;
ctx.fillStyle = 'rgba(155,93,229,0.75)';
ctx.beginPath(); ctx.roundRect(bx + 0.5, by, barW - 1, bh, 2); ctx.fill();
}
// MB theoretical curve
ctx.strokeStyle = 'rgba(255,209,102,0.9)'; ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]); ctx.beginPath();
let first = true;
for (let i = 0; i <= 80; i++) {
const v = (i / 80) * maxV;
const sc = this._mbCurve(v) * speeds.length * hist.binWidth / hist.max;
const cx2 = hx + pad.l + (v / maxV) * (hw - pad.l - pad.r);
const cy2 = hy + pad.t + barAreaH - sc * barAreaH;
if (first) { ctx.moveTo(cx2, cy2); first = false; }
else ctx.lineTo(cx2, cy2);
}
ctx.stroke(); ctx.setLineDash([]);
// Characteristic speed lines
const sigma = this.T * 60;
const v_mp = sigma; // v most probable (mode)
const v_rms = sigma * Math.sqrt(2); // v_rms in 2D = sqrt(2) * sigma
const vline = (v, color, label) => {
if (v > maxV) return;
const vx2 = hx + pad.l + (v / maxV) * (hw - pad.l - pad.r);
ctx.strokeStyle = color; ctx.lineWidth = 1;
ctx.setLineDash([2, 3]);
ctx.beginPath(); ctx.moveTo(vx2, hy + pad.t); ctx.lineTo(vx2, hy + pad.t + barAreaH); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color; ctx.font = '7px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(label, vx2, hy + pad.t - 3);
};
vline(v_mp, 'rgba(76,201,240,0.9)', 'v_mp');
vline(v_rms, 'rgba(239,71,111,0.9)', 'v_rms');
ctx.restore();
}
}
+493
View File
@@ -0,0 +1,493 @@
'use strict';
/* ═══════════════════════════════════════════════
GraphSim — interactive function plotter
Usage:
const sim = new GraphSim(canvasElement);
sim.setFn(0, 'sin(x)', '#9B5DE5');
sim.onHover = (mx, yVals) => { ... };
═══════════════════════════════════════════════ */
class GraphSim {
constructor(canvas) {
this.c = canvas;
this.ctx = canvas.getContext('2d');
this.ox = 0; // viewport centre x (math units)
this.oy = 0; // viewport centre y (math units)
this.scl = 50; // px per unit
this.fns = []; // [{ color, fn } | null]
this.hx = null; // hovered x (math) or null
this._dg = null; // drag state
this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null)
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); })
.observe(canvas.parentElement);
}
/* ── public ────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const r = this.c.parentElement.getBoundingClientRect();
const w = r.width || 600, h = r.height || 400;
this.c.width = w * dpr;
this.c.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this._cw = w; this._ch = h;
}
/** idx 0-2, expr string, color hex. Returns error string or null. */
setFn(idx, expr, color) {
if (!expr || !expr.trim()) {
this.fns[idx] = null;
this.draw();
return null;
}
try {
const fn = this._compile(expr);
fn(0); fn(1); fn(-1); fn(Math.PI); // smoke-test
this.fns[idx] = { color, fn };
this.draw();
return null;
} catch {
this.fns[idx] = null;
this.draw();
return 'Синтаксическая ошибка';
}
}
resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
/* ── formula compiler (CSP-safe: no eval / new Function) ── */
_compile(raw) {
const tokens = this._tokenize(raw.trim());
const expanded = this._insertImplicit(tokens);
return this._parseExpr(expanded);
}
_tokenize(src) {
const out = [];
let i = 0;
while (i < src.length) {
const ch = src[i];
if (/\s/.test(ch)) { i++; continue; }
/* number */
if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(src[i + 1] || ''))) {
let j = i;
while (j < src.length && /[0-9]/.test(src[j])) j++;
if (j < src.length && src[j] === '.') {
j++;
while (j < src.length && /[0-9]/.test(src[j])) j++;
}
if (j < src.length && /[eE]/.test(src[j])) {
j++;
if (j < src.length && /[+\-]/.test(src[j])) j++;
while (j < src.length && /[0-9]/.test(src[j])) j++;
}
out.push({ type: 'num', val: parseFloat(src.slice(i, j)) });
i = j;
continue;
}
/* identifier */
if (/[a-zA-Z_]/.test(ch)) {
let j = i;
while (j < src.length && /[a-zA-Z_0-9]/.test(src[j])) j++;
out.push({ type: 'id', val: src.slice(i, j) });
i = j;
continue;
}
/* operator / bracket */
if ('+-*/^()'.includes(ch)) {
out.push({ type: 'op', val: ch });
i++;
continue;
}
throw new Error('Unknown character: ' + ch);
}
return out;
}
/* Insert implicit '*' where adjacent tokens imply multiplication.
Covers: 2x <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2*x, 2( <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2*(, )( <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> )*(, )x <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> )*x */
_insertImplicit(tokens) {
const FUNS = new Set([
'sin','cos','tan','tg','ctg',
'asin','acos','atan','arcsin','arccos','arctan','arctg',
'sqrt','abs','exp','ln','log','log2','log10',
'ceil','floor','round','sign',
]);
const out = [];
for (let i = 0; i < tokens.length; i++) {
out.push(tokens[i]);
const cur = tokens[i], nxt = tokens[i + 1];
if (!nxt) continue;
const curEnds = cur.type === 'num' ||
(cur.type === 'id' && !FUNS.has(cur.val)) ||
(cur.type === 'op' && cur.val === ')');
const nxtStarts = nxt.type === 'num' ||
nxt.type === 'id' ||
(nxt.type === 'op' && nxt.val === '(');
if (curEnds && nxtStarts) out.push({ type: 'op', val: '*' });
}
return out;
}
/* Recursive-descent parser <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> returns a closure x => number */
_parseExpr(tokens) {
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
const eat = v => {
if (!peek() || peek().val !== v) throw new Error('Expected ' + v);
pos++;
};
/* All supported functions including Russian aliases */
const FN = {
sin: Math.sin, cos: Math.cos, tan: Math.tan, tg: Math.tan,
asin: Math.asin, acos: Math.acos, atan: Math.atan,
arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan,
sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp,
ln: Math.log, log: Math.log10, log2: Math.log2, log10: Math.log10,
ceil: Math.ceil, floor: Math.floor, round: Math.round, sign: Math.sign,
ctg: t => 1 / Math.tan(t),
};
/* additive: left-associative +/- */
const addSub = () => {
let l = mulDiv();
while (peek() && (peek().val === '+' || peek().val === '-')) {
const op = next().val;
const r = mulDiv();
const ll = l;
l = op === '+' ? x => ll(x) + r(x) : x => ll(x) - r(x);
}
return l;
};
/* multiplicative: left-associative */
const mulDiv = () => {
let l = power();
while (peek() && (peek().val === '*' || peek().val === '/')) {
const op = next().val;
const r = power();
const ll = l;
l = op === '*' ? x => ll(x) * r(x) : x => ll(x) / r(x);
}
return l;
};
/* power: right-associative ^ */
const power = () => {
const base = unary();
if (peek() && peek().val === '^') {
next();
const exp = power(); // right-recursive <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> right-associative
return x => Math.pow(base(x), exp(x));
}
return base;
};
/* unary minus / plus */
const unary = () => {
if (peek()?.val === '-') { next(); const v = unary(); return x => -v(x); }
if (peek()?.val === '+') { next(); return unary(); }
return primary();
};
/* primary: number | variable | constant | fn(…) | (…) */
const primary = () => {
const t = peek();
if (!t) throw new Error('Unexpected end of expression');
if (t.type === 'num') {
next();
const v = t.val;
return () => v;
}
if (t.type === 'id') {
next();
if (t.val === 'x') return x => x;
if (t.val === 'pi' || t.val === 'PI') return () => Math.PI;
if (t.val === 'e') return () => Math.E;
if (FN[t.val]) {
eat('(');
const arg = addSub();
eat(')');
const f = FN[t.val];
return x => f(arg(x));
}
throw new Error('Unknown identifier: ' + t.val);
}
if (t.type === 'op' && t.val === '(') {
next();
const v = addSub();
eat(')');
return v;
}
throw new Error('Unexpected token: ' + t.val);
};
const fn = addSub();
if (pos !== tokens.length) throw new Error('Unexpected tokens after expression');
return fn;
}
/* ── coordinate transforms ─────────────────── */
_toPx(mx, my) {
const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2;
return [cx + (mx - this.ox) * this.scl,
cy - (my - this.oy) * this.scl];
}
_toMx(px, py) {
const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2;
return [(px - cx) / this.scl + this.ox,
-(py - cy) / this.scl + this.oy];
}
/* ── main render ───────────────────────────── */
draw() {
const c = this.ctx, W = this._cw || this.c.width, H = this._ch || this.c.height;
if (!W || !H) return;
c.fillStyle = '#0D0D1A';
c.fillRect(0, 0, W, H);
this._drawGrid(c, W, H);
this._drawAxes(c, W, H);
for (const f of this.fns) if (f) this._drawCurve(c, W, H, f);
if (this.hx !== null) this._drawHover(c, W, H);
}
/* ── grid ──────────────────────────────────── */
_niceStep() {
const raw = (this._cw || this.c.width) / this.scl / 8;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const n of [1, 2, 5, 10]) if (n * p >= raw) return n * p;
return p;
}
_drawGrid(c, W, H) {
const step = this._niceStep();
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
const [, y0] = this._toMx(0, H), [, y1] = this._toMx(0, 0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
c.strokeStyle = 'rgba(255,255,255,0.065)';
c.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = this._toPx(x, 0);
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = this._toPx(0, y);
c.beginPath(); c.moveTo(0, py); c.lineTo(W, py); c.stroke();
}
/* number labels */
c.font = '11px Manrope, system-ui, sans-serif';
c.fillStyle = 'rgba(255,255,255,0.3)';
const [axX, axY] = this._toPx(0, 0);
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
c.textAlign = 'center'; c.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = this._toPx(x, 0);
if (px < 18 || px > W - 18) continue;
c.fillText(this._fmtN(x, step), px, lblY);
}
c.textAlign = 'right'; c.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = this._toPx(0, y);
if (py < 12 || py > H - 12) continue;
c.fillText(this._fmtN(y, step), lblX, py);
}
}
_fmtN(n, step) {
if (n === 0) return '0';
if (step >= 1 && Number.isInteger(n)) return String(n);
if (step < 0.001) return n.toExponential(1);
const dec = Math.max(0, -Math.floor(Math.log10(step)));
return n.toFixed(dec);
}
/* ── axes ──────────────────────────────────── */
_drawAxes(c, W, H) {
const [ax, ay] = this._toPx(0, 0);
c.strokeStyle = 'rgba(255,255,255,0.4)';
c.lineWidth = 1.5;
c.beginPath(); c.moveTo(0, ay); c.lineTo(W - 10, ay); c.stroke();
c.beginPath(); c.moveTo(ax, H); c.lineTo(ax, 8); c.stroke();
c.fillStyle = 'rgba(255,255,255,0.4)';
this._arrowHead(c, W - 8, ay, 0);
this._arrowHead(c, ax, 6, -Math.PI / 2);
c.fillStyle = 'rgba(255,255,255,0.55)';
c.font = 'bold 12px Manrope, sans-serif';
c.textBaseline = 'middle'; c.textAlign = 'left';
c.fillText('x', W - 10, ay - 13);
c.textBaseline = 'top'; c.textAlign = 'left';
c.fillText('y', ax + 7, 4);
}
_arrowHead(c, x, y, angle) {
const s = 5;
c.save(); c.translate(x, y); c.rotate(angle);
c.beginPath();
c.moveTo(0, 0); c.lineTo(-s * 1.6, -s * 0.6); c.lineTo(-s * 1.6, s * 0.6);
c.closePath(); c.fill();
c.restore();
}
/* ── curve ─────────────────────────────────── */
_drawCurve(c, W, H, { fn, color }) {
const steps = Math.min(W * 2, 2000);
const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0);
const dx = (x1 - x0) / steps;
const maxJmp = (H / this.scl) * 2; // discontinuity threshold (math units)
c.strokeStyle = color;
c.lineWidth = 2.5;
c.lineJoin = 'round';
c.beginPath();
let pen = false, pyPrev = null;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
let my;
try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; }
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
// discontinuity guard
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) {
pen = false;
}
const [px, py] = this._toPx(mx, my);
pen ? c.lineTo(px, py) : c.moveTo(px, py);
pen = true; pyPrev = my;
}
c.stroke();
}
/* ── hover crosshair ───────────────────────── */
_drawHover(c, W, H) {
const [px] = this._toPx(this.hx, 0);
c.strokeStyle = 'rgba(255,255,255,0.15)';
c.lineWidth = 1;
c.setLineDash([5, 5]);
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke();
c.setLineDash([]);
for (const f of this.fns) {
if (!f) continue;
let my;
try { my = f.fn(this.hx); } catch { continue; }
if (!isFinite(my) || isNaN(my)) continue;
const [, py] = this._toPx(this.hx, my);
if (py < -20 || py > H + 20) continue;
c.fillStyle = f.color;
c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill();
c.strokeStyle = 'rgba(255,255,255,0.8)';
c.lineWidth = 1.5; c.stroke();
}
}
/* ── events ─────────────────────────────────── */
_bind() {
const cv = this.c;
/* wheel zoom — zoom toward cursor */
cv.addEventListener('wheel', e => {
e.preventDefault();
const [mx, my] = this._toMx(e.offsetX, e.offsetY);
this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
const [nx, ny] = this._toMx(e.offsetX, e.offsetY);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
}, { passive: false });
/* mouse drag */
cv.addEventListener('mousedown', e => {
this._dg = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
cv.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => {
if (this._dg) {
this.ox = this._dg.ox - (e.clientX - this._dg.x) / this.scl;
this.oy = this._dg.oy + (e.clientY - this._dg.y) / this.scl;
this.draw();
} else {
const r = cv.getBoundingClientRect();
const lx = e.clientX - r.left, ly = e.clientY - r.top;
if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
this.hx = this._toMx(lx, ly)[0];
this.draw();
this._emitHover();
}
}
});
window.addEventListener('mouseup', () => {
this._dg = null;
cv.style.cursor = 'crosshair';
});
cv.addEventListener('mouseleave', () => {
this.hx = null; this.draw();
if (this.onHover) this.onHover(null, null);
});
cv.style.cursor = 'crosshair';
/* touch drag */
let t0 = null;
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1)
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
}, { passive: true });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw();
}
}, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; });
}
_emitHover() {
if (!this.onHover) return;
const vals = this.fns.map(f => {
if (!f) return null;
try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; }
});
this.onHover(this.hx, vals);
}
}
+355
View File
@@ -0,0 +1,355 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
GraphTransformSim — graph transformations explorer
y = a·f(k·x + b) + c with sliders for a, k, b, c
Original f(x) shown faded, transformed shown bold.
══════════════════════════════════════════════════════════════ */
class GraphTransformSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* base function */
this._baseFn = x => Math.sin(x);
this._baseLabel = 'sin(x)';
/* transform params */
this.a = 1;
this.k = 1;
this.b = 0;
this.c = 0;
/* view */
this.ox = 0;
this.oy = 0;
this.scl = 40;
this.hx = null;
this._drag = null;
this.onUpdate = null;
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public ──────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ a, k, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (k !== undefined) this.k = +k;
if (b !== undefined) this.b = +b;
if (c !== undefined) this.c = +c;
this.draw();
this._emit();
}
setBase(name) {
const BASES = {
'sin': { fn: x => Math.sin(x), label: 'sin(x)' },
'cos': { fn: x => Math.cos(x), label: 'cos(x)' },
'x^2': { fn: x => x * x, label: 'x²' },
'sqrt': { fn: x => x >= 0 ? Math.sqrt(x) : NaN, label: '√x' },
'|x|': { fn: x => Math.abs(x), label: '|x|' },
'1/x': { fn: x => x !== 0 ? 1 / x : NaN, label: '1/x' },
'x^3': { fn: x => x * x * x, label: 'x³' },
};
const b = BASES[name];
if (b) { this._baseFn = b.fn; this._baseLabel = b.label; this.draw(); this._emit(); }
}
resetView() { this.ox = 0; this.oy = 0; this.scl = 40; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
info() {
const { a, k, b, c } = this;
const parts = [];
if (a !== 1) parts.push(a === -1 ? '' : a.toFixed(1) + '·');
parts.push(this._baseLabel.replace('x', this._innerStr()));
if (c > 0) parts.push(' + ' + c.toFixed(1));
if (c < 0) parts.push(' ' + Math.abs(c).toFixed(1));
return {
base: this._baseLabel,
equation: 'y = ' + parts.join(''),
a: a.toFixed(1),
k: k.toFixed(1),
b: b.toFixed(1),
c: c.toFixed(1),
};
}
/* ── internals ──────────────────────────────────── */
_innerStr() {
const { k, b } = this;
let s = '';
if (k !== 1) s += (k === -1 ? '' : k.toFixed(1) + '·');
s += 'x';
if (b > 0) s += ' + ' + b.toFixed(1);
if (b < 0) s += ' ' + Math.abs(b).toFixed(1);
return s;
}
_fBase(x) { try { return this._baseFn(x); } catch { return NaN; } }
_fTransformed(x) {
const inner = this.k * x + this.b;
const base = this._fBase(inner);
return this.a * base + this.c;
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── coordinate transforms ────────────────────── */
_toPx(mx, my) {
return [
this.W / 2 + (mx - this.ox) * this.scl,
this.H / 2 - (my - this.oy) * this.scl,
];
}
_toMath(px, py) {
return [
(px - this.W / 2) / this.scl + this.ox,
-(py - this.H / 2) / this.scl + this.oy,
];
}
/* ── draw ────────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx, W, H);
this._drawAxes(ctx, W, H);
this._drawCurve(ctx, W, H, x => this._fBase(x), 'rgba(255,255,255,0.18)', 2); // original faded
this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); // transformed bold
this._drawEquation(ctx, W, H);
if (this.hx !== null) this._drawHover(ctx, W, H);
}
_drawGrid(ctx, W, H) {
const step = this._niceStep();
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = this._toPx(x, 0);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = this._toPx(0, y);
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
const [axX, axY] = this._toPx(0, 0);
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = this._toPx(x, 0);
if (px < 18 || px > W - 18) continue;
ctx.fillText(this._fmtLabel(x, step), px, lblY);
}
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = this._toPx(0, y);
if (py < 12 || py > H - 12) continue;
ctx.fillText(this._fmtLabel(y, step), lblX, py);
}
}
_niceStep() {
const raw = this.W / this.scl / 8;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
_fmtLabel(n, step) {
if (n === 0) return '0';
if (step >= 1 && Number.isInteger(n)) return String(n);
if (step < 0.001) return n.toExponential(1);
const dec = Math.max(0, -Math.floor(Math.log10(step)));
return n.toFixed(dec);
}
_drawAxes(ctx, W, H) {
const [ax, ay] = this._toPx(0, 0);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.4)';
const s = 5;
// x arrow
ctx.save(); ctx.translate(W - 8, ay); ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore();
// y arrow
ctx.save(); ctx.translate(ax, 6); ctx.rotate(-Math.PI / 2); ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
ctx.fillText('x', W - 10, ay - 13);
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('y', ax + 7, 4);
}
_drawCurve(ctx, W, H, fn, color, lw) {
const steps = Math.min(W * 2, 2000);
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const dx = (x1 - x0) / steps;
const maxJmp = (H / this.scl) * 2;
ctx.strokeStyle = color;
ctx.lineWidth = lw;
ctx.lineJoin = 'round';
ctx.beginPath();
let pen = false, pyPrev = null;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
const my = fn(mx);
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) pen = false;
const [px, py] = this._toPx(mx, my);
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true; pyPrev = my;
}
ctx.stroke();
}
_drawEquation(ctx, W, H) {
const info = this.info();
ctx.font = 'bold 13px Manrope, sans-serif';
const text = info.equation;
const tw = ctx.measureText(text).width;
const x = W - tw - 24, y = 14;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.fill();
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.stroke();
ctx.fillStyle = '#ddd';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(text, x + 8, y + 13);
// base function label (faded)
const base = 'f(x) = ' + this._baseLabel;
ctx.font = '11px Manrope, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillText(base, x + 8, y + 38);
}
_drawHover(ctx, W, H) {
const [px] = this._toPx(this.hx, 0);
const myOrig = this._fBase(this.hx);
const myTrans = this._fTransformed(this.hx);
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.setLineDash([]);
// original point
if (isFinite(myOrig)) {
const [, py] = this._toPx(this.hx, myOrig);
if (py > -20 && py < H + 20) {
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fill();
}
}
// transformed point
if (isFinite(myTrans)) {
const [, py2] = this._toPx(this.hx, myTrans);
if (py2 > -20 && py2 < H + 20) {
ctx.fillStyle = '#9B5DE5';
ctx.beginPath(); ctx.arc(px, py2, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
}
}
}
/* ── events ──────────────────────────────────── */
_bind() {
const cv = this.canvas;
cv.addEventListener('wheel', e => {
e.preventDefault();
const [mx, my] = this._toMath(e.offsetX, e.offsetY);
this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
const [nx, ny] = this._toMath(e.offsetX, e.offsetY);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
}, { passive: false });
cv.addEventListener('mousedown', e => {
this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
cv.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => {
if (this._drag) {
this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl;
this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl;
this.draw();
} else {
const r = cv.getBoundingClientRect();
const lx = e.clientX - r.left, ly = e.clientY - r.top;
if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
this.hx = this._toMath(lx, ly)[0];
this.draw();
}
}
});
window.addEventListener('mouseup', () => { this._drag = null; cv.style.cursor = 'crosshair'; });
cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); });
cv.style.cursor = 'crosshair';
let t0 = null;
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1)
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
}, { passive: true });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw();
}
}, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; });
}
}
+484
View File
@@ -0,0 +1,484 @@
'use strict';
/* ====================================================================
IonExSim — Реакции ионного обмена
==================================================================== */
class IonExSim {
/* ── Данные реакций ──────────────────────────────────────────────── */
static RXN = {
ba_so4: {
name: 'BaCl₂ + Na₂SO₄',
left: [{ f: 'Ba²⁺', color: '#4FC3F7', count: 7 }, { f: 'Cl⁻', color: '#AED581', count: 14 }],
right: [{ f: 'Na⁺', color: '#FFD54F', count: 14 }, { f: 'SO₄²⁻', color: '#F48FB1', count: 7 }],
reacts: ['Ba²⁺', 'SO₄²⁻'],
spectators: ['Cl⁻', 'Na⁺'],
product: { f: 'BaSO₄', color: '#E0E0E0' },
mol: 'BaCl₂ + Na₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaCl',
full_ion: 'Ba²⁺ + 2Cl⁻ + 2Na⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2Cl⁻',
net_ion: 'Ba²⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
type: 'precip', pcolor: '#E0E0E0', pname: 'BaSO₄ — белый осадок',
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#E0E0E0',
},
ag_cl: {
name: 'AgNO₃ + NaCl',
left: [{ f: 'Ag⁺', color: '#E0E0E0', count: 10 }, { f: 'NO₃⁻', color: '#FFCC02', count: 10 }],
right: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }],
reacts: ['Ag⁺', 'Cl⁻'],
spectators: ['NO₃⁻', 'Na⁺'],
product: { f: 'AgCl', color: '#F5F5F5' },
mol: 'AgNO₃ + NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + NaNO₃',
full_ion: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na⁺ + NO₃⁻',
net_ion: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
type: 'precip', pcolor: '#F5F5F5', pname: 'AgCl — белый творожистый осадок',
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F5F5F5',
},
co3_hcl: {
name: 'Na₂CO₃ + HCl',
left: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 5 }],
right: [{ f: 'H⁺', color: '#EF5350', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }],
reacts: ['CO₃²⁻', 'H⁺'],
spectators: ['Na⁺', 'Cl⁻'],
product: { f: 'CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', color: '#B0BEC5' },
mol: 'Na₂CO₃ + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
full_ion: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2Cl⁻ + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
net_ion: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> + H₂O',
type: 'gas', gcolor: '#B0BEC5', gname: 'CO₂ — углекислый газ',
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', signColor: '#B0BEC5',
},
pb_i: {
name: 'Pb(NO₃)₂ + KI',
left: [{ f: 'Pb²⁺', color: '#F48FB1', count: 6 }, { f: 'NO₃⁻', color: '#FFCC02', count: 12 }],
right: [{ f: 'K⁺', color: '#80CBC4', count: 12 }, { f: 'I⁻', color: '#CE93D8', count: 12 }],
reacts: ['Pb²⁺', 'I⁻'],
spectators: ['NO₃⁻', 'K⁺'],
product: { f: 'PbI₂', color: '#F9A825' },
mol: 'Pb(NO₃)₂ + 2KI <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KNO₃',
full_ion: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + 2I⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + 2NO₃⁻',
net_ion: 'Pb²⁺ + 2I⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbI₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
type: 'precip', pcolor: '#F9A825', pname: 'PbI₂ — ярко-жёлтый осадок',
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F9A825',
},
ca_co3: {
name: 'CaCl₂ + Na₂CO₃',
left: [{ f: 'Ca²⁺', color: '#FF8A65', count: 8 }, { f: 'Cl⁻', color: '#AED581', count: 16 }],
right: [{ f: 'Na⁺', color: '#FFD54F', count: 16 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 8 }],
reacts: ['Ca²⁺', 'CO₃²⁻'],
spectators: ['Cl⁻', 'Na⁺'],
product: { f: 'CaCO₃', color: '#F5F5F5' },
mol: 'CaCl₂ + Na₂CO₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaCl',
full_ion: 'Ca²⁺ + 2Cl⁻ + 2Na⁺ + CO₃²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2Cl⁻',
net_ion: 'Ca²⁺ + CO₃²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCO₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
type: 'precip', pcolor: '#F5F5F5', pname: 'CaCO₃ — белый осадок (мел)',
sign: '<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', signColor: '#F5F5F5',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.rxnId = 'ba_so4';
this._raf = null;
this._last = 0;
this._t = 0;
this._phase = 'idle'; // idle | mixing | pairing | done
this._prog = 0;
this._stepIdx = 0;
this._stepTimer = 0;
this._ions = [];
this._pairs = [];
this._precip = [];
this._gas = [];
this.W = 0; this.H = 0;
this.onUpdate = null;
this.fit();
this._initIons();
}
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 600;
const H = this.canvas.offsetHeight || 400;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._initIons();
}
setReaction(id) {
if (!IonExSim.RXN[id]) return;
this.rxnId = id;
this.reset();
}
reset() {
this._phase = 'idle'; this._prog = 0;
this._stepIdx = 0; this._stepTimer = 0;
this._pairs = []; this._precip = []; this._gas = [];
this._initIons();
this.draw();
}
_initIons() {
const { W, H } = this;
const rxn = IonExSim.RXN[this.rxnId];
const bTop = H * 0.10, bBot = H * 0.78;
this._ions = [];
/* Left beaker ions */
rxn.left.forEach(spec => {
for (let i = 0; i < spec.count; i++) {
this._ions.push({
x: W * 0.10 + Math.random() * W * 0.36,
y: bTop + 20 + Math.random() * (bBot - bTop - 40),
vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8,
spec: spec.f, color: spec.color,
r: 8 + Math.random() * 3,
phase: Math.random() * Math.PI * 2,
active: true, side: 'L',
reacts: rxn.reacts.includes(spec.f),
paired: false,
});
}
});
/* Right beaker ions */
rxn.right.forEach(spec => {
for (let i = 0; i < spec.count; i++) {
this._ions.push({
x: W * 0.54 + Math.random() * W * 0.36,
y: bTop + 20 + Math.random() * (bBot - bTop - 40),
vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8,
spec: spec.f, color: spec.color,
r: 8 + Math.random() * 3,
phase: Math.random() * Math.PI * 2,
active: true, side: 'R',
reacts: rxn.reacts.includes(spec.f),
paired: false,
});
}
});
}
start() {
if (this._phase !== 'idle') this.reset();
this._phase = 'mixing'; this._prog = 0;
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Физика ─────────────────────────────────────────────────────── */
_tick(t) {
const dt = Math.min((t - this._last) / 1000, 0.05);
this._last = t; this._t += dt;
const { W, H } = this;
const rxn = IonExSim.RXN[this.rxnId];
if (this._phase === 'mixing') {
this._prog = Math.min(1, this._prog + dt * 0.32);
this._ions.forEach(ion => {
if (!ion.active) return;
const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.72;
const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.38;
ion.vx += (tx - ion.x) * 0.003 * this._prog;
ion.vy += (ty - ion.y) * 0.003 * this._prog;
ion.vx += (Math.random() - 0.5) * 0.7;
ion.vy += (Math.random() - 0.5) * 0.7;
ion.vx *= 0.88; ion.vy *= 0.88;
ion.x += ion.vx; ion.y += ion.vy;
ion.phase += dt * 2;
this._clampIon(ion);
});
if (this._prog >= 1) { this._phase = 'pairing'; this._prog = 0; }
}
if (this._phase === 'pairing') {
this._prog = Math.min(1, this._prog + dt * 0.16);
this._stepTimer += dt;
if (this._stepTimer > 1.5 && this._stepIdx < 2) { this._stepIdx++; this._stepTimer = 0; }
this._ions.forEach(ion => {
if (!ion.active || ion.paired) return;
ion.vx += (Math.random() - 0.5) * 0.8;
ion.vy += (Math.random() - 0.5) * 0.8;
ion.vx *= 0.88; ion.vy *= 0.88;
ion.x += ion.vx; ion.y += ion.vy;
ion.phase += dt * 2;
this._clampIon(ion);
});
/* Pair up reactive ions */
if (Math.random() < 0.10 * (0.5 + this._prog)) this._doPair(rxn);
/* Animate pairs */
this._pairs.forEach(p => {
p.flashT = Math.max(0, p.flashT - dt * 2.5);
if (rxn.type === 'precip') {
p.vy = Math.min(p.vy + 0.15, 5);
p.y += p.vy;
if (p.y >= H * 0.78 && !p.settled) {
p.y = H * 0.78; p.vy = 0; p.settled = true;
this._precip.push({ x: p.x, y: p.y, r: p.r, id: p.id });
}
} else if (rxn.type === 'gas') {
p.vy = Math.max(p.vy - 0.08, -4);
p.y += p.vy;
p.alpha = Math.max(0, p.alpha - 0.004);
}
});
this._pairs = this._pairs.filter(p => !p.settled && (p.alpha === undefined || p.alpha > 0));
if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 2; }
}
if (this._phase === 'done') {
this._ions.forEach(ion => {
if (!ion.active || ion.paired) return;
ion.vx += (Math.random() - 0.5) * 0.45;
ion.vy += (Math.random() - 0.5) * 0.45;
ion.vx *= 0.92; ion.vy *= 0.92;
ion.x += ion.vx; ion.y += ion.vy;
ion.phase += dt;
this._clampIon(ion);
});
}
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
_clampIon(ion) {
const { W, H } = this;
const bTop = H * 0.10, bBot = H * 0.78;
if (ion.x < ion.r + 6) { ion.x = ion.r + 6; ion.vx *= -0.5; }
if (ion.x > W - ion.r - 6) { ion.x = W - ion.r - 6; ion.vx *= -0.5; }
if (ion.y < bTop + ion.r) { ion.y = bTop + ion.r; ion.vy *= -0.5; }
if (ion.y > bBot - ion.r) { ion.y = bBot - ion.r; ion.vy *= -0.5; }
}
_doPair(rxn) {
const r1 = rxn.reacts[0], r2 = rxn.reacts[1];
const pool1 = this._ions.filter(i => i.active && !i.paired && i.spec === r1);
const pool2 = this._ions.filter(i => i.active && !i.paired && i.spec === r2);
if (!pool1.length || !pool2.length) return;
const a = pool1[Math.floor(Math.random() * pool1.length)];
const b = pool2[Math.floor(Math.random() * pool2.length)];
a.paired = true; b.paired = true;
a.active = false; b.active = false;
this._pairs.push({
id: this._t + Math.random(),
x: (a.x + b.x) / 2, y: (a.y + b.y) / 2,
vy: rxn.type === 'gas' ? -2 : 0,
r: 7, flashT: 1, settled: false, alpha: 1,
});
}
/* ── Рендеринг ──────────────────────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
const rxn = IonExSim.RXN[this.rxnId];
ctx.fillStyle = '#07071A';
ctx.fillRect(0, 0, W, H);
/* Dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.07)';
for (let x = 0; x < W; x += 28) {
for (let y = 0; y < H; y += 28) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
}
if (this._phase === 'idle') {
this._drawTwoBeakers(ctx, W, H, rxn);
} else {
this._drawSingleBeaker(ctx, W, H);
}
this._drawIons(ctx, rxn);
this._drawPairs(ctx, rxn);
this._drawPrecipitate(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
}
_drawTwoBeakers(ctx, W, H, rxn) {
const drawB = (x, y, w, h, ions) => {
ctx.save();
ctx.strokeStyle = 'rgba(120,185,255,0.55)'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x, y + h);
ctx.lineTo(x + w, y + h); ctx.lineTo(x + w, y);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(x - 4, y); ctx.lineTo(x + w + 4, y); ctx.stroke();
/* Rim highlight */
const hlg = ctx.createLinearGradient(x, y, x + 14, y + h);
hlg.addColorStop(0, 'rgba(200,230,255,0.15)');
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
ctx.strokeStyle = hlg; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(x + 7, y + 6); ctx.lineTo(x + 7, y + h - 6); ctx.stroke();
/* Formula label */
ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
const label = ions.map(s => s.f).join(', ');
ctx.fillText(label, x + w / 2, y - 4);
ctx.restore();
};
const bTop = H * 0.10, bH = H * 0.70;
drawB(W * 0.04, bTop, W * 0.40, bH, rxn.left);
drawB(W * 0.56, bTop, W * 0.40, bH, rxn.right);
/* Mix arrow */
ctx.save();
const mx = W * 0.50, my = H * 0.44;
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.beginPath(); ctx.moveTo(mx - 16, my); ctx.lineTo(mx + 16, my); ctx.stroke();
ctx.beginPath(); ctx.moveTo(mx + 10, my - 5); ctx.lineTo(mx + 16, my); ctx.lineTo(mx + 10, my + 5); ctx.fill();
ctx.restore();
}
_drawSingleBeaker(ctx, W, H) {
const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.72;
ctx.save();
ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh);
ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke();
const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh);
hlg.addColorStop(0, 'rgba(200,230,255,0.18)');
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
ctx.strokeStyle = hlg; ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke();
ctx.restore();
}
_drawIons(ctx, rxn) {
this._ions.forEach(ion => {
if (!ion.active) return;
const isSpec = rxn.spectators.includes(ion.spec);
ctx.save();
ctx.globalAlpha = (isSpec && this._phase !== 'idle') ? 0.40 : 0.88;
ctx.shadowColor = ion.color;
ctx.shadowBlur = 7 + Math.sin(ion.phase) * 3;
ctx.beginPath(); ctx.arc(ion.x, ion.y, ion.r, 0, Math.PI * 2);
ctx.fillStyle = ion.color; ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.stroke();
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
/* Formula label */
const fs = Math.min(Math.round(ion.r * 0.60), 9);
ctx.fillStyle = 'rgba(0,0,0,0.80)';
ctx.font = `bold ${fs}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(ion.spec, ion.x, ion.y);
ctx.restore();
});
}
_drawPairs(ctx, rxn) {
const pcolor = rxn.pcolor || rxn.gcolor || '#FFF';
this._pairs.forEach(p => {
ctx.save();
const alpha = p.alpha !== undefined ? p.alpha : 1;
ctx.globalAlpha = alpha * 0.92;
ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : pcolor;
ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : pcolor;
ctx.fill();
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
/* Product label */
ctx.fillStyle = 'rgba(0,0,0,0.80)';
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(rxn.product.f, p.x, p.y);
ctx.restore();
});
}
_drawPrecipitate(ctx, rxn) {
if (rxn.type !== 'precip' || !this._precip.length) return;
ctx.save();
this._precip.forEach(p => {
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 3;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = rxn.pcolor; ctx.fill();
});
ctx.restore();
if (this._precip.length > 4) {
ctx.save();
ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6;
ctx.fillText(`${rxn.pname}`, this.W / 2, this.H * 0.80 - 4);
ctx.restore();
}
}
_drawPanel(ctx, W, H, rxn) {
const py = H * 0.82;
ctx.fillStyle = 'rgba(7,7,26,0.95)';
ctx.fillRect(0, py, W, H - py);
ctx.strokeStyle = 'rgba(100,165,255,0.22)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
if (this._phase === 'idle') {
ctx.fillStyle = '#37474F'; ctx.font = '11px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('← Нажми «Смешать» для начала реакции →', W / 2, py + (H - py) / 2);
return;
}
const steps = [
{ lbl: 'Молекулярное:', txt: rxn.mol, col: '#B0BEC5' },
{ lbl: 'Полное ионное:', txt: rxn.full_ion, col: '#CE93D8' },
{ lbl: 'Краткое ионное:', txt: rxn.net_ion, col: '#FFD166' },
];
const panH = H - py;
const n = Math.min(this._stepIdx + 1, steps.length);
for (let i = 0; i < n; i++) {
const s = steps[i];
const y = py + 11 + i * (panH * 0.29);
ctx.save();
if (i === this._stepIdx && this._phase !== 'done') {
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.fillRect(8, y - 9, W - 16, 20);
}
ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(s.lbl, 14, y);
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
ctx.font = '9.5px monospace';
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y);
ctx.restore();
}
if (this._phase === 'done') {
ctx.save();
ctx.fillStyle = rxn.signColor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.shadowColor = rxn.signColor; ctx.shadowBlur = 8;
const label = rxn.type === 'precip' ? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${rxn.sign} осадок` : `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${rxn.sign} газ`;
ctx.fillText(label, W - 14, py + 3);
ctx.restore();
}
}
info() {
const rxn = IonExSim.RXN[this.rxnId];
return {
rxn: rxn.name,
phase: this._phase,
prog: Math.round(this._prog * 100),
precip: this._precip.length,
};
}
}
+463
View File
@@ -0,0 +1,463 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses
n = 1, R = 0.0821 L·atm/mol·K; energies in Joules
Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W
Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU
Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W
Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1)
══════════════════════════════════════════════════════════════ */
class IsoprocessSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n = 1;
this.R = 0.0821; // L·atm / mol·K
this.R_J = 8.314; // J / mol·K
this.gamma = 1.4; // 7/5 diatomic default
/* state */
this.P1 = 3.0; // atm
this.V1 = 10.0; // L
this._ratio = 0.5; // 0..1, maps end state position along process
/* process */
this.process = 'isothermal';
/* axis range */
this.Vmin = 1; this.Vmax = 33;
this.Pmin = 0.2; this.Pmax = 9.5;
/* margins */
this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18;
this._drag = null; // 'state1' | 'state2'
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setProcess(p) { this.process = p; this.draw(); this._emit(); }
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
setParams({ P1, V1 } = {}) {
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
this.draw(); this._emit();
}
setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); }
/* ── coordinate transforms ─────────────────── */
_pw() { return this.W - this.ML - this.MR; }
_ph() { return this.H - this.MT - this.MB; }
_vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); }
_py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); }
_xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); }
_yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); }
/* ── physics ───────────────────────────────── */
_T(P, V) { return P * V / (this.n * this.R); }
_state2() {
const { P1, V1, _ratio, gamma } = this;
/* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */
const mult = 0.2 + _ratio * 3.3;
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p));
switch (this.process) {
case 'isothermal': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * V1 / V2), V2 };
}
case 'isochoric': {
return { P2: clampP(P1 * mult), V2: V1 };
}
case 'isobaric': {
const V2 = clampV(V1 * mult);
return { P2: P1, V2 };
}
case 'adiabatic': {
const V2 = clampV(V1 * mult);
return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 };
}
}
return { P2: P1, V2: V1 };
}
info() {
const { P1, V1, n, R_J, gamma } = this;
const T1 = this._T(P1, V1);
const { P2, V2 } = this._state2();
const T2 = this._T(P2, V2);
/* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */
const Cv_J = R_J / (gamma - 1);
const dU_J = n * Cv_J * (T2 - T1);
/* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */
const P1Pa = P1 * 101325, P2Pa = P2 * 101325;
const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001;
let W_J = 0, Q_J = 0;
switch (this.process) {
case 'isothermal':
W_J = n * R_J * T1 * Math.log(V2 / V1);
Q_J = W_J; break;
case 'isochoric':
W_J = 0; Q_J = dU_J; break;
case 'isobaric':
W_J = P1Pa * (V2m3 - V1m3);
Q_J = dU_J + W_J; break;
case 'adiabatic':
Q_J = 0;
W_J = -dU_J; break;
}
const fmt = x => (x >= 0 ? '+' : '') + Math.round(x);
return {
P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1),
P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2),
W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)),
W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J,
process: this.process,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx);
this._drawBgCurves(ctx);
this._drawActiveCurve(ctx);
this._drawPoints(ctx);
this._drawInfoBox(ctx);
}
_drawGrid(ctx) {
const { ML, MT, MR, MB } = this;
const pw = this._pw(), ph = this._ph();
/* plot background */
ctx.fillStyle = 'rgba(255,255,255,0.018)';
ctx.fillRect(ML, MT, pw, ph);
/* grid */
ctx.strokeStyle = 'rgba(255,255,255,0.055)';
ctx.lineWidth = 1; ctx.setLineDash([]);
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke();
}
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke();
}
/* axes */
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph);
ctx.stroke();
/* tick labels */
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let p = 1; p <= 9; p++) {
const y = this._py(p);
if (y < MT + 2 || y > MT + ph - 2) continue;
ctx.fillText(p, ML - 6, y);
}
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let v = 5; v <= 30; v += 5) {
const x = this._vx(v);
ctx.fillText(v, x, MT + ph + 5);
}
/* axis titles */
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('V, л', ML + pw / 2, MT + ph + 32);
ctx.save();
ctx.translate(13, MT + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('P, атм', 0, 0);
ctx.restore();
}
_COLORS = {
isothermal: '#EF476F',
isochoric: '#06D6E0',
isobaric: '#7BF5A4',
adiabatic: '#FFD166',
};
/* draw one process curve through (P1,V1) */
_curve(ctx, process, alpha, lw, dashed) {
const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = this._COLORS[process];
ctx.lineWidth = lw;
ctx.setLineDash(dashed ? [5, 4] : []);
ctx.beginPath();
if (process === 'isochoric') {
const x = this._vx(V1);
ctx.moveTo(x, this._py(Pmax));
ctx.lineTo(x, this._py(Pmin));
} else {
let started = false;
const steps = 300;
for (let i = 0; i <= steps; i++) {
const v = Vmin + (Vmax - Vmin) * i / steps;
let p;
if (process === 'isothermal') p = P1 * V1 / v;
else if (process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma); // adiabatic
if (p < Pmin || p > Pmax + 0.1) { started = false; continue; }
const x = this._vx(v), y = this._py(Math.min(p, Pmax));
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
_drawBgCurves(ctx) {
for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) {
if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true);
}
/* legend dots */
const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' };
ctx.font = '10px Manrope, system-ui, sans-serif';
let lx = this.ML + this._pw() - 8, ly = this.MT + 8;
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
for (const [proc, label] of Object.entries(names)) {
const col = this._COLORS[proc];
const isCur = proc === this.process;
ctx.globalAlpha = isCur ? 0.85 : 0.3;
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillText(label, lx - 3, ly);
ly += 16;
}
ctx.globalAlpha = 1;
}
_drawActiveCurve(ctx) {
/* full curve dimmed */
this._curve(ctx, this.process, 0.3, 1.5, false);
/* highlighted segment state1 → state2 */
const { P1, V1, gamma } = this;
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2.8;
ctx.setLineDash([]);
const steps = 200;
const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1];
if (this.process === 'isochoric') {
const x = this._vx(V1);
const y1c = this._py(P1), y2c = this._py(P2);
ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke();
this._arrowHead(ctx, x, y1c, x, y2c, color);
} else {
ctx.beginPath();
let started = false;
for (let i = 0; i <= steps; i++) {
const v = Vs + (Ve - Vs) * i / steps;
let p;
if (this.process === 'isothermal') p = P1 * V1 / v;
else if (this.process === 'isobaric') p = P1;
else p = P1 * Math.pow(V1 / v, gamma);
const x = this._vx(v), y = this._py(p);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
/* arrow at ~80% of segment */
const vArr = Vs + (Ve - Vs) * 0.8;
const vArr2 = Vs + (Ve - Vs) * 0.82;
let p1a, p2a;
if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; }
else if (this.process === 'isobaric') { p1a = P1; p2a = P1; }
else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); }
/* ensure arrow points from 1→2 */
const dir = V2 > V1 ? 1 : -1;
this._arrowHead(ctx,
this._vx(vArr + dir*0), this._py(p1a + dir*0),
this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color);
}
ctx.restore();
}
_arrowHead(ctx, x1, y1, x2, y2, color) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const s = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4));
ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4));
ctx.closePath(); ctx.fill();
}
_drawPoints(ctx) {
const { P2, V2 } = this._state2();
const color = this._COLORS[this.process];
const dot = (x, y, fill, label, textX, textY) => {
ctx.fillStyle = fill;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(label, textX, textY);
};
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
const x2 = this._vx(V2), y2 = this._py(P2);
dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4);
dot(x2, y2, color, '2', x2 + 12, y2 - 4);
}
_drawInfoBox(ctx) {
const info = this.info();
const color = this._COLORS[info.process];
const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' };
const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' };
const bx = this.ML + 6, by = this.MT + 6;
const boxW = 205, boxH = 98;
ctx.fillStyle = 'rgba(13,13,26,0.9)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = 'bold 11px Manrope, system-ui, sans-serif';
ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8);
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28);
const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)';
const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)';
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48);
ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65);
ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82);
ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const pos = e => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
px: (t.clientX - r.left) * (this.W / r.width),
py: (t.clientY - r.top) * (this.H / r.height),
};
};
const hit = (px, py) => {
const x1 = this._vx(this.V1), y1 = this._py(this.P1);
if (Math.hypot(px - x1, py - y1) < 18) return 'state1';
const { P2, V2 } = this._state2();
const x2 = this._vx(V2), y2 = this._py(P2);
if (Math.hypot(px - x2, py - y2) < 18) return 'state2';
return null;
};
const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v));
const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p));
const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); };
const onMove = e => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { px, py } = pos(e);
const v = this._xv(px), p = this._yp(py);
if (this._drag === 'state1') {
this.V1 = clampV(v); this.P1 = clampP(p);
} else {
/* constrain state2 to current process curve */
switch (this.process) {
case 'isothermal': case 'isobaric': case 'adiabatic': {
const V2 = clampV(v);
this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3));
break;
}
case 'isochoric': {
const P2 = clampP(p);
this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3));
break;
}
}
}
this.draw(); this._emit();
};
const onUp = () => { this._drag = null; };
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { px, py } = pos(e);
cv.style.cursor = hit(px, py) ? 'grab' : 'default';
});
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+393
View File
@@ -0,0 +1,393 @@
'use strict';
/**
* NormalDistSim v2 — интерактивное нормальное распределение
* μ, σ · правило 68-95-99.7 · Z-score · закрашивание области
* Чистый рерайт: без SVG-строк в info(), лучшая визуализация.
*/
class NormalDistSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.mu = 0;
this.sigma = 1;
this.shade = '1s'; // 'none' | '1s' | '2s' | '3s' | 'custom'
this.zLow = -1;
this.zHigh = 1;
this.hx = null;
this.onUpdate = null;
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
// ── public API ────────────────────────────────────────────────
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
if (mu !== undefined) this.mu = +mu;
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
if (shade !== undefined) this.shade = shade;
if (zLow !== undefined) this.zLow = +zLow;
if (zHigh !== undefined) this.zHigh = +zHigh;
this.draw(); this._emit();
}
info() {
const { mu, sigma, shade } = this;
let areaLabel = '\u2014', areaPct = 0;
if (shade === '1s') { areaPct = 68.27; areaLabel = '\u03bc \u00b1 1\u03c3 \u2192 68.27%'; }
else if (shade === '2s') { areaPct = 95.45; areaLabel = '\u03bc \u00b1 2\u03c3 \u2192 95.45%'; }
else if (shade === '3s') { areaPct = 99.73; areaLabel = '\u03bc \u00b1 3\u03c3 \u2192 99.73%'; }
else if (shade === 'custom') {
areaPct = (this._phi(this.zHigh) - this._phi(this.zLow)) * 100;
areaLabel = `Z \u2208 [${this.zLow.toFixed(1)}, ${this.zHigh.toFixed(1)}] \u2192 ${areaPct.toFixed(2)}%`;
}
return {
mu: mu.toFixed(1),
sigma: sigma.toFixed(2),
peak: (1 / (sigma * Math.sqrt(2 * Math.PI))).toFixed(4),
area: areaLabel,
areaPct: areaPct.toFixed(2),
};
}
// ── math ─────────────────────────────────────────────────────
_pdf(x) {
const z = (x - this.mu) / this.sigma;
return Math.exp(-0.5 * z * z) / (this.sigma * Math.sqrt(2 * Math.PI));
}
_phi(z) {
const a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911;
const sign = z < 0 ? -1 : 1;
const t = 1 / (1 + p * Math.abs(z) / Math.SQRT2);
const y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-z*z/2);
return 0.5 * (1 + sign * y);
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
// ── coordinate transforms ─────────────────────────────────────
_pad() { return { PL: 52, PR: 22, PT: 38, PB: 50 }; }
_xToP(x, xMin, xMax, PL, pw) { return PL + (x - xMin) / (xMax - xMin) * pw; }
_yToP(y, yMax, PT, ph) { return PT + ph - (y / yMax) * ph; }
_pToX(px, xMin, xMax, PL, pw){ return xMin + (px - PL) / pw * (xMax - xMin); }
// ── draw ─────────────────────────────────────────────────────
draw() {
const { ctx, W, H, mu, sigma } = this;
if (!W || !H) return;
const { PL, PR, PT, PB } = this._pad();
const pw = W - PL - PR, ph = H - PT - PB;
const xMin = mu - 4.5 * sigma, xMax = mu + 4.5 * sigma;
const yMax = this._pdf(mu) * 1.18;
ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H);
this._drawGrid (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawShade (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawCurve (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawLabels (PL, PT, pw, ph, xMin, xMax, yMax);
this._drawBadge (PL, PT, pw, ph);
if (this.hx !== null) this._drawHover(PL, PT, pw, ph, xMin, xMax, yMax);
}
_drawGrid(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, mu, sigma } = this;
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
// Horizontal grid
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1;
for (let i = 1; i <= 4; i++) {
const py = PT + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(PL, py); ctx.lineTo(PL + pw, py); ctx.stroke();
}
// Vertical sigma grid lines
for (let s = -4; s <= 4; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
ctx.strokeStyle = s === 0
? 'rgba(6,214,224,0.22)'
: `rgba(255,255,255,${0.04 + (Math.abs(s) <= 2 ? 0.03 : 0)})`;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
}
// Axes
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(PL, bottom); ctx.lineTo(PL + pw, bottom); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, bottom); ctx.stroke();
// X-axis labels (sigma notation)
ctx.font = `11px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
for (let s = -4; s <= 4; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
const lbl = s === 0 ? '\u03bc' : (s > 0 ? `+${s}\u03c3` : `${s}\u03c3`);
ctx.fillText(lbl, px, bottom + 6);
}
// Actual x values below
ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(255,255,255,0.18)';
for (let s = -3; s <= 3; s++) {
const x = mu + s * sigma;
if (x < xMin || x > xMax) continue;
const px = this._xToP(x, xMin, xMax, PL, pw);
const dec = sigma < 1 ? 1 : 0;
ctx.fillText(x.toFixed(dec), px, bottom + 20);
}
// Y-axis labels
ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = `10px ${FN}`;
for (let i = 0; i <= 4; i++) {
const v = (yMax / 4) * i;
const py = PT + ph - (v / yMax) * ph;
ctx.fillText(v.toFixed(2), PL - 6, py);
}
// Axis names
ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.font = `10px ${FN}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('x', PL + pw / 2, PT + ph + 36);
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('f(x)', PL + 6, PT);
}
_drawShade(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, mu, sigma, shade } = this;
let lo, hi;
if (shade === '1s') { lo = mu - sigma; hi = mu + sigma; }
else if (shade === '2s') { lo = mu - 2 * sigma; hi = mu + 2 * sigma; }
else if (shade === '3s') { lo = mu - 3 * sigma; hi = mu + 3 * sigma; }
else if (shade === 'custom') { lo = mu + this.zLow * sigma; hi = mu + this.zHigh * sigma; }
else return;
const bottom = PT + ph;
const steps = 240;
const dx = (hi - lo) / steps;
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Filled area with gradient
const grd = ctx.createLinearGradient(xp(lo), 0, xp(hi), 0);
grd.addColorStop(0, 'rgba(155,93,229,0.10)');
grd.addColorStop(0.5, 'rgba(155,93,229,0.30)');
grd.addColorStop(1, 'rgba(155,93,229,0.10)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.moveTo(xp(lo), bottom);
for (let i = 0; i <= steps; i++) {
const x = lo + i * dx;
ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.lineTo(xp(hi), bottom);
ctx.closePath(); ctx.fill();
// Border dashes
ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]);
for (const bx of [lo, hi]) {
const px = xp(bx);
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
}
ctx.setLineDash([]);
}
_drawCurve(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx } = this;
const steps = Math.min(pw * 2, 500);
const dx = (xMax - xMin) / steps;
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Glow layer
ctx.strokeStyle = 'rgba(155,93,229,0.1)'; ctx.lineWidth = 10; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = xMin + i * dx;
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.stroke();
// Main curve
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i <= steps; i++) {
const x = xMin + i * dx;
i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x)));
}
ctx.stroke();
// μ marker
const muPx = xp(this.mu);
const bottom = PT + ph;
ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(muPx, PT); ctx.lineTo(muPx, bottom); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#06D6E0';
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`\u03bc = ${this.mu.toFixed(1)}`, muPx, PT - 4);
// Peak label
const peakPx = xp(this.mu);
const peakPy = yp(this._pdf(this.mu));
ctx.fillStyle = 'rgba(155,93,229,0.5)';
ctx.font = '9px Manrope, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
const peakVal = (1 / (this.sigma * Math.sqrt(2 * Math.PI))).toFixed(3);
ctx.fillText('f(μ) = ' + peakVal, peakPx + 6, peakPy - 2);
}
_drawLabels(PL, PT, pw, ph, xMin, xMax, yMax) {
// sigma annotation brackets
const { ctx, mu, sigma, shade } = this;
if (shade === 'none') return;
const nSig = shade === '1s' ? 1 : shade === '2s' ? 2 : shade === '3s' ? 3 : null;
if (!nSig) return;
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
const xp = x => this._xToP(x, xMin, xMax, PL, pw);
const yp = y => this._yToP(y, yMax, PT, ph);
// Annotate ±nσ points with small bracket
const lo = mu - nSig * sigma, hi = mu + nSig * sigma;
const loPx = xp(lo), hiPx = xp(hi);
const midY = bottom + 32;
ctx.save();
ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(loPx, bottom + 4); ctx.lineTo(loPx, midY);
ctx.lineTo(hiPx, midY); ctx.lineTo(hiPx, bottom + 4);
ctx.stroke();
ctx.fillStyle = 'rgba(155,93,229,0.55)';
ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('\u00b1' + nSig + '\u03c3', (loPx + hiPx) / 2, midY + 2);
ctx.restore();
}
_drawBadge(PL, PT, pw, ph) {
const { ctx, shade } = this;
if (shade === 'none') return;
const info = this.info();
const pct = parseFloat(info.areaPct);
if (!pct) return;
const FN = 'Manrope, sans-serif';
ctx.save();
ctx.font = `bold 15px ${FN}`;
const text = pct.toFixed(2) + '%';
const tw = ctx.measureText(text).width;
const bw = tw + 24, bh = 28;
const bx = PL + pw - bw - 4, by = PT + 4;
ctx.fillStyle = 'rgba(155,93,229,0.16)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.stroke();
ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, bx + bw / 2, by + bh / 2);
const shadeNames = { '1s': '\u03bc \u00b1 1\u03c3', '2s': '\u03bc \u00b1 2\u03c3', '3s': '\u03bc \u00b1 3\u03c3', custom: 'произвольный Z' };
ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(155,93,229,0.55)';
ctx.fillText(shadeNames[shade] || '', bx + bw / 2, by + bh + 10);
ctx.restore();
}
_drawHover(PL, PT, pw, ph, xMin, xMax, yMax) {
const { ctx, W } = this;
const x = this.hx;
if (x < xMin || x > xMax) return;
const px = this._xToP(x, xMin, xMax, PL, pw);
const y = this._pdf(x);
const py = this._yToP(y, yMax, PT, ph);
const bottom = PT + ph;
const FN = 'Manrope, sans-serif';
// Vertical crosshair
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke();
ctx.setLineDash([]);
// Point on curve
ctx.fillStyle = '#FFD166'; ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.stroke();
// Tooltip
const z = (x - this.mu) / this.sigma;
const rows = [
['x', x.toFixed(3)],
['z', z.toFixed(3)],
['f(x)', y.toFixed(5)],
['\u03a6(z)', (this._phi(z) * 100).toFixed(2) + '%'],
];
ctx.font = `11px ${FN}`;
const maxKW = Math.max(...rows.map(([k]) => ctx.measureText(k).width));
const maxVW = Math.max(...rows.map(([, v]) => ctx.measureText(v).width));
const tw = maxKW + maxVW + 26, th = rows.length * 18 + 14;
let tx = px + 14, ty = py - th / 2;
if (tx + tw > W - 8) tx = px - tw - 14;
if (ty < PT + 4) ty = PT + 4;
if (ty + th > bottom) ty = bottom - th;
ctx.fillStyle = 'rgba(10,10,28,0.95)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.textBaseline = 'middle';
rows.forEach(([k, v], i) => {
const ry = ty + 7 + i * 18 + 9;
ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, tx + 10, ry);
ctx.fillStyle = '#FFD166'; ctx.textAlign = 'right'; ctx.fillText(v, tx + tw - 10, ry);
});
}
// ── events ────────────────────────────────────────────────────
_bind() {
const cv = this.canvas;
const getHx = e => {
const r = cv.getBoundingClientRect();
const { PL, PR } = this._pad();
const pw = this.W - PL - PR;
const xMin = this.mu - 4.5 * this.sigma;
const xMax = this.mu + 4.5 * this.sigma;
return this._pToX(e.clientX - r.left, xMin, xMax, PL, pw);
};
cv.addEventListener('mousemove', e => { this.hx = getHx(e); this.draw(); });
cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1) { this.hx = getHx(e.touches[0]); this.draw(); }
}, { passive: false });
cv.addEventListener('touchend', () => { this.hx = null; this.draw(); });
}
}
+342
View File
@@ -0,0 +1,342 @@
'use strict';
/* ═══════════════════════════════════════════════
OrbitalsSim — 3D molecular orbitals (Three.js)
s, p, d orbitals + H₂ / H₂O molecular bonding
═══════════════════════════════════════════════ */
class OrbitalsSim {
constructor(container) {
this.container = container;
this._running = false;
/* Three.js */
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x0D0D1A, 1);
container.appendChild(this.renderer.domElement);
/* lighting */
this.scene.add(new THREE.AmbientLight(0xffffff, 0.45));
const dir = new THREE.DirectionalLight(0xffffff, 0.7);
dir.position.set(5, 8, 6);
this.scene.add(dir);
const pt = new THREE.PointLight(0x9B5DE5, 0.3, 50);
pt.position.set(-4, 3, 5);
this.scene.add(pt);
this.camera.position.set(6, 4, 6);
this.camera.lookAt(0, 0, 0);
/* orbit controls (manual) */
this._drag = false;
this._prevX = 0;
this._prevY = 0;
this._rotY = 0.6;
this._rotX = 0.3;
this._dist = 8;
this._autoSpin = true;
const el = this.renderer.domElement;
el.style.cursor = 'grab';
el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; });
window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; });
window.addEventListener('pointermove', e => {
if (!this._drag) return;
this._rotY += (e.clientX - this._prevX) * 0.008;
this._rotX += (e.clientY - this._prevY) * 0.008;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._prevX = e.clientX; this._prevY = e.clientY;
});
el.addEventListener('wheel', e => {
e.preventDefault();
this._dist = Math.max(3, Math.min(20, this._dist + e.deltaY * 0.02));
}, { passive: false });
/* touch */
el.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false;
}
}, { passive: true });
el.addEventListener('touchmove', e => {
if (!this._drag || e.touches.length !== 1) return;
const t = e.touches[0];
this._rotY += (t.clientX - this._prevX) * 0.008;
this._rotX += (t.clientY - this._prevY) * 0.008;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._prevX = t.clientX; this._prevY = t.clientY;
}, { passive: true });
el.addEventListener('touchend', () => { this._drag = false; });
/* resize */
this._ro = new ResizeObserver(() => this.fit());
this._ro.observe(container);
/* state */
this._mode = 's';
this._group = new THREE.Group();
this.scene.add(this._group);
this._buildOrbital('s');
this.fit();
this.play();
}
/* ── public ── */
setMode(mode) {
this._mode = mode;
this._buildOrbital(mode);
}
fit() {
const w = this.container.clientWidth || 600;
const h = this.container.clientHeight || 400;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
play() { if (!this._running) { this._running = true; this._loop(); } }
stop() { this._running = false; }
pause() { this._running = false; }
/* ── clear scene group ── */
_clear() {
while (this._group.children.length) {
const c = this._group.children[0];
if (c.geometry) c.geometry.dispose();
if (c.material) {
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
this._group.remove(c);
}
}
/* ── orbital builders ── */
_buildOrbital(mode) {
this._clear();
const b = {
s: () => this._buildS(),
p: () => this._buildP(),
d: () => this._buildD(),
h2: () => this._buildH2(),
h2o: () => this._buildH2O(),
};
(b[mode] || b.s)();
}
/* nucleus dot */
_nucleus(pos, color = 0xffffff) {
const geo = new THREE.SphereGeometry(0.12, 16, 16);
const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5 });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
this._group.add(m);
return m;
}
/* cloud lobe (ellipsoid) */
_lobe(pos, scale, color, opacity = 0.3) {
const geo = new THREE.SphereGeometry(1, 32, 32);
const mat = new THREE.MeshPhysicalMaterial({
color, transparent: true, opacity,
metalness: 0, roughness: 0.6,
clearcoat: 0.3, side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
mesh.scale.set(scale.x, scale.y, scale.z);
this._group.add(mesh);
return mesh;
}
/* particle cloud (points) */
_cloud(center, radius, count, color) {
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// gaussian distribution
const r = radius * Math.pow(Math.random(), 0.33);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
positions[i * 3] = center.x + r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = center.y + r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = center.z + r * Math.cos(phi);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({ color, size: 0.04, transparent: true, opacity: 0.6, depthWrite: false });
const pts = new THREE.Points(geo, mat);
this._group.add(pts);
return pts;
}
/* ── s orbital: spherical ── */
_buildS() {
this._nucleus(new THREE.Vector3(0, 0, 0));
this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 1.5, 1.5), 0x9B5DE5, 0.18);
this._cloud(new THREE.Vector3(0, 0, 0), 1.5, 2000, 0x9B5DE5);
}
/* ── p orbitals: 3 dumbbell shapes ── */
_buildP() {
this._nucleus(new THREE.Vector3(0, 0, 0));
// px (red)
this._lobe(new THREE.Vector3(1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25);
this._lobe(new THREE.Vector3(-1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25);
// py (green)
this._lobe(new THREE.Vector3(0, 1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25);
this._lobe(new THREE.Vector3(0, -1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25);
// pz (blue)
this._lobe(new THREE.Vector3(0, 0, 1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25);
this._lobe(new THREE.Vector3(0, 0, -1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25);
// axis labels
this._addLabel('px', 2, 0, 0, 0xF15BB5);
this._addLabel('py', 0, 2, 0, 0x34d399);
this._addLabel('pz', 0, 0, 2, 0x60a5fa);
}
/* ── d orbital: dxy cloverleaf ── */
_buildD() {
this._nucleus(new THREE.Vector3(0, 0, 0));
// four lobes in xy plane
const r = 1.1, s = 0.45;
const angles = [Math.PI / 4, 3 * Math.PI / 4, 5 * Math.PI / 4, 7 * Math.PI / 4];
const colors = [0xF59E0B, 0x9B5DE5, 0xF59E0B, 0x9B5DE5]; // alternating sign
for (let i = 0; i < 4; i++) {
const x = r * Math.cos(angles[i]);
const y = r * Math.sin(angles[i]);
this._lobe(new THREE.Vector3(x, y, 0), new THREE.Vector3(s, s, s * 0.6), colors[i], 0.28);
}
// dz² torus + lobes
this._lobe(new THREE.Vector3(0, 0, 1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2);
this._lobe(new THREE.Vector3(0, 0, -1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2);
// torus ring
const tGeo = new THREE.TorusGeometry(0.7, 0.18, 16, 32);
const tMat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.15, side: THREE.DoubleSide, depthWrite: false });
const torus = new THREE.Mesh(tGeo, tMat);
this._group.add(torus);
this._addLabel('d_{xy}', 1.8, 1.8, 0, 0xF59E0B);
this._addLabel('d_{z²}', 0, 0, 2.2, 0x06D6E0);
}
/* ── H₂ sigma bond ── */
_buildH2() {
const sep = 1.5;
this._nucleus(new THREE.Vector3(-sep / 2, 0, 0), 0xffffff);
this._nucleus(new THREE.Vector3(sep / 2, 0, 0), 0xffffff);
// individual 1s orbitals (faded)
this._lobe(new THREE.Vector3(-sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1);
this._lobe(new THREE.Vector3(sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1);
// bonding σ (overlap region — elongated ellipsoid)
this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.4, 0.6, 0.6), 0x06D6E0, 0.22);
this._cloud(new THREE.Vector3(0, 0, 0), 1.0, 3000, 0x06D6E0);
// bond line
const bGeo = new THREE.CylinderGeometry(0.03, 0.03, sep, 8);
const bMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.3 });
const bond = new THREE.Mesh(bGeo, bMat);
bond.rotation.z = Math.PI / 2;
this._group.add(bond);
this._addLabel('H', -sep / 2 - 0.5, 0.6, 0, 0xffffff);
this._addLabel('H', sep / 2 + 0.3, 0.6, 0, 0xffffff);
this._addLabel('σ', 0, -0.9, 0, 0x06D6E0);
}
/* ── H₂O bent molecule ── */
_buildH2O() {
const angle = 104.5 * Math.PI / 180;
const bondLen = 1.6;
const oPos = new THREE.Vector3(0, 0, 0);
const h1 = new THREE.Vector3(-bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0);
const h2 = new THREE.Vector3(bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0);
// nuclei
const oNuc = this._nucleus(oPos, 0xF15BB5);
oNuc.scale.set(1.5, 1.5, 1.5);
this._nucleus(h1, 0xffffff);
this._nucleus(h2, 0xffffff);
// O lone pairs (above)
this._lobe(new THREE.Vector3(-0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2);
this._lobe(new THREE.Vector3(0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2);
// O-H σ bonds (electron density)
const mid1 = new THREE.Vector3().addVectors(oPos, h1).multiplyScalar(0.5);
const mid2 = new THREE.Vector3().addVectors(oPos, h2).multiplyScalar(0.5);
this._lobe(mid1, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2);
this._lobe(mid2, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2);
// bond lines
for (const hPos of [h1, h2]) {
const d = new THREE.Vector3().subVectors(hPos, oPos);
const len = d.length();
const bGeo = new THREE.CylinderGeometry(0.04, 0.04, len, 8);
const bMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });
const bond = new THREE.Mesh(bGeo, bMat);
bond.position.copy(oPos).add(d.clone().multiplyScalar(0.5));
bond.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d.normalize());
this._group.add(bond);
}
// electron cloud
this._cloud(oPos, 1.2, 1500, 0xF15BB5);
this._addLabel('O', 0.3, 0.3, 0, 0xF15BB5);
this._addLabel('H', h1.x - 0.4, h1.y - 0.3, 0, 0xffffff);
this._addLabel('H', h2.x + 0.2, h2.y - 0.3, 0, 0xffffff);
this._addLabel('104.5°', 0, -0.5, 0, 0x888888);
}
/* ── text label (using sprite) ── */
_addLabel(text, x, y, z, color) {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 48;
const ctx = canvas.getContext('2d');
ctx.font = 'bold 28px Manrope, sans-serif';
ctx.fillStyle = '#' + color.toString(16).padStart(6, '0');
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 64, 24);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false });
const sprite = new THREE.Sprite(mat);
sprite.position.set(x, y, z);
sprite.scale.set(1, 0.4, 1);
this._group.add(sprite);
}
/* ── animation ── */
_loop() {
if (!this._running) return;
requestAnimationFrame(() => this._loop());
if (this._autoSpin) this._rotY += 0.004;
this.camera.position.set(
this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
this._dist * Math.sin(this._rotX),
this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
);
this.camera.lookAt(0, 0, 0);
this.renderer.render(this.scene, this.camera);
}
}
+401
View File
@@ -0,0 +1,401 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
PendulumSim — simple pendulum simulation
θ'' = -(g/L)sin(θ) γ·θ'
RK4 integration · energy bar · trail · phase portrait
══════════════════════════════════════════════════════════════ */
class PendulumSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.L = 200; // px length
this.g = 9.81;
this.theta = Math.PI / 4; // angle (rad)
this.omega = 0; // angular velocity
this.damping = 0; // damping coefficient γ
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
this.speed = 1;
/* trail */
this._trail = []; // [{x, y, age}]
this._maxTrail = 200;
/* energy chart (bottom) */
this._eHistory = []; // [{t, ke, pe}]
this._tSim = 0;
this.onUpdate = null;
this._drag = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ L, g, theta, damping } = {}) {
if (L !== undefined) this.L = +L;
if (g !== undefined) this.g = +g;
if (theta !== undefined) { this.theta = +theta * Math.PI / 180; this.omega = 0; this._clearTrail(); }
if (damping !== undefined) this.damping = +damping;
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
reset() {
this.pause();
this.theta = Math.PI / 4;
this.omega = 0;
this._tSim = 0;
this._clearTrail();
this._eHistory = [];
this.draw();
this._emit();
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100)); // L in px <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> approx
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
const total = KE + PE;
return {
angle: (this.theta * 180 / Math.PI).toFixed(1) + '°',
omega: this.omega.toFixed(3) + ' рад/с',
period: T.toFixed(2) + ' с',
energy: total > 0 ? Math.round(KE / total * 100) + '% KE' : '—',
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_clearTrail() { this._trail = []; }
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
this._step(dt);
this._tSim += dt;
// trail
const { bx, by } = this._bobPos();
this._trail.push({ x: bx, y: by });
if (this._trail.length > this._maxTrail) this._trail.shift();
// energy history
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
this._eHistory.push({ t: this._tSim, ke: KE, pe: PE });
if (this._eHistory.length > 300) this._eHistory.shift();
this.draw();
this._emit();
this._tick();
});
}
/* RK4 step for θ'' = -(g/L)sinθ - γ·ω */
_step(dt) {
const gL = this.g * 100 / this.L; // scale g for px units
const c = this.damping;
const deriv = (th, om) => ({
dth: om,
dom: -gL * Math.sin(th) - c * om,
});
const k1 = deriv(this.theta, this.omega);
const k2 = deriv(this.theta + k1.dth * dt / 2, this.omega + k1.dom * dt / 2);
const k3 = deriv(this.theta + k2.dth * dt / 2, this.omega + k2.dom * dt / 2);
const k4 = deriv(this.theta + k3.dth * dt, this.omega + k3.dom * dt);
this.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth);
this.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom);
}
_bobPos() {
const cx = this.W / 2;
const cy = Math.min(this.H * 0.18, 80);
return {
px: cx,
py: cy,
bx: cx + this.L * Math.sin(this.theta),
by: cy + this.L * Math.cos(this.theta),
};
}
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const { px, py, bx, by } = this._bobPos();
// trail
this._drawTrail(ctx);
// support
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.fillRect(W / 2 - 30, py - 4, 60, 4);
// string
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke();
// pivot
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
// bob
const bobR = 18;
ctx.fillStyle = '#9B5DE5';
ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke();
// glow
const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2);
grad.addColorStop(0, 'rgba(155,93,229,0.25)');
grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill();
// angle arc
if (Math.abs(this.theta) > 0.02) {
ctx.strokeStyle = 'rgba(6,214,224,0.5)';
ctx.lineWidth = 1.5;
const arcR = 40;
const startAngle = Math.PI / 2;
const endAngle = Math.PI / 2 + this.theta;
ctx.beginPath();
ctx.arc(px, py, arcR, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle));
ctx.stroke();
ctx.fillStyle = '#06D6E0';
ctx.font = '12px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labelAngle = startAngle + this.theta / 2;
ctx.fillText(
(this.theta * 180 / Math.PI).toFixed(1) + '°',
px + (arcR + 16) * Math.cos(labelAngle),
py + (arcR + 16) * Math.sin(labelAngle)
);
}
// energy bar
this._drawEnergyBar(ctx, W, H);
// energy chart
this._drawEnergyChart(ctx, W, H);
}
_drawTrail(ctx) {
const n = this._trail.length;
if (n < 2) return;
for (let i = 1; i < n; i++) {
const a = i / n * 0.6;
ctx.strokeStyle = `rgba(155,93,229,${a})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(this._trail[i - 1].x, this._trail[i - 1].y);
ctx.lineTo(this._trail[i].x, this._trail[i].y);
ctx.stroke();
}
}
_drawEnergyBar(ctx, W, H) {
const KE = 0.5 * this.omega * this.omega * this.L * this.L;
const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta));
const total = KE + PE || 1;
const bw = 160, bh = 14;
const x = W - bw - 20, y = 20;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(x - 8, y - 6, bw + 16, bh + 32, 8); ctx.fill();
// KE bar
const kw = (KE / total) * bw;
ctx.fillStyle = '#EF476F';
ctx.beginPath(); ctx.roundRect(x, y, Math.max(2, kw), bh, 4); ctx.fill();
// PE bar
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.roundRect(x + kw, y, Math.max(2, bw - kw), bh, 4); ctx.fill();
ctx.font = '10px Manrope, sans-serif';
ctx.textBaseline = 'top';
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left';
ctx.fillText('KE ' + Math.round(KE / total * 100) + '%', x, y + bh + 4);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right';
ctx.fillText('PE ' + Math.round(PE / total * 100) + '%', x + bw, y + bh + 4);
}
_drawEnergyChart(ctx, W, H) {
const data = this._eHistory;
if (data.length < 2) return;
const cw = Math.min(300, W * 0.4);
const ch = 80;
const cx = W - cw - 20;
const cy = H - ch - 20;
ctx.fillStyle = 'rgba(22,22,38,0.7)';
ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill();
let maxE = 0;
for (const d of data) maxE = Math.max(maxE, d.ke + d.pe);
if (maxE < 0.01) return;
// PE filled area
ctx.fillStyle = 'rgba(6,214,224,0.2)';
ctx.beginPath();
ctx.moveTo(cx, cy + ch);
for (let i = 0; i < data.length; i++) {
const x = cx + (i / (data.length - 1)) * cw;
const y = cy + ch - (data[i].pe / maxE) * ch;
ctx.lineTo(x, y);
}
ctx.lineTo(cx + cw, cy + ch);
ctx.closePath(); ctx.fill();
// KE line
ctx.strokeStyle = '#EF476F';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = cx + (i / (data.length - 1)) * cw;
const y = cy + ch - (data[i].ke / maxE) * ch;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// total line
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
for (let i = 0; i < data.length; i++) {
const x = cx + (i / (data.length - 1)) * cw;
const y = cy + ch - ((data[i].ke + data[i].pe) / maxE) * ch;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.setLineDash([]);
// labels
ctx.font = '10px Manrope, sans-serif';
ctx.textBaseline = 'bottom';
ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE', cx + 2, cy);
ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.fillText('PE', cx + 30, cy);
ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.textAlign = 'right'; ctx.fillText('Total', cx + cw, cy);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
cv.addEventListener('mousedown', e => {
const { bx, by } = this._bobPos();
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (this.W / r.width);
const my = (e.clientY - r.top) * (this.H / r.height);
if (Math.hypot(mx - bx, my - by) < 30) {
this._drag = true;
this.pause();
}
});
window.addEventListener('mousemove', e => {
if (!this._drag) return;
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (this.W / r.width);
const my = (e.clientY - r.top) * (this.H / r.height);
const { px, py } = this._bobPos();
this.theta = Math.atan2(mx - px, my - py);
this.omega = 0;
this._clearTrail();
this.draw();
this._emit();
});
window.addEventListener('mouseup', () => {
if (this._drag) {
this._drag = false;
this.play();
}
});
// touch
cv.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
const { bx, by } = this._bobPos();
const r = cv.getBoundingClientRect();
const mx = (e.touches[0].clientX - r.left) * (this.W / r.width);
const my = (e.touches[0].clientY - r.top) * (this.H / r.height);
if (Math.hypot(mx - bx, my - by) < 40) {
this._drag = true;
this.pause();
}
}, { passive: true });
cv.addEventListener('touchmove', e => {
if (!this._drag) return;
e.preventDefault();
const r = cv.getBoundingClientRect();
const mx = (e.touches[0].clientX - r.left) * (this.W / r.width);
const my = (e.touches[0].clientY - r.top) * (this.H / r.height);
const { px, py } = this._bobPos();
this.theta = Math.atan2(mx - px, my - py);
this.omega = 0;
this._clearTrail();
this.draw();
this._emit();
}, { passive: false });
cv.addEventListener('touchend', () => {
if (this._drag) { this._drag = false; this.play(); }
});
}
}
+811
View File
@@ -0,0 +1,811 @@
'use strict';
/* ════════════════════════════════════════════════════════════════
PhotosynthesisSim — Фотосинтез и клеточное дыхание
Световые реакции · цикл Кальвина · митохондриальное дыхание
Молекулярная анимация · частицы · статистика
════════════════════════════════════════════════════════════════ */
class PhotosynthesisSim {
static C = {
bg: '#0a0e14',
// хлоропласт
chlorBg: 'rgba(34,211,153,0.07)',
chlorStroke: 'rgba(34,211,153,0.5)',
thylBg: 'rgba(34,211,153,0.18)',
thylStroke: 'rgba(34,211,153,0.6)',
stroma: 'rgba(34,211,153,0.04)',
// митохондрия
mitoBg: 'rgba(239,71,111,0.08)',
mitoStroke: 'rgba(239,71,111,0.5)',
cristaeBg: 'rgba(239,71,111,0.18)',
cristaeStroke:'rgba(239,71,111,0.55)',
matrix: 'rgba(239,71,111,0.04)',
// молекулы
photon: '#FFD166',
water: '#06D6E0',
co2: '#EF476F',
o2: '#4CC9F0',
atp: '#9B5DE5',
nadph: '#7BF5A4',
g3p: '#22d399',
glucose: '#FFD166',
pyruvate: '#FF6B35',
electron: '#4CC9F0',
// text
label: 'rgba(255,255,255,0.35)',
labelBright: 'rgba(255,255,255,0.8)',
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.mode = 'photo'; // 'photo' | 'resp'
this._light = 70; // 0..100
this._co2 = 50; // 0..100
this._particles = [];
this._time = 0;
this._stats = { atp: 0, atpRate: 0, o2: 0, co2Out: 0, efficiency: 0 };
this._atpAccum = 0;
this._atpSmoothR = 0;
// spawn timers
this._photonTimer = 0;
this._waterTimer = 0;
this._co2Timer = 0;
this._glucoseTimer = 0;
this._atpTimer = 0;
this._pyrTimer = 0;
this._krebsAngle = 0;
this._etcOffset = 0;
// layout (computed in fit)
this._layout = {};
this._raf = null;
this._last = 0;
this.W = 0; this.H = 0;
this.onUpdate = null;
this.fit();
}
/* ── Lifecycle ────────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 700;
const H = this.canvas.offsetHeight || 440;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._calcLayout();
if (!this._raf) this._draw();
}
start() {
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
reset() {
this._particles = [];
this._time = 0;
this._stats = { atp: 0, atpRate: 0, o2: 0, co2Out: 0, efficiency: 0 };
this._atpAccum = 0;
this._atpSmoothR = 0;
if (!this._raf) this._draw();
this._emitUpdate();
}
setMode(mode) {
this.mode = mode;
this.reset();
}
setLightIntensity(v) { this._light = v; }
setCO2(v) { this._co2 = v; }
/* ── Layout ───────────────────────────────────────────────── */
_calcLayout() {
const { W, H } = this;
const cx = W / 2, cy = H / 2;
const ow = Math.min(W * 0.82, 560), oh = Math.min(H * 0.72, 300);
if (this.mode === 'photo' || this.mode !== 'resp') {
// chloroplast outer
this._layout = {
cx, cy,
outerRx: ow / 2, outerRy: oh / 2,
// thylakoid band (horizontal, middle third)
thylY: cy - oh * 0.06,
thylH: oh * 0.28,
thylX1: cx - ow * 0.4,
thylX2: cx + ow * 0.4,
// stroma top / bottom
stromaTopY: cy - oh / 2,
stromaBotY: cy + oh / 2,
// label positions
thylLabelY: cy + oh * 0.08,
stromaLabelY: cy - oh * 0.34,
};
} else {
// mitochondria
this._layout = {
cx, cy,
outerRx: ow / 2, outerRy: oh / 2,
// inner membrane (cristae zone: inner 60% of organelle)
innerRx: ow * 0.3,
innerRy: oh * 0.3,
// zones
matrixCx: cx + ow * 0.12,
cytoCx: cx - ow * 0.32,
etcY: cy,
};
}
}
/* ── Tick ─────────────────────────────────────────────────── */
_tick(t) {
const dt = Math.min(t - this._last, 80);
this._last = t;
this._time += dt;
if (this.mode === 'photo') {
this._updatePhoto(dt);
} else {
this._updateResp(dt);
}
this._updateParticles(dt);
this._draw();
this._emitUpdate();
}
/* ── Photosynthesis update ────────────────────────────────── */
_updatePhoto(dt) {
const L = this._light / 100;
const CO = this._co2 / 100;
const rate = L * 0.8 + 0.2; // min rate even at low light
// фотоны (rain from top)
this._photonTimer += dt;
const photonInterval = 300 / (L * 3 + 0.5);
while (this._photonTimer > photonInterval) {
this._photonTimer -= photonInterval;
this._spawnPhoton();
}
// H2O splitting (thylakoid)
this._waterTimer += dt;
if (this._waterTimer > 600 / (rate + 0.2)) {
this._waterTimer = 0;
if (L > 0.1) this._spawnWaterSplit();
}
// CO2 into stroma
this._co2Timer += dt;
if (this._co2Timer > 500 / (CO * 2 + 0.3)) {
this._co2Timer = 0;
if (CO > 0.05) this._spawnCO2();
}
// ATP from thylakoid <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> stroma
this._atpTimer += dt;
if (this._atpTimer > 400 / (rate + 0.1)) {
this._atpTimer = 0;
if (L > 0.05) this._spawnATP();
}
// G3P output
this._glucoseTimer += dt;
if (this._glucoseTimer > 800 / (rate * CO + 0.1)) {
this._glucoseTimer = 0;
if (L > 0.1 && CO > 0.05) this._spawnG3P();
}
// Calvin cycle rotation
this._krebsAngle += dt * 0.0004 * rate;
// stats
const atpR = L * CO * 18;
this._atpSmoothR += (atpR - this._atpSmoothR) * 0.05;
this._atpAccum += atpR * dt / 1000;
this._stats.atpRate = this._atpSmoothR;
this._stats.atp = this._atpAccum;
this._stats.o2 += L * 0.4 * dt / 1000;
this._stats.co2Out = CO;
this._stats.efficiency = Math.round(L * CO * 100 * 0.38);
}
/* ── Respiration update ───────────────────────────────────── */
_updateResp(dt) {
const rate = 0.8;
// glucose <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> pyruvate (glycolysis)
this._glucoseTimer += dt;
if (this._glucoseTimer > 900) {
this._glucoseTimer = 0;
this._spawnGlucose();
}
// pyruvate <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> krebs cycle
this._pyrTimer += dt;
if (this._pyrTimer > 600) {
this._pyrTimer = 0;
this._spawnPyruvate();
}
// ATP bursts (ETC)
this._atpTimer += dt;
if (this._atpTimer > 350) {
this._atpTimer = 0;
this._spawnATPResp();
}
// CO2 from krebs
this._co2Timer += dt;
if (this._co2Timer > 500) {
this._co2Timer = 0;
this._spawnCO2Resp();
}
// electron flow along ETC
this._etcOffset = (this._etcOffset + dt * 0.0008) % 1;
this._krebsAngle += dt * 0.0005;
// stats
const atpR = 38 * 0.5;
this._atpSmoothR += (atpR - this._atpSmoothR) * 0.04;
this._atpAccum += atpR * dt / 1000;
this._stats.atpRate = this._atpSmoothR;
this._stats.atp = this._atpAccum;
this._stats.o2 += 0.3 * dt / 1000;
this._stats.co2Out += 0.5 * dt / 1000;
this._stats.efficiency = 38;
}
/* ── Particle spawners ────────────────────────────────────── */
_spawnPhoton() {
const L = this._layout;
const x = L.thylX1 + Math.random() * (L.thylX2 - L.thylX1);
this._particles.push({
type: 'photon', x, y: this.H * 0.02,
vx: (Math.random() - 0.5) * 15,
vy: 60 + Math.random() * 30,
life: 1, maxLife: 1,
targetY: L.thylY - L.thylH / 2,
});
}
_spawnWaterSplit() {
const L = this._layout;
const x = L.cx - L.outerRx * 0.35 + Math.random() * L.outerRx * 0.15;
const y = L.thylY;
// O2 bubbles rise
for (let i = 0; i < 2; i++) {
this._particles.push({
type: 'o2', x: x + i * 12, y,
vx: (Math.random() - 0.5) * 20,
vy: -(40 + Math.random() * 30),
life: 1, maxLife: 1,
});
}
}
_spawnCO2() {
const L = this._layout;
const x = L.cx + L.outerRx * 0.3;
const y = L.stromaTopY + Math.random() * (L.cy - L.stromaTopY);
this._particles.push({
type: 'co2', x, y,
vx: -(30 + Math.random() * 20),
vy: (Math.random() - 0.5) * 15,
life: 1, maxLife: 1,
});
}
_spawnATP() {
const L = this._layout;
const x = L.thylX1 + Math.random() * (L.thylX2 - L.thylX1);
const y = L.thylY - L.thylH * 0.1;
this._particles.push({
type: 'atp', x, y,
vx: (Math.random() - 0.5) * 25,
vy: -(35 + Math.random() * 25),
life: 1, maxLife: 1,
});
}
_spawnG3P() {
const L = this._layout;
const angle = Math.random() * Math.PI * 2;
const r = 30 + Math.random() * 20;
this._particles.push({
type: 'g3p',
x: L.cx + Math.cos(angle) * r,
y: L.cy - L.thylH * 0.8 + Math.sin(angle) * r * 0.5,
vx: (Math.random() - 0.5) * 20,
vy: -(20 + Math.random() * 15),
life: 1, maxLife: 1,
});
}
_spawnGlucose() {
const L = this._layout;
this._particles.push({
type: 'glucose',
x: L.cx - L.outerRx * 0.6,
y: L.cy + (Math.random() - 0.5) * 40,
vx: 35, vy: (Math.random() - 0.5) * 10,
life: 1, maxLife: 1,
});
}
_spawnPyruvate() {
const L = this._layout;
this._particles.push({
type: 'pyruvate',
x: L.cx - 20,
y: L.cy + (Math.random() - 0.5) * 30,
vx: 20, vy: (Math.random() - 0.5) * 15,
life: 1, maxLife: 1,
});
}
_spawnATPResp() {
const L = this._layout;
const angle = this._krebsAngle + Math.random() * 0.5;
const r = L.innerRx * 0.7;
this._particles.push({
type: 'atp',
x: L.cx + Math.cos(angle) * r,
y: L.cy + Math.sin(angle) * r * 0.75,
vx: (Math.random() - 0.5) * 30,
vy: -(25 + Math.random() * 20),
life: 1, maxLife: 1,
});
}
_spawnCO2Resp() {
const L = this._layout;
const angle = this._krebsAngle + Math.PI * (0.5 + Math.random() * 0.5);
const r = L.innerRx * 0.5;
this._particles.push({
type: 'co2',
x: L.cx + Math.cos(angle) * r,
y: L.cy + Math.sin(angle) * r * 0.75,
vx: (Math.random() - 0.5) * 20,
vy: -(30 + Math.random() * 20),
life: 1, maxLife: 1,
});
}
/* ── Particle update ──────────────────────────────────────── */
_updateParticles(dt) {
const s = dt / 1000;
for (const p of this._particles) {
if (p.targetY !== undefined && p.y > p.targetY) {
p.y += p.vy * s;
p.x += p.vx * s;
} else {
p.x += p.vx * s;
p.y += p.vy * s;
p.life -= s * (0.5 + Math.random() * 0.3);
}
if (p.type === 'photon' && p.y >= (p.targetY || 0)) {
p.targetY = undefined;
p.vy = -15;
p.vx = (Math.random() - 0.5) * 30;
p.life -= 0.4;
}
}
// cap at 120 particles
this._particles = this._particles.filter(p => p.life > 0).slice(-120);
}
/* ── Draw ─────────────────────────────────────────────────── */
_draw() {
const { ctx, W, H } = this;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = PhotosynthesisSim.C.bg;
ctx.fillRect(0, 0, W, H);
if (this.mode === 'photo') {
this._drawChloroplast();
} else {
this._drawMitochondria();
}
this._drawParticles();
this._drawEquation();
}
/* ── Chloroplast ──────────────────────────────────────────── */
_drawChloroplast() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
if (!L.outerRx) return;
// outer envelope
ctx.beginPath();
ctx.ellipse(L.cx, L.cy, L.outerRx, L.outerRy, 0, 0, Math.PI * 2);
ctx.fillStyle = C.chlorBg;
ctx.fill();
ctx.strokeStyle = C.chlorStroke;
ctx.lineWidth = 2.5;
ctx.stroke();
// stroma label
ctx.save();
ctx.font = '11px Manrope,sans-serif';
ctx.fillStyle = 'rgba(34,211,153,0.45)';
ctx.textAlign = 'center';
ctx.fillText('Строма (цикл Кальвина)', L.cx, L.stromaTopY + 22);
ctx.restore();
// thylakoid membrane band
const tY = L.thylY, tH = L.thylH;
const tX1 = L.thylX1, tX2 = L.thylX2;
const tW = tX2 - tX1;
ctx.beginPath();
_psRRect(ctx, tX1, tY - tH / 2, tW, tH, tH / 2);
ctx.fillStyle = C.thylBg;
ctx.fill();
ctx.strokeStyle = C.thylStroke;
ctx.lineWidth = 2;
ctx.stroke();
// thylakoid label
ctx.save();
ctx.font = '11px Manrope,sans-serif';
ctx.fillStyle = 'rgba(34,211,153,0.6)';
ctx.textAlign = 'center';
ctx.fillText('Тилакоид (световые реакции)', L.cx, L.thylY + tH / 2 + 16);
ctx.restore();
// Calvin cycle rotating wheel in stroma
this._drawCalvinCycle();
// light arrows
this._drawLightArrows();
}
_drawCalvinCycle() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
const cx = L.cx, cy = L.cy - L.thylH * 0.85;
const r = Math.min(L.outerRy * 0.28, 42);
const a = this._krebsAngle;
ctx.save();
ctx.globalAlpha = 0.6;
// circle arrow (rotating)
ctx.beginPath();
ctx.arc(cx, cy, r, a, a + Math.PI * 1.7);
ctx.strokeStyle = C.g3p;
ctx.lineWidth = 2.5;
ctx.stroke();
// arrowhead
const ex = cx + Math.cos(a + Math.PI * 1.7) * r;
const ey = cy + Math.sin(a + Math.PI * 1.7) * r;
const da = 0.4;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(a + Math.PI * 1.7 - da) * 8,
ey - Math.sin(a + Math.PI * 1.7 - da) * 8);
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(a + Math.PI * 1.7 + da) * 8,
ey - Math.sin(a + Math.PI * 1.7 + da) * 8);
ctx.strokeStyle = C.g3p;
ctx.lineWidth = 2;
ctx.stroke();
// center label
ctx.globalAlpha = 0.55;
ctx.font = 'bold 10px Manrope,sans-serif';
ctx.fillStyle = C.g3p;
ctx.textAlign = 'center';
ctx.fillText('цикл', cx, cy - 2);
ctx.fillText('Кальвина', cx, cy + 11);
ctx.restore();
}
_drawLightArrows() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
const tX1 = L.thylX1, tX2 = L.thylX2;
const topY = L.stromaTopY + 8;
const botY = L.thylY - L.thylH / 2;
const L_norm = this._light / 100;
ctx.save();
ctx.globalAlpha = 0.25 + L_norm * 0.55;
const n = 5;
for (let i = 0; i < n; i++) {
const x = tX1 + (i + 0.5) / n * (tX2 - tX1);
ctx.beginPath();
ctx.moveTo(x, topY);
ctx.lineTo(x, botY - 4);
ctx.strokeStyle = C.photon;
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
// arrowhead
ctx.beginPath();
ctx.moveTo(x, botY - 4);
ctx.lineTo(x - 5, botY - 14);
ctx.moveTo(x, botY - 4);
ctx.lineTo(x + 5, botY - 14);
ctx.strokeStyle = C.photon;
ctx.lineWidth = 1.5;
ctx.stroke();
// sun dot
ctx.beginPath();
ctx.arc(x, topY - 5, 4, 0, Math.PI * 2);
ctx.fillStyle = C.photon;
ctx.fill();
}
ctx.restore();
}
/* ── Mitochondria ─────────────────────────────────────────── */
_drawMitochondria() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
if (!L.outerRx) return;
// outer membrane
ctx.beginPath();
ctx.ellipse(L.cx, L.cy, L.outerRx, L.outerRy, 0, 0, Math.PI * 2);
ctx.fillStyle = C.mitoBg;
ctx.fill();
ctx.strokeStyle = C.mitoStroke;
ctx.lineWidth = 2.5;
ctx.stroke();
// inner membrane / cristae (zigzag folds)
this._drawCristae();
// zone labels
ctx.save();
ctx.font = '11px Manrope,sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(239,71,111,0.5)';
ctx.fillText('Матрикс (цикл Кребса)', L.cx + L.innerRx * 0.1, L.cy + 12);
ctx.fillStyle = 'rgba(239,71,111,0.35)';
ctx.fillText('Цитоплазма (гликолиз)', L.cx - L.outerRx * 0.62, L.cy);
ctx.restore();
// Krebs cycle arrow
this._drawKrebsWheel();
// ETC along inner membrane
this._drawETC();
}
_drawCristae() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
const iRx = L.innerRx || 110, iRy = L.innerRy || 80;
ctx.beginPath();
ctx.ellipse(L.cx, L.cy, iRx, iRy, 0, 0, Math.PI * 2);
ctx.fillStyle = C.cristaeBg;
ctx.fill();
ctx.strokeStyle = C.cristaeStroke;
ctx.lineWidth = 1.8;
ctx.stroke();
// cristae folds (vertical zigzag lines inside)
ctx.save();
ctx.globalAlpha = 0.45;
ctx.strokeStyle = C.cristaeStroke;
ctx.lineWidth = 1.5;
for (let i = -2; i <= 2; i++) {
const x = L.cx + i * iRx * 0.32;
const h = Math.sqrt(Math.max(0, 1 - (i * 0.32) ** 2)) * iRy * 0.7;
ctx.beginPath();
ctx.moveTo(x, L.cy - h);
ctx.bezierCurveTo(x - 12, L.cy - h * 0.3, x + 12, L.cy + h * 0.3, x, L.cy + h);
ctx.stroke();
}
ctx.restore();
}
_drawKrebsWheel() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
const cx = L.cx, cy = L.cy;
const r = (L.innerRx || 110) * 0.42;
const a = this._krebsAngle;
ctx.save();
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.arc(cx, cy, r, a, a + Math.PI * 1.65);
ctx.strokeStyle = C.pyruvate;
ctx.lineWidth = 2.5;
ctx.stroke();
// arrowhead
const ex = cx + Math.cos(a + Math.PI * 1.65) * r;
const ey = cy + Math.sin(a + Math.PI * 1.65) * r;
const da = 0.45;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(a + Math.PI * 1.65 - da) * 8,
ey - Math.sin(a + Math.PI * 1.65 - da) * 8);
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(a + Math.PI * 1.65 + da) * 8,
ey - Math.sin(a + Math.PI * 1.65 + da) * 8);
ctx.strokeStyle = C.pyruvate;
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 0.45;
ctx.font = 'bold 10px Manrope,sans-serif';
ctx.fillStyle = C.pyruvate;
ctx.textAlign = 'center';
ctx.fillText('цикл', cx, cy - 3);
ctx.fillText('Кребса', cx, cy + 11);
ctx.restore();
}
_drawETC() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const L = this._layout;
const iRx = L.innerRx || 110, iRy = L.innerRy || 80;
const n = 8;
ctx.save();
ctx.globalAlpha = 0.7;
for (let i = 0; i < n; i++) {
const frac = ((i / n) + this._etcOffset) % 1;
const a = frac * Math.PI * 2 - Math.PI / 2;
const x = L.cx + Math.cos(a) * iRx;
const y = L.cy + Math.sin(a) * iRy;
const size = 4 + 2 * Math.sin(frac * Math.PI * 4);
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fillStyle = C.electron;
ctx.shadowColor = C.electron;
ctx.shadowBlur = 6;
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.restore();
}
/* ── Particle rendering ───────────────────────────────────── */
_drawParticles() {
const { ctx } = this;
const C = PhotosynthesisSim.C;
const colorMap = {
photon: C.photon,
o2: C.o2,
co2: C.co2,
atp: C.atp,
nadph: C.nadph,
g3p: C.g3p,
glucose: C.glucose,
pyruvate: C.pyruvate,
electron: C.electron,
};
const labelMap = {
photon: '*',
o2: 'O₂',
co2: 'CO₂',
atp: 'ATP',
nadph: 'NADPH',
g3p: 'G3P',
glucose: 'Глк',
pyruvate: 'Пир',
electron: 'e⁻',
};
for (const p of this._particles) {
const alpha = Math.min(1, p.life * 2) * 0.9;
if (alpha <= 0) continue;
ctx.save();
ctx.globalAlpha = alpha;
const col = colorMap[p.type] || '#fff';
const lbl = labelMap[p.type] || '';
// glow
ctx.beginPath();
ctx.arc(p.x, p.y, 9, 0, Math.PI * 2);
ctx.fillStyle = col + '28';
ctx.fill();
// circle
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
// label
ctx.font = 'bold 8px Manrope,sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(lbl, p.x, p.y + 18);
ctx.restore();
}
}
/* ── Equation footer ──────────────────────────────────────── */
_drawEquation() {
const { ctx, W, H } = this;
const eq = this.mode === 'photo'
? '6CO₂ + 6H₂O + свет → C₆H₁₂O₆ + 6O₂'
: 'C₆H₁₂O₆ + 6O₂ → 6CO₂ + 6H₂O + 38 ATP';
ctx.save();
ctx.font = '12px Manrope,sans-serif';
const tw = ctx.measureText(eq).width;
const px = W / 2 - tw / 2 - 12, py = H - 28;
ctx.fillStyle = 'rgba(255,255,255,0.06)';
_psRRect(ctx, px, py - 2, tw + 24, 20, 6);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'center';
ctx.fillText(eq, W / 2, py + 13);
ctx.restore();
}
/* ── Stats emit ───────────────────────────────────────────── */
_emitUpdate() {
if (!this.onUpdate) return;
this.onUpdate({
mode: this.mode,
atpRate: this._stats.atpRate.toFixed(1),
o2: Math.floor(this._stats.o2),
co2: Math.floor(this._stats.co2Out),
efficiency: this._stats.efficiency.toFixed ? this._stats.efficiency.toFixed(0) : this._stats.efficiency,
light: this._light,
co2Level: this._co2,
});
}
}
/* helper */
function _psRRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
+570
View File
@@ -0,0 +1,570 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
ProbabilitySim — probability & law of large numbers
coin flip · single die · two-dice sum
histogram + convergence chart + animated visuals
══════════════════════════════════════════════════════════════ */
class ProbabilitySim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* parameters */
this.mode = 'coin'; // 'coin' | 'dice' | 'dice2'
this.trials = 100; // target total
this.speed = 5; // trials per frame
/* state */
this.results = []; // outcome per trial
this.distribution = {}; // outcome <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> count
this._convHist = []; // running freq for convergence chart
this._trackKey = null; // key tracked for convergence
/* animation */
this.playing = false;
this._raf = null;
this._animT = 0; // animation phase for coin/dice visual
this._lastOutcome = null;
this._shakeT = 0;
this.onUpdate = null;
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── presets ──────────────────────────────────── */
static PRESETS = {
coin_100: { mode: 'coin', trials: 100, speed: 2 },
coin_1000: { mode: 'coin', trials: 1000, speed: 10 },
dice_100: { mode: 'dice', trials: 100, speed: 2 },
dice2_500: { mode: 'dice2', trials: 500, speed: 5 },
};
preset(name) {
const p = ProbabilitySim.PRESETS[name];
if (p) { this.setParams(p); this.reset(); }
}
/* ── public API ──────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ mode, trials, speed } = {}) {
if (mode !== undefined) this.mode = mode;
if (trials !== undefined) this.trials = Math.max(1, +trials);
if (speed !== undefined) this.speed = Math.max(1, Math.min(50, +speed));
this._setupMode();
this.draw();
this._emit();
}
reset() {
this.pause();
this.results = [];
this.distribution = {};
this._convHist = [];
this._animT = 0;
this._lastOutcome = null;
this._shakeT = 0;
this._setupMode();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const n = this.results.length;
const dist = { ...this.distribution };
const theo = this._theoretical();
let chiSq = 0, maxDev = 0;
for (const k of Object.keys(theo)) {
const obs = (dist[k] || 0) / (n || 1);
const exp = theo[k];
const dev = Math.abs(obs - exp);
if (dev > maxDev) maxDev = dev;
if (n > 0) chiSq += ((dist[k] || 0) - n * exp) ** 2 / (n * exp || 1);
}
return {
mode: this.mode,
totalTrials: n,
distribution: dist,
chiSquare: +chiSq.toFixed(4),
maxDeviation: +maxDev.toFixed(6),
};
}
/* ── internals ───────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
_setupMode() {
const keys = this._outcomeKeys();
for (const k of keys) {
if (!(k in this.distribution)) this.distribution[k] = 0;
}
this._trackKey = keys[0]; // convergence tracks first outcome
}
_outcomeKeys() {
if (this.mode === 'coin') return ['О', 'Р'];
if (this.mode === 'dice') return ['1','2','3','4','5','6'];
// dice2: sums 2..12
const keys = [];
for (let i = 2; i <= 12; i++) keys.push(String(i));
return keys;
}
_theoretical() {
const t = {};
if (this.mode === 'coin') {
t['О'] = 0.5; t['Р'] = 0.5;
} else if (this.mode === 'dice') {
for (let i = 1; i <= 6; i++) t[String(i)] = 1 / 6;
} else {
// dice2: two dice sum probabilities
const ways = [0,0,1,2,3,4,5,6,5,4,3,2,1]; // index 0-12, sum 2-12
for (let s = 2; s <= 12; s++) t[String(s)] = ways[s] / 36;
}
return t;
}
_rollOnce() {
if (this.mode === 'coin') return Math.random() < 0.5 ? 'О' : 'Р';
if (this.mode === 'dice') return String(Math.floor(Math.random() * 6) + 1);
const d1 = Math.floor(Math.random() * 6) + 1;
const d2 = Math.floor(Math.random() * 6) + 1;
return String(d1 + d2);
}
_addTrial() {
if (this.results.length >= this.trials) return false;
const outcome = this._rollOnce();
this.results.push(outcome);
this.distribution[outcome] = (this.distribution[outcome] || 0) + 1;
this._lastOutcome = outcome;
// convergence: running frequency of tracked key
const n = this.results.length;
const freq = (this.distribution[this._trackKey] || 0) / n;
this._convHist.push(freq);
if (this._convHist.length > 500) this._convHist.shift();
return true;
}
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(() => {
let added = 0;
for (let i = 0; i < this.speed; i++) {
if (!this._addTrial()) break;
added++;
}
this._animT += 0.15;
if (added > 0) this._shakeT = 1;
else this._shakeT *= 0.9;
this.draw();
this._emit();
if (this.results.length >= this.trials) {
this.pause();
return;
}
this._tick();
});
}
/* ── drawing ─────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
const vizH = H * 0.28;
const histH = H * 0.48;
const convH = H * 0.24;
this._drawVisual(ctx, 0, 0, W, vizH);
this._drawHistogram(ctx, 0, vizH, W, histH);
this._drawConvergence(ctx, 0, vizH + histH, W, convH);
this._drawStats(ctx, W, H);
}
/* ── top visual: coin or dice ──────────────── */
_drawVisual(ctx, x0, y0, w, h) {
const cx = x0 + w / 2, cy = y0 + h / 2;
if (this.mode === 'coin') {
this._drawCoin(ctx, cx, cy, Math.min(w, h) * 0.32);
} else if (this.mode === 'dice') {
this._drawDie(ctx, cx, cy, Math.min(w, h) * 0.34, this._lastOutcome ? +this._lastOutcome : 1);
} else {
// dice2: two dice side by side
const sz = Math.min(w, h) * 0.28;
const gap = sz * 0.3;
const last = this._lastOutcome ? +this._lastOutcome : 7;
const d1 = Math.min(6, Math.max(1, Math.ceil(last / 2)));
const d2 = last - d1;
this._drawDie(ctx, cx - sz / 2 - gap, cy, sz, Math.max(1, Math.min(6, d1)));
this._drawDie(ctx, cx + sz / 2 + gap, cy, sz, Math.max(1, Math.min(6, d2)));
}
// trial counter
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.font = "bold 13px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`Испытание ${this.results.length} / ${this.trials}`, x0 + w / 2, y0 + h - 6);
}
_drawCoin(ctx, cx, cy, r) {
const phase = this._animT % (Math.PI * 2);
const squeeze = Math.abs(Math.cos(phase));
const showHeads = Math.cos(phase) >= 0;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(Math.max(0.05, squeeze), 1);
// shadow
ctx.fillStyle = 'rgba(155,93,229,0.15)';
ctx.beginPath(); ctx.ellipse(0, r * 0.15, r * 1.1, r * 0.18, 0, 0, Math.PI * 2); ctx.fill();
// coin body
const grad = ctx.createRadialGradient(-r * 0.2, -r * 0.2, 0, 0, 0, r);
if (showHeads) {
grad.addColorStop(0, '#FFD166');
grad.addColorStop(1, '#D4950A');
} else {
grad.addColorStop(0, '#9B5DE5');
grad.addColorStop(1, '#6B2FA0');
}
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill();
// rim
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 2;
ctx.stroke();
// label
ctx.fillStyle = showHeads ? '#5A3000' : '#E0D0FF';
ctx.font = `bold ${Math.round(r * 0.6)}px 'Manrope', system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(showHeads ? 'О' : 'Р', 0, 2);
ctx.restore();
}
_drawDie(ctx, cx, cy, size, value) {
const half = size / 2;
const shake = this._shakeT * 2;
const sx = shake * (Math.random() - 0.5);
const sy = shake * (Math.random() - 0.5);
ctx.save();
ctx.translate(cx + sx, cy + sy);
// shadow
ctx.fillStyle = 'rgba(6,214,224,0.08)';
ctx.beginPath(); ctx.roundRect(-half + 4, -half + 6, size, size, size * 0.18); ctx.fill();
// body
const grad = ctx.createLinearGradient(-half, -half, half, half);
grad.addColorStop(0, '#1E1E3A');
grad.addColorStop(1, '#12122A');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.roundRect(-half, -half, size, size, size * 0.18); ctx.fill();
// border
ctx.strokeStyle = 'rgba(155,93,229,0.4)';
ctx.lineWidth = 1.5;
ctx.stroke();
// dots
const dotR = size * 0.08;
const off = size * 0.26;
const dots = {
1: [[0, 0]],
2: [[-off, -off], [off, off]],
3: [[-off, -off], [0, 0], [off, off]],
4: [[-off, -off], [off, -off], [-off, off], [off, off]],
5: [[-off, -off], [off, -off], [0, 0], [-off, off], [off, off]],
6: [[-off, -off], [off, -off], [-off, 0], [off, 0], [-off, off], [off, off]],
};
const positions = dots[Math.max(1, Math.min(6, value))] || dots[1];
for (const [dx, dy] of positions) {
const dg = ctx.createRadialGradient(dx, dy, 0, dx, dy, dotR);
dg.addColorStop(0, '#FFFFFF');
dg.addColorStop(1, '#C0C0E0');
ctx.fillStyle = dg;
ctx.beginPath(); ctx.arc(dx, dy, dotR, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
/* ── histogram ─────────────────────────────── */
_drawHistogram(ctx, x0, y0, w, h) {
const keys = this._outcomeKeys();
const theo = this._theoretical();
const n = this.results.length || 1;
const pad = { l: 48, r: 16, t: 20, b: 34 };
const pw = w - pad.l - pad.r;
const ph = h - pad.t - pad.b;
const px = x0 + pad.l, py = y0 + pad.t;
// panel bg
ctx.fillStyle = 'rgba(5,5,20,0.5)';
ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 4, w - 16, h - 8, 8); ctx.fill();
// y-axis: relative frequency
let maxFreq = 0;
for (const k of keys) {
const f = (this.distribution[k] || 0) / n;
if (f > maxFreq) maxFreq = f;
}
for (const k of keys) {
const t = theo[k];
if (t > maxFreq) maxFreq = t;
}
maxFreq = Math.max(maxFreq * 1.15, 0.05);
// grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const gy = py + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
}
// y labels
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const v = (maxFreq * i / 4 * 100).toFixed(0) + '%';
ctx.fillText(v, px - 6, py + ph * (1 - i / 4));
}
// bars
const barCount = keys.length;
const gap = Math.max(2, pw * 0.03);
const barW = (pw - gap * (barCount + 1)) / barCount;
const colors = ['#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166',
'#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166','#EF476F'];
for (let i = 0; i < barCount; i++) {
const k = keys[i];
const freq = (this.distribution[k] || 0) / n;
const bh = (freq / maxFreq) * ph;
const bx = px + gap + i * (barW + gap);
const by = py + ph - bh;
// bar gradient
const bg = ctx.createLinearGradient(bx, by, bx, py + ph);
bg.addColorStop(0, colors[i % colors.length]);
bg.addColorStop(1, colors[i % colors.length] + '66');
ctx.fillStyle = bg;
ctx.beginPath(); ctx.roundRect(bx, by, barW, bh, [4, 4, 0, 0]); ctx.fill();
// glow at top
if (bh > 4) {
ctx.fillStyle = colors[i % colors.length] + '33';
ctx.beginPath(); ctx.roundRect(bx - 2, by - 2, barW + 4, 6, 3); ctx.fill();
}
// count + percentage label above bar
const count = this.distribution[k] || 0;
const pct = (freq * 100).toFixed(1);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = "bold 9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
if (bh > 16) {
ctx.fillText(count, bx + barW / 2, by - 2);
} else {
ctx.fillText(count, bx + barW / 2, py + ph - bh - 2);
}
// percentage inside bar
if (bh > 28) {
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textBaseline = 'top';
ctx.fillText(pct + '%', bx + barW / 2, by + 4);
}
// x label
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(k, bx + barW / 2, py + ph + 6);
}
// theoretical probability dashed lines
ctx.setLineDash([5, 4]);
ctx.lineWidth = 1.5;
for (let i = 0; i < barCount; i++) {
const k = keys[i];
const tp = theo[k];
const ly = py + ph - (tp / maxFreq) * ph;
const bx = px + gap + i * (barW + gap);
ctx.strokeStyle = colors[i % colors.length] + '88';
ctx.beginPath();
ctx.moveTo(bx - 2, ly);
ctx.lineTo(bx + barW + 2, ly);
ctx.stroke();
}
ctx.setLineDash([]);
// legend
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
ctx.setLineDash([5, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, y0 + h - 8); ctx.lineTo(px + 18, y0 + h - 8); ctx.stroke();
ctx.setLineDash([]);
ctx.fillText('— теор. вероятность', px + 22, y0 + h - 4);
}
/* ── convergence chart ─────────────────────── */
_drawConvergence(ctx, x0, y0, w, h) {
const pad = { l: 48, r: 16, t: 14, b: 20 };
const pw = w - pad.l - pad.r;
const ph = h - pad.t - pad.b;
const px = x0 + pad.l, py = y0 + pad.t;
// bg
ctx.fillStyle = 'rgba(5,5,20,0.5)';
ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 2, w - 16, h - 4, 8); ctx.fill();
// title
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = "9px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const trackLabel = this._trackKey;
ctx.fillText(`Сходимость частоты «${trackLabel}»`, px, y0 + 3);
// theoretical value
const theo = this._theoretical();
const tp = theo[this._trackKey] || 0;
// y range
const yMin = Math.max(0, tp - 0.35);
const yMax = Math.min(1, tp + 0.35);
const yRange = yMax - yMin || 0.01;
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 3; i++) {
const gy = py + ph * (i / 3);
ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke();
}
// y labels
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let i = 0; i <= 3; i++) {
const v = yMax - (i / 3) * yRange;
ctx.fillText(v.toFixed(2), px - 5, py + ph * (i / 3));
}
// theoretical dashed line
const theoY = py + ph * (1 - (tp - yMin) / yRange);
ctx.setLineDash([6, 4]);
ctx.strokeStyle = '#FFD166';
ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(px, theoY); ctx.lineTo(px + pw, theoY); ctx.stroke();
ctx.setLineDash([]);
// label for theoretical
ctx.fillStyle = '#FFD166';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
ctx.fillText('p=' + tp.toFixed(4), px + pw, theoY - 3);
// convergence line
const data = this._convHist;
if (data.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = '#06D6E0';
ctx.lineWidth = 1.5;
for (let i = 0; i < data.length; i++) {
const lx = px + (i / (data.length - 1)) * pw;
const ly = py + ph * (1 - (data[i] - yMin) / yRange);
const cly = Math.max(py, Math.min(py + ph, ly));
i === 0 ? ctx.moveTo(lx, cly) : ctx.lineTo(lx, cly);
}
ctx.stroke();
// x label
ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.font = "8px 'Manrope', system-ui, sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('номер испытания →', px + pw / 2, y0 + h - 14);
}
/* ── stats overlay ─────────────────────────── */
_drawStats(ctx, W) {
const info = this.info();
const px = 12, py = 10, pw = 170, ph = 72;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = "10px 'Manrope', system-ui, sans-serif";
const lh = 15;
const modeLabel = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' }[this.mode] || this.mode;
ctx.fillStyle = '#9B5DE5';
ctx.fillText(`Режим: ${modeLabel}`, px + 10, py + 8);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`N = ${info.totalTrials}`, px + 10, py + 8 + lh);
ctx.fillStyle = '#7BF5A4';
ctx.fillText(`χ² = ${info.chiSquare}`, px + 10, py + 8 + lh * 2);
ctx.fillStyle = '#FFD166';
ctx.fillText(`max Δ = ${info.maxDeviation.toFixed(4)}`, px + 10, py + 8 + lh * 3);
}
}
if (typeof module !== 'undefined') module.exports = ProbabilitySim;
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
QuadraticSim — interactive quadratic equation explorer
y = ax² + bx + c · discriminant, roots, vertex
══════════════════════════════════════════════════════════════ */
class QuadraticSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* coefficients */
this.a = 1;
this.b = 0;
this.c = -1;
/* view */
this.ox = 0;
this.oy = 0;
this.scl = 40; // px per unit
/* interaction */
this._drag = null;
this.hx = null; // hovered math x
/* callback */
this.onUpdate = null;
this._bind();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ───────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ a, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (b !== undefined) this.b = +b;
if (c !== undefined) this.c = +c;
this.draw();
this._emit();
}
resetView() { this.ox = 0; this.oy = 0; this.scl = 40; this.draw(); }
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
info() {
const { a, b, c } = this;
const D = b * b - 4 * a * c;
let roots = '—';
let rootCount = 0;
if (a === 0) {
roots = b !== 0 ? `x = ${this._fmt(-c / b)}` : '—';
rootCount = b !== 0 ? 1 : 0;
} else if (D > 0.0001) {
const sqD = Math.sqrt(D);
const x1 = (-b - sqD) / (2 * a);
const x2 = (-b + sqD) / (2 * a);
roots = `x₁=${this._fmt(x1)}, x₂=${this._fmt(x2)}`;
rootCount = 2;
} else if (Math.abs(D) <= 0.0001) {
roots = `x = ${this._fmt(-b / (2 * a))}`;
rootCount = 1;
}
const vx = a !== 0 ? -b / (2 * a) : 0;
const vy = a !== 0 ? c - b * b / (4 * a) : 0;
return {
D: this._fmt(D),
rootCount,
roots,
vertex: a !== 0 ? `(${this._fmt(vx)}; ${this._fmt(vy)})` : '—',
equation: `y = ${a !== 1 ? (a === -1 ? '' : this._fmt(a)) : ''}${b >= 0 ? '+' : ''} ${this._fmt(Math.abs(b))}x ${c >= 0 ? '+' : ''} ${this._fmt(Math.abs(c))}`,
};
}
/* ── internals ────────────────────────────────────── */
_fmt(n) {
if (Number.isInteger(n)) return String(n);
return Math.abs(n) < 0.005 ? '0' : n.toFixed(2).replace(/\.?0+$/, '');
}
_f(x) {
return this.a * x * x + this.b * x + this.c;
}
_emit() {
if (this.onUpdate) this.onUpdate(this.info());
}
/* ── coordinate transforms ─────────────────────────── */
_toPx(mx, my) {
return [
this.W / 2 + (mx - this.ox) * this.scl,
this.H / 2 - (my - this.oy) * this.scl,
];
}
_toMath(px, py) {
return [
(px - this.W / 2) / this.scl + this.ox,
-(py - this.H / 2) / this.scl + this.oy,
];
}
/* ── draw ─────────────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
this._drawGrid(ctx, W, H);
this._drawAxes(ctx, W, H);
this._drawParabola(ctx, W, H);
this._drawFeatures(ctx, W, H);
if (this.hx !== null) this._drawHover(ctx, W, H);
}
/* ── grid & axes ──────────────────────────────────── */
_niceStep() {
const raw = this.W / this.scl / 8;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
return p;
}
_drawGrid(ctx, W, H) {
const step = this._niceStep();
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0);
const gx = Math.floor(x0 / step) * step;
const gy = Math.floor(y0 / step) * step;
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
for (let x = gx; x <= x1 + step; x += step) {
const [px] = this._toPx(x, 0);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = gy; y <= y1 + step; y += step) {
const [, py] = this._toPx(0, y);
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
// labels
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.3)';
const [axX, axY] = this._toPx(0, 0);
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = gx; x <= x1; x += step) {
if (Math.abs(x) < step * 0.01) continue;
const [px] = this._toPx(x, 0);
if (px < 18 || px > W - 18) continue;
ctx.fillText(this._fmtLabel(x, step), px, lblY);
}
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let y = gy; y <= y1; y += step) {
if (Math.abs(y) < step * 0.01) continue;
const [, py] = this._toPx(0, y);
if (py < 12 || py > H - 12) continue;
ctx.fillText(this._fmtLabel(y, step), lblX, py);
}
}
_fmtLabel(n, step) {
if (n === 0) return '0';
if (step >= 1 && Number.isInteger(n)) return String(n);
if (step < 0.001) return n.toExponential(1);
const dec = Math.max(0, -Math.floor(Math.log10(step)));
return n.toFixed(dec);
}
_drawAxes(ctx, W, H) {
const [ax, ay] = this._toPx(0, 0);
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke();
// arrowheads
ctx.fillStyle = 'rgba(255,255,255,0.4)';
this._arrowHead(ctx, W - 8, ay, 0);
this._arrowHead(ctx, ax, 6, -Math.PI / 2);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
ctx.fillText('x', W - 10, ay - 13);
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('y', ax + 7, 4);
}
_arrowHead(ctx, x, y, angle) {
const s = 5;
ctx.save(); ctx.translate(x, y); ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
/* ── parabola curve ───────────────────────────────── */
_drawParabola(ctx, W, H) {
const steps = Math.min(W * 2, 2000);
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
const dx = (x1 - x0) / steps;
// glow
ctx.strokeStyle = 'rgba(155,93,229,0.15)';
ctx.lineWidth = 8;
ctx.lineJoin = 'round';
ctx.beginPath();
let pen = false;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
const my = this._f(mx);
if (!isFinite(my)) { pen = false; continue; }
const [px, py] = this._toPx(mx, my);
if (py < -200 || py > H + 200) { pen = false; continue; }
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
}
ctx.stroke();
// main curve
ctx.strokeStyle = '#9B5DE5';
ctx.lineWidth = 2.5;
ctx.beginPath();
pen = false;
for (let i = 0; i <= steps; i++) {
const mx = x0 + i * dx;
const my = this._f(mx);
if (!isFinite(my)) { pen = false; continue; }
const [px, py] = this._toPx(mx, my);
if (py < -200 || py > H + 200) { pen = false; continue; }
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
pen = true;
}
ctx.stroke();
}
/* ── vertex, roots, axis of symmetry ──────────────── */
_drawFeatures(ctx, W, H) {
const { a, b, c } = this;
if (a === 0) return; // linear — no features
const vx = -b / (2 * a);
const vy = this._f(vx);
const D = b * b - 4 * a * c;
// axis of symmetry
const [symPx] = this._toPx(vx, 0);
ctx.strokeStyle = 'rgba(6,214,224,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(symPx, 0); ctx.lineTo(symPx, H); ctx.stroke();
ctx.setLineDash([]);
// vertex point
const [vpx, vpy] = this._toPx(vx, vy);
if (vpy > -20 && vpy < H + 20) {
ctx.fillStyle = '#06D6E0';
ctx.beginPath(); ctx.arc(vpx, vpy, 6, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
// label
ctx.fillStyle = '#06D6E0';
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(`(${this._fmt(vx)}; ${this._fmt(vy)})`, vpx, vpy - 12);
}
// roots
if (D >= -0.0001) {
ctx.fillStyle = '#EF476F';
const roots = [];
if (D > 0.0001) {
const sqD = Math.sqrt(D);
roots.push((-b - sqD) / (2 * a));
roots.push((-b + sqD) / (2 * a));
} else {
roots.push(-b / (2 * a));
}
for (const rx of roots) {
const [rpx, rpy] = this._toPx(rx, 0);
if (rpx < -20 || rpx > W + 20) continue;
// root dot
ctx.fillStyle = '#EF476F';
ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
// label
ctx.fillStyle = '#EF476F';
ctx.font = '11px Manrope, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(this._fmt(rx), rpx, rpy + 10);
}
}
// discriminant badge
const badgeColor = D > 0.0001 ? '#7BF5A4' : (D < -0.0001 ? '#EF476F' : '#FFD166');
const badgeText = D > 0.0001 ? `D = ${this._fmt(D)} > 0 (2 корня)` :
D < -0.0001 ? `D = ${this._fmt(D)} < 0 (нет корней)` :
`D = 0 (1 корень)`;
ctx.font = 'bold 12px Manrope, sans-serif';
const tw = ctx.measureText(badgeText).width;
const bx = W - tw - 28, by = 16;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.fill();
ctx.strokeStyle = badgeColor; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.stroke();
ctx.fillStyle = badgeColor;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(badgeText, bx + 10, by + 14);
}
/* ── hover crosshair ──────────────────────────────── */
_drawHover(ctx, W, H) {
const [px] = this._toPx(this.hx, 0);
const my = this._f(this.hx);
if (!isFinite(my)) return;
const [, py] = this._toPx(this.hx, my);
// vertical line
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.setLineDash([]);
if (py < -20 || py > H + 20) return;
// point
ctx.fillStyle = '#FFD166';
ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
// tooltip
ctx.fillStyle = 'rgba(22,22,38,0.9)';
const text = `(${this._fmt(this.hx)}, ${this._fmt(my)})`;
ctx.font = '12px Manrope, sans-serif';
const tw2 = ctx.measureText(text).width;
const tx = px + 14, ty = py - 14;
ctx.beginPath(); ctx.roundRect(tx, ty - 10, tw2 + 16, 22, 6); ctx.fill();
ctx.fillStyle = '#FFD166';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(text, tx + 8, ty + 1);
}
/* ── events ───────────────────────────────────────── */
_bind() {
const cv = this.canvas;
cv.addEventListener('wheel', e => {
e.preventDefault();
const [mx, my] = this._toMath(e.offsetX, e.offsetY);
this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
const [nx, ny] = this._toMath(e.offsetX, e.offsetY);
this.ox -= nx - mx; this.oy -= ny - my;
this.draw();
}, { passive: false });
cv.addEventListener('mousedown', e => {
this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
cv.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', e => {
if (this._drag) {
this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl;
this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl;
this.draw();
} else {
const r = cv.getBoundingClientRect();
const lx = e.clientX - r.left, ly = e.clientY - r.top;
if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
this.hx = this._toMath(lx, ly)[0];
this.draw();
}
}
});
window.addEventListener('mouseup', () => {
this._drag = null;
cv.style.cursor = 'crosshair';
});
cv.addEventListener('mouseleave', () => {
this.hx = null; this.draw();
});
cv.style.cursor = 'crosshair';
// touch
let t0 = null;
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1)
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
}, { passive: true });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length === 1 && t0) {
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
this.draw();
}
}, { passive: false });
cv.addEventListener('touchend', () => { t0 = null; });
}
}
+618
View File
@@ -0,0 +1,618 @@
'use strict';
/**
* ReactionSim — Chemical reaction kinetics simulation.
* Particle-based A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> C (and variants) with Arrhenius kinetics.
* Renders: glowing molecules, flash effects on reaction,
* live concentration graph, energy profile diagram.
*/
class ReactionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.particles = [];
this.flashes = []; // [{x, y, t, maxT, color}]
this._history = []; // [{step, nA, nB, nC}]
this._nextId = 0;
// Parameters
this.N = 28; // initial molecules per reactive species
this.T = 1.2; // temperature 0.24.0
this.Ea = 2.0; // activation energy 0.55.0
this.mode = 'forward'; // 'forward' | 'reversible' | 'chain'
this.reactionOn = true;
// Runtime stats
this._steps = 0;
this._totalReactions = 0;
this._recentReactions = 0;
this._rate = 0; // reactions per step (ema)
this._raf = null;
this._dpr = 1;
this.onUpdate = null;
// Spatial grid
this._grid = new Map();
this._GRID_C = 22; // cell size (> max particle diameter)
}
/* ────────────────────────── Lifecycle ────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
this._dpr = dpr;
const w = this.canvas.offsetWidth;
const h = this.canvas.offsetHeight;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.W = w; this.H = h;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.reset();
}
reset() {
const { W, H } = this;
if (!W || !H) return;
this.particles = [];
this.flashes = [];
this._history = [];
this._steps = 0;
this._totalReactions = 0;
this._recentReactions = 0;
this._rate = 0;
this._nextId = 0;
// Spawn N of A and N of B
this._spawnType('A', this.N);
this._spawnType('B', this.N);
this._recordHistory();
}
_spawnType(type, count) {
const { W, H } = this;
const r = this._radius(type);
const margin = 12;
let placed = 0, attempts = 0;
while (placed < count && attempts < count * 60) {
attempts++;
const x = margin + r + Math.random() * (W - 2 * r - margin * 2);
const y = margin + r + Math.random() * (H - 2 * r - margin * 2);
let overlap = false;
for (const p of this.particles) {
const dx = p.x - x, dy = p.y - y;
if (dx * dx + dy * dy < (p.r + r + 1) ** 2) { overlap = true; break; }
}
if (overlap) continue;
const ang = Math.random() * Math.PI * 2;
const spd = this._baseSpeed(type) * (0.6 + Math.random() * 0.8);
this.particles.push({ x, y, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, r, type, id: this._nextId++ });
placed++;
}
}
start() {
if (this._raf) return;
const loop = () => {
this._raf = requestAnimationFrame(loop);
for (let i = 0; i < 3; i++) this._step();
this.draw();
};
this._raf = requestAnimationFrame(loop);
}
stop() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
/* ────────────────────────── Parameters ────────────────────────── */
setN(n) {
this.N = Math.max(5, Math.min(80, n));
this.reset();
}
setT(t) {
const ratio = Math.max(0.1, t) / Math.max(0.1, this.T);
this.T = Math.max(0.2, Math.min(4.0, t));
const scale = Math.sqrt(ratio);
for (const p of this.particles) { p.vx *= scale; p.vy *= scale; }
}
setEa(ea) {
this.Ea = Math.max(0.5, Math.min(5.0, ea));
}
setMode(mode) { this.mode = mode; }
toggleReaction() { this.reactionOn = !this.reactionOn; }
preset(name) {
this.reactionOn = true;
const presets = {
simple: { N: 28, T: 1.2, Ea: 1.8, mode: 'forward' },
reversible: { N: 22, T: 1.5, Ea: 1.5, mode: 'reversible' },
hot: { N: 25, T: 2.8, Ea: 2.0, mode: 'forward' },
cold: { N: 25, T: 0.4, Ea: 1.5, mode: 'forward' },
chain: { N: 18, T: 1.8, Ea: 0.9, mode: 'chain' },
};
Object.assign(this, presets[name] || {});
this.reset();
}
info() {
let nA = 0, nB = 0, nC = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else nC++;
}
return { nA, nB, nC, total: this.particles.length, reactions: this._totalReactions, rate: this._rate };
}
/* ────────────────────────── Helpers ────────────────────────── */
_radius(type) { return type === 'C' ? 7 : 5; }
_baseSpeed(type) { return (type === 'C' ? 0.55 : 1.0) * this.T * 3.2; }
_color(type) { return { A: '#06D6E0', B: '#EF476F', C: '#FFD166' }[type] || '#aaa'; }
/* ────────────────────────── Physics ────────────────────────── */
_buildGrid() {
this._grid.clear();
const cs = this._GRID_C;
for (const p of this.particles) {
const key = `${Math.floor(p.x / cs)},${Math.floor(p.y / cs)}`;
if (!this._grid.has(key)) this._grid.set(key, []);
this._grid.get(key).push(p);
}
}
_neighbors(p) {
const cs = this._GRID_C;
const gx = Math.floor(p.x / cs), gy = Math.floor(p.y / cs);
const out = [];
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++) {
const cell = this._grid.get(`${gx + dx},${gy + dy}`);
if (cell) for (const q of cell) if (q !== p) out.push(q);
}
return out;
}
_step() {
const { W, H } = this;
const dt = 0.55;
// Move + wall bounce
for (const p of this.particles) {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); }
if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); }
if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); }
if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); }
}
this._buildGrid();
const toRemove = new Set();
const toAdd = [];
// Pairwise: collision detection, reaction check, elastic bounce
for (const p of this.particles) {
if (toRemove.has(p.id)) continue;
for (const q of this._neighbors(p)) {
if (q.id <= p.id || toRemove.has(q.id)) continue;
const dx = q.x - p.x, dy = q.y - p.y;
const dist2 = dx * dx + dy * dy;
const minD = p.r + q.r;
if (dist2 >= minD * minD) continue;
const dist = Math.sqrt(dist2);
// Try chemical reaction
if (this.reactionOn && this._tryReact(p, q, dx, dy, dist, toRemove, toAdd)) continue;
// Elastic collision
const nx = dx / dist, ny = dy / dist;
const dvx = p.vx - q.vx, dvy = p.vy - q.vy;
const dot = dvx * nx + dvy * ny;
if (dot >= 0) {
// Just separate overlapping particles that are already moving apart
const ov = (minD - dist) * 0.5;
p.x -= nx * ov; p.y -= ny * ov;
q.x += nx * ov; q.y += ny * ov;
continue;
}
const m1 = p.r * p.r, m2 = q.r * q.r;
const imp = (2 * dot) / (m1 + m2);
p.vx -= imp * m2 * nx; p.vy -= imp * m2 * ny;
q.vx += imp * m1 * nx; q.vy += imp * m1 * ny;
const ov = (minD - dist) * 0.5;
p.x -= nx * ov; p.y -= ny * ov;
q.x += nx * ov; q.y += ny * ov;
}
}
// Spontaneous decomposition C <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> A + B (reversible mode)
if (this.mode === 'reversible') {
const prob = 0.00022 * this.T * Math.exp(-this.Ea * 0.38 / this.T);
for (const p of this.particles) {
if (p.type !== 'C' || toRemove.has(p.id)) continue;
if (Math.random() < prob) {
toRemove.add(p.id);
const ang = Math.random() * Math.PI * 2;
const spd = this._baseSpeed('A');
const mk = id => ({ x: p.x + Math.cos(ang + id * Math.PI) * 5,
y: p.y + Math.sin(ang + id * Math.PI) * 5,
vx: Math.cos(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6),
vy: Math.sin(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6),
r: 5, type: id === 0 ? 'A' : 'B', id: this._nextId++ });
toAdd.push(mk(0), mk(1));
this.flashes.push({ x: p.x, y: p.y, t: 0, maxT: 14, color: '100,160,255' });
}
}
}
// Apply changes
if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id));
for (const p of toAdd) this.particles.push(p);
// Age flashes
this.flashes = this.flashes.filter(f => ++f.t < f.maxT);
this._steps++;
if (this._steps % 30 === 0) {
this._rate = this._recentReactions / 30;
this._recentReactions = 0;
}
if (this._steps % 20 === 0) {
this._recordHistory();
if (this.onUpdate) this.onUpdate(this.info());
}
}
_tryReact(p, q, dx, dy, dist, toRemove, toAdd) {
const isAB = (p.type === 'A' && q.type === 'B') || (p.type === 'B' && q.type === 'A');
if (!isAB) return false;
// Arrhenius factor: k ∝ exp(-Ea / T)
if (Math.random() > Math.exp(-this.Ea / this.T) * 0.38) return false;
const m1 = p.r * p.r, m2 = q.r * q.r, mt = m1 + m2;
const cx = (p.x * m1 + q.x * m2) / mt;
const cy = (p.y * m1 + q.y * m2) / mt;
const pvx = (p.vx * m1 + q.vx * m2) / mt;
const pvy = (p.vy * m1 + q.vy * m2) / mt;
toRemove.add(p.id);
toRemove.add(q.id);
if (this.mode === 'chain') {
// Chain: A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2 C (two fast products — cascade reaction)
const spd = Math.sqrt(pvx * pvx + pvy * pvy) * 1.35 + this._baseSpeed('C') * 0.7;
const ang = Math.atan2(pvy || 0.001, pvx || 0.001);
for (let s = 0; s < 2; s++) {
const sign = s === 0 ? 1 : -1;
toAdd.push({
x: cx + Math.cos(ang) * sign * 5,
y: cy + Math.sin(ang) * sign * 5,
vx: Math.cos(ang) * sign * spd,
vy: Math.sin(ang) * sign * spd,
r: 6, type: 'C', id: this._nextId++
});
}
this.flashes.push({ x: cx, y: cy, t: 0, maxT: 28, color: '255,140,30' });
} else {
// Forward / reversible: A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 1 C
const cSpd = Math.sqrt(pvx * pvx + pvy * pvy) * 0.62 + this._baseSpeed('C') * 0.28;
const ang = Math.atan2(pvy || 0.001, pvx || 0.001);
toAdd.push({ x: cx, y: cy, vx: Math.cos(ang) * cSpd, vy: Math.sin(ang) * cSpd, r: 7, type: 'C', id: this._nextId++ });
this.flashes.push({ x: cx, y: cy, t: 0, maxT: 22, color: '255,200,50' });
}
this._totalReactions++;
this._recentReactions++;
return true;
}
_recordHistory() {
let nA = 0, nB = 0, nC = 0;
for (const p of this.particles) {
if (p.type === 'A') nA++;
else if (p.type === 'B') nB++;
else nC++;
}
this._history.push({ step: this._steps, nA, nB, nC });
if (this._history.length > 260) this._history.shift();
}
/* ────────────────────────── Rendering ────────────────────────── */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
// ── Background ──
ctx.fillStyle = '#080818';
ctx.fillRect(0, 0, W, H);
// ── Subtle dot grid ──
ctx.fillStyle = 'rgba(255,255,255,0.033)';
for (let x = 35; x < W; x += 35)
for (let y = 35; y < H; y += 35) {
ctx.beginPath();
ctx.arc(x, y, 1, 0, Math.PI * 2);
ctx.fill();
}
// ── Reaction flashes ──
for (const f of this.flashes) {
const prog = f.t / f.maxT;
const radius = prog * 48 + 4;
const alpha = (1 - prog) * 0.55;
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius);
g.addColorStop(0, `rgba(${f.color},${alpha * 1.6})`);
g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.5})`);
g.addColorStop(1, `rgba(${f.color},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, radius, 0, Math.PI * 2);
ctx.fill();
}
// ── Particles ──
for (const p of this.particles) this._drawParticle(ctx, p);
// ── Overlays ──
this._drawLegend(ctx);
this._drawConcentrationGraph(ctx);
this._drawEnergyDiagram(ctx);
// ── Empty state ──
if (this.particles.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.font = '14px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Все молекулы прореагировали — нажмите Сброс', W / 2, H / 2);
}
}
_drawParticle(ctx, p) {
const col = this._color(p.type);
const { x, y, r } = p;
// Outer glow
const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3);
glow.addColorStop(0, col + '50');
glow.addColorStop(1, col + '00');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(x, y, r * 3, 0, Math.PI * 2);
ctx.fill();
// Body (radial gradient for depth)
const body = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r);
body.addColorStop(0, col + 'ff');
body.addColorStop(0.65, col + 'cc');
body.addColorStop(1, col + '88');
ctx.fillStyle = body;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
// Specular highlight
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.beginPath();
ctx.arc(x - r * 0.27, y - r * 0.27, r * 0.3, 0, Math.PI * 2);
ctx.fill();
// Type label
ctx.fillStyle = 'rgba(0,0,0,0.72)';
ctx.font = `bold ${Math.round(r * 1.15)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(p.type, x, y + 0.5);
ctx.textBaseline = 'alphabetic';
}
_drawConcentrationGraph(ctx) {
if (this._history.length < 2) return;
const { W, H } = this;
const gW = 198, gH = 118;
const gX = W - gW - 10, gY = H - gH - 10;
// Panel
ctx.fillStyle = 'rgba(5,5,20,0.88)';
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
this._rrect(ctx, gX, gY, gW, gH, 7);
ctx.fill(); ctx.stroke();
// Title
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Концентрация молекул', gX + 7, gY + 12);
const pad = { l: 8, r: 6, t: 18, b: 24 };
const px = gX + pad.l, py = gY + pad.t;
const pw = gW - pad.l - pad.r, ph = gH - pad.t - pad.b;
const maxN = this.N * 2.3;
const n = this._history.length;
// Grid lines
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const yl = py + ph * (1 - i / 4);
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
}
// Data lines
const lines = [
{ key: 'nA', color: '#06D6E0', label: 'A — реагент' },
{ key: 'nB', color: '#EF476F', label: 'B — реагент' },
{ key: 'nC', color: '#FFD166', label: 'C — продукт' },
];
for (const { key, color } of lines) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.6;
for (let i = 0; i < n; i++) {
const lx = px + (i / Math.max(n - 1, 1)) * pw;
const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph;
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke();
}
// Legend + current values
const last = this._history[this._history.length - 1];
lines.forEach(({ color, label }, i) => {
const lx = gX + 8 + i * 58;
ctx.fillStyle = color;
ctx.fillRect(lx, gY + gH - 16, 11, 2.5);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(label.split(' ')[0], lx + 13, gY + gH - 12);
});
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '8px monospace';
ctx.textAlign = 'right';
ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC}`, gX + gW - 6, gY + gH - 12);
}
_drawEnergyDiagram(ctx) {
const { W } = this;
const dW = 158, dH = 100;
const dX = W - dW - 10, dY = 10;
ctx.fillStyle = 'rgba(5,5,20,0.88)';
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
this._rrect(ctx, dX, dY, dW, dH, 7);
ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.42)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Профиль энергии', dX + 7, dY + 12);
const pad = { l: 22, r: 10, t: 18, b: 20 };
const ex = dX + pad.l, ey_bot = dY + dH - pad.b;
const ew = dW - pad.l - pad.r, eh = dH - pad.t - pad.b;
const rE = 0.15;
const tE = 0.85;
const pE = Math.max(0.04, rE + this._diagDeltaH());
const toY = e => ey_bot - e * eh;
// Smooth reaction path
ctx.beginPath();
ctx.strokeStyle = 'rgba(255,200,60,0.78)';
ctx.lineWidth = 2;
ctx.moveTo(ex, toY(rE));
ctx.lineTo(ex + ew * 0.15, toY(rE));
ctx.bezierCurveTo(
ex + ew * 0.32, toY(rE),
ex + ew * 0.40, toY(tE),
ex + ew * 0.50, toY(tE)
);
ctx.bezierCurveTo(
ex + ew * 0.60, toY(tE),
ex + ew * 0.68, toY(pE),
ex + ew * 0.85, toY(pE)
);
ctx.lineTo(ex + ew, toY(pE));
ctx.stroke();
// Horizontal dashes at levels
ctx.setLineDash([2, 3]);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 0.75;
[rE, pE].forEach(e => {
ctx.beginPath(); ctx.moveTo(ex, toY(e)); ctx.lineTo(ex + ew, toY(e)); ctx.stroke();
});
ctx.setLineDash([]);
// Ea bracket (left side)
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ex - 3, toY(rE)); ctx.lineTo(ex - 8, toY(rE));
ctx.moveTo(ex - 3, toY(tE)); ctx.lineTo(ex - 8, toY(tE));
ctx.moveTo(ex - 7, toY(rE)); ctx.lineTo(ex - 7, toY(tE));
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.38)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('Ea', ex - 9, toY((rE + tE) / 2) + 3);
// Labels
ctx.fillStyle = '#06D6E0cc';
ctx.font = '8px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('A+B', ex, toY(rE) - 4);
ctx.fillStyle = '#FFD166cc';
ctx.textAlign = 'right';
ctx.fillText('C', ex + ew, toY(pE) - 4);
// Mode label at bottom
const modeTxt = { forward: '<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> A + B <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> C', reversible: '⇌ A + B ⇌ C', chain: 'цепная реакция' }[this.mode] || '';
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.font = '8px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(modeTxt, dX + dW / 2, dY + dH - 6);
}
_diagDeltaH() {
// Visual ΔH for energy diagram: exothermic by default
return -(0.10 + this.Ea * 0.045);
}
_drawLegend(ctx) {
const items = [
{ color: '#06D6E0', label: 'A — реагент' },
{ color: '#EF476F', label: 'B — реагент' },
{ color: '#FFD166', label: 'C — продукт' },
];
const lX = 10, lY = 10, lW = 120, lH = 14 * items.length + 14;
ctx.fillStyle = 'rgba(5,5,20,0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 1;
this._rrect(ctx, lX, lY, lW, lH, 6);
ctx.fill(); ctx.stroke();
items.forEach(({ color, label }, i) => {
const iy = lY + 14 + i * 14;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(lX + 12, iy, 4.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.52)';
ctx.font = '9px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(label, lX + 22, iy + 3.5);
});
}
_rrect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
+503
View File
@@ -0,0 +1,503 @@
'use strict';
/* ====================================================================
RedoxSim — Окислительно-восстановительные реакции
==================================================================== */
class RedoxSim {
/* ── Данные реакций ──────────────────────────────────────────────── */
static RXN = {
fe_cu: {
name: 'Fe + CuSO₄',
reducer: { f: 'Fe', name: 'Железо', color: '#A0856A', ox: 0 },
oxidizer: { f: 'Cu²⁺', name: 'Ион меди', color: '#29B6F6', ox: 2 },
prod_r: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 },
prod_o: { f: 'Cu', color: '#C87840', ox: 0, solid: true },
e: 2,
half_r: 'Fe⁰ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ окисление',
half_o: 'Cu²⁺ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu⁰ восстановление',
eq_ion: 'Fe + Cu²⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
eq_mol: 'Fe + CuSO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
sol_a: '#1565C040', sol_b: '#2E7D3230',
precip: true, pcolor: '#C87840', pname: 'медь Cu',
},
zn_hcl: {
name: 'Zn + HCl',
reducer: { f: 'Zn', name: 'Цинк', color: '#90A4AE', ox: 0 },
oxidizer: { f: 'H⁺', name: 'Ион H⁺', color: '#EF5350', ox: 1 },
prod_r: { f: 'Zn²⁺', color: '#80CBC4', ox: 2 },
prod_o: { f: 'H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', color: '#EEEEEE', ox: 0, gas: true },
e: 2,
half_r: 'Zn⁰ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ окисление',
half_o: '2H⁺ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg> восстановление',
eq_ion: 'Zn + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
eq_mol: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
sol_a: '#EF525228', sol_b: '#E0F2F118',
gas: true, gcolor: '#CFD8DC', gname: 'водород H₂',
},
cl2_ki: {
name: 'Cl₂ + KI',
reducer: { f: 'I⁻', name: 'Иодид-ион', color: '#CE93D8', ox: -1 },
oxidizer: { f: 'Cl₂', name: 'Хлор', color: '#D4E157', ox: 0 },
prod_r: { f: 'I₂', color: '#6A1B9A', ox: 0, solid: true },
prod_o: { f: 'Cl⁻', color: '#AED581', ox: -1 },
e: 1,
half_r: '2I⁻ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> I₂ окисление',
half_o: 'Cl₂ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Cl⁻ восстановление',
eq_ion: 'Cl₂ + 2I⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> I₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Cl⁻',
eq_mol: 'Cl₂ + 2KI <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> I₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KCl',
sol_a: '#7B1FA230', sol_b: '#F9A82520',
precip: true, pcolor: '#6A1B9A', pname: 'йод I₂',
},
kmno4: {
name: 'KMnO₄ + FeSO₄',
reducer: { f: 'Fe²⁺', name: 'Ион Fe²⁺', color: '#66BB6A', ox: 2 },
oxidizer: { f: 'MnO₄⁻', name: 'Перманганат', color: '#AB47BC', ox: 7 },
prod_r: { f: 'Fe³⁺', color: '#FFA726', ox: 3 },
prod_o: { f: 'Mn²⁺', color: '#FFF9C4', ox: 2 },
e: 5,
half_r: 'Fe²⁺ e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe³⁺ (×5) окисление',
half_o: 'MnO₄⁻+8H⁺+5e⁻<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>Mn²⁺+4H₂O восстановление',
eq_ion: 'MnO₄⁻ + 5Fe²⁺ + 8H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mn²⁺ + 5Fe³⁺ + 4H₂O',
eq_mol: '2KMnO₄ + 10FeSO₄ + 8H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2MnSO₄ + 5Fe₂(SO₄)₃ + K₂SO₄ + 8H₂O',
sol_a: '#7B1FA250', sol_b: '#F9A82515',
colorChange: true, newSolColor: '#FFF9C428', newName: 'бесцветный MnSO₄',
},
cu_fecl3: {
name: 'Cu + FeCl₃',
reducer: { f: 'Cu', name: 'Медь', color: '#C87840', ox: 0 },
oxidizer: { f: 'Fe³⁺', name: 'Ион Fe³⁺', color: '#FFA726', ox: 3 },
prod_r: { f: 'Cu²⁺', color: '#29B6F6', ox: 2 },
prod_o: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 },
e: 1,
half_r: 'Cu⁰ 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ окисление',
half_o: '2Fe³⁺ + 2e⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Fe²⁺ восстановление',
eq_ion: 'Cu + 2Fe³⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2Fe²⁺',
eq_mol: 'Cu + 2FeCl₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CuCl₂ + 2FeCl₂',
sol_a: '#E6510018', sol_b: '#1565C018',
colorChange: true, newSolColor: '#1565C030', newName: 'синий CuCl₂',
},
};
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.rxnId = 'fe_cu';
this._raf = null;
this._last = 0;
this._t = 0;
this._phase = 'idle'; // idle | mixing | reacting | done
this._prog = 0;
this._colorT = 0;
this._stepIdx = 0;
this._stepTimer = 0;
this._eParts = [];
this._rParts = [];
this._oParts = [];
this._precip = [];
this._gas = [];
this.W = 0; this.H = 0;
this.onUpdate = null;
this.fit();
this._initParts();
}
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 600;
const H = this.canvas.offsetHeight || 400;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._initParts();
}
setReaction(id) {
if (!RedoxSim.RXN[id]) return;
this.rxnId = id;
this.reset();
}
reset() {
this._phase = 'idle'; this._prog = 0; this._colorT = 0;
this._stepIdx = 0; this._stepTimer = 0;
this._eParts = []; this._precip = []; this._gas = [];
this._initParts();
this.draw();
}
_initParts() {
const { W, H } = this;
const N = 16;
this._rParts = Array.from({ length: N }, () => ({
x: W * 0.22 + (Math.random() - 0.5) * W * 0.22,
y: H * 0.42 + (Math.random() - 0.5) * H * 0.34,
vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6,
r: 11 + Math.random() * 4,
phase: Math.random() * Math.PI * 2,
trans: false, flashT: 0,
}));
this._oParts = Array.from({ length: N }, () => ({
x: W * 0.78 + (Math.random() - 0.5) * W * 0.22,
y: H * 0.42 + (Math.random() - 0.5) * H * 0.34,
vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6,
r: 11 + Math.random() * 4,
phase: Math.random() * Math.PI * 2,
trans: false, flashT: 0,
}));
}
start() {
if (this._phase !== 'idle') this.reset();
this._phase = 'mixing'; this._prog = 0;
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Физика ─────────────────────────────────────────────────────── */
_tick(t) {
const dt = Math.min((t - this._last) / 1000, 0.05);
this._last = t; this._t += dt;
const { W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
if (this._phase === 'mixing') {
this._prog = Math.min(1, this._prog + dt * 0.38);
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.52;
const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.34;
p.vx += (tx - p.x) * 0.003 * this._prog;
p.vy += (ty - p.y) * 0.003 * this._prog;
p.vx += (Math.random() - 0.5) * 0.5;
p.vy += (Math.random() - 0.5) * 0.5;
p.vx *= 0.90; p.vy *= 0.90;
p.x += p.vx; p.y += p.vy;
p.phase += dt * 1.5;
this._clamp(p);
});
if (this._prog >= 1) { this._phase = 'reacting'; this._prog = 0; }
}
if (this._phase === 'reacting') {
this._prog = Math.min(1, this._prog + dt * 0.14);
this._colorT = this._prog;
this._stepTimer += dt;
if (this._stepTimer > 1.6 && this._stepIdx < 3) { this._stepIdx++; this._stepTimer = 0; }
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
p.vx += (Math.random() - 0.5) * 0.9;
p.vy += (Math.random() - 0.5) * 0.9;
p.vx *= 0.87; p.vy *= 0.87;
p.x += p.vx; p.y += p.vy;
p.phase += dt * 2;
p.flashT = Math.max(0, p.flashT - dt * 3);
this._clamp(p);
});
/* Transform particles proportional to progress */
const rT = Math.floor(this._prog * this._rParts.length);
const oT = Math.floor(this._prog * this._oParts.length);
this._rParts.forEach((p, i) => {
if (i < rT && !p.trans) { p.trans = true; p.flashT = 1; }
});
this._oParts.forEach((p, i) => {
if (i < oT && !p.trans) {
p.trans = true; p.flashT = 1;
if (rxn.precip) this._precip.push({ x: p.x, y: p.y, vy: 0, r: 3 + Math.random() * 3, settled: false });
if (rxn.gas) this._gas.push({ x: p.x, y: p.y, vy: -(1.5 + Math.random()), vx: (Math.random() - 0.5) * 0.5, r: 2 + Math.random() * 3, alpha: 1 });
}
});
if (Math.random() < 0.22 && this._prog > 0.05) this._spawnE();
if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 3; }
}
if (this._phase === 'done') {
const all = [...this._rParts, ...this._oParts];
all.forEach(p => {
p.vx += (Math.random() - 0.5) * 0.45;
p.vy += (Math.random() - 0.5) * 0.45;
p.vx *= 0.92; p.vy *= 0.92;
p.x += p.vx; p.y += p.vy;
p.phase += dt;
this._clamp(p);
});
}
/* Electrons — quadratic bezier arc */
this._eParts = this._eParts.filter(e => e.t < 1);
this._eParts.forEach(e => {
e.t = Math.min(1, e.t + dt * e.spd);
const u = e.t;
e.x = (1-u)*(1-u)*e.x0 + 2*(1-u)*u*e.mx + u*u*e.x1;
e.y = (1-u)*(1-u)*e.y0 + 2*(1-u)*u*e.my + u*u*e.y1;
e.alpha = u < 0.1 ? u * 10 : u > 0.85 ? (1 - u) / 0.15 : 1;
});
/* Precipitate */
this._precip.forEach(p => {
if (!p.settled) {
p.vy = Math.min(p.vy + 0.15, 5);
p.y += p.vy;
if (p.y >= H * 0.78) { p.y = H * 0.78; p.vy = 0; p.settled = true; }
}
});
/* Gas */
this._gas.forEach(b => { b.y += b.vy; b.x += b.vx; b.vy -= 0.01; b.alpha -= 0.005; });
this._gas = this._gas.filter(b => b.alpha > 0 && b.y > 10);
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
}
_clamp(p) {
const { W, H } = this;
const bot = H * 0.78;
if (p.x < p.r + 6) { p.x = p.r + 6; p.vx *= -0.5; }
if (p.x > W - p.r - 6) { p.x = W - p.r - 6; p.vx *= -0.5; }
if (p.y < p.r + 6) { p.y = p.r + 6; p.vy *= -0.5; }
if (p.y > bot - p.r) { p.y = bot - p.r; p.vy *= -0.5; }
}
_spawnE() {
const freeR = this._rParts.filter(p => !p.trans);
const freeO = this._oParts.filter(p => !p.trans);
if (!freeR.length || !freeO.length) return;
const rp = freeR[Math.floor(Math.random() * freeR.length)];
const op = freeO[Math.floor(Math.random() * freeO.length)];
const mx = (rp.x + op.x) / 2;
const my = Math.min(rp.y, op.y) - 45 - Math.random() * 40;
this._eParts.push({
x0: rp.x, y0: rp.y, x1: op.x, y1: op.y,
mx, my, x: rp.x, y: rp.y,
t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0,
});
}
/* ── Рендеринг ──────────────────────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
const rxn = RedoxSim.RXN[this.rxnId];
/* Background */
ctx.fillStyle = '#07071A';
ctx.fillRect(0, 0, W, H);
/* Dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.07)';
for (let x = 0; x < W; x += 28) {
for (let y = 0; y < H; y += 28) {
ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill();
}
}
/* Solution tint */
const bx = W * 0.04, bw = W * 0.92, bTop = H * 0.08, bBot = H * 0.80;
if (this._phase === 'idle') {
if (rxn.sol_a) { ctx.save(); ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw / 2, bBot - bTop); ctx.restore(); }
if (rxn.sol_b) { ctx.save(); ctx.fillStyle = rxn.sol_b; ctx.fillRect(bx + bw / 2, bTop, bw / 2, bBot - bTop); ctx.restore(); }
/* Dashed divider */
ctx.save();
ctx.setLineDash([6, 5]); ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(W / 2, bTop + 4); ctx.lineTo(W / 2, bBot - 4); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
/* Zone labels */
ctx.save();
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif';
ctx.fillText(rxn.reducer.name, W * 0.22, bTop + 8);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif';
ctx.fillText('восстановитель', W * 0.22, bTop + 26);
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif';
ctx.fillText(rxn.oxidizer.name, W * 0.78, bTop + 8);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif';
ctx.fillText('окислитель', W * 0.78, bTop + 26);
ctx.restore();
} else if (this._colorT > 0) {
if (rxn.sol_a) {
ctx.save(); ctx.globalAlpha = 1 - this._colorT * 0.7;
ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore();
}
if (rxn.colorChange && rxn.newSolColor) {
ctx.save(); ctx.globalAlpha = this._colorT * 0.55;
ctx.fillStyle = rxn.newSolColor; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore();
}
}
this._drawBeaker(ctx, W, H);
this._drawParticles(ctx, rxn);
this._drawElectrons(ctx);
if (rxn.precip) this._drawPrecip(ctx, rxn);
if (rxn.gas) this._drawGas(ctx, rxn);
this._drawPanel(ctx, W, H, rxn);
}
_drawBeaker(ctx, W, H) {
const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.73;
ctx.save();
ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh);
ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by);
ctx.stroke();
ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke();
/* Left highlight */
const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh);
hlg.addColorStop(0, 'rgba(200,230,255,0.18)');
hlg.addColorStop(1, 'rgba(200,230,255,0.02)');
ctx.strokeStyle = hlg; ctx.lineWidth = 6;
ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke();
ctx.restore();
}
_drawParticles(ctx, rxn) {
const draw1 = (p, spec, prod) => {
const s = p.trans ? prod : spec;
ctx.save();
ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : s.color;
ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8 + Math.sin(p.phase) * 3;
ctx.globalAlpha = 0.88;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : s.color;
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
/* Oxidation state */
const ox = p.trans ? prod.ox : spec.ox;
const oxStr = ox > 0 ? `+${ox}` : ox < 0 ? `${ox}` : '0';
ctx.fillStyle = p.trans ? '#FFD166' : 'rgba(255,255,255,0.88)';
ctx.font = `bold ${Math.round(p.r * 0.78)}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(oxStr, p.x, p.y);
ctx.restore();
};
this._rParts.forEach(p => draw1(p, rxn.reducer, rxn.prod_r));
this._oParts.forEach(p => draw1(p, rxn.oxidizer, rxn.prod_o));
}
_drawElectrons(ctx) {
this._eParts.forEach(e => {
ctx.save();
ctx.globalAlpha = e.alpha;
ctx.shadowColor = '#4FC3F7'; ctx.shadowBlur = 16;
ctx.beginPath(); ctx.arc(e.x, e.y, 5.5, 0, Math.PI * 2);
ctx.fillStyle = '#4FC3F7'; ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('e⁻', e.x, e.y);
ctx.restore();
});
}
_drawPrecip(ctx, rxn) {
if (!this._precip.length) return;
ctx.save();
this._precip.forEach(p => {
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 4;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = rxn.pcolor; ctx.fill();
});
ctx.restore();
/* Label when settled */
const settled = this._precip.filter(p => p.settled);
if (settled.length > 3) {
ctx.save();
ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6;
ctx.fillText(`${rxn.pname}`, this.W / 2, this.H * 0.80 - 4);
ctx.restore();
}
}
_drawGas(ctx, rxn) {
this._gas.forEach(b => {
ctx.save(); ctx.globalAlpha = b.alpha * 0.75;
ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 5;
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.strokeStyle = rxn.gcolor; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
});
const count = this._gas.length;
if (count > 2) {
ctx.save();
ctx.fillStyle = rxn.gcolor; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 6;
ctx.fillText(`${rxn.gname}`, this.W / 2, this.H * 0.12);
ctx.restore();
}
}
_drawPanel(ctx, W, H, rxn) {
const py = H * 0.82;
ctx.fillStyle = 'rgba(7,7,26,0.94)';
ctx.fillRect(0, py, W, H - py);
ctx.strokeStyle = 'rgba(100,165,255,0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
if (this._phase === 'idle') {
ctx.fillStyle = '#37474F'; ctx.font = '11px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('← Нажми «Начать» для запуска реакции →', W / 2, py + (H - py) / 2);
return;
}
const steps = [
{ lbl: 'Молекулярное:', txt: rxn.eq_mol, col: '#B0BEC5' },
{ lbl: 'Окисление:', txt: rxn.half_r, col: '#EF476F' },
{ lbl: 'Восстановление:', txt: rxn.half_o, col: '#4CC9F0' },
{ lbl: 'Ионное:', txt: rxn.eq_ion, col: '#FFD166' },
];
const panH = H - py;
const n = Math.min(this._stepIdx + 1, steps.length);
for (let i = 0; i < n; i++) {
const s = steps[i];
const y = py + 11 + i * (panH * 0.22);
ctx.save();
if (i === this._stepIdx && this._phase !== 'done') {
ctx.fillStyle = 'rgba(255,255,255,0.04)';
ctx.fillRect(8, y - 9, W - 16, 20);
}
ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(s.lbl, 14, y);
ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)';
ctx.font = '9.5px monospace';
ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y);
ctx.restore();
}
if (this._phase === 'done') {
ctx.save();
ctx.fillStyle = '#7BF5A4'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 8;
ctx.fillText('✓ Реакция завершена', W - 14, py + 3);
ctx.restore();
}
}
info() {
const rxn = RedoxSim.RXN[this.rxnId];
return {
rxn: rxn.name,
phase: this._phase,
prog: Math.round((this._phase === 'reacting' ? this._prog : this._phase === 'done' ? 1 : 0) * 100),
e: rxn.e,
};
}
}
+497
View File
@@ -0,0 +1,497 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
RefractionSim — light refraction simulation (Snell's law)
n₁·sin(θ₁) = n₂·sin(θ₂)
Total internal reflection · Fresnel coefficients · Dispersion
Interactive incident ray drag · Presets
══════════════════════════════════════════════════════════════ */
class RefractionSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics */
this.n1 = 1.0; // refractive index of top medium
this.n2 = 1.5; // refractive index of bottom medium
this.angle = 30; // incidence angle in degrees
/* dispersion mode */
this.dispersion = false;
/* drag state */
this._drag = false;
/* callback */
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle));
if (dispersion !== undefined) this.dispersion = !!dispersion;
this.draw();
this._emit();
}
reset() {
this.n1 = 1.0; this.n2 = 1.5; this.angle = 30;
this.dispersion = false;
this.draw();
this._emit();
}
info() {
const { n1, n2, angle } = this;
const theta1Rad = angle * Math.PI / 180;
const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
const criticalAngle = n1 > n2
? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1)
: null;
let angle2;
if (isTIR) {
angle2 = 'ПВО';
} else {
angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1);
}
return {
n1: +n1.toFixed(2),
n2: +n2.toFixed(2),
angle1: +angle.toFixed(1),
angle2,
criticalAngle,
isTIR,
};
}
/* ── presets ────────────────────────────────── */
static PRESETS = {
air_glass: { n1: 1.0, n2: 1.5, angle: 30 },
glass_air: { n1: 1.5, n2: 1.0, angle: 30 },
water_glass: { n1: 1.33, n2: 1.5, angle: 30 },
diamond: { n1: 1.0, n2: 2.42, angle: 45 },
};
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const midY = H / 2;
const hitX = W / 2;
const hitY = midY;
/* --- background: two media --- */
// top medium (lighter)
const gradTop = ctx.createLinearGradient(0, 0, 0, midY);
gradTop.addColorStop(0, '#131328');
gradTop.addColorStop(1, '#1a1a3a');
ctx.fillStyle = gradTop;
ctx.fillRect(0, 0, W, midY);
// bottom medium (darker, denser feel)
const gradBot = ctx.createLinearGradient(0, midY, 0, H);
gradBot.addColorStop(0, '#0e1a2e');
gradBot.addColorStop(1, '#0D0D1A');
ctx.fillStyle = gradBot;
ctx.fillRect(0, midY, W, H - midY);
/* --- interface line with glow --- */
ctx.save();
ctx.shadowColor = 'rgba(155, 93, 229, 0.4)';
ctx.shadowBlur = 12;
ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
ctx.restore();
/* --- normal line (dashed vertical) --- */
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke();
ctx.setLineDash([]);
/* --- physics --- */
const theta1Rad = this.angle * Math.PI / 180;
const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
const isTIR = Math.abs(sinTheta2) > 1;
/* Fresnel reflectance (simplified) */
let R = 1;
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const cosT1 = Math.cos(theta1Rad);
const cosT2 = Math.cos(theta2Rad);
const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2);
R = rs * rs;
}
/* ray length (from edge to hit point) */
const rayLen = Math.max(W, H) * 0.6;
/* --- critical angle indicator --- */
if (this.n1 > this.n2) {
const critRad = Math.asin(this.n2 / this.n1);
const critDx = Math.sin(critRad);
const critDy = Math.cos(critRad);
ctx.strokeStyle = 'rgba(255,209,102,0.25)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
// critical angle ray in top medium
ctx.beginPath();
ctx.moveTo(hitX, hitY);
ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5);
ctx.stroke();
ctx.setLineDash([]);
// label
ctx.font = '10px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,209,102,0.5)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const lblX = hitX - critDx * rayLen * 0.35 + 6;
const lblY = hitY - critDy * rayLen * 0.35;
ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', lblX, lblY);
}
if (this.dispersion && !isTIR) {
this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen);
} else {
this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen);
}
/* --- angle arcs --- */
this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR);
/* --- medium labels --- */
this._drawMediumLabels(ctx, W, H, midY);
/* --- info box --- */
this._drawInfoBox(ctx, isTIR, R);
/* --- drag handle indicator (incident ray endpoint) --- */
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
const handleX = hitX - incDx * rayLen * 0.55;
const handleY = hitY - incDy * rayLen * 0.55;
const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10);
grad.addColorStop(0, 'rgba(155,93,229,0.4)');
grad.addColorStop(1, 'rgba(155,93,229,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill();
}
_drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) {
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
/* incident ray */
const incStartX = hitX - incDx * rayLen;
const incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#9B5DE5', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#9B5DE5');
/* reflected ray */
const refDx = incDx; // same x component
const refDy = -incDy; // flipped y
const refEndX = hitX + refDx * rayLen;
const refEndY = hitY + refDy * rayLen; // goes up (refDy is negative of incDy)
const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R));
ctx.globalAlpha = refAlpha;
this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5);
this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F');
ctx.globalAlpha = 1;
/* refracted ray */
if (!isTIR) {
const theta2Rad = Math.asin(sinTheta2);
const refracDx = Math.sin(theta2Rad);
const refracDy = Math.cos(theta2Rad);
const refracEndX = hitX + refracDx * rayLen;
const refracEndY = hitY + refracDy * rayLen;
const T = 1 - R;
ctx.globalAlpha = Math.max(0.3, Math.sqrt(T));
this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, '#06D6E0', 2.5);
this._drawArrowhead(ctx, refracEndX, refracEndY,
Math.atan2(refracEndY - hitY, refracEndX - hitX), '#06D6E0');
ctx.globalAlpha = 1;
}
}
_drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) {
/* Cauchy dispersion: n(λ) = A + B/λ² */
const spectral = [
{ name: 'red', color: '#FF0000', wave: 656 },
{ name: 'orange', color: '#FF7F00', wave: 589 },
{ name: 'yellow', color: '#FFFF00', wave: 550 },
{ name: 'green', color: '#00FF00', wave: 510 },
{ name: 'cyan', color: '#00FFFF', wave: 475 },
{ name: 'blue', color: '#0000FF', wave: 450 },
{ name: 'violet', color: '#8B00FF', wave: 400 },
];
/* incident white ray */
const incDx = Math.sin(theta1Rad);
const incDy = Math.cos(theta1Rad);
const incStartX = hitX - incDx * rayLen;
const incStartY = hitY - incDy * rayLen;
this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5);
this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF');
/* Cauchy coefficients derived from base n2 */
const A = this.n2 - 4500 / (550 * 550);
const B = 4500;
for (const s of spectral) {
const n2w = A + B / (s.wave * s.wave);
const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) continue;
const t2 = Math.asin(sinT2);
const dx = Math.sin(t2);
const dy = Math.cos(t2);
ctx.globalAlpha = 0.85;
this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5);
ctx.globalAlpha = 1;
}
/* reflected (white, partial) */
const refDx = incDx;
const refDy = -incDy;
ctx.globalAlpha = 0.35;
this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5);
ctx.globalAlpha = 1;
}
_drawRay(ctx, x1, y1, x2, y2, color, width) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
/* subtle glow */
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.globalAlpha = 0.3;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.restore();
}
_drawArrowhead(ctx, x, y, angle, color) {
const aLen = 10;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3));
ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3));
ctx.closePath(); ctx.fill();
}
_drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) {
const arcR = 50;
const font = '12px Manrope, system-ui, sans-serif';
/* θ₁ arc (incidence angle, measured from normal = vertical up) */
if (this.angle > 1) {
ctx.strokeStyle = 'rgba(155,93,229,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
// normal points up from hit: angle = -π/2 in canvas coords
// incident ray comes from upper-left
// Arc from normal (straight up = -π/2) to incident ray direction
const normAngle = -Math.PI / 2;
const incAngle = -Math.PI / 2 - theta1Rad;
ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle));
ctx.stroke();
// label
ctx.font = font;
ctx.fillStyle = '#9B5DE5';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA = normAngle - theta1Rad / 2;
ctx.fillText(
'θ₁=' + this.angle.toFixed(1) + '°',
hitX + (arcR + 20) * Math.cos(midA),
hitY + (arcR + 20) * Math.sin(midA)
);
}
/* θ₂ arc (refraction angle, measured from normal = vertical down) */
if (!isTIR && Math.abs(sinTheta2) <= 1) {
const theta2Rad = Math.asin(sinTheta2);
if (theta2Rad > 0.02) {
ctx.strokeStyle = 'rgba(6,214,224,0.6)';
ctx.lineWidth = 1.5;
ctx.beginPath();
const normDown = Math.PI / 2;
const refAngle = Math.PI / 2 + theta2Rad;
ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle));
ctx.stroke();
// label
const angle2Deg = theta2Rad * 180 / Math.PI;
ctx.font = font;
ctx.fillStyle = '#06D6E0';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const midA2 = normDown + theta2Rad / 2;
ctx.fillText(
'θ₂=' + angle2Deg.toFixed(1) + '°',
hitX + (arcR * 0.8 + 20) * Math.cos(midA2),
hitY + (arcR * 0.8 + 20) * Math.sin(midA2)
);
}
}
}
_drawMediumLabels(ctx, W, H, midY) {
ctx.font = '13px Manrope, system-ui, sans-serif';
ctx.textBaseline = 'middle';
/* top medium */
ctx.fillStyle = 'rgba(155,93,229,0.6)';
ctx.textAlign = 'left';
ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30);
/* bottom medium */
ctx.fillStyle = 'rgba(6,214,224,0.6)';
ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30);
/* TIR badge */
const theta1Rad = this.angle * Math.PI / 180;
const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad);
if (Math.abs(sinT2) > 1) {
ctx.font = 'bold 14px Manrope, system-ui, sans-serif';
ctx.fillStyle = '#EF476F';
ctx.textAlign = 'center';
ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60);
}
}
_drawInfoBox(ctx, isTIR, R) {
const boxW = 220, boxH = 72;
const bx = this.W - boxW - 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10);
const info = this.info();
ctx.fillStyle = 'rgba(255,255,255,0.5)';
const a2str = info.isTIR ? 'ПВО' : info.angle2 + '°';
ctx.fillText(`θ₁ = ${info.angle1}° θ₂ = ${a2str}`, bx + 10, by + 28);
const rPct = (R * 100).toFixed(1);
const tPct = ((1 - R) * 100).toFixed(1);
ctx.fillStyle = '#EF476F';
ctx.fillText(`R = ${rPct}%`, bx + 10, by + 46);
ctx.fillStyle = '#06D6E0';
ctx.fillText(`T = ${isTIR ? '0' : tPct}%`, bx + 90, by + 46);
if (info.criticalAngle !== null) {
ctx.fillStyle = '#FFD166';
ctx.fillText(`θc = ${info.criticalAngle}°`, bx + 160, by + 46);
}
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
mx: (t.clientX - r.left) * (this.W / r.width),
my: (t.clientY - r.top) * (this.H / r.height),
};
};
const hitTest = (mx, my) => {
/* Check if near the incident ray line (top half only) */
const hitX = this.W / 2;
const hitY = this.H / 2;
if (my >= hitY) return false;
/* distance from mouse to the hit point — if within top half, allow drag */
const dx = mx - hitX;
const dy = my - hitY;
const dist = Math.hypot(dx, dy);
return dist > 20 && dist < Math.max(this.W, this.H) * 0.6;
};
const angleFromMouse = (mx, my) => {
const hitX = this.W / 2;
const hitY = this.H / 2;
const dx = mx - hitX;
const dy = hitY - my; // flip: canvas y goes down, angle measured from vertical up
// angle from vertical = atan2(|dx|, dy)
const a = Math.atan2(Math.abs(dx), dy) * 180 / Math.PI;
return Math.max(0, Math.min(89, a));
};
const onDown = (e) => {
const { mx, my } = getPos(e);
if (hitTest(mx, my)) this._drag = true;
};
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx, my } = getPos(e);
this.angle = angleFromMouse(mx, my);
this.draw();
this._emit();
};
const onUp = () => { this._drag = false; };
/* mouse */
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
/* touch */
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1) onDown(e);
}, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
/* cursor style */
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
+620
View File
@@ -0,0 +1,620 @@
/**
* StatesSim v4 — Aggregate States of Matter (Lennard-Jones MD)
* Clean rewrite: stable physics, proper layout, canvas clipping, no boundary artifacts.
*/
class StatesSim {
// ── layout / physics constants ───────────────────────────────────────────
static PAD_B = 112; // px reserved at bottom for charts
static PAD_L = 38; // px reserved on left for temperature bar
static SIG = 14; // Lennard-Jones σ (px)
static EPS = 1.0; // Lennard-Jones ε
static DT = 0.16; // time step
static CUTOFF = 3.5; // force cutoff in σ units
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.N = 64;
this.T = 0.15;
this.particles = [];
this._raf = null;
this._stepCount = 0;
this._loop = this._loop.bind(this);
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._energyHistory = [];
this._rdfData = null;
this._rdfMaxG = 3;
this._rdfTick = 0;
this._phaseFlash = 0;
this._flashColor = '#4CC9F0';
this._prevPhase = '';
this._phasePulse = 0;
this._hover = null;
this._showVectors = false;
this.onUpdate = null;
canvas.addEventListener('mousemove', e => this._onMouse(e));
canvas.addEventListener('mouseleave', () => { this._hover = null; });
}
// ── public API ────────────────────────────────────────────────────────────
fit() {
this.W = this.canvas.offsetWidth || 400;
this.H = this.canvas.offsetHeight || 400;
this.canvas.width = this.W * devicePixelRatio;
this.canvas.height = this.H * devicePixelRatio;
this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
this.reset();
}
reset() {
this.particles = [];
const { N, T } = this;
const { SIG, PAD_L, PAD_B } = StatesSim;
const spacing = SIG * 1.15;
const simW = this.W - PAD_L;
const simH = this.H - PAD_B;
const cols = Math.ceil(Math.sqrt(N));
const rows = Math.ceil(N / cols);
const gridW = (cols - 1) * spacing;
const gridH = (rows - 1) * spacing * Math.sqrt(3) / 2;
const ox = PAD_L + (simW - gridW) / 2;
const oy = (simH - gridH) / 2;
let n = 0;
for (let r = 0; r < rows && n < N; r++) {
const xOff = (r % 2) * spacing * 0.5;
for (let c = 0; c < cols && n < N; c++) {
this.particles.push({
x: ox + xOff + c * spacing,
y: oy + r * spacing * Math.sqrt(3) / 2,
vx: (Math.random() - 0.5) * T * 3,
vy: (Math.random() - 0.5) * T * 3,
ax: 0, ay: 0,
});
n++;
}
}
this._stepCount = 0;
this._wallImpulse = 0;
this._pressureSmooth = 0;
this._energyHistory = [];
this._rdfData = null;
this._rdfMaxG = 3;
this._rdfTick = 0;
this._phaseFlash = 0;
this._prevPhase = '';
this._hover = null;
}
setT(t) {
const old = this.T;
this.T = Math.max(0.01, t);
if (old > 0) {
const f = Math.min(4, Math.sqrt(this.T / old));
for (const p of this.particles) { p.vx *= f; p.vy *= f; }
}
}
setN(n) {
this.N = Math.max(16, Math.min(120, n));
this.reset();
}
toggleVectors() { this._showVectors = !this._showVectors; }
start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop); }
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
// ── simulation ────────────────────────────────────────────────────────────
_loop() {
for (let i = 0; i < 5; i++) this._stepPhysics();
this.draw();
this._raf = requestAnimationFrame(this._loop);
}
_stepPhysics() {
const { particles } = this;
const { SIG, EPS, DT, CUTOFF, PAD_L, PAD_B } = StatesSim;
const dt = DT;
const pr = SIG * 0.48;
const cut2 = (CUTOFF * SIG) ** 2;
const xMin = PAD_L + pr, xMax = this.W - pr;
const yMin = pr, yMax = this.H - PAD_B - pr;
// Velocity Verlet — step 1
for (const p of particles) {
p.vx += 0.5 * p.ax * dt; p.vy += 0.5 * p.ay * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.x < xMin) { p.x = xMin; p.vx = Math.abs(p.vx); this._wallImpulse += Math.abs(p.vx); }
else if (p.x > xMax) { p.x = xMax; p.vx = -Math.abs(p.vx); this._wallImpulse += Math.abs(p.vx); }
if (p.y < yMin) { p.y = yMin; p.vy = Math.abs(p.vy); this._wallImpulse += Math.abs(p.vy); }
else if (p.y > yMax) { p.y = yMax; p.vy = -Math.abs(p.vy); this._wallImpulse += Math.abs(p.vy); }
}
// Lennard-Jones forces
for (const p of particles) { p.ax = 0; p.ay = 0; }
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const pi = particles[i], pj = particles[j];
const dx = pj.x - pi.x, dy = pj.y - pi.y;
const r2 = dx * dx + dy * dy;
if (r2 >= cut2 || r2 < 0.25) continue;
const sr2 = (SIG * SIG) / r2, sr6 = sr2 * sr2 * sr2;
const f = Math.max(-40, Math.min(40, 24 * EPS * (2 * sr6 * sr6 - sr6) / r2));
pi.ax += f * dx; pi.ay += f * dy;
pj.ax -= f * dx; pj.ay -= f * dy;
}
}
// Velocity Verlet — step 2
for (const p of particles) {
p.vx += 0.5 * p.ax * dt; p.vy += 0.5 * p.ay * dt;
}
// Berendsen thermostat
this._stepCount++;
let ke2 = 0;
for (const p of particles) ke2 += p.vx * p.vx + p.vy * p.vy;
const ke = ke2 / (2 * particles.length);
if (ke > 1e-8) {
const lam = Math.max(0.92, Math.min(1.08, Math.sqrt(1 + (dt / 60) * (this.T / ke - 1))));
for (const p of particles) { p.vx *= lam; p.vy *= lam; }
}
// smooth pressure
this._pressureSmooth = this._pressureSmooth * 0.95 + this._wallImpulse * 0.05;
this._wallImpulse = 0;
// energy + phase history (every 8 steps)
if (this._stepCount % 8 === 0) {
const info = this.info();
this._energyHistory.push({ ke: +info.avgKE, pe: +info.avgPE, te: +info.avgKE + +info.avgPE });
if (this._energyHistory.length > 300) this._energyHistory.shift();
const ph = info.phase;
if (this._prevPhase && ph !== this._prevPhase) {
const fc = { solid: '#4CC9F0', liquid: '#7BF5A4', gas: '#FFB347' };
this._phaseFlash = 1; this._flashColor = fc[ph] || '#ffffff';
}
this._prevPhase = ph;
}
// RDF every 25 steps
if (++this._rdfTick % 25 === 0) this._computeRDF();
if (this._stepCount % 25 === 0 && this.onUpdate) this.onUpdate(this.info());
}
// ── RDF g(r) ──────────────────────────────────────────────────────────────
_computeRDF() {
const { particles } = this;
const N = particles.length;
if (N < 4) return;
const { SIG, PAD_L, PAD_B } = StatesSim;
const nBins = 32, maxR = 3.8 * SIG, dr = maxR / nBins;
const hist = new Float32Array(nBins);
for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) {
const r = Math.hypot(particles[j].x - particles[i].x, particles[j].y - particles[i].y);
if (r < maxR) hist[Math.floor(r / dr)]++;
}
const area = (this.W - PAD_L) * (this.H - PAD_B);
const g = new Float32Array(nBins);
for (let i = 0; i < nBins; i++) {
const rc = (i + 0.5) * dr;
const ideal = N * (N - 1) * Math.PI * rc * dr / area;
g[i] = ideal > 1e-10 ? hist[i] / ideal : 0;
}
if (!this._rdfData) { this._rdfData = g; }
else { for (let i = 0; i < nBins; i++) this._rdfData[i] = this._rdfData[i] * 0.65 + g[i] * 0.35; }
this._rdfMaxG = Math.max(1.5, ...Array.from(this._rdfData.slice(1)));
}
// ── info / phase ──────────────────────────────────────────────────────────
_phase() {
return this.T < 0.2 ? 'solid' : this.T < 0.5 ? 'liquid' : 'gas';
}
info() {
const { particles, T } = this;
const { SIG, EPS, CUTOFF, PAD_L, PAD_B } = StatesSim;
let ke2 = 0;
for (const p of particles) ke2 += p.vx * p.vx + p.vy * p.vy;
const avgKE = particles.length ? 0.5 * ke2 / particles.length : 0;
const cut2 = (CUTOFF * SIG) ** 2;
let peTot = 0;
for (let i = 0; i < particles.length; i++) for (let j = i + 1; j < particles.length; j++) {
const dx = particles[j].x - particles[i].x, dy = particles[j].y - particles[i].y;
const r2 = dx * dx + dy * dy;
if (r2 < cut2 && r2 > 0.1) {
const sr2 = SIG * SIG / r2, sr6 = sr2 * sr2 * sr2;
peTot += 4 * EPS * (sr6 * sr6 - sr6);
}
}
const avgPE = particles.length ? peTot / particles.length : 0;
const perim = 2 * ((this.W - PAD_L) + (this.H - PAD_B));
const P = this._pressureSmooth / perim * 80;
return {
phase: this._phase(),
T,
avgKE: avgKE.toFixed(3),
avgPE: avgPE.toFixed(3),
P: P.toFixed(1),
};
}
// ── mouse ─────────────────────────────────────────────────────────────────
_onMouse(e) {
const r = this.canvas.getBoundingClientRect();
const x = (e.clientX - r.left) * (this.W / r.width);
const y = (e.clientY - r.top) * (this.H / r.height);
let best = null, bd = 20;
for (const p of this.particles) {
const d = Math.hypot(p.x - x, p.y - y);
if (d < bd) { bd = d; best = p; }
}
this._hover = best;
}
// ── draw ──────────────────────────────────────────────────────────────────
draw() {
const { ctx, W, H, T } = this;
const { SIG, PAD_B, PAD_L } = StatesSim;
const simH = H - PAD_B;
const phase = this._phase();
// full background
ctx.fillStyle = '#08091a'; ctx.fillRect(0, 0, W, H);
// ── clip everything to simulation area ─────────────────────────────────
ctx.save();
ctx.beginPath(); ctx.rect(0, 0, W, simH); ctx.clip();
// phase flash
if (this._phaseFlash > 0) {
this._phaseFlash = Math.max(0, this._phaseFlash - 0.02);
const [fr, fg, fb] = this._hex3(this._flashColor);
ctx.fillStyle = `rgba(${fr},${fg},${fb},${this._phaseFlash * 0.16})`;
ctx.fillRect(0, 0, W, simH);
}
// pressure wall glow (walls at simulation boundaries)
const P = parseFloat(this.info().P);
const wi = Math.min(1, P / 25);
if (wi > 0.04) {
const a = wi * 0.28, gd = 30;
const walls = [
{ x: PAD_L, y: 0, w: gd, h: simH, d: 'r' },
{ x: W - gd, y: 0, w: gd, h: simH, d: 'l' },
{ x: 0, y: 0, w: W, h: gd, d: 'd' },
{ x: 0, y: simH-gd, w: W, h: gd, d: 'u' },
];
for (const { x, y, w, h, d } of walls) {
let gr;
if (d==='r') gr = ctx.createLinearGradient(x, 0, x+w, 0);
else if (d==='l') gr = ctx.createLinearGradient(x+w, 0, x, 0);
else if (d==='d') gr = ctx.createLinearGradient(0, y, 0, y+h);
else gr = ctx.createLinearGradient(0, y+h, 0, y);
gr.addColorStop(0, `rgba(139,92,246,${a})`);
gr.addColorStop(1, 'rgba(139,92,246,0)');
ctx.fillStyle = gr; ctx.fillRect(x, y, w, h);
}
}
// per-particle speeds
const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy));
const maxSpd = Math.max(...speeds, 1e-6);
// bonds (solid / liquid)
const bondCut = SIG * 1.85;
if (phase !== 'gas') {
ctx.save();
ctx.strokeStyle = phase === 'solid'
? 'rgba(96,210,250,0.45)'
: 'rgba(120,130,255,0.22)';
ctx.lineWidth = phase === 'solid' ? 1.2 : 0.8;
ctx.beginPath();
for (let i = 0; i < this.particles.length; i++) {
for (let j = i + 1; j < this.particles.length; j++) {
const pi = this.particles[i], pj = this.particles[j];
if (Math.hypot(pj.x - pi.x, pj.y - pi.y) < bondCut) {
ctx.moveTo(pi.x, pi.y); ctx.lineTo(pj.x, pj.y);
}
}
}
ctx.stroke(); ctx.restore();
}
// velocity vectors (optional)
if (this._showVectors) {
ctx.save();
const vScale = SIG * 2 / maxSpd;
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
const len = speeds[i] * vScale;
if (len < 1.5) continue;
const ang = Math.atan2(p.vy, p.vx);
const ex = p.x + Math.cos(ang) * len, ey = p.y + Math.sin(ang) * len;
const hue = 240 - (speeds[i] / maxSpd) * 200;
ctx.strokeStyle = `hsla(${hue},85%,65%,0.55)`;
ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke();
const hl = Math.min(7, len * 0.38);
ctx.fillStyle = `hsla(${hue},85%,65%,0.55)`;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - hl * Math.cos(ang - 0.45), ey - hl * Math.sin(ang - 0.45));
ctx.lineTo(ex - hl * Math.cos(ang + 0.45), ey - hl * Math.sin(ang + 0.45));
ctx.closePath(); ctx.fill();
}
ctx.restore();
}
// particles
ctx.save();
for (let i = 0; i < this.particles.length; i++) {
const p = this.particles[i];
const t = speeds[i] / maxSpd;
const hue = 240 - t * 220; // blue (cold) → green → yellow → red (hot)
const col = `hsl(${hue},85%,62%)`;
const isH = this._hover === p;
const rad = isH ? SIG * 0.62 : SIG * 0.5;
ctx.shadowBlur = isH ? 22 : 5 + t * 12;
ctx.shadowColor = col;
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, Math.PI * 2); ctx.fill();
if (isH) {
ctx.shadowBlur = 0;
ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, rad + 4, 0, Math.PI * 2); ctx.stroke();
}
}
ctx.restore();
// phase badge
this._phasePulse += 0.04;
this._drawPhaseBadge(ctx, W, phase);
// temperature bar
this._drawTempBar(ctx, simH, T);
ctx.restore(); // end simulation clip
// chart separator
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, simH); ctx.lineTo(W, simH); ctx.stroke();
// charts (outside clip)
this._drawEnergyChart(ctx, W, H, PAD_B);
this._drawRDFChart(ctx, W, H, PAD_B);
// hover inspector (may extend into chart area)
if (this._hover) this._drawInspector(ctx, this._hover, speeds, maxSpd, W, H);
}
// ── helpers ───────────────────────────────────────────────────────────────
_hex3(hex) {
const h = hex.replace('#', '');
return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)];
}
// ── sub-drawing ───────────────────────────────────────────────────────────
_drawPhaseBadge(ctx, W, phase) {
const cfg = {
solid: { icon: '❄', label: 'Твёрдое', color: '#4CC9F0', bg: 'rgba(76,201,240,0.12)' },
liquid: { icon: '~', label: 'Жидкость', color: '#7BF5A4', bg: 'rgba(123,245,164,0.12)' },
gas: { icon: '·', label: 'Газ', color: '#FFB347', bg: 'rgba(255,179,71,0.12)' },
}[phase];
const sc = 1 + 0.028 * Math.sin(this._phasePulse);
ctx.save();
ctx.font = 'bold 13px sans-serif';
const text = `${cfg.icon} ${cfg.label}`;
const tw = ctx.measureText(text).width;
const bw = tw + 24, bh = 27, bx = W / 2 - bw / 2, by = 10;
ctx.translate(W/2, by+bh/2); ctx.scale(sc,sc); ctx.translate(-W/2, -(by+bh/2));
ctx.fillStyle = cfg.bg;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill();
ctx.strokeStyle = cfg.color + '50'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.stroke();
ctx.fillStyle = cfg.color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, W/2, by+bh/2);
ctx.restore();
}
_drawTempBar(ctx, simH, T) {
const bx = 10, by = 50, bw = 9;
const bh = Math.max(50, Math.min(simH - 72, 260));
ctx.save();
// gradient track
const grad = ctx.createLinearGradient(0, by, 0, by + bh);
grad.addColorStop(0, '#EF476F');
grad.addColorStop(0.4, '#FFD166');
grad.addColorStop(1, '#4CC9F0');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill();
// phase transition markers
ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1; ctx.setLineDash([2,3]);
ctx.font = '7px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
for (const [tv, lbl] of [[0.2,'Жидк.'],[0.5,'Газ']]) {
const y = by + bh - (tv / 0.7) * bh;
if (y > by + 4 && y < by + bh - 4) {
ctx.fillStyle = 'rgba(255,255,255,0)';
ctx.beginPath(); ctx.moveTo(bx-3, y); ctx.lineTo(bx+bw+3, y); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText(lbl, bx+bw+5, y);
}
}
ctx.setLineDash([]);
// indicator
const tNorm = Math.min(1, T / 0.7);
const iy = by + bh - tNorm * bh;
ctx.fillStyle = '#fff'; ctx.shadowBlur = 8; ctx.shadowColor = '#fff';
ctx.beginPath(); ctx.arc(bx + bw/2, iy, 5, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
// T value
const labelY = iy < by + 18 ? iy + 14 : iy - 14;
ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = "bold 9px 'Manrope',monospace";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(T.toFixed(2), bx + bw/2, labelY);
ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.font = '9px sans-serif';
ctx.fillText('T', bx + bw/2, by - 8);
ctx.restore();
}
_drawEnergyChart(ctx, W, H, padB) {
const hist = this._energyHistory;
const cw = Math.min(196, Math.floor((W - 16) * 0.46));
const ch = padB - 18;
const cx = 8, cy = H - ch - 8;
ctx.save();
ctx.fillStyle = 'rgba(4,6,20,0.82)';
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = "9px 'Manrope',sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('Энергия / частицу', cx + 8, cy + 5);
if (hist.length > 3) {
const pL=8, pR=6, pT=17, pB=13;
const pw = cw-pL-pR, ph = ch-pT-pB;
const allV = hist.flatMap(h => [h.ke, h.pe, h.te]);
const minV = Math.min(...allV, 0), maxV = Math.max(...allV, 0.001);
const rng = maxV - minV || 0.001;
const px = i => cx + pL + (i / (hist.length-1)) * pw;
const py = v => cy + pT + ph - ((v - minV) / rng) * ph;
// zero line
const zy = py(0);
if (zy > cy + pT && zy < cy + pT + ph) {
ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.moveTo(cx+pL, zy); ctx.lineTo(cx+pL+pw, zy); ctx.stroke();
ctx.setLineDash([]);
}
for (const [key, color, lw, dash] of [
['pe','#9B5DE5',1.2,false],
['ke','#FFD166',1.2,false],
['te','rgba(255,255,255,0.38)',1,true],
]) {
ctx.strokeStyle = color; ctx.lineWidth = lw;
if (dash) ctx.setLineDash([3,4]);
ctx.beginPath();
hist.forEach((h,i) => {
const x = px(i), y = py(h[key]);
i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
});
ctx.stroke(); ctx.setLineDash([]);
}
// legend
[['#FFD166','КЕ'],['#9B5DE5','ПЭ'],['rgba(255,255,255,0.4)','Е']].forEach(([c,l],li) => {
const lx = cx + 8 + li * 34;
ctx.fillStyle = c; ctx.beginPath(); ctx.arc(lx, cy+ch-7, 3, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = '8px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(l, lx+5, cy+ch-7);
});
} else {
ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = "9px 'Manrope',sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('накапливается…', cx+cw/2, cy+ch/2);
}
ctx.restore();
}
_drawRDFChart(ctx, W, H, padB) {
const g = this._rdfData;
const cw = Math.min(196, Math.floor((W - 16) * 0.46));
const ch = padB - 18;
const cx = W - cw - 8, cy = H - ch - 8;
ctx.save();
ctx.fillStyle = 'rgba(4,6,20,0.82)';
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = "9px 'Manrope',sans-serif";
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('g(r) — радиальная функция', cx+8, cy+5);
if (g) {
const pL=8, pR=6, pT=17, pB=14;
const pw = cw-pL-pR, ph = ch-pT-pB;
const nBins = g.length, barW = pw/nBins, maxG = this._rdfMaxG;
// g=1 reference
const refY = cy+pT+ph - (1/maxG)*ph;
ctx.strokeStyle = 'rgba(255,209,102,0.38)'; ctx.lineWidth=1; ctx.setLineDash([4,3]);
ctx.beginPath(); ctx.moveTo(cx+pL,refY); ctx.lineTo(cx+pL+pw,refY); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(255,209,102,0.35)'; ctx.font='7px sans-serif';
ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText('1', cx+pL-2, refY);
for (let i = 0; i < nBins; i++) {
const v = Math.min(g[i], maxG), frac = v / maxG;
const bh = frac * ph;
const bx = cx+pL+i*barW, by = cy+pT+ph-bh;
const hue = 220 - frac * 180;
ctx.fillStyle = `hsla(${hue},70%,55%,0.82)`;
ctx.beginPath(); ctx.roundRect(bx+0.5, by, barW-1, bh, 1); ctx.fill();
}
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font='7px sans-serif'; ctx.textAlign='center';
for (let v=0; v<=3; v++) {
ctx.fillText(v, cx+pL+(v/3.8)*pw, cy+pT+ph+8);
}
ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.textBaseline='bottom';
ctx.fillText('r / σ', cx+pL+pw/2, cy+ch);
} else {
ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = "9px 'Manrope',sans-serif";
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('накапливается…', cx+cw/2, cy+ch/2);
}
ctx.restore();
}
_drawInspector(ctx, p, speeds, maxSpd, W, H) {
const { SIG } = StatesSim;
const spd = Math.hypot(p.vx, p.vy);
const ke = 0.5 * spd * spd;
const ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
let coord = 0;
for (const q of this.particles) {
if (q !== p && Math.hypot(q.x-p.x, q.y-p.y) < SIG*1.5) coord++;
}
const t = spd / maxSpd, hue = 240 - t * 220;
const clr = `hsl(${hue},85%,62%)`;
const rows = [
['|v|', spd.toFixed(3)], ['vx', p.vx.toFixed(2)], ['vy', p.vy.toFixed(2)],
['KE', ke.toFixed(3)], ['угол', ang.toFixed(1)+'°'], ['z', coord+' сос.'],
];
const tw=136, th=rows.length*17+20;
let tx = p.x+14, ty = p.y-th/2;
if (tx+tw > W-8) tx = p.x-tw-14;
ty = Math.max(8, Math.min(H-th-8, ty));
ctx.save();
ctx.fillStyle = 'rgba(5,7,22,0.95)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill();
ctx.fillStyle = clr;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8,8,0,0]); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke();
ctx.font = "11px 'Manrope',monospace"; ctx.textBaseline = 'middle';
rows.forEach(([k,v],i) => {
const ry = ty+15+i*17;
ctx.fillStyle='rgba(255,255,255,0.38)'; ctx.textAlign='left'; ctx.fillText(k, tx+10, ry);
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.textAlign='right'; ctx.fillText(v, tx+tw-10, ry);
});
ctx.restore();
}
}
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
ThinLensSim — thin lens ray tracing simulation
1/f = 1/d + 1/d' M = -d'/d
Three principal rays · draggable object & focal point
Converging (f>0) and diverging (f<0) lenses
══════════════════════════════════════════════════════════════ */
class ThinLensSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* physics (px units) */
this.f = 100; // focal length
this.d = 200; // object distance (positive, measured from lens)
this.h = 50; // object height
/* drag state */
this._drag = null; // 'object' | 'focus' | null
/* callback */
this.onUpdate = null;
this._bindEvents();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── public API ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
if (h !== undefined) this.h = Math.max(20, Math.min(80, +h));
this.draw();
this._emit();
}
reset() {
this.f = 100; this.d = 200; this.h = 50;
this.draw();
this._emit();
}
info() {
const { f, d, h } = this;
const denom = d - f;
const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom;
const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d;
const hPrime = M === Infinity ? Infinity : M * h;
const isVirtual = dPrime < 0;
return {
f: +f.toFixed(1),
d: +d.toFixed(1),
dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1),
M: M === Infinity ? Infinity : +M.toFixed(3),
imageType: isVirtual ? 'мнимое' : 'действительное',
h: +h.toFixed(1),
hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1),
};
}
/* ── internals ─────────────────────────────── */
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/** Convert simulation coords to canvas coords.
* Origin = lens center; +x right, +y up.
* Canvas: lensX = W/2, axisY = H/2 */
_toCanvas(sx, sy) {
return { cx: this.W / 2 + sx, cy: this.H / 2 - sy };
}
_fromCanvas(cx, cy) {
return { sx: cx - this.W / 2, sy: this.H / 2 - cy };
}
/* ── draw ──────────────────────────────────── */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
const { f, d, h } = this;
const lensX = W / 2;
const axisY = H / 2;
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* optical axis */
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke();
ctx.setLineDash([]);
/* lens */
this._drawLens(ctx, lensX, axisY, f);
/* focal & 2F points */
this._drawFocalPoints(ctx, lensX, axisY, f);
/* object arrow */
const objX = lensX - d;
this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false);
/* compute image */
const denom = d - f;
let dPrime, hPrime;
if (Math.abs(denom) < 0.5) {
/* object at focal point — rays parallel, no image */
dPrime = null;
hPrime = null;
} else {
dPrime = (f * d) / denom;
const M = -dPrime / d;
hPrime = M * h;
}
/* principal rays */
this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime);
/* image arrow */
if (dPrime !== null && isFinite(dPrime)) {
const isVirtual = dPrime < 0;
const imgX = lensX + dPrime;
const imgTop = axisY - hPrime;
this._drawArrow(ctx, imgX, axisY, imgX, imgTop,
isVirtual ? '#FFD166' : '#EF476F', isVirtual);
}
/* labels */
this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime);
}
_drawLens(ctx, lx, ay, f) {
const lensH = Math.min(this.H * 0.38, 140);
const converging = f > 0;
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.lineWidth = 2.5;
if (converging) {
/* biconvex shape */
const bulge = Math.min(18, Math.abs(f) * 0.12);
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH);
ctx.stroke();
/* arrowheads (converging) */
this._lensArrow(ctx, lx, ay - lensH, -1);
this._lensArrow(ctx, lx, ay + lensH, 1);
} else {
/* biconcave shape */
const bulge = Math.min(14, Math.abs(f) * 0.1);
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(lx, ay - lensH);
ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH);
ctx.stroke();
/* arrowheads (diverging) */
this._lensArrowDiv(ctx, lx, ay - lensH, -1);
this._lensArrowDiv(ctx, lx, ay + lensH, 1);
}
/* center line */
ctx.strokeStyle = 'rgba(155,93,229,0.3)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke();
}
_lensArrow(ctx, x, y, dir) {
const sz = 7;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - sz, y + dir * sz * 1.2);
ctx.lineTo(x + sz, y + dir * sz * 1.2);
ctx.closePath(); ctx.fill();
}
_lensArrowDiv(ctx, x, y, dir) {
const sz = 6;
ctx.fillStyle = 'rgba(155,93,229,0.8)';
ctx.beginPath();
ctx.moveTo(x - sz, y);
ctx.lineTo(x, y - dir * sz);
ctx.lineTo(x + sz, y);
ctx.closePath(); ctx.fill();
}
_drawFocalPoints(ctx, lx, ay, f) {
const pts = [
{ sx: f, label: "F'" },
{ sx: -f, label: 'F' },
{ sx: 2 * f, label: "2F'" },
{ sx: -2 * f, label: '2F' },
];
for (const p of pts) {
const px = lx + p.sx;
if (px < 10 || px > this.W - 10) continue;
const isFocal = !p.label.startsWith('2');
const r = isFocal ? 5 : 3.5;
const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = col;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText(p.label, px, ay + 10);
}
}
_drawArrow(ctx, x1, y1, x2, y2, color, dashed) {
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 2.5;
if (dashed) ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
if (dashed) ctx.setLineDash([]);
/* arrowhead */
const angle = Math.atan2(y2 - y1, x2 - x1);
const aLen = 10;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35));
ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35));
ctx.closePath(); ctx.fill();
}
_drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) {
const objX = lx - d;
const objY = ay - h;
const colors = ['#06D6E0', '#7BF5A4', '#FFD166'];
const hasImage = dPrime !== null && isFinite(dPrime);
const isVirtual = hasImage && dPrime < 0;
ctx.lineWidth = 1.5;
/* Ray 1: parallel to axis <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> through F' (converging) or from F' (diverging) */
{
ctx.strokeStyle = colors[0];
ctx.setLineDash([]);
/* incoming: object tip <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> lens, parallel */
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke();
/* outgoing */
if (hasImage) {
const imgX = lx + dPrime;
const imgY = ay - hPrime;
if (!isVirtual) {
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
/* extend past image */
this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]);
} else {
/* diverging outgoing ray + dashed virtual extension */
const outSlope = (objY - ay) / f;
ctx.beginPath(); ctx.moveTo(lx, objY);
ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke();
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke();
ctx.setLineDash([]);
}
}
}
/* Ray 2: through center <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> straight */
{
ctx.strokeStyle = colors[1];
ctx.setLineDash([]);
const slope = (objY - ay) / (objX - lx);
const farX = lx + 350;
const farY = ay + slope * 350;
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke();
if (isVirtual) {
/* extend behind lens too */
const backX = lx - 350;
const backY = ay - slope * 350;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke();
ctx.setLineDash([]);
}
}
/* Ray 3: through F <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> parallel after lens */
{
ctx.strokeStyle = colors[2]; ctx.setLineDash([]);
const fx = lx - f;
const slope = (objY - ay) / (objX - fx);
const hitY = objY + slope * (lx - objX);
ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke();
const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300;
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke();
if (hasImage && isVirtual) {
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke();
ctx.setLineDash([]);
}
}
}
_extendRay(ctx, x1, y1, x2, y2, color) {
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const ex = x2 + (dx / len) * 80;
const ey = y2 + (dy / len) * 80;
ctx.globalAlpha = 0.3;
ctx.strokeStyle = color;
ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(ex, ey); ctx.stroke();
ctx.globalAlpha = 1;
}
_drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) {
ctx.font = '12px Manrope, system-ui, sans-serif';
ctx.textBaseline = 'top';
/* d label */
const objX = lx - d;
ctx.fillStyle = '#9B5DE5';
ctx.textAlign = 'center';
ctx.fillText(`d = ${d.toFixed(0)}`, (objX + lx) / 2, ay + 26);
/* f label */
ctx.fillStyle = '#06D6E0';
ctx.fillText(`f = ${f.toFixed(0)}`, lx, ay + 42);
/* d' label */
if (dPrime !== null && isFinite(dPrime)) {
const imgX = lx + dPrime;
ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166';
ctx.textAlign = 'center';
ctx.fillText(`d' = ${dPrime.toFixed(1)}`, (lx + imgX) / 2, ay + 26);
}
/* formula box */
const info = this.info();
const boxW = 200, boxH = 52;
const bx = 12, by = 12;
ctx.fillStyle = 'rgba(22,22,38,0.85)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill();
ctx.font = '11px Manrope, system-ui, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const mStr = info.M === Infinity ? '---' : info.M.toFixed(2);
const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1);
ctx.fillText(`1/f = 1/d + 1/d'`, bx + 10, by + 10);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText(`M = ${mStr} d' = ${dpStr} ${info.imageType}`, bx + 10, by + 30);
}
/* ── events ─────────────────────────────────── */
_bindEvents() {
const cv = this.canvas;
const getPos = (e) => {
const r = cv.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return {
mx: (t.clientX - r.left) * (this.W / r.width),
my: (t.clientY - r.top) * (this.H / r.height),
};
};
const hitTest = (mx, my) => {
const lx = this.W / 2, ay = this.H / 2;
/* object tip */
const objX = lx - this.d;
const objY = ay - this.h;
if (Math.hypot(mx - objX, my - objY) < 20) return 'object';
/* focal point F (front) */
const fx = lx - this.f;
if (Math.hypot(mx - fx, my - ay) < 16) return 'focus';
return null;
};
const onDown = (e) => {
const { mx, my } = getPos(e);
this._drag = hitTest(mx, my);
};
const onMove = (e) => {
if (!this._drag) return;
if (e.cancelable) e.preventDefault();
const { mx } = getPos(e);
const lx = this.W / 2;
if (this._drag === 'object') {
this.d = Math.max(30, Math.min(400, lx - mx));
} else if (this._drag === 'focus') {
const newF = lx - mx;
this.f = Math.max(-200, Math.min(200, newF));
}
this.draw();
this._emit();
};
const onUp = () => { this._drag = null; };
/* mouse */
cv.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
/* touch */
cv.addEventListener('touchstart', e => {
if (e.touches.length === 1) onDown(e);
}, { passive: true });
cv.addEventListener('touchmove', e => onMove(e), { passive: false });
cv.addEventListener('touchend', onUp);
/* cursor style */
cv.addEventListener('mousemove', e => {
if (this._drag) { cv.style.cursor = 'grabbing'; return; }
const { mx, my } = getPos(e);
cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default';
});
}
}
+657
View File
@@ -0,0 +1,657 @@
'use strict';
/* ══════════════════════════════════════════════════════════════
TitrationSim — acid-base titration simulation
Strong acid (HCl) / weak acid (CH₃COOH) + strong base (NaOH)
Left 60%: burette + Erlenmeyer flask with indicator colour
Right 40%: real-time pH vs V(base) titration curve
Henderson-Hasselbalch for weak acid buffer region
══════════════════════════════════════════════════════════════ */
class TitrationSim {
static PINK = '#EF476F';
static VIOLET = '#9B5DE5';
static CYAN = '#06D6E0';
static GREEN = '#7BF5A4';
static YELLOW = '#FFD166';
static BG = '#0D0D1A';
static FONT = "Manrope, system-ui, sans-serif";
/* ── Constructor ────────────────────────────────────────── */
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
/* chemistry */
this.acidConc = 0.1; // mol/L
this.baseConc = 0.1; // mol/L
this.acidVol = 50; // mL
this.acidType = 'strong'; // 'strong' | 'weak'
this.indicator = 'phenolphthalein'; // 'phenolphthalein' | 'methyl_orange' | 'litmus'
this.Ka = 1.8e-5; // CH₃COOH dissociation constant
/* state */
this.baseAdded = 0; // mL of base added
this._curve = []; // [{v, pH}]
this._drops = []; // [{x, y, vy, r}]
this._splashes = []; // [{x, y, vx, vy, r, life}]
this._ripples = []; // [{x, y, radius, life}]
this._dropAccum = 0;
this._wave = 0;
/* animation */
this.playing = false;
this._raf = null;
this._lastTs = null;
this.speed = 1;
this.onUpdate = null;
this._recordPoint();
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
}
/* ── Geometry ───────────────────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.offsetWidth || 600;
const h = this.canvas.offsetHeight || 400;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = w; this.H = h;
}
/* ── Public API ─────────────────────────────────────────── */
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
if (acidVol !== undefined) this.acidVol = Math.max(25, Math.min(100, +acidVol));
if (indicator !== undefined) this.indicator = indicator;
if (acidType !== undefined) this.acidType = acidType;
this.reset();
}
preset(name) {
const presets = {
strong_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'strong', indicator: 'phenolphthalein' },
weak_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'weak', indicator: 'phenolphthalein' },
concentrated: { acidConc: 0.5, baseConc: 0.5, acidVol: 25, acidType: 'strong', indicator: 'methyl_orange' },
};
const p = presets[name] || presets.strong_strong;
Object.assign(this, p);
this.reset();
}
reset() {
this.pause();
this.baseAdded = 0;
this._curve = [];
this._drops = [];
this._splashes = [];
this._ripples = [];
this._dropAccum = 0;
this._wave = 0;
this._recordPoint();
this.draw();
this._emit();
}
play() {
if (this.playing) return;
this.playing = true;
this._lastTs = null;
this._tick();
}
pause() {
this.playing = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
info() {
const eqVol = this._eqVolume();
return {
pH: +this._calcPH(this.baseAdded).toFixed(2),
baseAdded: +this.baseAdded.toFixed(2),
eqPoint: +eqVol.toFixed(2),
indicator: this.indicator,
acidType: this.acidType,
};
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
/* ── Chemistry ──────────────────────────────────────────── */
_eqVolume() { return (this.acidConc * this.acidVol) / this.baseConc; }
_maxVolume() { return this._eqVolume() * 1.5; }
_calcPH(vBase) {
const nAcid = this.acidConc * this.acidVol / 1000;
const nBase = this.baseConc * vBase / 1000;
const vTotal = (this.acidVol + vBase) / 1000;
return this.acidType === 'strong'
? this._strongPH(nAcid, nBase, vTotal)
: this._weakPH(nAcid, nBase, vTotal);
}
_strongPH(nA, nB, vT) {
const d = nA - nB;
if (Math.abs(d) < 1e-10) return 7.0;
if (d > 0) return -Math.log10(d / vT);
return 14 + Math.log10(-d / vT);
}
_weakPH(nA, nB, vT) {
const Ka = this.Ka;
const d = nA - nB;
if (d < -1e-10) return 14 + Math.log10(-d / vT); // excess base
if (Math.abs(d) < 1e-10) { // equivalence — hydrolysis
const Kb = 1e-14 / Ka;
return 14 + Math.log10(Math.sqrt(Kb * (nB / vT)));
}
if (nB < 1e-10) { // pure weak acid
const c = nA / vT;
const cH = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * c)) / 2;
return -Math.log10(cH);
}
return -Math.log10(Ka) + Math.log10((nB / vT) / (d / vT)); // Henderson-Hasselbalch
}
_recordPoint() {
this._curve.push({ v: this.baseAdded, pH: this._calcPH(this.baseAdded) });
}
/* ── Indicator colour ───────────────────────────────────── */
_indicatorColor(pH) {
if (this.indicator === 'phenolphthalein') {
if (pH < 8.2) return 'rgba(255,255,255,0.04)';
if (pH > 10) return 'rgba(220,20,120,0.60)';
const t = (pH - 8.2) / 1.8;
return `rgba(220,${200 - Math.round(180 * t)},${255 - Math.round(135 * t)},${(0.04 + 0.56 * t).toFixed(2)})`;
}
if (this.indicator === 'methyl_orange') {
if (pH < 3.1) return 'rgba(220,40,40,0.50)';
if (pH > 4.4) return 'rgba(240,210,60,0.35)';
const t = (pH - 3.1) / 1.3;
return `rgba(${220 + Math.round(20 * t)},${40 + Math.round(170 * t)},${40 + Math.round(20 * t)},${(0.50 - 0.15 * t).toFixed(2)})`;
}
/* litmus */
if (pH < 5) return 'rgba(220,50,60,0.55)';
if (pH > 8) return 'rgba(60,80,210,0.55)';
const t = (pH - 5) / 3;
return `rgba(${220 - Math.round(160 * t)},${50 + Math.round(30 * t)},${60 + Math.round(150 * t)},0.55)`;
}
_liquidRGB(pH) {
if (this.indicator === 'phenolphthalein') {
if (pH < 8.2) return [180, 210, 255];
const t = Math.min(1, (pH - 8.2) / 1.8);
return [180 + t * 40, 210 - t * 140, 255 - t * 135];
}
if (this.indicator === 'methyl_orange') {
if (pH < 3.1) return [220, 80, 80];
if (pH > 4.4) return [240, 210, 80];
const t = (pH - 3.1) / 1.3;
return [220 + t * 20, 80 + t * 130, 80];
}
/* litmus */
if (pH < 5) return [220, 70, 70];
if (pH > 8) return [80, 100, 220];
const t = (pH - 5) / 3;
return [220 - 140 * t, 70 + 30 * t, 70 + 150 * t];
}
_phColor(pH) {
if (pH < 3) return TitrationSim.PINK;
if (pH < 5) return TitrationSim.YELLOW;
if (pH < 9) return TitrationSim.GREEN;
if (pH < 11) return TitrationSim.CYAN;
return TitrationSim.VIOLET;
}
/* ── Animation loop ─────────────────────────────────────── */
_tick() {
if (!this.playing) return;
this._raf = requestAnimationFrame(ts => {
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
this._wave += rawDt * 2.0;
const maxV = this._maxVolume();
if (this.baseAdded < maxV) {
this.baseAdded = Math.min(this.baseAdded + (maxV / 14) * dt, maxV);
this._recordPoint();
if (this._curve.length > 600) this._curve.shift();
this._spawnDrops(dt);
} else {
this.pause();
}
/* move drops */
for (const d of this._drops) { d.vy += 480 * dt; d.y += d.vy * dt; }
const surfY = this.H * 0.72;
/* spawn splashes when drops hit surface */
for (const d of this._drops) {
if (d.y >= surfY && !d.hit) {
d.hit = true;
const bx = d.x;
for (let i = 0; i < 3; i++) {
const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI;
const s = 15 + Math.random() * 25;
this._splashes.push({ x: bx, y: surfY, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 8, r: 1 + Math.random(), life: 1 });
}
this._ripples.push({ x: bx, y: surfY, radius: 2, life: 1 });
}
}
this._drops = this._drops.filter(d => d.y < surfY + 4);
/* animate splashes */
for (const s of this._splashes) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 160 * dt; s.life -= dt * 3.5; }
this._splashes = this._splashes.filter(s => s.life > 0);
for (const r of this._ripples) { r.radius += dt * 30; r.life -= dt * 2.2; }
this._ripples = this._ripples.filter(r => r.life > 0);
this.draw();
this._emit();
if (this.playing) this._tick();
});
}
_spawnDrops(dt) {
this._dropAccum += dt;
const interval = 0.18 / Math.max(0.5, this.speed);
while (this._dropAccum >= interval) {
this._dropAccum -= interval;
const simW = this.W * 0.6;
const bx = simW * 0.42;
this._drops.push({
x: bx + (Math.random() - 0.5) * 3,
y: this.H * 0.38 + 14,
vy: 10 + Math.random() * 8,
r: 2.2 + Math.random() * 1.4,
hit: false,
});
}
}
/* ═══════════════════════ Rendering ═══════════════════════ */
draw() {
const { ctx, W, H } = this;
if (!W || !H) return;
const simW = W * 0.6;
ctx.fillStyle = TitrationSim.BG;
ctx.fillRect(0, 0, W, H);
/* dot grid */
ctx.fillStyle = 'rgba(255,255,255,0.04)';
for (let x = 0; x < W; x += 28) for (let y = 0; y < H; y += 28) {
ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
}
/* divider */
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(simW, 16); ctx.lineTo(simW, H - 16); ctx.stroke();
this._drawStand(ctx, simW);
this._drawBurette(ctx, simW);
this._drawFlask(ctx, simW);
this._drawParticles(ctx);
this._drawOverlay(ctx);
this._drawPHCurve(ctx, simW, W, H);
}
/* ── Lab stand ──────────────────────────────────────────── */
_drawStand(ctx, simW) {
const H = this.H, sx = simW * 0.2;
const g = ctx.createLinearGradient(sx - 3, 0, sx + 3, 0);
g.addColorStop(0, 'rgba(120,130,160,0.5)');
g.addColorStop(0.5, 'rgba(180,190,210,0.7)');
g.addColorStop(1, 'rgba(100,110,140,0.4)');
ctx.fillStyle = g;
ctx.fillRect(sx - 3, H * 0.06, 6, H * 0.84);
ctx.fillStyle = 'rgba(150,160,190,0.40)';
ctx.beginPath(); ctx.roundRect(sx - 36, H * 0.90, 72, 7, 3); ctx.fill();
ctx.fillStyle = 'rgba(140,150,180,0.55)';
ctx.fillRect(sx - 1, H * 0.12, simW * 0.22 + 2, 5);
}
/* ── Burette ────────────────────────────────────────────── */
_drawBurette(ctx, simW) {
const H = this.H, FNT = TitrationSim.FONT;
const bx = simW * 0.42, bT = H * 0.06, bB = H * 0.38, bW = 12;
const maxV = this._maxVolume();
const frac = Math.max(0, 1 - this.baseAdded / maxV);
/* glass tube */
const gg = ctx.createLinearGradient(bx - bW, 0, bx + bW, 0);
gg.addColorStop(0, 'rgba(120,170,255,0.18)');
gg.addColorStop(0.4, 'rgba(160,200,255,0.08)');
gg.addColorStop(0.6, 'rgba(160,200,255,0.08)');
gg.addColorStop(1, 'rgba(100,150,240,0.15)');
ctx.fillStyle = gg;
ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.fill();
/* liquid level */
if (frac > 0.01) {
const lt = bT + (bB - bT) * (1 - frac) + 4;
const lg = ctx.createLinearGradient(0, lt, 0, bB);
lg.addColorStop(0, 'rgba(100,160,255,0.25)');
lg.addColorStop(1, 'rgba(80,140,240,0.40)');
ctx.fillStyle = lg;
ctx.beginPath(); ctx.roundRect(bx - bW + 2, lt, bW * 2 - 4, bB - lt - 4, 3); ctx.fill();
}
/* glass outline + highlight */
ctx.strokeStyle = 'rgba(120,175,255,0.50)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.stroke();
ctx.strokeStyle = 'rgba(200,225,255,0.25)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(bx - bW + 3, bT + 8); ctx.lineTo(bx - bW + 3, bB - 8); ctx.stroke();
/* graduations */
ctx.strokeStyle = 'rgba(180,210,255,0.30)'; ctx.lineWidth = 0.8;
ctx.font = `8px ${FNT}`; ctx.fillStyle = 'rgba(180,210,255,0.45)'; ctx.textAlign = 'right';
for (let i = 0; i <= 10; i++) {
const y = bT + 6 + (bB - bT - 12) * (i / 10), maj = i % 2 === 0;
ctx.beginPath(); ctx.moveTo(bx + bW, y); ctx.lineTo(bx + bW + (maj ? 8 : 4), y); ctx.stroke();
if (maj) ctx.fillText(((i / 10) * maxV).toFixed(0), bx + bW + 22, y + 3);
}
/* stopcock + nozzle */
ctx.fillStyle = 'rgba(180,190,220,0.55)'; ctx.fillRect(bx - 4, bB - 2, 8, 8);
ctx.fillStyle = 'rgba(140,165,210,0.50)';
ctx.beginPath();
ctx.moveTo(bx - 3, bB + 6); ctx.lineTo(bx + 3, bB + 6);
ctx.lineTo(bx + 1.5, bB + 14); ctx.lineTo(bx - 1.5, bB + 14);
ctx.closePath(); ctx.fill();
/* forming drip */
if (this.playing && this.baseAdded < maxV) {
const pulse = 0.5 + 0.5 * Math.sin(this._wave * 4);
const dr = 2.5 + pulse * 1.5;
ctx.save(); ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(100,180,255,0.65)';
ctx.beginPath(); ctx.arc(bx, bB + 14 + dr, dr, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
/* labels */
ctx.fillStyle = 'rgba(180,210,255,0.60)'; ctx.font = `bold 10px ${FNT}`; ctx.textAlign = 'center';
ctx.fillText('NaOH', bx, bT - 6);
ctx.fillText(`${this.baseConc} M`, bx, bT - 18);
}
/* ── Erlenmeyer flask ───────────────────────────────────── */
_drawFlask(ctx, simW) {
const H = this.H, cx = simW * 0.42, pH = this._calcPH(this.baseAdded);
const fB = H * 0.88, fNT = H * 0.58, fNW = 10, fBW = simW * 0.22;
const flaskP = () => {
ctx.beginPath();
ctx.moveTo(cx - fNW, fNT); ctx.lineTo(cx - fNW, fNT + 16);
ctx.bezierCurveTo(cx - fNW, fNT + 40, cx - fBW, fB - 30, cx - fBW, fB);
ctx.lineTo(cx + fBW, fB);
ctx.bezierCurveTo(cx + fBW, fB - 30, cx + fNW, fNT + 40, cx + fNW, fNT + 16);
ctx.lineTo(cx + fNW, fNT); ctx.closePath();
};
const lY = H * 0.72, [lr, lg, lb] = this._liquidRGB(pH);
const amp = 2 + (this.playing ? 1.5 : 0);
const wY = x => lY + Math.sin((x - cx) * 0.08 + this._wave) * amp
+ Math.sin((x - cx) * 0.15 - this._wave * 1.4) * amp * 0.3;
/* liquid clipped to flask */
ctx.save(); flaskP(); ctx.clip();
ctx.beginPath();
for (let x = cx - fBW - 2; x <= cx + fBW + 2; x += 2) {
x === cx - fBW - 2 ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x));
}
ctx.lineTo(cx + fBW + 2, fB + 4); ctx.lineTo(cx - fBW - 2, fB + 4); ctx.closePath();
const lGrad = ctx.createLinearGradient(0, lY, 0, fB);
lGrad.addColorStop(0, `rgba(${lr},${lg},${lb},0.30)`);
lGrad.addColorStop(0.5, `rgba(${lr},${lg},${lb},0.45)`);
lGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0.55)`);
ctx.fillStyle = lGrad; ctx.fill();
ctx.fillStyle = this._indicatorColor(pH); ctx.fill();
/* surface shimmer */
ctx.beginPath();
for (let x = cx - fBW; x <= cx + fBW; x += 2) {
x === cx - fBW ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x));
}
ctx.strokeStyle = `rgba(${Math.min(255, lr + 80)},${Math.min(255, lg + 80)},${Math.min(255, lb + 80)},0.45)`;
ctx.lineWidth = 1.2; ctx.stroke();
ctx.restore();
/* glass outline */
ctx.strokeStyle = 'rgba(120,175,255,0.55)'; ctx.lineWidth = 2; flaskP(); ctx.stroke();
/* left highlight */
ctx.save(); ctx.beginPath();
ctx.moveTo(cx - fNW + 2, fNT + 4); ctx.lineTo(cx - fNW + 2, fNT + 18);
ctx.bezierCurveTo(cx - fNW + 2, fNT + 42, cx - fBW + 8, fB - 32, cx - fBW + 6, fB - 4);
const hg = ctx.createLinearGradient(cx - fBW, fNT, cx - fBW, fB);
hg.addColorStop(0, 'rgba(200,230,255,0.30)'); hg.addColorStop(1, 'rgba(200,230,255,0.03)');
ctx.strokeStyle = hg; ctx.lineWidth = 3; ctx.stroke(); ctx.restore();
/* neck rim */
ctx.strokeStyle = 'rgba(140,185,255,0.60)'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(cx - fNW - 4, fNT); ctx.lineTo(cx + fNW + 4, fNT); ctx.stroke();
/* acid label */
const label = this.acidType === 'strong' ? 'HCl' : 'CH\u2083COOH';
ctx.fillStyle = 'rgba(180,210,255,0.55)'; ctx.font = `9px ${TitrationSim.FONT}`; ctx.textAlign = 'center';
ctx.fillText(`${label} ${this.acidConc} M, ${this.acidVol} mL`, cx, fB + 14);
/* pH value */
ctx.font = `bold 14px ${TitrationSim.FONT}`;
ctx.fillStyle = this._phColor(pH);
ctx.fillText(`pH ${pH.toFixed(2)}`, cx, fB + 32);
}
/* ── Drops / splashes / ripples ─────────────────────────── */
_drawParticles(ctx) {
for (const d of this._drops) {
ctx.save(); ctx.globalAlpha = 0.85;
ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 8;
const st = Math.min(2.5, 1 + d.vy * 0.003);
ctx.beginPath(); ctx.ellipse(d.x, d.y, d.r, d.r * st, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(100,180,255,0.70)'; ctx.fill();
ctx.fillStyle = 'rgba(220,240,255,0.55)';
ctx.beginPath(); ctx.arc(d.x - d.r * 0.3, d.y - d.r * 0.4, d.r * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
const [sr, sg, sb] = this._liquidRGB(this._calcPH(this.baseAdded));
for (const s of this._splashes) {
ctx.save(); ctx.globalAlpha = s.life * 0.7;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgb(${Math.min(255, sr + 60)},${Math.min(255, sg + 60)},${Math.min(255, sb + 60)})`; ctx.fill();
ctx.restore();
}
for (const r of this._ripples) {
ctx.save(); ctx.globalAlpha = r.life * 0.4;
ctx.strokeStyle = 'rgba(180,220,255,0.6)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
}
}
/* ── Stats overlay ──────────────────────────────────────── */
_drawOverlay(ctx) {
const pH = this._calcPH(this.baseAdded);
const eqV = this._eqVolume();
const bx = 10, by = 10, bw = 150, bh = 78;
ctx.fillStyle = 'rgba(5,5,20,0.82)';
ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
const lh = 16;
ctx.font = `bold 12px ${TitrationSim.FONT}`;
ctx.fillStyle = TitrationSim.CYAN;
ctx.fillText(`pH = ${pH.toFixed(2)}`, bx + 10, by + 8);
ctx.font = `10px ${TitrationSim.FONT}`;
ctx.fillStyle = TitrationSim.YELLOW;
ctx.fillText(`V(NaOH) = ${this.baseAdded.toFixed(1)} mL`, bx + 10, by + 8 + lh);
ctx.fillStyle = TitrationSim.GREEN;
ctx.fillText(`V\u044D\u043A\u0432 = ${eqV.toFixed(1)} mL`, bx + 10, by + 8 + lh * 2);
const names = { phenolphthalein: '\u0424\u0435\u043D\u043E\u043B\u0444\u0442.', methyl_orange: '\u041C\u0435\u0442.\u043E\u0440.', litmus: '\u041B\u0430\u043A\u043C\u0443\u0441' };
ctx.fillStyle = 'rgba(255,255,255,0.40)';
ctx.fillText(names[this.indicator] || this.indicator, bx + 10, by + 8 + lh * 3);
}
/* ── pH titration curve (right 40%) ─────────────────────── */
_drawPHCurve(ctx, x0, W, H) {
const gW = W - x0;
const pad = { l: 36, r: 12, t: 30, b: 32 };
const px = x0 + pad.l, py = pad.t;
const pw = gW - pad.l - pad.r, ph = H - pad.t - pad.b;
const maxV = this._maxVolume();
const eqV = this._eqVolume();
const FNT = TitrationSim.FONT;
/* panel bg */
ctx.fillStyle = 'rgba(5,5,20,0.85)';
ctx.fillRect(x0, 0, gW, H);
/* title */
ctx.fillStyle = 'rgba(200,220,255,0.65)';
ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'center';
ctx.fillText('\u041A\u0440\u0438\u0432\u0430\u044F \u0442\u0438\u0442\u0440\u043E\u0432\u0430\u043D\u0438\u044F', x0 + gW / 2, 14);
/* grid + y labels */
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.5;
ctx.fillStyle = 'rgba(180,210,255,0.35)';
ctx.font = `9px ${FNT}`; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
for (let p = 0; p <= 14; p += 2) {
const yl = py + ph - (p / 14) * ph;
ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
ctx.fillText(p.toString(), px - 5, yl);
}
/* x labels */
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
const vs = maxV > 60 ? 20 : maxV > 30 ? 10 : 5;
for (let v = 0; v <= maxV; v += vs) {
const xl = px + (v / maxV) * pw;
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.beginPath(); ctx.moveTo(xl, py); ctx.lineTo(xl, py + ph); ctx.stroke();
ctx.fillText(v.toFixed(0), xl, py + ph + 6);
}
ctx.fillStyle = 'rgba(180,210,255,0.50)'; ctx.font = `bold 10px ${FNT}`;
ctx.fillText('V (mL)', x0 + gW / 2, py + ph + 22);
/* y-axis label */
ctx.save();
ctx.translate(x0 + 10, py + ph / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('pH', 0, 0);
ctx.restore();
/* dashed pH=7 */
const y7 = py + ph * (1 - 7 / 14);
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(123,245,164,0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(px, y7); ctx.lineTo(px + pw, y7); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(123,245,164,0.40)'; ctx.font = `9px ${FNT}`;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('pH 7', px + pw + 4, y7);
/* axes */
ctx.strokeStyle = 'rgba(160,200,255,0.40)'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py + ph); ctx.lineTo(px + pw, py + ph); ctx.stroke();
/* curve */
if (this._curve.length > 1) {
ctx.strokeStyle = TitrationSim.CYAN; ctx.lineWidth = 2.5;
ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6;
ctx.beginPath();
for (let i = 0; i < this._curve.length; i++) {
const pt = this._curve[i];
const lx = px + (pt.v / maxV) * pw;
const ly = py + ph * (1 - Math.max(0, Math.min(14, pt.pH)) / 14);
i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
}
ctx.stroke(); ctx.shadowBlur = 0;
/* current point dot + tooltip */
const last = this._curve[this._curve.length - 1];
const dx = px + (last.v / maxV) * pw;
const dy = py + ph * (1 - Math.max(0, Math.min(14, last.pH)) / 14);
ctx.save();
ctx.shadowColor = '#FFF'; ctx.shadowBlur = 10; ctx.fillStyle = '#FFF';
ctx.beginPath(); ctx.arc(dx, dy, 4.5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
const tw = 72, th = 30;
const tx = Math.min(dx + 8, px + pw - tw - 4);
const ty = Math.max(dy - th - 8, py + 2);
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 5); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = this._phColor(last.pH);
ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(`pH ${last.pH.toFixed(2)}`, tx + 6, ty + 5);
ctx.fillStyle = 'rgba(200,220,255,0.60)'; ctx.font = `9px ${FNT}`;
ctx.fillText(`${last.v.toFixed(1)} mL`, tx + 6, ty + 18);
ctx.restore();
}
/* equivalence point markers */
const eqX = px + (eqV / maxV) * pw;
const eqPH = this._calcPH(eqV);
const eqY = py + ph * (1 - Math.max(0, Math.min(14, eqPH)) / 14);
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(155,93,229,0.45)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(eqX, py); ctx.lineTo(eqX, py + ph); ctx.stroke();
ctx.setLineDash([]);
/* equivalence diamond */
ctx.save();
ctx.shadowColor = TitrationSim.VIOLET; ctx.shadowBlur = 10;
ctx.fillStyle = TitrationSim.VIOLET;
ctx.beginPath();
ctx.moveTo(eqX, eqY - 6); ctx.lineTo(eqX + 5, eqY);
ctx.lineTo(eqX, eqY + 6); ctx.lineTo(eqX - 5, eqY);
ctx.closePath(); ctx.fill();
ctx.shadowBlur = 0; ctx.restore();
ctx.fillStyle = 'rgba(155,93,229,0.70)';
ctx.font = `bold 9px ${FNT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('\u044D\u043A\u0432', eqX, eqY - 10);
ctx.fillStyle = 'rgba(155,93,229,0.50)'; ctx.font = `8px ${FNT}`;
ctx.fillText(`${eqV.toFixed(1)} mL`, eqX, eqY - 20);
}
}
if (typeof module !== 'undefined') module.exports = TitrationSim;
+961
View File
@@ -0,0 +1,961 @@
'use strict';
/* ══════════════════════════════════════════════════════
TriangleSim — interactive triangle geometry simulation
Draggable vertices A / B / C, toggleable layers:
medians, altitudes, bisectors, circumcircle, incircle
══════════════════════════════════════════════════════ */
class TriangleSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.pts = null; // [{x,y}, {x,y}, {x,y}]
this._dragging = null;
this._hovered = null;
// visible layers
this.layers = {
medians : false,
altitudes : false,
bisectors : false,
circumcircle: false,
incircle : false,
eulerLine : false,
sineLaw : false,
cosineLaw : false,
pythagorean : false,
grid : true,
};
this.onUpdate = null; // cb(stats)
this._bindEvents();
}
/* ── sizing ── */
fit() {
const rect = this.canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = rect.width;
this.H = rect.height;
if (!this.pts) this._initPts();
else this._clampPts();
this.draw();
}
_initPts() {
const cx = this.W / 2, cy = this.H / 2;
const r = Math.min(this.W, this.H) * 0.30;
this.pts = [
{ x: cx, y: cy - r }, // A top
{ x: cx - r * 0.88, y: cy + r * 0.62 }, // B bottom-left
{ x: cx + r * 0.88, y: cy + r * 0.62 }, // C bottom-right
];
}
_clampPts() {
const pad = 48;
for (const p of this.pts) {
p.x = Math.max(pad, Math.min(this.W - pad, p.x));
p.y = Math.max(pad, Math.min(this.H - pad, p.y));
}
}
reset() {
this.pts = null;
this._initPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
}
/* ── pointer events ── */
_bindEvents() {
const c = this.canvas;
const pos = e => {
const r = c.getBoundingClientRect();
const s = e.touches ? e.touches[0] : e;
return { x: s.clientX - r.left, y: s.clientY - r.top };
};
const hit = p => {
if (!this.pts) return -1;
for (let i = 0; i < 3; i++) {
if (Math.hypot(p.x - this.pts[i].x, p.y - this.pts[i].y) < 20) return i;
}
return -1;
};
const drag = (p) => {
this.pts[this._dragging].x = p.x;
this.pts[this._dragging].y = p.y;
this._clampPts();
this.draw();
if (this.onUpdate) this.onUpdate(this.stats());
};
c.addEventListener('mousedown', e => {
const i = hit(pos(e));
if (i >= 0) { this._dragging = i; c.style.cursor = 'grabbing'; }
});
c.addEventListener('mousemove', e => {
const p = pos(e);
if (this._dragging !== null) { drag(p); return; }
const i = hit(p);
const was = this._hovered;
this._hovered = i >= 0 ? i : null;
c.style.cursor = i >= 0 ? 'grab' : 'default';
if (was !== this._hovered) this.draw();
});
c.addEventListener('mouseup', () => {
this._dragging = null;
c.style.cursor = this._hovered !== null ? 'grab' : 'default';
});
c.addEventListener('touchstart', e => { e.preventDefault(); const i = hit(pos(e)); if (i >= 0) this._dragging = i; }, { passive: false });
c.addEventListener('touchmove', e => { e.preventDefault(); if (this._dragging !== null) drag(pos(e)); }, { passive: false });
c.addEventListener('touchend', () => { this._dragging = null; });
}
/* ── layer toggles ── */
toggleLayer(name) { this.layers[name] = !this.layers[name]; this.draw(); }
setLayer(name, v) { this.layers[name] = v; this.draw(); }
/* ══════════════════════════════════════
Geometry helpers (canvas coords)
Scale: SCALE px = 1 unit for UI display
══════════════════════════════════════ */
static SCALE = 50; // px per unit
_len(P1, P2) { return Math.hypot(P2.x - P1.x, P2.y - P1.y); }
_sides() {
const [A, B, C] = this.pts;
return { a: this._len(B, C), b: this._len(A, C), c: this._len(A, B) };
}
_area() {
const [A, B, C] = this.pts;
return Math.abs((B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y)) / 2;
}
_angles() {
const { a, b, c } = this._sides();
const cl = v => Math.max(-1, Math.min(1, v));
const A = Math.acos(cl((b*b + c*c - a*a) / (2*b*c)));
const B = Math.acos(cl((a*a + c*c - b*b) / (2*a*c)));
const C = Math.PI - A - B;
return { A, B, C };
}
_centroid() {
const [A, B, C] = this.pts;
return { x: (A.x + B.x + C.x) / 3, y: (A.y + B.y + C.y) / 3 };
}
_circumcenter() {
const [A, B, C] = this.pts;
const D = 2 * (A.x*(B.y-C.y) + B.x*(C.y-A.y) + C.x*(A.y-B.y));
if (Math.abs(D) < 1e-8) return null;
const a2 = A.x*A.x + A.y*A.y, b2 = B.x*B.x + B.y*B.y, c2 = C.x*C.x + C.y*C.y;
return {
x: (a2*(B.y-C.y) + b2*(C.y-A.y) + c2*(A.y-B.y)) / D,
y: (a2*(C.x-B.x) + b2*(A.x-C.x) + c2*(B.x-A.x)) / D,
};
}
_circumR() {
const O = this._circumcenter();
return O ? this._len(this.pts[0], O) : 0;
}
_incenter() {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const s = a + b + c;
if (s < 1e-8) return null;
return { x: (a*A.x + b*B.x + c*C.x)/s, y: (a*A.y + b*B.y + c*C.y)/s };
}
_inR() {
const { a, b, c } = this._sides();
const s = a + b + c;
return s < 1e-8 ? 0 : (2 * this._area()) / s;
}
_orthocenter() {
const [A, B, C] = this.pts;
const bcDx = C.x - B.x, bcDy = C.y - B.y;
const acDx = C.x - A.x, acDy = C.y - A.y;
const denom = bcDy * (-acDx) - (-bcDx) * acDy;
if (Math.abs(denom) < 1e-8) return null;
const t = ((B.x-A.x)*(-acDy) - (B.y-A.y)*(-acDx)) / denom;
return { x: A.x + t*bcDy, y: A.y - t*bcDx };
}
_foot(P, L1, L2) {
const dx = L2.x - L1.x, dy = L2.y - L1.y;
const l2 = dx*dx + dy*dy;
if (l2 < 1e-10) return { ...L1 };
const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2;
return { x: L1.x + t*dx, y: L1.y + t*dy };
}
_mid(P1, P2) { return { x: (P1.x+P2.x)/2, y: (P1.y+P2.y)/2 }; }
/* bisector foot: divides opposite side by angle-bisector theorem */
_bisFoot(V, L1, L2) {
const d1 = this._len(V, L1), d2 = this._len(V, L2);
const s = d1 + d2;
if (s < 1e-8) return { ...L1 };
return { x: (d2*L1.x + d1*L2.x)/s, y: (d2*L1.y + d1*L2.y)/s };
}
stats() {
const S = TriangleSim.SCALE;
const { a, b, c } = this._sides();
const { A, B, C } = this._angles();
const area = this._area();
const perim = a + b + c;
const R = this._circumR();
const r = this._inR();
const deg = rad => rad * 180 / Math.PI;
const dA = deg(A), dB = deg(B), dC = deg(C);
let type = '';
const eps = 1.8; // degrees tolerance
const isRight = [dA, dB, dC].some(d => Math.abs(d - 90) < eps);
const isObtuse = [dA, dB, dC].some(d => d > 90 + eps);
const sidesArr = [a, b, c].sort((x, y) => x - y);
const isEquil = sidesArr[2] - sidesArr[0] < 2;
const isIsoc = !isEquil && (
Math.abs(a - b) < 2 || Math.abs(b - c) < 2 || Math.abs(a - c) < 2
);
if (isEquil) type = 'Равносторонний';
else if (isRight) type = isIsoc ? 'Прямоугольный равнобедр.' : 'Прямоугольный';
else if (isObtuse) type = isIsoc ? 'Тупоугольный равнобедр.' : 'Тупоугольный';
else type = isIsoc ? 'Остроугольный равнобедр.' : 'Остроугольный';
return {
a: a/S, b: b/S, c: c/S,
A: dA, B: dB, C: dC,
S: area / (S*S),
perim: perim / S,
R: R / S,
r: r / S,
type,
};
}
/* ══════════════════════════════════════
Drawing
══════════════════════════════════════ */
draw() {
const ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H || !this.pts) return;
ctx.clearRect(0, 0, W, H);
// Background
const bg = ctx.createLinearGradient(0, 0, W, H);
bg.addColorStop(0, '#07071A');
bg.addColorStop(1, '#0D1830');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
if (this.layers.grid) this._drawGrid(ctx, W, H);
// Layer order: circles behind <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> fill <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> construction lines <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> edges <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> vertices <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> labels
if (this.layers.circumcircle) this._drawCircumcircle(ctx);
if (this.layers.incircle) this._drawIncircle(ctx);
this._drawFill(ctx);
if (this.layers.medians) this._drawMedians(ctx);
if (this.layers.altitudes) this._drawAltitudes(ctx);
if (this.layers.bisectors) this._drawBisectors(ctx);
if (this.layers.eulerLine) this._drawEulerLine(ctx);
if (this.layers.pythagorean) this._drawPythagorean(ctx);
if (this.layers.sineLaw) this._drawSineLaw(ctx);
if (this.layers.cosineLaw) this._drawCosineLaw(ctx);
this._drawAngleArcs(ctx);
this._drawEdges(ctx);
this._drawRightAngleMark(ctx);
this._drawVertices(ctx);
this._drawSideLabels(ctx);
this._drawAngleLabels(ctx);
}
/* helpers */
_line(ctx, x1, y1, x2, y2) {
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
_dot(ctx, x, y, r, fill, shadow) {
ctx.save();
ctx.shadowColor = shadow || fill; ctx.shadowBlur = 12;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2);
ctx.fillStyle = fill; ctx.fill(); ctx.restore();
}
_label(ctx, text, x, y, color, size=13) {
ctx.save();
ctx.font = `bold ${size}px Manrope, sans-serif`;
ctx.fillStyle = color;
ctx.shadowColor = color; ctx.shadowBlur = 8;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, x, y); ctx.restore();
}
/* ── grid ── */
_drawGrid(ctx, W, H) {
const step = 50;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.045)'; ctx.lineWidth = 1;
for (let x = 0; x <= W; x += step) this._line(ctx, x, 0, x, H);
for (let y = 0; y <= H; y += step) this._line(ctx, 0, y, W, y);
// axes
ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.2;
this._line(ctx, 0, H/2, W, H/2);
this._line(ctx, W/2, 0, W/2, H);
ctx.restore();
}
/* ── triangle fill ── */
_drawFill(ctx) {
const [A, B, C] = this.pts;
ctx.save();
const g = ctx.createLinearGradient(A.x, A.y, (B.x+C.x)/2, (B.y+C.y)/2);
g.addColorStop(0, 'rgba(155,93,229,0.20)');
g.addColorStop(1, 'rgba(6,214,224,0.07)');
ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath();
ctx.fillStyle = g; ctx.fill();
ctx.restore();
}
/* ── edges ── */
_drawEdges(ctx) {
const [A, B, C] = this.pts;
ctx.save();
ctx.shadowColor = 'rgba(155,93,229,0.55)'; ctx.shadowBlur = 10;
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath(); ctx.stroke();
ctx.restore();
}
/* ── vertices ── */
_drawVertices(ctx) {
const names = ['A', 'B', 'C'];
const colors = ['#9B5DE5', '#06D6E0', '#F15BB5'];
this.pts.forEach((p, i) => {
const active = this._hovered === i || this._dragging === i;
const col = colors[i];
ctx.save();
ctx.shadowColor = col; ctx.shadowBlur = active ? 24 : 14;
ctx.beginPath(); ctx.arc(p.x, p.y, active ? 13 : 10, 0, Math.PI*2);
ctx.fillStyle = active ? col : 'rgba(7,7,26,0.92)';
ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.stroke();
if (!active) {
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI*2);
ctx.fillStyle = col; ctx.shadowBlur = 0; ctx.fill();
}
ctx.restore();
});
}
/* ── vertex name labels (outside) ── */
_drawSideLabels(ctx) {
const [A, B, C] = this.pts;
// sides: a=BC, b=AC, c=AB
const sides = [
{ from: B, to: C, label: 'a', col: '#9B5DE5' },
{ from: A, to: C, label: 'b', col: '#06D6E0' },
{ from: A, to: B, label: 'c', col: '#F15BB5' },
];
ctx.save();
sides.forEach(({ from, to, label, col }) => {
const mx = (from.x+to.x)/2, my = (from.y+to.y)/2;
const dx = to.x-from.x, dy = to.y-from.y;
const len = Math.hypot(dx, dy); if (len < 1) return;
// perpendicular outward — pick side toward centroid and invert
const cx_ = (this.pts[0].x+this.pts[1].x+this.pts[2].x)/3;
const cy_ = (this.pts[0].y+this.pts[1].y+this.pts[2].y)/3;
let nx = -dy/len, ny = dx/len;
if ((mx+nx*10-cx_)*nx + (my+ny*10-cy_)*ny < 0) { nx=-nx; ny=-ny; }
this._label(ctx, label, mx + nx*20, my + ny*20, col, 14);
});
ctx.restore();
}
/* ── vertex A/B/C labels ── */
_drawAngleLabels(ctx) {
const names = ['A', 'B', 'C'];
const colors = ['#9B5DE5', '#06D6E0', '#F15BB5'];
const nexts = [this.pts[1], this.pts[2], this.pts[0]];
const prevs = [this.pts[2], this.pts[0], this.pts[1]];
this.pts.forEach((V, i) => {
const P = nexts[i], Q = prevs[i];
// direction from vertex away from triangle (outward bisector)
const dp = { x: P.x-V.x, y: P.y-V.y }; const lp = Math.hypot(dp.x, dp.y);
const dq = { x: Q.x-V.x, y: Q.y-V.y }; const lq = Math.hypot(dq.x, dq.y);
if (lp < 1 || lq < 1) return;
// outward: negate inward bisector
const bx = dp.x/lp + dq.x/lq, by = dp.y/lp + dq.y/lq;
const bl = Math.hypot(bx, by) || 1;
const ox = -bx/bl * 26, oy = -by/bl * 26;
this._label(ctx, names[i], V.x + ox, V.y + oy, colors[i], 15);
});
}
/* ── angle arcs ── */
_drawAngleArcs(ctx) {
const nexts = [this.pts[1], this.pts[2], this.pts[0]];
const prevs = [this.pts[2], this.pts[0], this.pts[1]];
const colors= ['rgba(155,93,229,0.7)','rgba(6,214,224,0.7)','rgba(241,91,181,0.7)'];
const fills = ['rgba(155,93,229,0.12)','rgba(6,214,224,0.12)','rgba(241,91,181,0.12)'];
ctx.save();
this.pts.forEach((V, i) => {
const P = nexts[i], Q = prevs[i];
const r = 30;
const a1 = Math.atan2(P.y-V.y, P.x-V.x);
const a2 = Math.atan2(Q.y-V.y, Q.x-V.x);
let diff = a2 - a1;
while (diff > Math.PI) diff -= 2*Math.PI;
while (diff < -Math.PI) diff += 2*Math.PI;
const ccw = diff < 0;
ctx.strokeStyle = colors[i]; ctx.lineWidth = 1.5;
ctx.fillStyle = fills[i];
ctx.beginPath();
ctx.moveTo(V.x, V.y);
ctx.arc(V.x, V.y, r, a1, a2, ccw);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(V.x, V.y, r, a1, a2, ccw);
ctx.stroke();
});
ctx.restore();
}
/* ── right angle mark ── */
_drawRightAngleMark(ctx) {
const { A, B, C } = this._angles();
const verts = this.pts;
const angles = [A, B, C];
const nexts = [verts[1], verts[2], verts[0]];
const prevs = [verts[2], verts[0], verts[1]];
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5;
verts.forEach((V, i) => {
if (Math.abs(angles[i] - Math.PI/2) > 0.035) return;
const P = nexts[i], Q = prevs[i];
const sz = 14;
const lp = Math.hypot(P.x-V.x, P.y-V.y), lq = Math.hypot(Q.x-V.x, Q.y-V.y);
if (lp < 1 || lq < 1) return;
const d1 = { x:(P.x-V.x)/lp*sz, y:(P.y-V.y)/lp*sz };
const d2 = { x:(Q.x-V.x)/lq*sz, y:(Q.y-V.y)/lq*sz };
ctx.beginPath();
ctx.moveTo(V.x+d1.x, V.y+d1.y);
ctx.lineTo(V.x+d1.x+d2.x, V.y+d1.y+d2.y);
ctx.lineTo(V.x+d2.x, V.y+d2.y);
ctx.stroke();
});
ctx.restore();
}
/* ── medians (green) ── */
_drawMedians(ctx) {
const [A, B, C] = this.pts;
const mA = this._mid(B, C), mB = this._mid(A, C), mC = this._mid(A, B);
const G = this._centroid();
ctx.save();
ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#22d55e'; ctx.shadowBlur = 7;
[[A, mA],[B, mB],[C, mC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
// midpoint ticks
[mA, mB, mC].forEach(m => this._dot(ctx, m.x, m.y, 4, '#22d55e'));
// centroid
this._dot(ctx, G.x, G.y, 7, '#22d55e');
this._label(ctx, 'G', G.x + 14, G.y - 8, '#22d55e', 13);
ctx.restore();
}
/* ── altitudes (amber) ── */
_drawAltitudes(ctx) {
const [A, B, C] = this.pts;
const fA = this._foot(A, B, C);
const fB = this._foot(B, A, C);
const fC = this._foot(C, A, B);
const H = this._orthocenter();
ctx.save();
ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 7;
[[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
// foot right-angle marks
[[A, B, C, fA],[B, A, C, fB],[C, A, B, fC]].forEach(([P, L1, L2, ft]) => {
const lp = Math.hypot(P.x-ft.x, P.y-ft.y);
const ll = Math.hypot(L2.x-L1.x, L2.y-L1.y);
if (lp < 1 || ll < 1) return;
const sz = 9;
const d1 = { x:(P.x-ft.x)/lp*sz, y:(P.y-ft.y)/lp*sz };
const d2 = { x:(L2.x-L1.x)/ll*sz, y:(L2.y-L1.y)/ll*sz };
ctx.strokeStyle = 'rgba(245,158,11,0.6)'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(ft.x+d1.x, ft.y+d1.y);
ctx.lineTo(ft.x+d1.x+d2.x, ft.y+d1.y+d2.y);
ctx.lineTo(ft.x+d2.x, ft.y+d2.y);
ctx.stroke();
});
[fA, fB, fC].forEach(f => this._dot(ctx, f.x, f.y, 4, '#f59e0b'));
if (H) {
this._dot(ctx, H.x, H.y, 7, '#f59e0b');
this._label(ctx, 'H', H.x + 14, H.y - 8, '#f59e0b', 13);
}
ctx.restore();
}
/* ── bisectors (pink) ── */
_drawBisectors(ctx) {
const [A, B, C] = this.pts;
const fA = this._bisFoot(A, B, C);
const fB = this._bisFoot(B, A, C);
const fC = this._bisFoot(C, A, B);
const I = this._incenter();
ctx.save();
ctx.strokeStyle = '#ec4899'; ctx.lineWidth = 1.8;
ctx.setLineDash([6, 4]); ctx.shadowColor = '#ec4899'; ctx.shadowBlur = 7;
[[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => {
ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke();
});
ctx.setLineDash([]);
if (I) {
this._dot(ctx, I.x, I.y, 7, '#ec4899');
this._label(ctx, 'I', I.x + 14, I.y - 8, '#ec4899', 13);
}
ctx.restore();
}
/* ── circumscribed circle (pink dashed) ── */
_drawCircumcircle(ctx) {
const O = this._circumcenter();
if (!O) return;
const R = this._circumR();
ctx.save();
ctx.strokeStyle = 'rgba(241,91,181,0.75)'; ctx.lineWidth = 1.8;
ctx.setLineDash([7, 4]); ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
// center O
this._dot(ctx, O.x, O.y, 5, '#F15BB5');
this._label(ctx, 'O', O.x + 10, O.y - 10, '#F15BB5', 12);
// radii (faint)
ctx.strokeStyle = 'rgba(241,91,181,0.18)'; ctx.lineWidth = 1;
this.pts.forEach(P => {
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(P.x, P.y); ctx.stroke();
});
ctx.restore();
}
/* ── inscribed circle (cyan dashed) ── */
_drawIncircle(ctx) {
const I = this._incenter();
if (!I) return;
const r = this._inR();
ctx.save();
ctx.strokeStyle = 'rgba(6,214,224,0.75)'; ctx.lineWidth = 1.8;
ctx.setLineDash([7, 4]); ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 10;
ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2);
ctx.fillStyle = 'rgba(6,214,224,0.05)'; ctx.fill();
this._dot(ctx, I.x, I.y, 5, '#06D6E0');
ctx.restore();
}
/* ── Euler line: O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> G <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H ── */
_drawEulerLine(ctx) {
const O = this._circumcenter();
const G = this._centroid();
const H = this._orthocenter();
if (!O || !H) return;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,100,0.5)'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]); ctx.shadowColor = 'rgba(255,255,100,0.4)'; ctx.shadowBlur = 6;
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(H.x, H.y); ctx.stroke();
ctx.setLineDash([]);
// Label
const mx = (O.x + H.x)/2 + 16, my = (O.y + H.y)/2 - 8;
ctx.font = '11px Manrope, sans-serif';
ctx.fillStyle = 'rgba(255,255,100,0.6)';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText('прямая Эйлера', mx, my);
ctx.restore();
}
/* ══════════════════════════════════════════════════════
THEOREM VISUALIZATIONS
══════════════════════════════════════════════════════ */
/* ── Law of Sines: a/sinA = b/sinB = c/sinC = 2R ── */
_drawSineLaw(ctx) {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const angles = this._angles();
const S = TriangleSim.SCALE;
const O = this._circumcenter();
const R = this._circumR();
if (!O || R < 1) return;
ctx.save();
// Draw circumscribed circle (faint, if not already enabled)
if (!this.layers.circumcircle) {
ctx.strokeStyle = 'rgba(96,165,250,0.3)'; ctx.lineWidth = 1.2;
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
this._dot(ctx, O.x, O.y, 3, 'rgba(96,165,250,0.5)');
}
// Draw radii from O to each vertex with labels
const verts = [A, B, C];
const sideNames = ['a', 'b', 'c'];
const angNames = ['A', 'B', 'C'];
const angVals = [angles.A, angles.B, angles.C];
const sideVals = [a, b, c];
const colors = ['#60a5fa', '#34d399', '#fbbf24'];
// Draw radius lines from center to vertices
ctx.strokeStyle = 'rgba(96,165,250,0.25)'; ctx.lineWidth = 1;
verts.forEach(v => {
ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(v.x, v.y); ctx.stroke();
});
// Compute 2R
const twoR = 2 * R / S;
// For each side, show a/sinA value annotation near the side midpoint
for (let i = 0; i < 3; i++) {
const ratio = (sideVals[i] / S) / Math.sin(angVals[i]);
const from = verts[(i + 1) % 3], to = verts[(i + 2) % 3];
const mx = (from.x + to.x) / 2, my = (from.y + to.y) / 2;
// offset outward from centroid
const cx_ = (A.x + B.x + C.x) / 3, cy_ = (A.y + B.y + C.y) / 3;
const dx = mx - cx_, dy = my - cy_;
const dl = Math.hypot(dx, dy) || 1;
const ox = dx / dl * 38, oy = dy / dl * 38;
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.fillStyle = colors[i];
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = colors[i]; ctx.shadowBlur = 4;
ctx.fillText(`${sideNames[i]}/sin${angNames[i]} = ${ratio.toFixed(2)}`, mx + ox, my + oy);
ctx.shadowBlur = 0;
}
// Formula box at bottom
this._drawFormulaBox(ctx, this.W, this.H,
`a/sinA = b/sinB = c/sinC = 2R = ${twoR.toFixed(2)}`,
'#60a5fa');
ctx.restore();
}
/* ── Law of Cosines: c² = a² + b² 2ab·cosC ── */
_drawCosineLaw(ctx) {
const [A, B, C] = this.pts;
const sides = this._sides();
const angles = this._angles();
const S = TriangleSim.SCALE;
// Pick the largest angle to demonstrate
const angArr = [angles.A, angles.B, angles.C];
const maxIdx = angArr.indexOf(Math.max(...angArr));
const angVertex = this.pts[maxIdx];
const oppFrom = this.pts[(maxIdx + 1) % 3];
const oppTo = this.pts[(maxIdx + 2) % 3];
const sNames = ['a', 'b', 'c'];
const aNames = ['A', 'B', 'C'];
const sVals = [sides.a, sides.b, sides.c];
// The opposite side to the chosen angle
const oppSide = sVals[maxIdx] / S;
const adjSide1Name = sNames[(maxIdx + 1) % 3]; // side opposite to vertex (maxIdx+1)
const adjSide2Name = sNames[(maxIdx + 2) % 3]; // side opposite to vertex (maxIdx+2)
const adjSide1 = sVals[(maxIdx + 1) % 3] / S;
const adjSide2 = sVals[(maxIdx + 2) % 3] / S;
const oppSideName = sNames[maxIdx];
const angName = aNames[maxIdx];
const angDeg = angArr[maxIdx] * 180 / Math.PI;
ctx.save();
// Highlight the two adjacent sides with thicker lines
ctx.lineWidth = 3.5; ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(251,191,36,0.7)';
ctx.shadowColor = '#fbbf24'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(angVertex.x, angVertex.y); ctx.lineTo(oppFrom.x, oppFrom.y); ctx.stroke();
ctx.strokeStyle = 'rgba(52,211,153,0.7)';
ctx.shadowColor = '#34d399'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(angVertex.x, angVertex.y); ctx.lineTo(oppTo.x, oppTo.y); ctx.stroke();
// Highlight the opposite side
ctx.strokeStyle = 'rgba(239,71,111,0.7)';
ctx.shadowColor = '#EF476F'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.moveTo(oppFrom.x, oppFrom.y); ctx.lineTo(oppTo.x, oppTo.y); ctx.stroke();
ctx.shadowBlur = 0;
// Angle arc highlight at the chosen vertex
const r = 36;
const a1 = Math.atan2(oppFrom.y - angVertex.y, oppFrom.x - angVertex.x);
const a2 = Math.atan2(oppTo.y - angVertex.y, oppTo.x - angVertex.x);
let diff = a2 - a1;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
const ccw = diff < 0;
ctx.fillStyle = 'rgba(251,191,36,0.15)';
ctx.beginPath();
ctx.moveTo(angVertex.x, angVertex.y);
ctx.arc(angVertex.x, angVertex.y, r, a1, a2, ccw);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(251,191,36,0.6)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(angVertex.x, angVertex.y, r, a1, a2, ccw); ctx.stroke();
// Angle label
const aMid = a1 + diff / 2;
ctx.font = 'bold 12px Manrope, sans-serif';
ctx.fillStyle = '#fbbf24'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(`${angDeg.toFixed(1)}°`, angVertex.x + Math.cos(aMid) * 50, angVertex.y + Math.sin(aMid) * 50);
// Compute c² vs a²+b²-2ab·cosC
const c2 = oppSide ** 2;
const check = adjSide1 ** 2 + adjSide2 ** 2 - 2 * adjSide1 * adjSide2 * Math.cos(angArr[maxIdx]);
// Formula
this._drawFormulaBox(ctx, this.W, this.H,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² ${adjSide1Name}·${adjSide2Name}·cos${angName} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${c2.toFixed(2)} = ${check.toFixed(2)}`,
'#fbbf24');
ctx.restore();
}
/* ── Pythagorean theorem: c² = a² + b² ── */
_drawPythagorean(ctx) {
const [A, B, C] = this.pts;
const { a, b, c } = this._sides();
const angles = this._angles();
const S = TriangleSim.SCALE;
// Find the largest angle (closest to being right)
const angArr = [angles.A, angles.B, angles.C];
const maxIdx = angArr.indexOf(Math.max(...angArr));
const maxAngle = angArr[maxIdx];
const isRight = Math.abs(maxAngle - Math.PI / 2) < 0.035;
const hyp = this.pts[maxIdx]; // vertex at the largest angle
const p1 = this.pts[(maxIdx + 1) % 3];
const p2 = this.pts[(maxIdx + 2) % 3];
// Side lengths (the side opposite the right angle is the hypotenuse)
const sNames = ['a', 'b', 'c'];
const sVals = [a / S, b / S, c / S];
const hypSide = sVals[maxIdx];
const leg1 = sVals[(maxIdx + 1) % 3];
const leg2 = sVals[(maxIdx + 2) % 3];
const hypName = sNames[maxIdx];
const leg1Name = sNames[(maxIdx + 1) % 3];
const leg2Name = sNames[(maxIdx + 2) % 3];
ctx.save();
// Draw squares on each side
this._drawSideSquare(ctx, p1, p2, '#EF476F', 0.12); // hypotenuse
this._drawSideSquare(ctx, hyp, p2, '#06D6E0', 0.12); // leg 1
this._drawSideSquare(ctx, hyp, p1, '#9B5DE5', 0.12); // leg 2
// Labels on squares with areas
const hypArea = hypSide ** 2;
const leg1Area = leg1 ** 2;
const leg2Area = leg2 ** 2;
this._labelSquare(ctx, p1, p2, `${hypName}² = ${hypArea.toFixed(2)}`, '#EF476F');
this._labelSquare(ctx, hyp, p2, `${leg1Name}² = ${leg1Area.toFixed(2)}`, '#06D6E0');
this._labelSquare(ctx, hyp, p1, `${leg2Name}² = ${leg2Area.toFixed(2)}`, '#9B5DE5');
// Status: how close to Pythagorean
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
const statusCol = isRight ? '#22d55e' : '#f59e0b';
const statusText = isRight
? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${leg1Name}² + ${leg2Name}² = ${hypName}² (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)}${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
// Hint if not right angle
if (!isRight) {
ctx.font = '11px Manrope, sans-serif';
ctx.fillStyle = 'rgba(245,158,11,0.7)';
ctx.textAlign = 'center';
ctx.fillText('Перетащи вершину чтобы ∠ ≈ 90°', this.W / 2, this.H - 16);
}
ctx.restore();
}
/* ── Helpers for theorem visuals ── */
_drawSideSquare(ctx, p1, p2, color, alpha) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
// perpendicular direction (outward from triangle centroid)
const cx_ = (this.pts[0].x + this.pts[1].x + this.pts[2].x) / 3;
const cy_ = (this.pts[0].y + this.pts[1].y + this.pts[2].y) / 3;
let nx = -dy, ny = dx; // perpendicular
// ensure outward
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
if ((mx + nx * 0.1 - cx_) * nx + (my + ny * 0.1 - cy_) * ny < 0) { nx = -nx; ny = -ny; }
const q1 = { x: p1.x + nx, y: p1.y + ny };
const q2 = { x: p2.x + nx, y: p2.y + ny };
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.lineTo(q2.x, q2.y);
ctx.lineTo(q1.x, q1.y);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.lineTo(q2.x, q2.y);
ctx.lineTo(q1.x, q1.y);
ctx.closePath();
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
}
_labelSquare(ctx, p1, p2, text, color) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const cx_ = (this.pts[0].x + this.pts[1].x + this.pts[2].x) / 3;
const cy_ = (this.pts[0].y + this.pts[1].y + this.pts[2].y) / 3;
let nx = -dy, ny = dx;
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
if ((mx + nx * 0.1 - cx_) * nx + (my + ny * 0.1 - cy_) * ny < 0) { nx = -nx; ny = -ny; }
const center = { x: mx + nx * 0.5, y: my + ny * 0.5 };
ctx.save();
ctx.font = 'bold 11px Manrope, sans-serif';
ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.7)'; ctx.shadowBlur = 4;
ctx.fillText(text, center.x, center.y);
ctx.restore();
}
_drawFormulaBox(ctx, W, H, text, color) {
ctx.save();
const bw = ctx.measureText(text).width || 300;
const pad = 12;
const boxW = Math.min(W - 40, bw + pad * 2 + 20);
const boxH = 28;
const bx = (W - boxW) / 2;
const by = H - 42;
ctx.font = 'bold 12px Manrope, monospace';
// background
ctx.fillStyle = 'rgba(7,7,26,0.85)';
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.globalAlpha = 0.9;
const r = 8;
ctx.beginPath();
ctx.moveTo(bx + r, by); ctx.lineTo(bx + boxW - r, by);
ctx.quadraticCurveTo(bx + boxW, by, bx + boxW, by + r);
ctx.lineTo(bx + boxW, by + boxH - r);
ctx.quadraticCurveTo(bx + boxW, by + boxH, bx + boxW - r, by + boxH);
ctx.lineTo(bx + r, by + boxH);
ctx.quadraticCurveTo(bx, by + boxH, bx, by + boxH - r);
ctx.lineTo(bx, by + r);
ctx.quadraticCurveTo(bx, by, bx + r, by);
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.globalAlpha = 1;
// text
ctx.fillStyle = color;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = color; ctx.shadowBlur = 6;
ctx.fillText(text, W / 2, by + boxH / 2);
ctx.restore();
}
}
+969
View File
@@ -0,0 +1,969 @@
'use strict';
/* ═══════════════════════════════════════════════════════════════════════
TrigCircleSim — premium interactive unit-circle + graph visualisation
v3 — maximum polish
═══════════════════════════════════════════════════════════════════════ */
const _TC_NOTABLE = [
{ a: 0, l: '0', d: '0°' },
{ a: Math.PI / 6, l: 'π/6', d: '30°' },
{ a: Math.PI / 4, l: 'π/4', d: '45°' },
{ a: Math.PI / 3, l: 'π/3', d: '60°' },
{ a: Math.PI / 2, l: 'π/2', d: '90°' },
{ a: 2*Math.PI / 3, l: '2π/3', d: '120°' },
{ a: 3*Math.PI / 4, l: '3π/4', d: '135°' },
{ a: 5*Math.PI / 6, l: '5π/6', d: '150°' },
{ a: Math.PI, l: 'π', d: '180°' },
{ a: 7*Math.PI / 6, l: '7π/6', d: '210°' },
{ a: 5*Math.PI / 4, l: '5π/4', d: '225°' },
{ a: 4*Math.PI / 3, l: '4π/3', d: '240°' },
{ a: 3*Math.PI / 2, l: '3π/2', d: '270°' },
{ a: 5*Math.PI / 3, l: '5π/3', d: '300°' },
{ a: 7*Math.PI / 4, l: '7π/4', d: '315°' },
{ a: 11*Math.PI / 6, l: '11π/6', d: '330°' },
];
const _TC = {
sin: '#EF476F', cos: '#06D6E0', tan: '#FFD166', cot: '#7BF5A4',
point: '#9B5DE5', violet: '#9B5DE5',
};
function _tcRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function _tcRgba(hex, a) {
const [r, g, b] = _tcRgb(hex);
return `rgba(${r},${g},${b},${a})`;
}
class TrigCircleSim {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0; this.dpr = 1;
this.angle = Math.PI / 4;
this.showSin = true;
this.showCos = true;
this.showTan = false;
this.showCot = false;
this.showGraph = true;
this.graphFn = 'sin';
this.snapToNotable = true;
this.animating = false;
this._cx = 0; this._cy = 0; this._r = 0;
this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0;
this._drag = false;
this._hover = false;
this._raf = null;
this._animTarget = null;
this._animSpeed = 3;
this._idlePulse = 0;
this._idleRaf = null;
/* snap particles */
this._particles = [];
this._lastSnap = -1;
this.onUpdate = null;
this._bindEvents();
this._ro = new ResizeObserver(() => { this.fit(); this.draw(); });
this._ro.observe(canvas.parentElement);
}
/* ═══ Public ═══════════════════════════════════════════════════════ */
fit() {
const p = this.canvas.parentElement.getBoundingClientRect();
this.dpr = window.devicePixelRatio || 1;
this.W = p.width || 800; this.H = p.height || 500;
this.canvas.width = this.W * this.dpr;
this.canvas.height = this.H * this.dpr;
this.canvas.style.width = this.W + 'px';
this.canvas.style.height = this.H + 'px';
this._layout();
}
draw() {
const c = this.ctx;
c.save(); c.scale(this.dpr, this.dpr);
c.clearRect(0, 0, this.W, this.H);
this._drawBg(c);
this._drawCircle(c);
if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); }
this._drawParticles(c);
c.restore();
this._fireUpdate();
}
setAngle(a) { this.angle = this._norm(a); this.draw(); }
setGraphFn(f){ this.graphFn = f; this.draw(); }
toggleLayer(n, v) {
if (n === 'sin') this.showSin = v;
if (n === 'cos') this.showCos = v;
if (n === 'tan') this.showTan = v;
if (n === 'cot') this.showCot = v;
if (n === 'graph') this.showGraph = v;
this._layout(); this.draw();
}
goToAngle(rad) {
this._animTarget = this._norm(rad);
if (!this.animating) this._startAnim();
}
start() { this._startIdle(); }
stop() { this._stopAnim(); this._stopIdle(); }
stats() {
const a = this.angle, s = Math.sin(a), co = Math.cos(a);
const t = Math.abs(co) > 1e-9 ? s / co : undefined;
const ct = Math.abs(s) > 1e-9 ? co / s : undefined;
const deg = a * 180 / Math.PI;
const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4;
return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q };
}
/* ═══ Layout ═══════════════════════════════════════════════════════ */
_layout() {
const m = 44;
if (this.showGraph) {
const cW = this.W * 0.50;
this._r = Math.min(cW - m * 2, this.H - m * 2) / 2 * 0.76;
this._cx = cW / 2;
this._cy = this.H / 2;
this._gx = cW + 24;
this._gw = this.W - this._gx - m;
this._gh = this.H - m * 2;
this._gy = m;
} else {
this._r = Math.min(this.W - m * 2, this.H - m * 2) / 2 * 0.76;
this._cx = this.W / 2;
this._cy = this.H / 2;
}
this._r = Math.max(55, this._r);
}
/* ═══ Background ═══════════════════════════════════════════════════ */
_drawBg(c) {
const g = c.createRadialGradient(this._cx, this._cy, 0, this._cx, this._cy, this._r * 2.4);
g.addColorStop(0, 'rgba(155,93,229,0.055)');
g.addColorStop(0.5,'rgba(155,93,229,0.02)');
g.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = g; c.fillRect(0, 0, this.W, this.H);
/* decorative rings */
c.strokeStyle = 'rgba(255,255,255,0.016)'; c.lineWidth = 1;
for (let i = 1; i <= 3; i++) {
c.beginPath(); c.arc(this._cx, this._cy, this._r * (0.5 + i * 0.35), 0, Math.PI * 2);
c.stroke();
}
}
/* ═══ Unit Circle ══════════════════════════════════════════════════ */
_drawCircle(c) {
const cx = this._cx, cy = this._cy, r = this._r;
const a = this.angle;
const cosA = Math.cos(a), sinA = Math.sin(a);
const px = cx + r * cosA, py = cy - r * sinA;
const ext = Math.min(55, r * 0.35);
/* ── quadrant soft fill ── */
const q = this.stats().quadrant;
const qS = [0, Math.PI/2, Math.PI, 3*Math.PI/2][q-1];
c.fillStyle = 'rgba(155,93,229,0.022)';
c.beginPath(); c.moveTo(cx, cy);
c.arc(cx, cy, r, -(qS + Math.PI/2), -qS);
c.closePath(); c.fill();
/* ── degree tick marks (every 10°, bigger every 30°) ── */
for (let deg = 0; deg < 360; deg += 10) {
const rad = deg * Math.PI / 180;
const big = deg % 30 === 0;
const len = big ? 8 : 4;
const x1 = cx + (r - len) * Math.cos(rad);
const y1 = cy - (r - len) * Math.sin(rad);
const x2 = cx + r * Math.cos(rad);
const y2 = cy - r * Math.sin(rad);
c.strokeStyle = big ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.05)';
c.lineWidth = big ? 1.5 : 1;
c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
}
/* ── axes (gradient fade) ── */
const axGrad = (x1,y1,x2,y2) => {
const g = c.createLinearGradient(x1,y1,x2,y2);
g.addColorStop(0, 'rgba(255,255,255,0.0)');
g.addColorStop(0.08,'rgba(255,255,255,0.30)');
g.addColorStop(0.5, 'rgba(255,255,255,0.50)');
g.addColorStop(0.92,'rgba(255,255,255,0.30)');
g.addColorStop(1, 'rgba(255,255,255,0.0)');
return g;
};
c.lineWidth = 1.5;
c.strokeStyle = axGrad(cx - r - ext, cy, cx + r + ext, cy);
c.beginPath(); c.moveTo(cx - r - ext, cy); c.lineTo(cx + r + ext, cy); c.stroke();
c.strokeStyle = axGrad(cx, cy + r + ext, cx, cy - r - ext);
c.beginPath(); c.moveTo(cx, cy + r + ext); c.lineTo(cx, cy - r - ext); c.stroke();
/* arrows */
this._arrowH(c, cx + r + ext, cy, 0, 'rgba(255,255,255,0.5)');
this._arrowH(c, cx, cy - r - ext, -Math.PI/2, 'rgba(255,255,255,0.5)');
/* axis labels */
c.font = '700 13px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.45)';
c.textAlign = 'left'; c.textBaseline = 'top';
c.fillText('x', cx + r + ext - 12, cy + 8);
c.textAlign = 'right'; c.textBaseline = 'bottom';
c.fillText('y', cx - 10, cy - r - ext + 16);
/* ±1 ticks & labels */
c.strokeStyle = 'rgba(255,255,255,0.30)'; c.lineWidth = 1.5;
c.font = '600 11px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.45)';
const tk = 6;
c.beginPath(); c.moveTo(cx+r, cy-tk); c.lineTo(cx+r, cy+tk); c.stroke();
c.textAlign='center'; c.textBaseline='top'; c.fillText('1', cx+r, cy+9);
c.beginPath(); c.moveTo(cx-r, cy-tk); c.lineTo(cx-r, cy+tk); c.stroke();
c.fillText('1', cx-r, cy+9);
c.beginPath(); c.moveTo(cx-tk, cy-r); c.lineTo(cx+tk, cy-r); c.stroke();
c.textAlign='right'; c.textBaseline='middle'; c.fillText('1', cx-10, cy-r);
c.beginPath(); c.moveTo(cx-tk, cy+r); c.lineTo(cx+tk, cy+r); c.stroke();
c.fillText('1', cx-10, cy+r);
/* origin dot */
c.fillStyle = 'rgba(255,255,255,0.35)';
c.beginPath(); c.arc(cx, cy, 2.5, 0, Math.PI*2); c.fill();
/* ── unit circle (multi-layer) ── */
c.strokeStyle = _tcRgba(_TC.violet, 0.05 + Math.sin(this._idlePulse) * 0.02);
c.lineWidth = 14;
c.beginPath(); c.arc(cx, cy, r, 0, Math.PI*2); c.stroke();
c.strokeStyle = 'rgba(255,255,255,0.13)'; c.lineWidth = 2;
c.beginPath(); c.arc(cx, cy, r, 0, Math.PI*2); c.stroke();
/* ── notable angle dots + labels ── */
for (const n of _TC_NOTABLE) {
const nx = cx + r * Math.cos(n.a), ny = cy - r * Math.sin(n.a);
const act = Math.abs(a - n.a) < 0.03;
if (act) {
c.fillStyle = _tcRgba(_TC.violet, 0.5);
c.shadowColor = _TC.violet; c.shadowBlur = 10;
c.beginPath(); c.arc(nx, ny, 5, 0, Math.PI*2); c.fill();
c.shadowBlur = 0;
c.strokeStyle = _TC.violet; c.lineWidth = 1.5;
c.beginPath(); c.arc(nx, ny, 5, 0, Math.PI*2); c.stroke();
} else {
c.fillStyle = 'rgba(255,255,255,0.12)';
c.beginPath(); c.arc(nx, ny, 2.5, 0, Math.PI*2); c.fill();
}
if (n.l && n.l !== '0') {
const d = act ? 24 : 20;
const lx = cx + (r + d) * Math.cos(n.a);
const ly = cy - (r + d) * Math.sin(n.a);
c.font = act ? '700 11px Manrope,sans-serif' : '400 9px Manrope,sans-serif';
c.fillStyle = act ? _tcRgba(_TC.violet, 0.95) : 'rgba(255,255,255,0.18)';
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(n.l, lx, ly);
}
}
/* ── angle arc ── */
if (a > 0.015) {
const ar = Math.min(r * 0.22, 44);
c.fillStyle = _tcRgba(_TC.violet, 0.06);
c.beginPath(); c.moveTo(cx, cy); c.arc(cx, cy, ar, 0, -a, true); c.closePath(); c.fill();
const ag = c.createConicGradient(0, cx, cy);
ag.addColorStop(0, _tcRgba(_TC.violet, 0.7));
ag.addColorStop(Math.min(a / (Math.PI*2), 0.99), _tcRgba(_TC.violet, 0.25));
ag.addColorStop(1, _tcRgba(_TC.violet, 0.0));
c.strokeStyle = ag; c.lineWidth = 2.5;
c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke();
/* label */
const mid = a / 2, lr = ar + 18;
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet;
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid));
}
/* ── radius ── */
const rg = c.createLinearGradient(cx, cy, px, py);
rg.addColorStop(0, 'rgba(255,255,255,0.12)'); rg.addColorStop(1, 'rgba(255,255,255,0.40)');
c.strokeStyle = rg; c.lineWidth = 1.5;
c.beginPath(); c.moveTo(cx, cy); c.lineTo(px, py); c.stroke();
/* ── projection dashes ── */
c.strokeStyle = 'rgba(255,255,255,0.08)'; c.lineWidth = 1;
c.setLineDash([4, 4]);
c.beginPath(); c.moveTo(px, py); c.lineTo(px, cy); c.stroke();
c.beginPath(); c.moveTo(px, py); c.lineTo(cx, py); c.stroke();
c.setLineDash([]);
const projX = cx + r * cosA;
/* ── triangle fill (sin+cos) ── */
if (this.showSin && this.showCos && Math.abs(cosA) > 0.04 && Math.abs(sinA) > 0.04) {
c.fillStyle = 'rgba(155,93,229,0.035)';
c.beginPath(); c.moveTo(cx, cy); c.lineTo(projX, cy); c.lineTo(px, py); c.closePath(); c.fill();
}
/* ═══ trig segments ═══ */
if (this.showCos) {
this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4);
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cos;
c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'top' : 'bottom';
c.fillText('cos', (cx + projX) / 2, cy + (sinA >= 0 ? 12 : -12));
}
if (this.showSin) {
this._glowLine(c, projX, cy, px, py, _TC.sin, 4);
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.sin;
c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle';
c.fillText('sin', projX + (cosA >= 0 ? 9 : -9), (cy + py) / 2);
}
if (this.showTan && Math.abs(cosA) > 0.025) {
const tanV = sinA / cosA;
if (Math.abs(tanV) < 10) {
const tX = cosA >= 0 ? cx + r : cx - r;
const tY = cosA >= 0 ? cy - r * tanV : cy + r * tanV;
/* faint tangent guide line */
c.strokeStyle = _tcRgba(_TC.tan, 0.06); c.lineWidth = 1;
c.beginPath(); c.moveTo(tX, cy - r - ext); c.lineTo(tX, cy + r + ext); c.stroke();
this._glowLine(c, tX, cy, tX, tY, _TC.tan, 3.5);
c.strokeStyle = _tcRgba(_TC.tan, 0.18); c.lineWidth = 1;
c.setLineDash([5, 4]); c.beginPath(); c.moveTo(px, py); c.lineTo(tX, tY); c.stroke(); c.setLineDash([]);
c.fillStyle = _TC.tan; c.shadowColor = _TC.tan; c.shadowBlur = 8;
c.beginPath(); c.arc(tX, tY, 4.5, 0, Math.PI*2); c.fill(); c.shadowBlur = 0;
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.tan;
c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle';
c.fillText('tg', tX + (cosA >= 0 ? 8 : -8), (cy + tY) / 2);
}
}
if (this.showCot && Math.abs(sinA) > 0.025) {
const cotV = cosA / sinA;
if (Math.abs(cotV) < 10) {
const cX = sinA >= 0 ? cx + r * cotV : cx - r * cotV;
const cY = sinA >= 0 ? cy - r : cy + r;
c.strokeStyle = _tcRgba(_TC.cot, 0.06); c.lineWidth = 1;
c.beginPath(); c.moveTo(cx - r - ext, cY); c.lineTo(cx + r + ext, cY); c.stroke();
this._glowLine(c, cx, cY, cX, cY, _TC.cot, 3.5);
c.strokeStyle = _tcRgba(_TC.cot, 0.18); c.lineWidth = 1;
c.setLineDash([5, 4]); c.beginPath(); c.moveTo(px, py); c.lineTo(cX, cY); c.stroke(); c.setLineDash([]);
c.fillStyle = _TC.cot; c.shadowColor = _TC.cot; c.shadowBlur = 8;
c.beginPath(); c.arc(cX, cY, 4.5, 0, Math.PI*2); c.fill(); c.shadowBlur = 0;
c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cot;
c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'bottom' : 'top';
c.fillText('ctg', (cx + cX) / 2, cY + (sinA >= 0 ? -8 : 8));
}
}
/* ── right-angle marker ── */
if (this.showSin && this.showCos && Math.abs(cosA) > 0.06 && Math.abs(sinA) > 0.06) {
const sz = 8, dx = cosA > 0 ? -sz : sz, dy = sinA > 0 ? sz : -sz;
c.strokeStyle = 'rgba(255,255,255,0.18)'; c.lineWidth = 1;
c.beginPath(); c.moveTo(projX+dx, cy); c.lineTo(projX+dx, cy-dy); c.lineTo(projX, cy-dy); c.stroke();
}
/* ── axis value badges ── */
if (this.showSin && Math.abs(sinA) > 0.04)
this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle');
if (this.showCos && Math.abs(cosA) > 0.04)
this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top');
/* ── main point ── */
const ps = this._hover || this._drag ? 10 : 8;
const blur = this._hover || this._drag ? 22 : 16;
c.fillStyle = _tcRgba(_TC.point, 0.10);
c.beginPath(); c.arc(px, py, ps + 10, 0, Math.PI*2); c.fill();
c.shadowColor = _TC.point; c.shadowBlur = blur;
c.fillStyle = _TC.point;
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.fill();
c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.85)';
c.beginPath(); c.arc(px, py, ps * 0.35, 0, Math.PI*2); c.fill();
c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2;
c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke();
/* ── coordinate tooltip ── */
this._tooltip(c, px, py, cosA, sinA);
/* ── quadrant roman numeral ── */
const qOff = r * 0.46;
const qx = (q===1||q===4) ? cx+qOff : cx-qOff;
const qy = (q<=2) ? cy-qOff : cy+qOff;
c.font = 'bold 22px Manrope,sans-serif'; c.fillStyle = _tcRgba(_TC.violet, 0.07);
c.textAlign = 'center'; c.textBaseline = 'middle';
c.fillText(['I','II','III','IV'][q-1]||'', qx, qy);
/* ── sign pills per quadrant ── */
this._quadSigns(c, cx, cy, r);
/* ── Pythagorean identity bar ── */
this._pythBar(c);
/* ── connection line to graph ── */
if (this.showGraph) this._connLine(c, px, py, sinA, cosA);
}
/* ═══ Connection line: circle <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> graph ═══════════════════════════════ */
_connLine(c, px, py, sinA, cosA) {
const fn = this.graphFn;
const val = fn === 'sin' ? sinA : fn === 'cos' ? cosA :
fn === 'tan' ? (Math.abs(cosA)>0.02 ? sinA/cosA : null) :
(Math.abs(sinA)>0.02 ? cosA/sinA : null);
if (val === null || !isFinite(val)) return;
const yR = (fn === 'tan' || fn === 'cot') ? 4 : 1.5;
if (Math.abs(val) > yR * 2) return;
const gy = this._gy, gh = this._gh;
const targetY = gy + gh/2 - val / yR * (gh/2);
/* source Y = py for sin, cy for cos, depends on fn */
const srcY = (fn === 'sin') ? py : (fn === 'cos') ? this._cy : py;
const srcX = (fn === 'sin' || fn === 'tan') ? px : this._cx;
c.strokeStyle = _tcRgba(_TC[fn] || _TC.sin, 0.12);
c.lineWidth = 1;
c.setLineDash([3, 5]);
c.beginPath(); c.moveTo(srcX, srcY); c.lineTo(this._gx, targetY); c.stroke();
c.setLineDash([]);
}
/* ═══ Quadrant sign pills ═══════════════════════════════════════════ */
_quadSigns(c, cx, cy, r) {
const signs = [
{ q: 1, s:'+', co:'+', t:'+' }, { q: 2, s:'+', co:'', t:'' },
{ q: 3, s:'', co:'', t:'+' }, { q: 4, s:'', co:'+', t:'' },
];
const curr = this.stats().quadrant;
const off = r * 0.78;
for (const sg of signs) {
const sx = (sg.q===1||sg.q===4) ? cx+off : cx-off;
const sy = (sg.q<=2) ? cy-off : cy+off;
const isCurr = sg.q === curr;
c.font = '500 8px Manrope,sans-serif';
c.fillStyle = isCurr ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.07)';
c.textAlign = 'center'; c.textBaseline = 'middle';
const txt = `s${sg.s} c${sg.co} t${sg.t}`;
c.fillText(txt, sx, sy);
}
}
/* ═══ Pythagorean identity bar ══════════════════════════════════════ */
_pythBar(c) {
const s = Math.sin(this.angle), co = Math.cos(this.angle);
const sin2 = s * s, cos2 = co * co;
const bw = Math.min(this._r * 1.4, 180);
const bh = 6;
const bx = this._cx - bw / 2;
const by = this._cy + this._r + 38;
if (by + bh + 16 > this.H) return;
/* background track */
c.fillStyle = 'rgba(255,255,255,0.04)';
c.beginPath(); c.roundRect(bx, by, bw, bh, 3); c.fill();
/* sin² portion */
const sw = bw * sin2;
if (sw > 0.5) {
c.fillStyle = _tcRgba(_TC.sin, 0.5);
c.beginPath(); c.roundRect(bx, by, sw, bh, [3,0,0,3]); c.fill();
}
/* cos² portion */
const cw = bw * cos2;
if (cw > 0.5) {
c.fillStyle = _tcRgba(_TC.cos, 0.5);
c.beginPath(); c.roundRect(bx + sw, by, cw, bh, [0,3,3,0]); c.fill();
}
/* label */
c.font = '500 9px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.25)';
c.textAlign = 'center'; c.textBaseline = 'top';
c.fillText(`sin² + cos² = 1`, this._cx, by + bh + 4);
}
/* ═══ Divider ══════════════════════════════════════════════════════ */
_drawDivider(c) {
const x = this._gx - 14;
const pad = 20;
const lg = c.createLinearGradient(x, pad, x, this.H - pad);
lg.addColorStop(0, 'rgba(155,93,229,0.0)');
lg.addColorStop(0.15,'rgba(155,93,229,0.18)');
lg.addColorStop(0.5, 'rgba(155,93,229,0.28)');
lg.addColorStop(0.85,'rgba(155,93,229,0.18)');
lg.addColorStop(1, 'rgba(155,93,229,0.0)');
c.strokeStyle = lg; c.lineWidth = 1;
c.beginPath(); c.moveTo(x, pad); c.lineTo(x, this.H - pad); c.stroke();
/* glow */
const gg = c.createLinearGradient(x - 16, 0, x + 16, 0);
gg.addColorStop(0, 'rgba(155,93,229,0.0)');
gg.addColorStop(0.5, 'rgba(155,93,229,0.035)');
gg.addColorStop(1, 'rgba(155,93,229,0.0)');
c.fillStyle = gg; c.fillRect(x - 16, pad, 32, this.H - pad*2);
}
/* ═══ Graph ════════════════════════════════════════════════════════ */
_drawGraph(c) {
const gx = this._gx, gy = this._gy, gw = this._gw, gh = this._gh;
if (gw < 50 || gh < 50) return;
const fn = this.graphFn;
const col = _TC[fn] || _TC.sin;
const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x';
const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x));
const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5;
const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI;
const sx = x => gx + (x-xMin)/(xMax-xMin)*gw;
const sy = y => gy + gh/2 - y/yR*(gh/2);
/* ── glass panel ── */
const pp = 12;
const px1 = gx-pp, py1 = gy-pp, pw = gw+pp*2, ph = gh+pp*2;
c.fillStyle = 'rgba(10,10,20,0.50)';
c.beginPath(); c.roundRect(px1, py1, pw, ph, 20); c.fill();
/* gradient border */
const bg = c.createLinearGradient(px1, py1, px1+pw, py1+ph);
bg.addColorStop(0, 'rgba(155,93,229,0.18)');
bg.addColorStop(0.3,'rgba(255,255,255,0.06)');
bg.addColorStop(0.7,'rgba(255,255,255,0.06)');
bg.addColorStop(1, 'rgba(155,93,229,0.18)');
c.strokeStyle = bg; c.lineWidth = 1.5;
c.beginPath(); c.roundRect(px1, py1, pw, ph, 20); c.stroke();
/* top highlight */
const hg = c.createLinearGradient(px1, py1, px1, py1+50);
hg.addColorStop(0, 'rgba(255,255,255,0.025)'); hg.addColorStop(1, 'rgba(255,255,255,0.0)');
c.fillStyle = hg;
c.beginPath(); c.roundRect(px1+1, py1+1, pw-2, 50, [20,20,0,0]); c.fill();
/* ── zero axis ── */
const zy = sy(0);
c.strokeStyle = 'rgba(255,255,255,0.14)'; c.lineWidth = 1;
c.beginPath(); c.moveTo(gx, zy); c.lineTo(gx+gw, zy); c.stroke();
/* y-axis on graph */
const x0 = sx(0);
if (x0 > gx + 4 && x0 < gx + gw - 4) {
c.strokeStyle = 'rgba(255,255,255,0.08)'; c.lineWidth = 1;
c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke();
}
/* ±1 lines */
if (fn==='sin'||fn==='cos') {
c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]);
[1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); });
c.setLineDash([]);
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)';
c.textAlign='right'; c.textBaseline='middle';
c.fillText('1', gx-5, sy(1)); c.fillText('1', gx-5, sy(-1));
}
/* x ticks */
const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']];
c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)';
c.textAlign='center'; c.textBaseline='top';
for (const [v,l] of ticks) {
const xx = sx(v);
if (xx < gx+6 || xx > gx+gw-6) continue;
c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1;
c.setLineDash([3,3]);
c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke();
c.setLineDash([]);
c.fillText(l, xx, gy+gh+6);
}
/* ── ghost curves (other functions, dimmed) ── */
c.save();
c.beginPath(); c.rect(gx, gy, gw, gh); c.clip();
const allFns = [
{ id: 'sin', ev: Math.sin, c: _TC.sin },
{ id: 'cos', ev: Math.cos, c: _TC.cos },
{ id: 'tan', ev: Math.tan, c: _TC.tan },
{ id: 'cot', ev: x => 1/Math.tan(x), c: _TC.cot },
];
const step = (xMax - xMin) / (gw * 1.5);
for (const f of allFns) {
if (f.id === fn) continue; /* skip active — draw it last */
const show = (f.id==='sin'&&this.showSin) || (f.id==='cos'&&this.showCos) ||
(f.id==='tan'&&this.showTan) || (f.id==='cot'&&this.showCot);
if (!show) continue;
const yRg = (f.id==='tan'||f.id==='cot') ? 4 : 1.5;
const syG = y => gy + gh/2 - y/yRg*(gh/2);
c.strokeStyle = _tcRgba(f.c, 0.18); c.lineWidth = 1.5;
c.beginPath(); let on = false;
for (let x = xMin; x <= xMax; x += step) {
const yv = f.ev(x);
if (!isFinite(yv) || Math.abs(yv) > yRg*2) { on = false; continue; }
const spx = sx(x), spy = syG(yv);
if (!on) { c.moveTo(spx, spy); on = true; } else c.lineTo(spx, spy);
}
c.stroke();
}
/* gradient fill under active curve (sin/cos) */
if (fn==='sin'||fn==='cos') {
const pts = [];
for (let x = xMin; x <= xMax; x += step) {
const yv = evFn(x);
if (isFinite(yv)) pts.push({ sx: sx(x), sy: sy(yv) });
}
if (pts.length > 2) {
const fg = c.createLinearGradient(0, gy, 0, gy+gh);
fg.addColorStop(0, _tcRgba(col, 0.10));
fg.addColorStop(0.5, _tcRgba(col, 0.0));
fg.addColorStop(1, _tcRgba(col, 0.10));
c.fillStyle = fg; c.beginPath();
c.moveTo(pts[0].sx, zy);
pts.forEach(p => c.lineTo(p.sx, p.sy));
c.lineTo(pts[pts.length-1].sx, zy);
c.closePath(); c.fill();
}
}
/* active curve: glow + main */
c.strokeStyle = _tcRgba(col, 0.12); c.lineWidth = 10; c.lineCap='round'; c.lineJoin='round';
c.beginPath(); let on2 = false;
for (let x = xMin; x <= xMax; x += step) {
const yv = evFn(x);
if (!isFinite(yv)||Math.abs(yv)>yR*2) { on2 = false; continue; }
const spx = sx(x), spy = sy(yv);
if (!on2) { c.moveTo(spx, spy); on2 = true; } else c.lineTo(spx, spy);
}
c.stroke();
c.strokeStyle = col; c.lineWidth = 2.5;
c.beginPath(); on2 = false;
for (let x = xMin; x <= xMax; x += step) {
const yv = evFn(x);
if (!isFinite(yv)||Math.abs(yv)>yR*2) { on2 = false; continue; }
const spx = sx(x), spy = sy(yv);
if (!on2) { c.moveTo(spx, spy); on2 = true; } else c.lineTo(spx, spy);
}
c.stroke();
/* ── current angle marker ── */
const curY = evFn(this.angle);
if (isFinite(curY) && Math.abs(curY) <= yR*2) {
const mx = sx(this.angle), my = sy(curY);
c.strokeStyle = _tcRgba(_TC.violet, 0.18); c.lineWidth = 1;
c.setLineDash([4, 4]);
c.beginPath(); c.moveTo(mx, gy); c.lineTo(mx, gy+gh); c.stroke();
c.strokeStyle = _tcRgba(col, 0.18);
c.beginPath(); c.moveTo(gx, my); c.lineTo(mx, my); c.stroke();
c.setLineDash([]);
/* dot */
c.fillStyle = _tcRgba(_TC.point, 0.12);
c.beginPath(); c.arc(mx, my, 13, 0, Math.PI*2); c.fill();
c.fillStyle = _TC.point; c.shadowColor = _TC.point; c.shadowBlur = 12;
c.beginPath(); c.arc(mx, my, 5.5, 0, Math.PI*2); c.fill();
c.shadowBlur = 0;
c.fillStyle = 'rgba(255,255,255,0.7)';
c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill();
/* value badge */
const txt = this._fmt(curY);
c.font = 'bold 11px Manrope,sans-serif';
const tm = c.measureText(txt);
const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20;
c.fillStyle='rgba(12,12,22,0.85)';
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill();
c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1;
c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(txt, bx2+7, by2);
}
c.restore();
/* fn name badge */
c.font='bold 13px Manrope,sans-serif';
const tm2 = c.measureText(lbl);
const bw3 = tm2.width+18, bh3 = 26;
c.fillStyle='rgba(12,12,22,0.7)';
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill();
c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1;
c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke();
c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle';
c.fillText(lbl, gx+17, gy+21);
}
/* ═══ Snap particles ═══════════════════════════════════════════════ */
_spawnSnap(px, py) {
for (let i = 0; i < 8; i++) {
const ang = Math.random() * Math.PI * 2;
const speed = 30 + Math.random() * 50;
this._particles.push({
x: px, y: py,
vx: Math.cos(ang) * speed,
vy: Math.sin(ang) * speed,
life: 1,
col: _TC.violet,
});
}
}
_drawParticles(c) {
const dt = 0.016;
for (let i = this._particles.length - 1; i >= 0; i--) {
const p = this._particles[i];
p.x += p.vx * dt; p.y += p.vy * dt;
p.life -= dt * 1.8;
if (p.life <= 0) { this._particles.splice(i, 1); continue; }
c.fillStyle = _tcRgba(p.col, p.life * 0.6);
c.shadowColor = p.col; c.shadowBlur = 6;
c.beginPath(); c.arc(p.x, p.y, 2 * p.life, 0, Math.PI*2); c.fill();
c.shadowBlur = 0;
}
}
/* ═══ Drawing helpers ══════════════════════════════════════════════ */
_glowLine(c, x1, y1, x2, y2, col, w) {
c.lineCap = 'round';
c.strokeStyle = _tcRgba(col, 0.14); c.lineWidth = w + 8;
c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke();
c.strokeStyle = col; c.lineWidth = w;
c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke();
c.strokeStyle = _tcRgba(col, 0.45); c.lineWidth = 1;
c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke();
}
_arrowH(c, x, y, angle, col) {
c.save(); c.translate(x, y); c.rotate(angle);
c.fillStyle = col;
c.beginPath(); c.moveTo(0,0); c.lineTo(-9,-4.5); c.lineTo(-9,4.5); c.closePath(); c.fill();
c.restore();
}
_badge(c, x, y, txt, col, tA, tB) {
c.font='600 10px Manrope,sans-serif';
const m = c.measureText(txt);
const pw = m.width+10, ph = 17;
let bx = x, by = y;
if (tA==='right') bx = x - pw;
else if (tA==='center') bx = x - pw/2;
if (tB==='middle') by = y - ph/2;
c.fillStyle='rgba(12,12,22,0.75)';
c.beginPath(); c.roundRect(bx, by, pw, ph, 4); c.fill();
c.strokeStyle = _tcRgba(col, 0.35); c.lineWidth = 1;
c.beginPath(); c.roundRect(bx, by, pw, ph, 4); c.stroke();
c.fillStyle = col; c.textAlign='center'; c.textBaseline='middle';
c.fillText(txt, bx + pw/2, by + ph/2);
}
_tooltip(c, px, py, cosA, sinA) {
const txt = `(${this._fmt(cosA)}; ${this._fmt(sinA)})`;
c.font='600 11px Manrope,sans-serif';
const m = c.measureText(txt);
const pw = m.width+16, ph = 24;
const offX = cosA >= 0 ? 16 : -pw-16;
const offY = sinA >= 0 ? -ph-12 : 12;
const bx = px+offX, by = py+offY;
c.fillStyle='rgba(12,12,22,0.80)';
c.beginPath(); c.roundRect(bx, by, pw, ph, 8); c.fill();
c.strokeStyle = _tcRgba(_TC.violet, 0.3); c.lineWidth = 1;
c.beginPath(); c.roundRect(bx, by, pw, ph, 8); c.stroke();
c.fillStyle='rgba(255,255,255,0.82)';
c.textAlign='center'; c.textBaseline='middle';
c.fillText(txt, bx+pw/2, by+ph/2);
}
/* ═══ Formatting ═══════════════════════════════════════════════════ */
_fmt(v) {
const a = Math.abs(v), s = v < 0 ? '' : '';
if (a < 5e-4) return '0';
if (Math.abs(a-0.5)<1e-3) return s+'½';
if (Math.abs(a-1)<1e-3) return s+'1';
if (Math.abs(a-Math.SQRT2/2)<1e-3) return s+'√2/2';
if (Math.abs(a-Math.sqrt(3)/2)<1e-3) return s+'√3/2';
if (Math.abs(a-Math.sqrt(3)/3)<1e-3) return s+'√3/3';
if (Math.abs(a-Math.sqrt(3))<1e-3) return s+'√3';
if (Math.abs(a-2)<1e-3) return s+'2';
if (Math.abs(a-2*Math.sqrt(3)/3)<1e-3)return s+'2√3/3';
return v.toFixed(3);
}
_radLbl(a) {
for (const n of _TC_NOTABLE) { if (Math.abs(a-n.a)<0.02) return n.l; }
return (a*180/Math.PI).toFixed(1)+'°';
}
_norm(a) { return ((a%(2*Math.PI))+2*Math.PI)%(2*Math.PI); }
_fireUpdate() { if (this.onUpdate) this.onUpdate(this.stats()); }
/* ═══ Events ═══════════════════════════════════════════════════════ */
_bindEvents() {
const cv = this.canvas;
const mp = e => {
const r = cv.getBoundingClientRect();
return { x:(e.clientX??e.touches?.[0]?.clientX??0)-r.left, y:(e.clientY??e.touches?.[0]?.clientY??0)-r.top };
};
const snapAngle = e => {
const m = mp(e);
let a = Math.atan2(-(m.y-this._cy), m.x-this._cx);
if (a<0) a += 2*Math.PI;
if (this.snapToNotable) {
for (const n of _TC_NOTABLE) {
if (Math.abs(a-n.a)<0.09) { a = n.a; break; }
}
}
return a;
};
const hit = e => {
const m = mp(e);
const px = this._cx + this._r*Math.cos(this.angle);
const py = this._cy - this._r*Math.sin(this.angle);
if (Math.hypot(m.x-px, m.y-py) < 30) return true;
return Math.abs(Math.hypot(m.x-this._cx, m.y-this._cy) - this._r) < 22;
};
const checkSnap = (newA) => {
for (const n of _TC_NOTABLE) {
if (Math.abs(newA-n.a)<0.02 && this._lastSnap !== n.a) {
this._lastSnap = n.a;
const nx = this._cx + this._r*Math.cos(n.a);
const ny = this._cy - this._r*Math.sin(n.a);
this._spawnSnap(nx, ny);
return;
}
}
};
const end = () => {
if (this._drag) { this._drag = false; this.draw(); }
cv.style.cursor = 'default';
};
cv.addEventListener('mousedown', e => {
if (!hit(e)) return;
this._drag = true; this._stopAnim();
const na = snapAngle(e); checkSnap(na);
this.angle = na; this.draw();
cv.style.cursor = 'grabbing';
});
cv.addEventListener('mousemove', e => {
if (this._drag) {
const na = snapAngle(e); checkSnap(na);
this.angle = na; this.draw();
} else {
const h = hit(e);
if (h !== this._hover) { this._hover = h; this.draw(); }
cv.style.cursor = h ? 'grab' : 'default';
}
});
cv.addEventListener('mouseup', end);
cv.addEventListener('mouseleave', () => { if (this._hover){this._hover=false;this.draw();} end(); });
/* scroll wheel fine-tune */
cv.addEventListener('wheel', e => {
e.preventDefault();
const step = e.shiftKey ? 0.01 : (Math.PI / 180);
this.angle = this._norm(this.angle - Math.sign(e.deltaY) * step);
this._lastSnap = -1;
this.draw();
}, { passive: false });
/* keyboard arrows */
cv.setAttribute('tabindex', '0');
cv.style.outline = 'none';
cv.addEventListener('keydown', e => {
const step = e.shiftKey ? (Math.PI/180) : (Math.PI/36); /* 1° or 5° */
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault(); this.angle = this._norm(this.angle + step); this.draw();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault(); this.angle = this._norm(this.angle - step); this.draw();
}
});
/* touch */
cv.addEventListener('touchstart', e => {
e.preventDefault();
if (!hit(e)) return;
this._drag = true; this._stopAnim();
const na = snapAngle(e); checkSnap(na); this.angle = na; this.draw();
}, { passive: false });
cv.addEventListener('touchmove', e => {
e.preventDefault();
if (this._drag) { const na = snapAngle(e); checkSnap(na); this.angle = na; this.draw(); }
}, { passive: false });
cv.addEventListener('touchend', end);
}
/* ═══ Animation ════════════════════════════════════════════════════ */
_startAnim() {
this.animating = true;
let last = performance.now();
const loop = now => {
if (!this.animating) return;
const dt = (now-last)/1000; last = now;
let d = this._animTarget - this.angle;
if (d > Math.PI) d -= 2*Math.PI;
if (d < -Math.PI) d += 2*Math.PI;
if (Math.abs(d) < 0.012) {
this.angle = this._animTarget;
this.animating = false;
/* snap particle at end */
const nx = this._cx + this._r*Math.cos(this.angle);
const ny = this._cy - this._r*Math.sin(this.angle);
this._spawnSnap(nx, ny);
this.draw(); return;
}
const speed = this._animSpeed * Math.max(0.3, Math.min(1, Math.abs(d)/0.5));
this.angle += Math.sign(d) * Math.min(Math.abs(d), speed * dt);
this.angle = this._norm(this.angle);
this.draw();
this._raf = requestAnimationFrame(loop);
};
this._raf = requestAnimationFrame(loop);
}
_stopAnim() {
this.animating = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
_startIdle() {
if (this._idleRaf) return;
let last = performance.now();
const loop = now => {
const dt = (now-last)/1000; last = now;
this._idlePulse += dt * 1.5;
/* update particles */
if (this._particles.length > 0 || (!this._drag && !this.animating)) this.draw();
this._idleRaf = requestAnimationFrame(loop);
};
this._idleRaf = requestAnimationFrame(loop);
}
_stopIdle() {
if (this._idleRaf) { cancelAnimationFrame(this._idleRaf); this._idleRaf = null; }
}
}
if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim;
+452
View File
@@ -0,0 +1,452 @@
'use strict';
/* ═══════════════════════════════════════════
WavesSim v2 — Волны и звук
Modes: transverse | longitudinal | superposition | standing
─────────────────────────────────────────── */
class WavesSim {
static BG = '#0D0D1A';
static FONT = "700 12px 'Manrope',sans-serif";
static V = '#9B5DE5'; /* violet */
static C = '#06D6E0'; /* cyan */
static P = '#F15BB5'; /* pink */
static G = '#FFD166'; /* gold */
constructor(canvas) {
this._c = canvas;
this._ctx = canvas.getContext('2d');
this._dpr = 1;
this._W = 0;
this._H = 0;
this._mode = 'transverse';
this._t = 0;
this._last = null;
this._raf = null;
this._paused = true;
this._A1 = 50; this._f1 = 1.0; this._phi1 = 0;
this._A2 = 40; this._f2 = 1.5; this._phi2 = 0;
this._n = 1;
this._speed = 2.0;
this._resizeObs = null;
this.onUpdate = null;
}
/* ── публичное API ── */
fit() {
const par = this._c.parentElement;
const dpr = window.devicePixelRatio || 1;
const w = par.clientWidth || 600;
const h = par.clientHeight || 400;
this._c.width = Math.round(w * dpr);
this._c.height = Math.round(h * dpr);
this._dpr = dpr;
this._W = w;
this._H = h;
if (this._resizeObs) this._resizeObs.disconnect();
this._resizeObs = new ResizeObserver(() => this.fit());
this._resizeObs.observe(par);
this.draw();
}
setMode(mode) {
this._mode = mode;
this._t = 0;
this._last = null;
this.draw();
this._emit();
}
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
if (phi1 !== undefined) this._phi1 = +phi1;
if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2));
if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2));
if (phi2 !== undefined) this._phi2 = +phi2;
if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n)));
if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed));
this.draw();
this._emit();
}
play() {
this._paused = false;
this._last = null;
if (!this._raf) this._raf = requestAnimationFrame(t => this._tick(t));
}
pause() {
this._paused = true;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
start() { this.play(); }
stop() { this.pause(); }
reset() { this._t = 0; this._last = null; this.draw(); this._emit(); }
info() {
const v = (this._W || 600) / 3;
return {
T: (1 / this._f1).toFixed(2),
lambda: (v / this._f1).toFixed(0),
v: v.toFixed(0),
f1: this._f1
};
}
/* ── анимационный цикл ── */
_tick(ts) {
if (!this._paused) {
if (this._last !== null)
this._t += Math.min((ts - this._last) / 1000, 0.05) * this._speed;
this._last = ts;
this._raf = requestAnimationFrame(t => this._tick(t));
} else {
this._raf = null;
}
this.draw();
this._emit();
}
/* ── главный draw ── */
draw() {
const { _ctx: ctx, _W: W, _H: H, _dpr: dpr } = this;
if (!W || !H) return;
/* сбрасываем трансформ + заливаем фон */
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = WavesSim.BG;
ctx.fillRect(0, 0, W, H);
if (this._mode === 'transverse') this._transvDraw(ctx, W, H);
else if (this._mode === 'longitudinal') this._longDraw(ctx, W, H);
else if (this._mode === 'superposition') this._superDraw(ctx, W, H);
else this._standDraw(ctx, W, H);
}
/* ══════════════════════════════════════
ПОПЕРЕЧНАЯ ВОЛНА
══════════════════════════════════════ */
_transvDraw(ctx, W, H) {
const PL = 48, PR = 20, PT = 50, PB = 48;
const cw = W - PL - PR;
const ch = H - PT - PB;
const cy = PT + ch / 2;
this._grid(ctx, PL, PR, PT, PB, W, H);
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
const A = Math.max(4, Math.min(this._A1, ch / 2 - 8));
const v = cw / 3;
const lam = v / this._f1;
const k = (2 * Math.PI) / lam;
const om = 2 * Math.PI * this._f1;
const t = this._t;
const phi = this._phi1;
const y = x => A * Math.sin(om * t - k * (x - PL) + phi);
/* волновая кривая */
ctx.save();
ctx.shadowColor = WavesSim.V;
ctx.shadowBlur = 16;
ctx.strokeStyle = WavesSim.V;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let x = PL; x <= PL + cw; x += 1) {
const py = cy + y(x);
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke();
ctx.restore();
/* частицы */
const step = Math.max(12, Math.floor(lam / 10));
for (let x = PL + step * 0.5; x < PL + cw; x += step) {
const py = cy + y(x);
const norm = Math.abs(y(x)) / (A || 1);
ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, py);
ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1; ctx.stroke();
ctx.save();
ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(x, py, 4, 0, 6.28);
ctx.fillStyle = `rgba(155,93,229,${(0.4 + 0.6 * norm).toFixed(2)})`; ctx.fill();
ctx.restore();
}
/* выделенная частица */
const hx = PL + Math.min(lam * 0.5, cw * 0.22);
const hy = cy + y(hx);
ctx.save();
ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18;
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.28);
ctx.fillStyle = WavesSim.G; ctx.fill();
ctx.restore();
ctx.save();
ctx.setLineDash([3, 4]);
ctx.strokeStyle = 'rgba(255,209,102,0.22)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(hx, cy - A); ctx.lineTo(hx, cy + A); ctx.stroke();
ctx.restore();
/* аннотация длины волны */
const ld = Math.min(lam, cw - 16);
if (ld > 36) {
const ay = cy + A + 26;
ctx.save();
ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(PL + 8, ay); ctx.lineTo(PL + 8 + ld, ay);
ctx.moveTo(PL + 13, ay - 4); ctx.lineTo(PL + 8, ay); ctx.lineTo(PL + 13, ay + 4);
ctx.moveTo(PL + 8 + ld - 5, ay - 4); ctx.lineTo(PL + 8 + ld, ay); ctx.lineTo(PL + 8 + ld - 5, ay + 4);
ctx.stroke();
ctx.fillStyle = WavesSim.C; ctx.textAlign = 'center';
ctx.font = "700 10px 'Manrope',sans-serif";
ctx.fillText('\u03bb = ' + ld.toFixed(0), PL + 8 + ld / 2, ay - 5);
ctx.restore();
}
/* аннотация амплитуды */
if (A > 16) {
const ax = PL - 20;
ctx.save();
ctx.strokeStyle = 'rgba(241,91,181,0.7)'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(ax, cy); ctx.lineTo(ax, cy - A);
ctx.moveTo(ax - 4, cy - A + 5); ctx.lineTo(ax, cy - A); ctx.lineTo(ax + 4, cy - A + 5);
ctx.moveTo(ax - 3, cy - 3); ctx.lineTo(ax, cy); ctx.lineTo(ax + 3, cy - 3);
ctx.stroke();
ctx.fillStyle = WavesSim.P; ctx.textAlign = 'center';
ctx.font = "700 10px 'Manrope',sans-serif";
ctx.save(); ctx.translate(ax - 12, cy - A / 2); ctx.rotate(-Math.PI / 2);
ctx.fillText('A', 0, 0); ctx.restore();
ctx.restore();
}
/* подпись */
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
ctx.fillText('y = A sin(\u03c9t \u2212 kx + \u03c6)', PL, PT - 14);
}
/* ══════════════════════════════════════
ПРОДОЛЬНАЯ ВОЛНА
══════════════════════════════════════ */
_longDraw(ctx, W, H) {
const PL = 24, PR = 24, PT = 50, PB = 60;
const cw = W - PL - PR;
const ch = H - PT - PB;
const nRows = 5;
const rowH = ch / nRows;
const nPart = Math.max(20, Math.floor(cw / 10));
const dx0 = cw / nPart;
const v = cw / 3;
const lam = v / this._f1;
const k = (2 * Math.PI) / lam;
const om = 2 * Math.PI * this._f1;
const A = Math.min(this._A1 * 0.5, lam / 4, rowH * 0.36);
const t = this._t;
const phi = this._phi1;
/* ряды частиц */
for (let row = 0; row < nRows; row++) {
const cy = PT + rowH * (row + 0.5);
for (let i = 0; i < nPart; i++) {
const x0 = PL + (i + 0.5) * dx0;
const phase = om * t - k * (x0 - PL) + phi;
const disp = A * Math.sin(phase);
const xd = Math.max(PL + 1, Math.min(PL + cw - 1, x0 + disp));
const dens = 1 / Math.max(0.15, 1 + (-A * k * Math.cos(phase)));
const alpha = Math.max(0.1, Math.min(0.95, dens * 0.55));
ctx.beginPath(); ctx.arc(xd, cy, 3, 0, 6.28);
ctx.fillStyle = `rgba(155,93,229,${alpha.toFixed(2)})`; ctx.fill();
}
}
/* график давления */
const pTop = PT + ch + 10;
const pH = H - pTop - 8;
if (pH > 20) {
const axY = pTop + pH / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(PL, axY); ctx.lineTo(PL + cw, axY); ctx.stroke();
ctx.setLineDash([]);
ctx.save();
ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 8;
ctx.strokeStyle = WavesSim.C; ctx.lineWidth = 2;
ctx.beginPath();
for (let x = PL; x <= PL + cw; x += 1) {
const py = axY - Math.cos(om * t - k * (x - PL) + phi) * pH * 0.4;
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke(); ctx.restore();
ctx.fillStyle = WavesSim.C; ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left';
ctx.fillText('P(x,t)', PL + 2, pTop + 11);
}
ctx.fillStyle = 'rgba(255,255,255,0.28)';
ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
ctx.fillText('Продольная волна', PL, PT - 16);
}
/* ══════════════════════════════════════
СУПЕРПОЗИЦИЯ
══════════════════════════════════════ */
_superDraw(ctx, W, H) {
const PL = 48, PR = 20, PT = 70, PB = 48;
const cw = W - PL - PR;
const ch = H - PT - PB;
const cy = PT + ch / 2;
this._grid(ctx, PL, PR, PT, PB, W, H);
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
const v = cw / 3;
const t = this._t;
const mk = (f, A) => {
const lam = v / f, k = (2 * Math.PI) / lam, om = 2 * Math.PI * f;
const amp = Math.max(4, Math.min(A, ch / 2 - 8));
return { k, om, amp };
};
const w1 = mk(this._f1, this._A1);
const w2 = mk(this._f2, this._A2);
const y1 = x => w1.amp * Math.sin(w1.om * t - w1.k * (x - PL) + this._phi1);
const y2 = x => w2.amp * Math.sin(w2.om * t - w2.k * (x - PL) + this._phi2);
const yR = x => y1(x) + y2(x);
this._waveLine(ctx, PL, cw, cy, y1, WavesSim.V, 1.5, 0.45, false);
this._waveLine(ctx, PL, cw, cy, y2, WavesSim.C, 1.5, 0.45, false);
this._waveLine(ctx, PL, cw, cy, yR, WavesSim.P, 2.8, 1.0, true);
/* легенда */
const items = [
{ c: WavesSim.V, txt: 'y\u2081 = A\u2081 sin(\u03c9\u2081t \u2212 k\u2081x + \u03c6\u2081)' },
{ c: WavesSim.C, txt: 'y\u2082 = A\u2082 sin(\u03c9\u2082t \u2212 k\u2082x + \u03c6\u2082)' },
{ c: WavesSim.P, txt: 'y = y\u2081 + y\u2082' },
];
ctx.font = "600 9px 'Manrope',sans-serif";
items.forEach((it, i) => {
const lx = PL + 6, ly = PT - 56 + i * 18;
ctx.save();
ctx.shadowColor = it.c; ctx.shadowBlur = 8;
ctx.fillStyle = it.c;
ctx.beginPath(); ctx.arc(lx + 4, ly + 4, 3.5, 0, 6.28); ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textAlign = 'left';
ctx.fillText(it.txt, lx + 13, ly + 8);
});
}
/* ══════════════════════════════════════
СТОЯЧАЯ ВОЛНА
══════════════════════════════════════ */
_standDraw(ctx, W, H) {
const PL = 48, PR = 20, PT = 50, PB = 48;
const cw = W - PL - PR;
const ch = H - PT - PB;
const cy = PT + ch / 2;
this._grid(ctx, PL, PR, PT, PB, W, H);
this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);
const n = this._n;
const k = (n * Math.PI) / cw;
const om = 2 * Math.PI * this._f1;
const A = Math.max(4, Math.min(this._A1, ch / 2 - 10));
const t = this._t;
/* прямая и обратная (тусклые) */
this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t - k * (x - PL)), WavesSim.V, 1.0, 0.25, false);
this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t + k * (x - PL) + Math.PI), WavesSim.C, 1.0, 0.25, false);
/* огибающая */
ctx.save();
ctx.globalAlpha = 0.12;
ctx.fillStyle = WavesSim.V;
ctx.beginPath(); ctx.moveTo(PL, cy);
for (let x = PL; x <= PL + cw; x++) ctx.lineTo(x, cy - 2 * A * Math.abs(Math.sin(k * (x - PL))));
for (let x = PL + cw; x >= PL; x--) ctx.lineTo(x, cy + 2 * A * Math.abs(Math.sin(k * (x - PL))));
ctx.closePath(); ctx.fill(); ctx.restore();
/* стоячая волна */
const cosT = Math.cos(om * t + this._phi1);
this._waveLine(ctx, PL, cw, cy, x => 2 * A * Math.sin(k * (x - PL)) * cosT, WavesSim.G, 2.8, 1.0, true);
/* узлы (cyan) */
ctx.save(); ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 10; ctx.fillStyle = WavesSim.C;
for (let m = 0; m <= n; m++) {
ctx.beginPath(); ctx.arc(PL + m * cw / n, cy, 5, 0, 6.28); ctx.fill();
}
ctx.restore();
/* пучности (pink) */
ctx.save(); ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12; ctx.fillStyle = WavesSim.P;
for (let m = 0; m < n; m++) {
const ax = PL + (m + 0.5) * cw / n;
const ay = cy + 2 * A * Math.sin(k * (ax - PL)) * cosT;
ctx.beginPath(); ctx.arc(ax, ay, 5, 0, 6.28); ctx.fill();
}
ctx.restore();
/* легенда */
const lx = W - PR - 128, ly = PT - 20;
ctx.font = "600 9px 'Manrope',sans-serif";
[{ c: WavesSim.C, t: 'Узел (y\u22610)', dy: 0 }, { c: WavesSim.P, t: 'Пучность', dy: 16 }].forEach(r => {
ctx.save(); ctx.shadowColor = r.c; ctx.shadowBlur = 8; ctx.fillStyle = r.c;
ctx.beginPath(); ctx.arc(lx + 5, ly + r.dy + 5, 4, 0, 6.28); ctx.fill(); ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left';
ctx.fillText(r.t, lx + 14, ly + r.dy + 9);
});
ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
ctx.fillText('n = ' + n + ' \u03bb = 2L/' + n, PL, PT - 14);
}
/* ══════════════════════════════════════
ВСПОМОГАТЕЛЬНЫЕ
══════════════════════════════════════ */
_waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) {
ctx.save();
ctx.globalAlpha = alpha;
if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
ctx.strokeStyle = color; ctx.lineWidth = lw;
ctx.beginPath();
for (let x = PL; x <= PL + cw; x += 1) {
const py = cy + fn(x);
x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
}
ctx.stroke(); ctx.restore();
}
_grid(ctx, PL, PR, PT, PB, W, H) {
ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let y = PT; y <= H - PB; y += 28) { ctx.moveTo(PL, y); ctx.lineTo(W - PR, y); }
for (let x = PL; x <= W - PR; x += 40) { ctx.moveTo(x, PT); ctx.lineTo(x, H - PB); }
ctx.stroke();
}
_axisLine(ctx, PL, PR, PT, PB, W, H, cy) {
ctx.save();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PL, cy); ctx.lineTo(W - PR, cy); ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PL, PT - 6); ctx.lineTo(PL, H - PB);
ctx.moveTo(PL, H - PB); ctx.lineTo(W - PR + 6, H - PB);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.font = "600 9px 'Manrope',sans-serif";
ctx.textAlign = 'right'; ctx.fillText('y', PL - 4, PT);
ctx.textAlign = 'left'; ctx.fillText('x', W - PR + 8, H - PB + 4);
}
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
}