diff --git a/backend/tests/math6-page.test.js b/backend/tests/math6-page.test.js index fc0b97f..e851232 100644 --- a/backend/tests/math6-page.test.js +++ b/backend/tests/math6-page.test.js @@ -20,6 +20,7 @@ function buildPage(file) { let html = readF('frontend/textbooks/' + file); const inl = { '/js/math6_svg.js': readF('frontend/js/math6_svg.js'), + '/js/math6_anim.js': readF('frontend/js/math6_anim.js'), '/js/math6_engine.js': readF('frontend/js/math6_engine.js') }; html = html @@ -167,6 +168,20 @@ test('ch6: наглядная геометрия — интерактивы §1 assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); }); +test('анимации: canvas-демо монтируются (headless-safe)', async () => { + // Глава 6 §2: колесо + заметание площади + const r6 = await loadDom('math_6_ch6.html'); + r6.doc.defaultView.goTo('p2'); await wait(100); + assert.ok(r6.doc.querySelector('#p2-roll canvas'), 'canvas «колесо» §6.2'); + assert.ok(r6.doc.querySelector('#p2-sweep canvas'), 'canvas «заметание площади» §6.2'); + assert.deepEqual(r6.errors, [], 'ch6 без ошибок: ' + r6.errors.join(' | ')); + // Глава 1 §6: площадная модель умножения + const r1 = await loadDom('math_6_ch1.html'); + r1.doc.defaultView.goTo('p6'); await wait(100); + assert.ok(r1.doc.querySelector('#p6-area canvas'), 'canvas «площадная модель» §1.6'); + assert.deepEqual(r1.errors, [], 'ch1 без ошибок: ' + r1.errors.join(' | ')); +}); + test('hub: 6 карточек глав + курсовой финал', async () => { const { doc, errors } = await loadDom('math_6_hub.html'); assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); diff --git a/frontend/js/math6_anim.js b/frontend/js/math6_anim.js new file mode 100644 index 0000000..af2672c --- /dev/null +++ b/frontend/js/math6_anim.js @@ -0,0 +1,177 @@ +/* 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 }; +}; + +})(window); diff --git a/frontend/textbooks/math_6_ch1.html b/frontend/textbooks/math_6_ch1.html index dcd0974..94c007d 100644 --- a/frontend/textbooks/math_6_ch1.html +++ b/frontend/textbooks/math_6_ch1.html @@ -17,6 +17,7 @@ + @@ -532,7 +533,7 @@ function buildP6(){ +'