Files
Learn_System/frontend/js/math6_anim.js
T
Maxim Dolgolyov 61de12e2de feat(math6): ещё 2 canvas-демо — прыжки по прямой (±) и машинка+график
Math6Anim расширен: numberLineWalk (анимированные стрелки-шаги a→b на
числовой прямой для сложения рациональных) и carGraph (машина едет по
дороге, а график «путь–время» вычерчивается синхронно; горизонталь = стоянка).
Вшито: Гл.4 §4 (прыжки, ползунки a,b) и Гл.5 §2 (машинка+график).
Headless-safe. Тесты math6: 19/19 (анимации в Гл.1/4/5/6 монтируются).

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

240 lines
16 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 };
};
})(window);