Files
Learn_System/frontend/js/math6_anim.js
T
Maxim Dolgolyov f4ece6f5b1 feat(math6): термометр (Гл.4 §1) — ±числа и модуль наглядно
Math6Anim.thermometer: вертикальный термометр на canvas, ртуть плавно
поднимается/опускается к значению (easing), выше нуля — красный, ниже — синий;
подпись поясняет знак и |x| как расстояние до нуля. Ползунок −10..10.
Вшит в Гл.4 §1. Headless-safe. Тесты math6: 20/20.

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

370 lines
26 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); } };
};
/* ============================ КОМПОНЕНТ: ПОШАГОВЫЙ ПЛЕЕР (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);