From 302b0626491370474946d60ea7ce6ee9e447b230 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 2 Jun 2026 22:00:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(math6):=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D0=BD=D1=82=D0=B0=20(?= =?UTF-8?q?=D0=93=D0=BB.2=20=C2=A71)=20+=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D0=BC=D0=BD=D0=BE=D0=B6=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= =?UTF-8?q?=20(=D0=93=D0=BB.3=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Math6Anim.barModel — полоса 0..100%, заполняется (easing) к проценту, синхронно %↔десятичная↔дробь; вшита в §2.1 на тот же ползунок, что и сетка 100. Math6Anim.setFilter — числа 1..12 по очереди проходят сквозь «фильтр свойства» (чётные/кратные 3/больше 6), подходящие падают в множество; кнопки смены свойства; вшита в §3.1. Теперь во ВСЕХ 6 главах есть canvas-анимации + stepPlayer везде. Headless-safe. Тесты math6: 20/20. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/math6-page.test.js | 10 ++++++ frontend/js/math6_anim.js | 55 ++++++++++++++++++++++++++++++ frontend/textbooks/math_6_ch2.html | 7 ++-- frontend/textbooks/math_6_ch3.html | 9 +++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/backend/tests/math6-page.test.js b/backend/tests/math6-page.test.js index b959a65..dbb53b4 100644 --- a/backend/tests/math6-page.test.js +++ b/backend/tests/math6-page.test.js @@ -203,6 +203,16 @@ test('анимации: canvas-демо монтируются (headless-safe)', r5.doc.defaultView.goTo('p1'); await wait(100); assert.ok(r5.doc.querySelector('#p1-game canvas'), 'canvas «координатный тир» §5.1'); assert.deepEqual(r5.errors, [], 'ch5 без ошибок: ' + r5.errors.join(' | ')); + // Глава 2 §1 — полоса процента + const r2 = await loadDom('math_6_ch2.html'); + r2.doc.defaultView.goTo('p1'); await wait(100); + assert.ok(r2.doc.querySelector('#p1-bar canvas'), 'canvas «полоса процента» §2.1'); + assert.deepEqual(r2.errors, [], 'ch2 без ошибок: ' + r2.errors.join(' | ')); + // Глава 3 §1 — фильтр множества + const r3 = await loadDom('math_6_ch3.html'); + r3.doc.defaultView.goTo('p1'); await wait(100); + assert.ok(r3.doc.querySelector('#p1-filterfig canvas'), 'canvas «фильтр множества» §3.1'); + assert.deepEqual(r3.errors, [], 'ch3 без ошибок: ' + r3.errors.join(' | ')); }); test('stepPlayer: «Разбор по шагам» становится интерактивным плеером', async () => { diff --git a/frontend/js/math6_anim.js b/frontend/js/math6_anim.js index a2e7a57..b97c2b7 100644 --- a/frontend/js/math6_anim.js +++ b/frontend/js/math6_anim.js @@ -415,6 +415,61 @@ M.reflectFold = function (host, opts) { 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(); } }; +}; + /* ============================ КОМПОНЕНТ: ПОШАГОВЫЙ ПЛЕЕР (DOM, не canvas) ============================ */ M.stepPlayer = function (host, opts) { opts = opts || {}; var steps = opts.steps || []; if (!steps.length) return { stop: function () {} }; diff --git a/frontend/textbooks/math_6_ch2.html b/frontend/textbooks/math_6_ch2.html index 0644b34..751bc62 100644 --- a/frontend/textbooks/math_6_ch2.html +++ b/frontend/textbooks/math_6_ch2.html @@ -129,7 +129,7 @@ function buildP1(){ h+='
Интерактив 1
Процент наглядно
' +'
Двигай ползунок — закрашенные клетки из 100 показывают процент.
' +'
' - +'
'; + +'
'; h+='
Интерактив 2
Переводи проценты
' +'
Переведи между процентами и десятичной дробью. Ответ — число.
' +'
Вопрос 1 / 6Очки: 0 / 6
' @@ -140,9 +140,10 @@ function buildP1(){ box.innerHTML=h; renderMath(box); (function(){ - var sl=document.getElementById('p1-p'), fig=document.getElementById('p1-fig'), out=document.getElementById('p1-out'); + var sl=document.getElementById('p1-p'), fig=document.getElementById('p1-fig'), out=document.getElementById('p1-out'), bar=null; function render(){ var p=+sl.value; document.getElementById('p1-pv').textContent=p; fig.innerHTML=grid100(p); - out.innerHTML='
$'+p+'\\% = \\dfrac{'+p+'}{100} = '+_kf(p/100)+'$
'; renderMath(out); } + out.innerHTML='
$'+p+'\\% = \\dfrac{'+p+'}{100} = '+_kf(p/100)+'$
'; renderMath(out); + if(window.Math6Anim){ if(!bar) bar=Math6Anim.barModel(document.getElementById('p1-bar'),{percent:p}); else bar.set(p); } } sl.oninput=render; render(); })(); diff --git a/frontend/textbooks/math_6_ch3.html b/frontend/textbooks/math_6_ch3.html index 003ebaf..5c11e39 100644 --- a/frontend/textbooks/math_6_ch3.html +++ b/frontend/textbooks/math_6_ch3.html @@ -137,9 +137,18 @@ function buildP1(){ +'
' +'
' +'
'; + h+='
Анимация
Фильтр множества
' + +'
Выбери свойство — числа $1\\ldots12$ по очереди проходят через фильтр; подходящие падают в множество, остальные отсеиваются.
' + +'
' + +'
'; h+=secNav(null,'p2')+readBtn('p1'); box.innerHTML=h; renderMath(box); + (function(){ if(!window.Math6Anim) return; var ctrl=Math6Anim.setFilter(document.getElementById('p1-filterfig'),{label:'чётные',test:function(n){return n%2===0;}}); + var F={even:['чётные',function(n){return n%2===0;}],mul3:['кратные 3',function(n){return n%3===0;}],gt6:['больше 6',function(n){return n>6;}]}; + document.querySelectorAll('#p1-filter [data-f]').forEach(function(b){ b.addEventListener('click',function(){ document.querySelectorAll('#p1-filter [data-f]').forEach(function(x){x.classList.remove('primary');}); b.classList.add('primary'); var f=F[b.getAttribute('data-f')]; ctrl.set(f[0],f[1]); }); }); + })(); + (function(){ var i=0,score=0,cur=null; function gen(){ var A=_distinct(_ri(3,5),1,9), inside=_pick([true,false]); var e; if(inside)e=_pick(A); else { do{e=_ri(1,9);}while(A.indexOf(e)>=0); } cur={A:A,e:e,in:A.indexOf(e)>=0}; }