Files
Learn_System/frontend/js/math6_anim.js
T
Maxim Dolgolyov 51db000545 feat(math6): полировка Гл.2 — pieGrow, balanceScale, constAreaRect
Math6Anim.pieGrow (растущие сектора, §7 — заменил статичный Math6.pie,
цвета синхронны легенде), balanceScale (весы a·d ? b·c, §3, кнопка «другой
пример»), constAreaRect (обратная проп. = постоянная площадь, §4, ползунок x).
Headless-safe. Тесты math6: 20/20 (поправлен ассерт §7 svg→canvas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:07:56 +03:00

605 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* math6_anim.js — движок анимированных canvas-демонстраций «Математики 6».
* Неймспейс window.Math6Anim. Используется билдерами глав (math_6_chN.html).
*
* Принципы (как в chem7_anim.js — проверено):
* - один RAF-цикл на демо, пауза вне вьюпорта (IntersectionObserver), stop() при уходе;
* - prefers-reduced-motion → один статичный кадр вместо анимации;
* - HEADLESS (jsdom-тесты / HeadlessChrome): getContext НЕ вызывается (ctx=null),
* строится только DOM-каркас → тесты не падают;
* - тёмная тема и акценты — через CSS-переменные; ⛔ без эмодзи.
*
* Каждое демо: fn(host, opts) → { stop(), set(opts) }. Перерисовка при смене параметра — set().
*/
(function (W) {
'use strict';
if (W.Math6Anim && W.Math6Anim.__installed) return;
var D = W.document;
var M = W.Math6Anim = W.Math6Anim || {};
M.__installed = true;
var HEADLESS = (typeof navigator !== 'undefined' && /jsdom|HeadlessChrome/i.test(navigator.userAgent || ''));
function reduced() { try { return !!(W.matchMedia && W.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { return false; } }
function now() { try { return W.performance && W.performance.now ? W.performance.now() : Date.now(); } catch (e) { return Date.now(); } }
function cssVar(name, fb) { try { var v = getComputedStyle(D.documentElement).getPropertyValue(name).trim(); return v || fb; } catch (e) { return fb; } }
function kf(x) { var s = (Math.round(x * 100) / 100).toString(); return s.replace('.', ','); }
function observeVisible(host, cb) {
if (typeof IntersectionObserver === 'undefined') { cb(true); return { disconnect: function () {} }; }
var io = new IntersectionObserver(function (es) { cb(es[0] && es[0].isIntersecting); }, { threshold: 0.01 });
io.observe(host); return io;
}
/* canvas в host; ctx=null в HEADLESS (getContext не зовём) */
function sceneCanvas(host, w, h) {
host.innerHTML = '';
var cv = D.createElement('canvas');
var dpr = HEADLESS ? 1 : (W.devicePixelRatio || 1);
cv.width = w * dpr; cv.height = h * dpr;
cv.style.width = '100%'; cv.style.maxWidth = w + 'px'; cv.style.height = 'auto';
cv.style.borderRadius = '12px'; cv.style.display = 'block'; cv.style.margin = '0 auto';
cv.style.background = 'var(--card,#fff)'; cv.style.border = '1px solid var(--border,#e2e8f0)';
host.appendChild(cv);
var ctx = null;
if (!HEADLESS) { try { ctx = cv.getContext('2d'); if (ctx) ctx.scale(dpr, dpr); } catch (e) { ctx = null; } }
return { cv: cv, ctx: ctx, w: w, h: h };
}
/* RAF-цикл; step(tSec). В HEADLESS/reduced — один кадр step(0). Возвращает {stop}. */
function loop(host, step) {
if (HEADLESS || reduced()) { try { step(0); } catch (e) {} return { stop: function () {} }; }
var raf = 0, running = true, visible = true, t0 = now();
var io = observeVisible(host, function (v) { visible = v; });
function frame() {
if (!running) return;
if (visible) { try { step((now() - t0) / 1000); } catch (e) { running = false; return; } }
raf = W.requestAnimationFrame(frame);
}
raf = W.requestAnimationFrame(frame);
return { stop: function () { running = false; try { W.cancelAnimationFrame(raf); } catch (e) {} try { io.disconnect(); } catch (e) {} } };
}
function caption(host, html) {
var c = D.createElement('div');
c.style.cssText = 'text-align:center;font-weight:700;color:var(--pri2,#3730a3);margin-top:8px;font-size:1.02rem';
c.innerHTML = html; host.appendChild(c);
if (W.renderMathInElement) try { W.renderMathInElement(c, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return c;
}
/* ============================ ДЕМО 1: КОЛЕСО КАТИТСЯ (C = πd = 2πr) ============================ */
M.rollingCircle = function (host, opts) {
opts = opts || {}; var r = opts.r || 3;
var W0 = 520, H0 = 200;
var sc = sceneCanvas(host, W0, H0);
var cap = caption(host, '');
var PI = 3.14, period = 4.2;
function draw(tSec) {
var ctx = sc.ctx; var pri = cssVar('--pri', '#4f46e5'), acc = cssVar('--pri2', '#3730a3'), mut = cssVar('--muted', '#64748b');
if (!ctx) return;
var Rpx = 26 + r * 5; if (Rpx > 60) Rpx = 60;
var baseY = H0 - 46, startX = 34;
var p = (tSec % period) / period; /* 0..1 — один оборот */
var travel = p * 2 * Math.PI * Rpx;
var cx = startX + Rpx + travel, cy = baseY - Rpx, ang = p * 2 * Math.PI;
ctx.clearRect(0, 0, W0, H0);
/* линия земли */
ctx.strokeStyle = mut; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(20, baseY); ctx.lineTo(W0 - 20, baseY); ctx.stroke();
/* развёрнутый путь (длина окружности) — толстая лента */
ctx.strokeStyle = acc; ctx.lineWidth = 5; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(startX + Rpx, baseY + 10); ctx.lineTo(startX + Rpx + travel, baseY + 10); ctx.stroke();
/* колесо */
ctx.strokeStyle = pri; ctx.lineWidth = 3; ctx.fillStyle = 'rgba(79,70,229,0.08)';
ctx.beginPath(); ctx.arc(cx, cy, Rpx, 0, 2 * Math.PI); ctx.fill(); ctx.stroke();
/* спица + метка на ободе (видно вращение) */
var mx = cx + Rpx * Math.sin(ang), my = cy + Rpx * Math.cos(ang);
ctx.strokeStyle = pri; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(mx, my); ctx.stroke();
ctx.fillStyle = '#e11d48'; ctx.beginPath(); ctx.arc(mx, my, 5, 0, 2 * Math.PI); ctx.fill();
/* центр */
ctx.fillStyle = acc; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, 2 * Math.PI); ctx.fill();
/* подпись пройденного пути */
ctx.fillStyle = mut; ctx.font = '12px JetBrains Mono, monospace'; ctx.textAlign = 'center';
ctx.fillText('путь за ' + (p < 0.999 ? Math.round(p * 100) + '% оборота' : 'полный оборот'), startX + Rpx + travel / 2, baseY + 30);
}
var L = loop(host, draw);
cap.innerHTML = 'За один полный оборот колесо проходит путь, равный длине окружности: $C = 2\\pi r = \\pi d = ' + kf(2 * PI * r) + '$ (при $r=' + r + '$).';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop, host: host };
};
/* ============================ ДЕМО 2: ЗАМЕТАНИЕ ПЛОЩАДИ КРУГА (S = πr²) ============================ */
M.sweepArea = function (host, opts) {
opts = opts || {}; var r = opts.r || 3;
var W0 = 300, H0 = 300; var sc = sceneCanvas(host, W0, H0);
var cap = caption(host, '');
var PI = 3.14, period = 3.6;
function draw(tSec) {
var ctx = sc.ctx; if (!ctx) return;
var pri = cssVar('--pri', '#4f46e5'), acc = cssVar('--pri2', '#3730a3'), mut = cssVar('--muted', '#64748b');
var cx = W0 / 2, cy = H0 / 2, Rpx = 40 + r * 12; if (Rpx > 130) Rpx = 130;
var p = (tSec % period) / period; var sweep = p * 2 * Math.PI;
ctx.clearRect(0, 0, W0, H0);
/* контур круга */
ctx.strokeStyle = pri; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.arc(cx, cy, Rpx, 0, 2 * Math.PI); ctx.stroke();
/* заметённый сектор */
ctx.fillStyle = 'rgba(79,70,229,0.22)'; ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.arc(cx, cy, Rpx, -Math.PI / 2, -Math.PI / 2 + sweep); ctx.closePath(); ctx.fill();
/* движущийся радиус */
var ex = cx + Rpx * Math.cos(-Math.PI / 2 + sweep), ey = cy + Rpx * Math.sin(-Math.PI / 2 + sweep);
ctx.strokeStyle = '#e11d48'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex, ey); ctx.stroke();
/* подпись r */
ctx.fillStyle = acc; ctx.font = '13px JetBrains Mono, monospace'; ctx.textAlign = 'left';
ctx.fillText('r = ' + r, cx + 6, cy - 6);
ctx.fillStyle = acc; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, 2 * Math.PI); ctx.fill();
}
var L = loop(host, draw);
cap.innerHTML = 'Радиус заметает круг — его площадь $S = \\pi r^2 = ' + kf(PI * r * r) + '$ (при $r=' + r + '$).';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop, host: host };
};
/* ============================ ДЕМО 3: ПЛОЩАДНАЯ МОДЕЛЬ УМНОЖЕНИЯ (a · b) ============================ */
M.areaModel = function (host, opts) {
opts = opts || {}; var a = opts.a || 1.2, b = opts.b || 0.3;
var W0 = 360, H0 = 300; var sc = sceneCanvas(host, W0, H0);
var cap = caption(host, '');
var period = 2.8;
function draw(tSec) {
var ctx = sc.ctx; if (!ctx) return;
var pri = cssVar('--pri', '#4f46e5'), acc = cssVar('--pri2', '#3730a3'), mut = cssVar('--muted', '#64748b'), bd = cssVar('--border', '#e2e8f0');
var pad = 40, plotW = W0 - pad - 16, plotH = H0 - pad - 16;
var maxU = Math.max(3, Math.ceil(a), Math.ceil(b)); /* единиц по каждой стороне на сетке (адаптивно) */
var unit = Math.min(plotW, plotH) / maxU;
var x0 = pad, y0 = H0 - pad;
var aw = a * unit, bh = b * unit;
var p = Math.min(1, (tSec % period) / (period * 0.7)); /* заполнение 0..1, потом пауза */
ctx.clearRect(0, 0, W0, H0);
/* сетка 0,1 */
ctx.strokeStyle = bd; ctx.lineWidth = 0.7;
for (var gx = 0; gx <= maxU * 10; gx++) { var X = x0 + gx * unit / 10; if (X > x0 + maxU * unit + 0.5) break; ctx.globalAlpha = (gx % 10 === 0) ? 1 : 0.4; ctx.beginPath(); ctx.moveTo(X, y0); ctx.lineTo(X, y0 - maxU * unit); ctx.stroke(); }
for (var gy = 0; gy <= maxU * 10; gy++) { var Y = y0 - gy * unit / 10; ctx.globalAlpha = (gy % 10 === 0) ? 1 : 0.4; ctx.beginPath(); ctx.moveTo(x0, Y); ctx.lineTo(x0 + maxU * unit, Y); ctx.stroke(); }
ctx.globalAlpha = 1;
/* прямоугольник a×b, заполняется слева направо */
ctx.fillStyle = 'rgba(79,70,229,0.28)';
ctx.fillRect(x0, y0 - bh, aw * p, bh);
ctx.strokeStyle = pri; ctx.lineWidth = 2.5; ctx.strokeRect(x0, y0 - bh, aw, bh);
/* оси-подписи */
ctx.fillStyle = acc; ctx.font = '13px JetBrains Mono, monospace';
ctx.textAlign = 'center'; ctx.fillText('a = ' + kf(a), x0 + aw / 2, y0 + 22);
ctx.save(); ctx.translate(x0 - 14, y0 - bh / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('b = ' + kf(b), 0, 0); ctx.restore();
}
var L = loop(host, draw);
var prod = Math.round(a * b * 1e6) / 1e6;
cap.innerHTML = 'Площадь прямоугольника = произведению сторон: $' + kf(a) + ' \\cdot ' + kf(b) + ' = ' + kf(prod) + '$.';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop, host: host };
};
/* ============================ ДЕМО 4: ПРЫЖКИ ПО ЧИСЛОВОЙ ПРЯМОЙ (a + b) ============================ */
M.numberLineWalk = function (host, opts) {
opts = opts || {}; var a = opts.a != null ? opts.a : 3, b = opts.b != null ? opts.b : -5;
var sum = a + b, W0 = 540, H0 = 150;
var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var min = Math.min(-3, 0, a, sum) - 1, max = Math.max(3, 0, a, sum) + 1, period = 4.2;
function X(v) { var pad = 30; return pad + (v - min) / (max - min) * (W0 - 2 * pad); }
function arrow(ctx, x1, x2, y, col) {
ctx.strokeStyle = col; ctx.fillStyle = col; ctx.lineWidth = 3; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x1, y); ctx.lineTo(x2, y); ctx.stroke();
var d = x2 >= x1 ? 1 : -1; ctx.beginPath(); ctx.moveTo(x2, y); ctx.lineTo(x2 - d * 9, y - 5); ctx.lineTo(x2 - d * 9, y + 5); ctx.closePath(); ctx.fill();
}
function draw(t) {
var ctx = sc.ctx; if (!ctx) return; var mut = cssVar('--muted', '#64748b'), acc = cssVar('--pri2', '#3730a3');
var axisY = H0 - 50; ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = mut; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(15, axisY); ctx.lineTo(W0 - 15, axisY); ctx.stroke();
ctx.font = '11px JetBrains Mono, monospace'; ctx.textAlign = 'center';
for (var v = Math.ceil(min); v <= Math.floor(max); v++) { var x = X(v); ctx.strokeStyle = mut; ctx.beginPath(); ctx.moveTo(x, axisY - 5); ctx.lineTo(x, axisY + 5); ctx.stroke(); ctx.fillStyle = (v === 0 ? acc : mut); ctx.fillText(v, x, axisY + 20); }
var p = (t % period) / period, p1 = Math.min(1, p / 0.4), p2 = Math.max(0, Math.min(1, (p - 0.45) / 0.4));
arrow(ctx, X(0), X(0) + (X(a) - X(0)) * p1, axisY - 14, a >= 0 ? '#059669' : '#e11d48');
if (p2 > 0) arrow(ctx, X(a), X(a) + (X(sum) - X(a)) * p2, axisY - 30, b >= 0 ? '#059669' : '#e11d48');
if (p > 0.88) { ctx.fillStyle = '#e11d48'; ctx.beginPath(); ctx.arc(X(sum), axisY, 6, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = acc; ctx.font = '14px Unbounded, sans-serif'; ctx.fillText('' + sum, X(sum), axisY - 42); }
}
var L = loop(host, draw);
cap.innerHTML = '$' + a + ' + (' + b + ') = ' + sum + '$ — от нуля шагаем ' + (a >= 0 ? 'вправо' : 'влево') + ' на ' + Math.abs(a) + ', затем ' + (b >= 0 ? 'вправо' : 'влево') + ' на ' + Math.abs(b) + '.';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop };
};
/* ============================ ДЕМО 5: МАШИНКА + ГРАФИК «ПУТЬ–ВРЕМЯ» ============================ */
M.carGraph = function (host, opts) {
var W0 = 460, H0 = 330; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var J = [{ t: 0, s: 0 }, { t: 1, s: 40 }, { t: 2, s: 80 }, { t: 3, s: 80 }, { t: 4, s: 120 }, { t: 5, s: 160 }];
var Tmax = 5, Smax = 160, period = 7;
function sAt(tt) { for (var i = 0; i < J.length - 1; i++) { if (tt >= J[i].t && tt <= J[i + 1].t) { var f = (tt - J[i].t) / ((J[i + 1].t - J[i].t) || 1); return J[i].s + f * (J[i + 1].s - J[i].s); } } return J[J.length - 1].s; }
function draw(t) {
var ctx = sc.ctx; if (!ctx) return;
var pri = cssVar('--pri', '#059669'), mut = cssVar('--muted', '#64748b'), bd = cssVar('--border', '#e2e8f0');
var cur = (t % period) / period * Tmax;
ctx.clearRect(0, 0, W0, H0);
var roadY = 52, rx0 = 24, rx1 = W0 - 24;
ctx.strokeStyle = bd; ctx.lineWidth = 8; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(rx0, roadY); ctx.lineTo(rx1, roadY); ctx.stroke();
var carX = rx0 + (sAt(cur) / Smax) * (rx1 - rx0);
ctx.fillStyle = pri; ctx.fillRect(carX - 13, roadY - 11, 26, 14);
ctx.fillStyle = '#1e293b'; ctx.beginPath(); ctx.arc(carX - 7, roadY + 4, 4, 0, 6.3); ctx.arc(carX + 7, roadY + 4, 4, 0, 6.3); ctx.fill();
ctx.fillStyle = mut; ctx.font = '13px Inter, sans-serif'; ctx.textAlign = 'center';
ctx.fillText('время: ' + (Math.round(cur * 10) / 10) + ' ч · путь: ' + Math.round(sAt(cur)) + ' км', W0 / 2, 24);
var gx0 = 46, gy0 = H0 - 28, gw = W0 - 70, gh = H0 - 120;
function GX(tt) { return gx0 + (tt / Tmax) * gw; } function GY(ss) { return gy0 - (ss / Smax) * gh; }
ctx.strokeStyle = mut; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(gx0, gy0 - gh); ctx.lineTo(gx0, gy0); ctx.lineTo(gx0 + gw, gy0); ctx.stroke();
ctx.fillStyle = mut; ctx.font = '11px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('t, ч', gx0 + gw, gy0 + 16); ctx.textAlign = 'left'; ctx.fillText('s, км', gx0 - 38, gy0 - gh + 4);
ctx.strokeStyle = bd; ctx.lineWidth = 1.5; ctx.beginPath(); J.forEach(function (pt, i) { var x = GX(pt.t), y = GY(pt.s); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.stroke();
ctx.strokeStyle = pri; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(GX(0), GY(0));
for (var tt = 0; tt <= cur; tt += 0.04) ctx.lineTo(GX(tt), GY(sAt(tt)));
ctx.stroke();
ctx.fillStyle = '#e11d48'; ctx.beginPath(); ctx.arc(GX(cur), GY(sAt(cur)), 5, 0, 2 * Math.PI); ctx.fill();
}
var L = loop(host, draw);
cap.innerHTML = 'Машина едет — график «путь–время» вычерчивается сам. Где линия <b>горизонтальна</b> (с 2 до 3 ч) — машина <b>стоит</b>: время идёт, а путь не растёт.';
return { stop: L.stop };
};
/* ============================ ДЕМО 6: ЖИВОЙ ГРАФИК y=kx / y=k/x ============================ */
M.plotLive = function (host, opts) {
opts = opts || {};
var W0 = 360, H0 = 360; var sc = sceneCanvas(host, W0, H0);
var st = { k: opts.k != null ? opts.k : 2, target: opts.k != null ? opts.k : 2, mode: opts.mode || 'kx' };
var XMIN = -6, XMAX = 6, YMIN = -6, YMAX = 6, pad = 24;
function X(x) { return pad + (x - XMIN) / (XMAX - XMIN) * (W0 - 2 * pad); }
function Y(y) { return H0 - pad - (y - YMIN) / (YMAX - YMIN) * (H0 - 2 * pad); }
function draw() {
var ctx = sc.ctx; if (!ctx) return;
var pri = cssVar('--pri', '#059669'), acc = cssVar('--pri2', '#047857'), bd = cssVar('--border', '#e2e8f0'), axc = cssVar('--text', '#0f172a');
st.k += (st.target - st.k) * 0.12;
ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = bd; ctx.lineWidth = 0.8;
for (var gx = XMIN; gx <= XMAX; gx++) { if (gx === 0) continue; ctx.beginPath(); ctx.moveTo(X(gx), Y(YMIN)); ctx.lineTo(X(gx), Y(YMAX)); ctx.stroke(); }
for (var gy = YMIN; gy <= YMAX; gy++) { if (gy === 0) continue; ctx.beginPath(); ctx.moveTo(X(XMIN), Y(gy)); ctx.lineTo(X(XMAX), Y(gy)); ctx.stroke(); }
ctx.strokeStyle = axc; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(X(XMIN), Y(0)); ctx.lineTo(X(XMAX), Y(0)); ctx.moveTo(X(0), Y(YMIN)); ctx.lineTo(X(0), Y(YMAX)); ctx.stroke();
ctx.fillStyle = axc; ctx.font = 'italic 13px serif'; ctx.textAlign = 'left'; ctx.fillText('x', X(XMAX) - 4, Y(0) + 16); ctx.fillText('y', X(0) + 8, Y(YMAX) + 12);
ctx.strokeStyle = pri; ctx.lineWidth = 3; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
if (st.mode === 'kx') {
ctx.beginPath(); var started = false;
for (var x = XMIN; x <= XMAX; x += 0.06) { var y = st.k * x; if (y < YMIN - 1 || y > YMAX + 1) { started = false; continue; } if (started) ctx.lineTo(X(x), Y(y)); else { ctx.moveTo(X(x), Y(y)); started = true; } }
ctx.stroke();
} else {
[[XMIN, -0.12], [0.12, XMAX]].forEach(function (seg) {
ctx.beginPath(); var s2 = false;
for (var x2 = seg[0]; x2 <= seg[1]; x2 += 0.04) { if (Math.abs(x2) < 0.08) continue; var y2 = st.k / x2; if (y2 < YMIN - 1 || y2 > YMAX + 1) { s2 = false; continue; } if (s2) ctx.lineTo(X(x2), Y(y2)); else { ctx.moveTo(X(x2), Y(y2)); s2 = true; } }
ctx.stroke();
});
}
ctx.fillStyle = acc; ctx.font = 'bold 16px Inter, sans-serif'; ctx.textAlign = 'left';
var kk = Math.round(st.k * 10) / 10;
ctx.fillText(st.mode === 'kx' ? ('y = ' + kf(kk) + ' · x') : ('y = ' + kf(kk) + ' / x'), pad + 4, pad + 10);
}
var L = loop(host, draw);
return {
stop: L.stop,
setK: function (v) { st.target = v; if (st.mode === 'kdx' && Math.abs(v) < 0.5) st.target = (v < 0 ? -0.5 : 0.5); },
setMode: function (m) { st.mode = m; if (m === 'kdx' && Math.abs(st.target) < 0.5) st.target = 0.5; }
};
};
/* ============================ ДЕМО 7: ТЕРМОМЕТР (±числа, модуль) ============================ */
M.thermometer = function (host, opts) {
opts = opts || {}; var W0 = 220, H0 = 320; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var st = { v: opts.value != null ? opts.value : 5, target: opts.value != null ? opts.value : 5 };
var MIN = -10, MAX = 10, cx = 64, top = 26, bot = H0 - 56, bulbR = 18;
function Y(v) { return bot - (v - MIN) / (MAX - MIN) * (bot - top); }
function draw() {
var ctx = sc.ctx; if (!ctx) return; st.v += (st.target - st.v) * 0.15;
var mut = cssVar('--muted', '#64748b'), txt = cssVar('--text', '#0f172a');
ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = mut; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx - 9, top); ctx.arc(cx, top, 9, Math.PI, 0); ctx.lineTo(cx + 9, bot); ctx.moveTo(cx - 9, top); ctx.lineTo(cx - 9, bot); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, bot + bulbR - 4, bulbR, 0, 2 * Math.PI); ctx.fillStyle = '#fee2e2'; ctx.fill(); ctx.strokeStyle = mut; ctx.stroke();
ctx.font = '11px JetBrains Mono, monospace'; ctx.textAlign = 'left';
for (var v = MIN; v <= MAX; v += 2) { var y = Y(v); ctx.strokeStyle = mut; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx + 12, y); ctx.lineTo(cx + 18, y); ctx.stroke(); ctx.fillStyle = (v === 0 ? txt : mut); ctx.fillText(v + '°', cx + 22, y + 4); }
var col = st.v >= 0 ? '#dc2626' : '#2563eb', y0 = Y(0), yv = Y(st.v);
ctx.fillStyle = col; ctx.fillRect(cx - 5, Math.min(y0, yv), 10, Math.abs(yv - y0));
ctx.beginPath(); ctx.arc(cx, bot + bulbR - 4, bulbR - 3, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath(); ctx.arc(cx, yv, 4, 0, 2 * Math.PI); ctx.fill();
ctx.fillStyle = txt; ctx.font = 'bold 17px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(Math.round(st.v) + '°', cx, H0 - 14);
}
var L = loop(host, draw);
function setCap(v) { if (!cap) return; cap.innerHTML = '$' + v + '°$ — это ' + (v > 0 ? 'тепло, выше нуля' : (v < 0 ? 'мороз, ниже нуля' : 'ровно ноль')) + '. Модуль $|' + v + '| = ' + Math.abs(v) + '$ — это расстояние до нуля.'; if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} }
setCap(st.target);
return { stop: L.stop, set: function (v) { st.target = v; setCap(v); } };
};
function _riA(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
/* ============================ ДЕМО 8: УМНОЖЕНИЕ КАК ПРЫЖКИ (a · b) ============================ */
M.numberLineJumps = function (host, opts) {
opts = opts || {}; var a = opts.a != null ? opts.a : 3, b = opts.b != null ? opts.b : -2; // a прыжков по b
var prod = a * b, W0 = 540, H0 = 150; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var min = Math.min(-2, 0, prod) - 1, max = Math.max(2, 0, prod) + 1, period = Math.max(3.5, a * 0.8 + 1.6);
function X(v) { var pad = 30; return pad + (v - min) / (max - min) * (W0 - 2 * pad); }
function hop(ctx, x1, x2, baseY, col) {
var mx = (x1 + x2) / 2; ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x1, baseY); ctx.quadraticCurveTo(mx, baseY - 24, x2, baseY); ctx.stroke();
var d = x2 >= x1 ? 1 : -1; ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x2, baseY); ctx.lineTo(x2 - d * 7, baseY - 6); ctx.lineTo(x2 - d * 7, baseY + 2); ctx.closePath(); ctx.fill();
}
function draw(t) {
var ctx = sc.ctx; if (!ctx) return; var mut = cssVar('--muted', '#64748b'), acc = cssVar('--pri2', '#3730a3');
var axisY = H0 - 46; ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = mut; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(15, axisY); ctx.lineTo(W0 - 15, axisY); ctx.stroke();
ctx.font = '11px JetBrains Mono, monospace'; ctx.textAlign = 'center';
for (var v = Math.ceil(min); v <= Math.floor(max); v++) { var x = X(v); ctx.strokeStyle = mut; ctx.beginPath(); ctx.moveTo(x, axisY - 5); ctx.lineTo(x, axisY + 5); ctx.stroke(); ctx.fillStyle = (v === 0 ? acc : mut); ctx.fillText(v, x, axisY + 20); }
var p = (t % period) / period, prog = p * (a + 0.5), doneJumps = Math.min(a, Math.floor(prog)), partial = Math.min(1, prog - doneJumps);
var col = b >= 0 ? '#059669' : '#e11d48', prev = 0;
for (var j = 0; j < doneJumps; j++) { hop(ctx, X(prev), X(prev + b), axisY, col); prev += b; }
if (doneJumps < a) { hop(ctx, X(prev), X(prev + b * partial), axisY, col); }
var pos = doneJumps >= a ? prod : prev;
ctx.fillStyle = '#e11d48'; ctx.beginPath(); ctx.arc(X(pos), axisY, 5, 0, 2 * Math.PI); ctx.fill();
if (doneJumps >= a) { ctx.fillStyle = acc; ctx.font = '14px Unbounded, sans-serif'; ctx.fillText('' + prod, X(prod), axisY - 30); }
}
var L = loop(host, draw);
var bp = b < 0 ? '(' + b + ')' : '' + b;
cap.innerHTML = '$' + a + ' \\cdot ' + bp + ' = ' + prod + '$ — это ' + a + ' ' + (a === 1 ? 'прыжок' : (a < 5 ? 'прыжка' : 'прыжков')) + ' по ' + b + ' от нуля.';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop };
};
/* ============================ ДЕМО 9: КООРДИНАТНЫЙ ТИР («поставь точку») ============================ */
M.coordGame = function (host, opts) {
var W0 = 320, H0 = 320; var sc = sceneCanvas(host, W0, H0);
var ui = D.createElement('div'); ui.style.cssText = 'text-align:center;margin-top:8px';
ui.innerHTML = '<div class="qbox" id="cg-q" style="margin-bottom:6px"></div><div style="color:var(--muted);font-size:.9rem">Очки: <b id="cg-s">0</b> · кликни по узлу сетки</div>';
host.appendChild(ui);
var XMIN = -5, XMAX = 5, YMIN = -5, YMAX = 5, pad = 24;
function X(x) { return pad + (x - XMIN) / (XMAX - XMIN) * (W0 - 2 * pad); }
function Y(y) { return H0 - pad - (y - YMIN) / (YMAX - YMIN) * (H0 - 2 * pad); }
var st = { tx: 2, ty: 3, score: 0, placed: null, ok: false, reveal: 0 };
function setQ() { var q = ui.querySelector('#cg-q'); if (q) { q.innerHTML = 'Поставь точку $(' + st.tx + ';\\,' + st.ty + ')$'; if (W.renderMathInElement) try { W.renderMathInElement(q, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} } }
function newTarget() { st.tx = _riA(-5, 5); st.ty = _riA(-5, 5); st.placed = null; st.reveal = 0; setQ(); }
function draw() {
var ctx = sc.ctx; if (!ctx) return; var bd = cssVar('--border', '#e2e8f0'), axc = cssVar('--text', '#0f172a'), mut = cssVar('--muted', '#64748b');
ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = bd; ctx.lineWidth = 0.8;
for (var gx = XMIN; gx <= XMAX; gx++) { ctx.beginPath(); ctx.moveTo(X(gx), Y(YMIN)); ctx.lineTo(X(gx), Y(YMAX)); ctx.stroke(); }
for (var gy = YMIN; gy <= YMAX; gy++) { ctx.beginPath(); ctx.moveTo(X(XMIN), Y(gy)); ctx.lineTo(X(XMAX), Y(gy)); ctx.stroke(); }
ctx.strokeStyle = axc; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(X(XMIN), Y(0)); ctx.lineTo(X(XMAX), Y(0)); ctx.moveTo(X(0), Y(YMIN)); ctx.lineTo(X(0), Y(YMAX)); ctx.stroke();
ctx.fillStyle = axc; ctx.font = 'italic 12px serif'; ctx.fillText('x', X(XMAX) - 2, Y(0) + 14); ctx.fillText('y', X(0) + 7, Y(YMAX) + 11);
if (st.reveal > 0) { ctx.fillStyle = '#059669'; ctx.beginPath(); ctx.arc(X(st.tx), Y(st.ty), 7, 0, 2 * Math.PI); ctx.fill(); }
if (st.placed) { ctx.fillStyle = st.ok ? '#059669' : '#e11d48'; ctx.beginPath(); ctx.arc(X(st.placed.x), Y(st.placed.y), 6, 0, 2 * Math.PI); ctx.fill(); }
}
var L = loop(host, draw);
if (!HEADLESS && sc.ctx) {
sc.cv.style.cursor = 'crosshair';
sc.cv.addEventListener('pointerdown', function (e) {
var r = sc.cv.getBoundingClientRect();
var dx = Math.round(XMIN + (e.clientX - r.left) / r.width * (XMAX - XMIN));
var dy = Math.round(YMIN + (r.height - (e.clientY - r.top)) / r.height * (YMAX - YMIN));
dx = Math.max(XMIN, Math.min(XMAX, dx)); dy = Math.max(YMIN, Math.min(YMAX, dy));
st.placed = { x: dx, y: dy }; st.ok = (dx === st.tx && dy === st.ty);
if (st.ok) { st.score++; var s = ui.querySelector('#cg-s'); if (s) s.textContent = st.score; setTimeout(newTarget, 700); }
else { st.reveal = 1; setTimeout(function () { st.reveal = 0; st.placed = null; }, 1100); }
});
}
setQ();
return { stop: L.stop };
};
/* ============================ ДЕМО 10: СИММЕТРИЯ (осевая / центральная) ============================ */
M.reflectFold = function (host, opts) {
opts = opts || {}; var mode = opts.mode || 'axial';
var W0 = 340, H0 = 340; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var XMIN = -6, XMAX = 6, YMIN = -6, YMAX = 6, pad = 22, period = 4;
var fig = [{ x: 1, y: 1 }, { x: 4, y: 1 }, { x: 2, y: 4 }];
function img(p) { return mode === 'central' ? { x: -p.x, y: -p.y } : { x: -p.x, y: p.y }; }
function X(x) { return pad + (x - XMIN) / (XMAX - XMIN) * (W0 - 2 * pad); }
function Y(y) { return H0 - pad - (y - YMIN) / (YMAX - YMIN) * (H0 - 2 * pad); }
function poly(ctx, pts, fill, stroke, dash) {
ctx.beginPath(); pts.forEach(function (p, i) { var x = X(p.x), y = Y(p.y); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.closePath();
ctx.setLineDash(dash || []); if (fill) { ctx.fillStyle = fill; ctx.fill(); } ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); ctx.setLineDash([]);
}
function draw(t) {
var ctx = sc.ctx; if (!ctx) return; var bd = cssVar('--border', '#e2e8f0'), axc = cssVar('--text', '#0f172a');
ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = bd; ctx.lineWidth = 0.7;
for (var gx = XMIN; gx <= XMAX; gx++) { ctx.beginPath(); ctx.moveTo(X(gx), Y(YMIN)); ctx.lineTo(X(gx), Y(YMAX)); ctx.stroke(); }
for (var gy = YMIN; gy <= YMAX; gy++) { ctx.beginPath(); ctx.moveTo(X(XMIN), Y(gy)); ctx.lineTo(X(XMAX), Y(gy)); ctx.stroke(); }
ctx.strokeStyle = axc; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(X(XMIN), Y(0)); ctx.lineTo(X(XMAX), Y(0)); ctx.moveTo(X(0), Y(YMIN)); ctx.lineTo(X(0), Y(YMAX)); ctx.stroke();
if (mode === 'axial') { ctx.strokeStyle = '#e11d48'; ctx.lineWidth = 2.5; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(X(0), Y(YMIN)); ctx.lineTo(X(0), Y(YMAX)); ctx.stroke(); ctx.setLineDash([]); }
else { ctx.fillStyle = '#e11d48'; ctx.beginPath(); ctx.arc(X(0), Y(0), 5, 0, 2 * Math.PI); ctx.fill(); ctx.font = '12px JetBrains Mono, monospace'; ctx.fillText('O', X(0) + 8, Y(0) - 8); }
var imgPts = fig.map(img);
poly(ctx, imgPts, 'rgba(225,29,72,0.06)', 'rgba(225,29,72,0.5)', [5, 4]);
poly(ctx, fig, 'rgba(37,99,235,0.12)', '#2563eb', null);
var p = (t % period) / period, e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
var ghost = fig.map(function (pt, i) { var ip = imgPts[i]; return { x: pt.x + (ip.x - pt.x) * e, y: pt.y + (ip.y - pt.y) * e }; });
poly(ctx, ghost, 'rgba(217,119,6,0.32)', '#d97706', null);
}
var L = loop(host, draw);
cap.innerHTML = mode === 'central'
? 'Центральная симметрия: точка $(x;\\,y)$ переходит в $(-x;\\,-y)$ — поворот на $180°$ вокруг центра $O$.'
: 'Осевая симметрия: точка $(x;\\,y)$ переходит в $(-x;\\,y)$ — отражение через ось $Oy$, как складывание листа по оси.';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop };
};
/* ============================ ДЕМО 11: ПОЛОСА ПРОЦЕНТА (% ↔ дробь ↔ десятичная) ============================ */
M.barModel = function (host, opts) {
opts = opts || {}; var p = opts.percent != null ? opts.percent : 35;
var W0 = 460, H0 = 130; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var st = { p: p, target: p }; var pad = 30, barY = 30, barH = 42, x0 = pad, x1 = W0 - pad;
function draw() {
var ctx = sc.ctx; if (!ctx) return; st.p += (st.target - st.p) * 0.18;
var pri = cssVar('--pri', '#0891b2'), acc = cssVar('--pri2', '#0e7490'), mut = cssVar('--muted', '#64748b'), bd = cssVar('--border', '#e2e8f0');
ctx.clearRect(0, 0, W0, H0);
ctx.fillStyle = bd; ctx.fillRect(x0, barY, x1 - x0, barH);
var w = (x1 - x0) * Math.max(0, Math.min(100, st.p)) / 100;
ctx.fillStyle = pri; ctx.fillRect(x0, barY, w, barH);
ctx.strokeStyle = acc; ctx.lineWidth = 2; ctx.strokeRect(x0, barY, x1 - x0, barH);
ctx.font = '11px JetBrains Mono, monospace'; ctx.textAlign = 'center';
[0, 25, 50, 75, 100].forEach(function (tk) { var x = x0 + (x1 - x0) * tk / 100; ctx.strokeStyle = mut; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, barY + barH); ctx.lineTo(x, barY + barH + 6); ctx.stroke(); ctx.fillStyle = mut; ctx.fillText(tk + '%', x, barY + barH + 20); });
var pr = Math.round(st.p);
if (w > 36) { ctx.fillStyle = '#fff'; ctx.font = 'bold 15px Inter, sans-serif'; ctx.fillText(pr + '%', x0 + w / 2, barY + barH / 2 + 5); }
}
var L = loop(host, draw);
function gcd(a, b) { return b ? gcd(b, a % b) : a; }
function setCap(v) { var k = gcd(v, 100) || 1; cap.innerHTML = '$' + v + '\\% = ' + (v / 100).toString().replace('.', '{,}') + ' = \\dfrac{' + (v / k) + '}{' + (100 / k) + '}$'; if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} }
setCap(p);
return { stop: L.stop, set: function (v) { st.target = v; setCap(v); } };
};
/* ============================ ДЕМО 12: ФИЛЬТР МНОЖЕСТВА (числа сквозь свойство) ============================ */
M.setFilter = function (host, opts) {
opts = opts || {}; var W0 = 420, H0 = 300; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var st = { label: opts.label || 'чётные', test: opts.test || function (n) { return n % 2 === 0; }, nums: opts.nums || [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] };
var filterY = 132, boxY = 200, idx = 0, cur = null, startT = 0, period = 1.0, collected = [];
function reset() { idx = 0; cur = null; collected = []; startT = 0; }
function draw(t) {
var ctx = sc.ctx; if (!ctx) return;
var pri = cssVar('--pri', '#7c3aed'), acc = cssVar('--pri2', '#6d28d9'), mut = cssVar('--muted', '#64748b');
ctx.clearRect(0, 0, W0, H0);
ctx.fillStyle = 'rgba(124,58,237,0.12)'; ctx.fillRect(40, filterY, W0 - 80, 16);
ctx.strokeStyle = pri; ctx.lineWidth = 2; ctx.strokeRect(40, filterY, W0 - 80, 16);
ctx.fillStyle = acc; ctx.font = 'bold 13px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Фильтр: ' + st.label, W0 / 2, filterY - 8);
ctx.strokeStyle = mut; ctx.setLineDash([5, 4]); ctx.strokeRect(40, boxY, W0 - 80, 64); ctx.setLineDash([]);
ctx.fillStyle = mut; ctx.font = '14px JetBrains Mono, monospace'; ctx.textAlign = 'left'; ctx.fillText('{ ' + collected.join('; ') + ' }', 52, boxY + 38);
ctx.textAlign = 'center';
for (var q = idx; q < st.nums.length; q++) { var x = 70 + (q - idx) * 28; if (x > W0 - 40) break; ctx.fillStyle = mut; ctx.font = '14px JetBrains Mono, monospace'; ctx.fillText(st.nums[q], x, 36); }
if (cur != null) {
var p = Math.min(1, (t - startT) / period), matched = st.test(cur);
var toY = matched ? boxY + 38 : filterY - 4, y = 36 + (toY - 36) * p, col = matched ? '#059669' : '#e11d48';
ctx.fillStyle = col; ctx.font = 'bold 19px Inter, sans-serif'; ctx.fillText(cur, W0 / 2, y);
if (p >= 1) { if (matched && collected.indexOf(cur) < 0) collected.push(cur); cur = null; startT = t + 0.25; }
} else if (idx < st.nums.length) { if (t - startT > 0.25) { cur = st.nums[idx]; idx++; startT = t; } }
else if (t - startT > 1.4) { reset(); startT = t; }
}
var L = loop(host, draw);
cap.innerHTML = 'Числа по очереди проходят через фильтр. Подходящие падают в множество, остальные — отсеиваются.';
return { stop: L.stop, set: function (label, test) { st.label = label; st.test = test; reset(); } };
};
/* ============================ ДЕМО 13: РАСТУЩАЯ КРУГОВАЯ ДИАГРАММА ============================ */
M.pieGrow = function (host, opts) {
opts = opts || {}; var W0 = 240, H0 = 240; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var pal = ['#0891b2', '#f59e0b', '#e11d48', '#059669', '#7c3aed'];
var st = { segs: opts.segs || [{ label: 'A', value: 1 }], t0: null }; var period = 3.4;
function draw(t) {
var ctx = sc.ctx; if (!ctx) return; if (st.t0 === null) st.t0 = t; var p = Math.min(1, ((t - st.t0) % period) / (period * 0.7));
var cx = W0 / 2, cy = H0 / 2, r = W0 / 2 - 12, total = st.segs.reduce(function (a, s) { return a + s.value; }, 0) || 1, sweep = -Math.PI / 2 + p * 2 * Math.PI;
ctx.clearRect(0, 0, W0, H0); var ang = -Math.PI / 2;
st.segs.forEach(function (s, i) {
var frac = s.value / total, a0 = ang, a1 = ang + frac * 2 * Math.PI, drawTo = Math.min(a1, sweep);
if (drawTo > a0) {
ctx.fillStyle = s.color || pal[i % pal.length]; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, a0, drawTo); ctx.closePath(); ctx.fill();
ctx.strokeStyle = cssVar('--card', '#fff'); ctx.lineWidth = 1.5; ctx.stroke();
if (sweep >= a1 && frac > 0.05) { var mid = a0 + frac * Math.PI, lx = cx + r * 0.6 * Math.cos(mid), ly = cy + r * 0.6 * Math.sin(mid); ctx.fillStyle = '#fff'; ctx.font = 'bold 12px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(Math.round(frac * 100) + '%', lx, ly + 4); }
}
ang = a1;
});
}
var L = loop(host, draw);
return { stop: L.stop, set: function (segs) { st.segs = segs; st.t0 = null; } };
};
/* ============================ ДЕМО 14: ВЕСЫ ПРОПОРЦИИ (a·d ? b·c) ============================ */
M.balanceScale = function (host, opts) {
opts = opts || {}; var W0 = 380, H0 = 230; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var st = { a: 3, b: 4, c: 6, d: 8, ang: 0, target: 0 }; if (opts.a != null) { st.a = opts.a; st.b = opts.b; st.c = opts.c; st.d = opts.d; }
function info() { var L = st.a * st.d, R = st.b * st.c; return { L: L, R: R, eq: L === R }; }
function setTarget() { var f = info(); st.target = Math.max(-0.26, Math.min(0.26, (f.R - f.L) * 0.02)); }
setTarget();
function draw() {
var ctx = sc.ctx; if (!ctx) return; st.ang += (st.target - st.ang) * 0.15;
var mut = cssVar('--muted', '#64748b'), pri = cssVar('--pri', '#0891b2'); var f = info();
ctx.clearRect(0, 0, W0, H0); var cx = W0 / 2, pivotY = 64, beamLen = 128;
ctx.strokeStyle = mut; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(cx, pivotY); ctx.lineTo(cx, H0 - 26); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx - 40, H0 - 26); ctx.lineTo(cx + 40, H0 - 26); ctx.stroke();
var dx = Math.cos(st.ang) * beamLen, dy = Math.sin(st.ang) * beamLen;
ctx.strokeStyle = pri; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(cx - dx, pivotY - dy); ctx.lineTo(cx + dx, pivotY + dy); ctx.stroke();
function pan(x, y, label, col) { ctx.strokeStyle = mut; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + 22); ctx.stroke(); ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(x, y + 30, 30, 9, 0, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold 14px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(label, x, y + 34); }
pan(cx - dx, pivotY - dy, '' + f.L, f.eq ? '#059669' : '#0891b2');
pan(cx + dx, pivotY + dy, '' + f.R, f.eq ? '#059669' : '#e11d48');
ctx.fillStyle = f.eq ? '#059669' : mut; ctx.font = 'bold 13px Inter, sans-serif'; ctx.textAlign = 'center';
ctx.fillText('a·d = ' + f.L + (f.eq ? ' = ' : ' ≠ ') + f.R + ' = b·c' + (f.eq ? ' ✓ пропорция верна' : ''), cx, 22);
}
var L = loop(host, draw);
cap.innerHTML = 'Пропорция $a:b = c:d$ верна, когда произведение крайних равно произведению средних: $a\\cdot d = b\\cdot c$ (весы в равновесии).';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop, set: function (a, b, c, d) { st.a = a; st.b = b; st.c = c; st.d = d; setTarget(); } };
};
/* ============================ ДЕМО 15: ОБРАТНАЯ ПРОПОРЦИЯ = ПОСТОЯННАЯ ПЛОЩАДЬ ============================ */
M.constAreaRect = function (host, opts) {
opts = opts || {}; var K = opts.area || 12; var W0 = 360, H0 = 250; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var st = { w: 3, tw: 3 };
function draw() {
var ctx = sc.ctx; if (!ctx) return; st.w += (st.tw - st.w) * 0.18; var w = st.w, h = K / w;
var pri = cssVar('--pri', '#0891b2'), acc = cssVar('--pri2', '#0e7490'); var pad = 40, unit = Math.min((W0 - 2 * pad) / K, (H0 - 2 * pad) / K), x0 = pad, y0 = H0 - pad;
ctx.clearRect(0, 0, W0, H0);
ctx.fillStyle = 'rgba(8,145,178,0.25)'; ctx.fillRect(x0, y0 - h * unit, w * unit, h * unit);
ctx.strokeStyle = pri; ctx.lineWidth = 2.5; ctx.strokeRect(x0, y0 - h * unit, w * unit, h * unit);
ctx.fillStyle = acc; ctx.font = '13px JetBrains Mono, monospace'; ctx.textAlign = 'center'; ctx.fillText('x = ' + (Math.round(w * 10) / 10), x0 + w * unit / 2, y0 + 20);
ctx.save(); ctx.translate(x0 - 16, y0 - h * unit / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('y = ' + (Math.round(h * 10) / 10), 0, 0); ctx.restore();
ctx.fillStyle = acc; ctx.font = 'bold 15px Inter, sans-serif'; ctx.fillText('x · y = ' + K + ' — площадь постоянна', W0 / 2, 22);
}
var L = loop(host, draw);
cap.innerHTML = 'Обратная пропорциональность: чем больше $x$, тем меньше $y$, но произведение (площадь) не меняется: $x\\cdot y = ' + K + '$.';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop, set: function (w) { st.tw = w; } };
};
/* ============================ КОМПОНЕНТ: ПОШАГОВЫЙ ПЛЕЕР (DOM, не canvas) ============================ */
M.stepPlayer = function (host, opts) {
opts = opts || {}; var steps = opts.steps || []; if (!steps.length) return { stop: function () {} };
var i = 0, auto = null;
host.innerHTML = '';
var wrap = D.createElement('div');
wrap.innerHTML =
'<div class="m6-step-view" style="min-height:58px;padding:14px 16px;background:var(--sec-acc-soft,var(--pri-soft));border-radius:10px;font-size:1.02rem;line-height:1.65"></div>'
+ '<div style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:10px;flex-wrap:wrap">'
+ '<button class="btn" data-act="prev" type="button">Назад</button>'
+ '<span class="m6-step-dots" style="display:inline-flex;gap:5px"></span>'
+ '<button class="btn primary" data-act="next" type="button">Дальше</button>'
+ '<button class="btn" data-act="auto" type="button">Авто</button></div>';
host.appendChild(wrap);
var view = wrap.querySelector('.m6-step-view'), dots = wrap.querySelector('.m6-step-dots');
var prevB = wrap.querySelector('[data-act="prev"]'), nextB = wrap.querySelector('[data-act="next"]'), autoB = wrap.querySelector('[data-act="auto"]');
for (var k = 0; k < steps.length; k++) { var dt = D.createElement('span'); dt.style.cssText = 'width:9px;height:9px;border-radius:50%;background:var(--border,#cbd5e1);transition:background .2s'; dots.appendChild(dt); }
function render() {
view.innerHTML = steps[i] || '';
if (W.renderMathInElement) try { W.renderMathInElement(view, { delimiters: [{ left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\[', right: '\\]', display: true }, { left: '\\(', right: '\\)', display: false }], throwOnError: false }); } catch (e) {}
var ds = dots.children; for (var k2 = 0; k2 < ds.length; k2++) ds[k2].style.background = (k2 === i ? 'var(--pri,#4f46e5)' : (k2 < i ? 'var(--ok,#10b981)' : 'var(--border,#cbd5e1)'));
prevB.disabled = i <= 0; nextB.disabled = i >= steps.length - 1;
prevB.style.opacity = prevB.disabled ? 0.5 : 1; nextB.style.opacity = nextB.disabled ? 0.5 : 1;
}
function go(d) { i = Math.max(0, Math.min(steps.length - 1, i + d)); render(); }
function stopAuto() { if (auto) { try { clearInterval(auto); } catch (e) {} auto = null; autoB.textContent = 'Авто'; } }
prevB.addEventListener('click', function () { stopAuto(); go(-1); });
nextB.addEventListener('click', function () { stopAuto(); go(1); });
autoB.addEventListener('click', function () {
if (auto) { stopAuto(); return; }
if (i >= steps.length - 1) { i = 0; render(); }
autoB.textContent = 'Пауза';
auto = setInterval(function () { if (i >= steps.length - 1) { stopAuto(); return; } go(1); }, 1500);
});
render();
return { stop: stopAuto };
};
/* Превратить карточки «Разбор по шагам» (.card с <ol> в теле) в интерактивный stepPlayer. */
M.stepifyExamples = function (root) {
if (!root) return;
var cards = root.querySelectorAll('.card');
for (var ci = 0; ci < cards.length; ci++) {
var card = cards[ci];
var titleEl = card.querySelector('.card-title'); if (!titleEl) continue;
if (!/шаг/i.test(titleEl.textContent || '')) continue;
var body = card.querySelector('.card-body'); if (!body || body.__stepified) continue;
var ol = body.querySelector('ol'); if (!ol) continue;
var lis = ol.querySelectorAll('li'); if (lis.length < 2) continue;
var steps = [], intro = '', outro = '', node = body.firstChild;
while (node && node !== ol) { intro += (node.nodeType === 1 ? node.outerHTML : (node.nodeType === 3 ? node.textContent : '')); node = node.nextSibling; }
node = ol.nextSibling; while (node) { outro += (node.nodeType === 1 ? node.outerHTML : (node.nodeType === 3 ? node.textContent : '')); node = node.nextSibling; }
if (intro.replace(/<[^>]*>/g, '').trim()) steps.push('<div style="font-weight:600">' + intro + '</div>');
for (var li = 0; li < lis.length; li++) steps.push('<div><b style="color:var(--pri,#4f46e5)">Шаг ' + (li + 1) + '.</b> ' + lis[li].innerHTML + '</div>');
if (outro.replace(/<[^>]*>/g, '').trim()) steps.push(outro);
body.__stepified = true; body.innerHTML = '';
var hostEl = D.createElement('div'); body.appendChild(hostEl);
M.stepPlayer(hostEl, { steps: steps });
}
};
})(window);