From 51db0005453bb3b02f64e67a4df6fecfc27844c8 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 2 Jun 2026 22:07:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(math6):=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=93=D0=BB.2=20=E2=80=94=20pieGrow?= =?UTF-8?q?,=20balanceScale,=20constAreaRect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/tests/math6-page.test.js | 8 +++- frontend/js/math6_anim.js | 70 ++++++++++++++++++++++++++++++ frontend/textbooks/math_6_ch2.html | 23 ++++++++-- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/backend/tests/math6-page.test.js b/backend/tests/math6-page.test.js index dbb53b4..9fcce77 100644 --- a/backend/tests/math6-page.test.js +++ b/backend/tests/math6-page.test.js @@ -86,7 +86,7 @@ test('ch2: проценты и пропорции — интерактивы + win.goTo('p3'); await wait(80); assert.ok(doc.querySelector('#p3-q') && doc.querySelectorAll('#p3-iv2 [data-v]').length === 2, 'пропорция §3'); win.goTo('p7'); await wait(80); - assert.ok(doc.querySelector('#p7-fig svg') && doc.querySelector('#p7-pick [data-k]'), 'круговая диаграмма §7'); + assert.ok(doc.querySelector('#p7-fig canvas') && doc.querySelector('#p7-pick [data-k]'), 'круговая диаграмма §7'); win.goTo('final'); await wait(80); assert.ok(doc.querySelector('#fin-go'), 'арена боссов §2'); win.bumpProgress('final', 100); await wait(20); @@ -207,6 +207,12 @@ test('анимации: canvas-демо монтируются (headless-safe)', 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'); + r2.doc.defaultView.goTo('p3'); await wait(100); + assert.ok(r2.doc.querySelector('#p3-balfig canvas'), 'canvas «весы пропорции» §2.3'); + r2.doc.defaultView.goTo('p4'); await wait(100); + assert.ok(r2.doc.querySelector('#p4-carfig canvas'), 'canvas «постоянная площадь» §2.4'); + r2.doc.defaultView.goTo('p7'); await wait(100); + assert.ok(r2.doc.querySelector('#p7-fig canvas'), 'canvas «растущая диаграмма» §2.7'); assert.deepEqual(r2.errors, [], 'ch2 без ошибок: ' + r2.errors.join(' | ')); // Глава 3 §1 — фильтр множества const r3 = await loadDom('math_6_ch3.html'); diff --git a/frontend/js/math6_anim.js b/frontend/js/math6_anim.js index b97c2b7..0c03784 100644 --- a/frontend/js/math6_anim.js +++ b/frontend/js/math6_anim.js @@ -470,6 +470,76 @@ M.setFilter = function (host, opts) { 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 () {} }; diff --git a/frontend/textbooks/math_6_ch2.html b/frontend/textbooks/math_6_ch2.html index 751bc62..ac3279c 100644 --- a/frontend/textbooks/math_6_ch2.html +++ b/frontend/textbooks/math_6_ch2.html @@ -269,9 +269,16 @@ function buildP3(){ +'
' +'
' +''; + h+='
Анимация
Весы пропорции
' + +'
Пропорция верна, когда произведение крайних равно произведению средних: $a\\cdot d = b\\cdot c$ — весы в равновесии. Жми «другой пример».
' + +'
'; h+=secNav('p2','p4')+readBtn('p3'); box.innerHTML=h; renderMath(box); + (function(){ if(!window.Math6Anim) return; var EX=[[3,4,6,8],[2,5,4,10],[3,4,5,8],[6,9,2,3],[2,3,5,8]]; + var bal=Math6Anim.balanceScale(document.getElementById('p3-balfig'),{a:EX[0][0],b:EX[0][1],c:EX[0][2],d:EX[0][3]}); var k=0; + document.getElementById('p3-bal-next').addEventListener('click',function(){ k=(k+1)%EX.length; var e=EX[k]; bal.set(e[0],e[1],e[2],e[3]); }); })(); + (function(){ var i=0,score=0,cur=null; function gen(){ var a=_ri(2,6), b=_ri(2,9), m=_ri(2,6); cur={a:a,b:b,c:a*m,x:b*m}; } @@ -333,9 +340,16 @@ function buildP4(){ +'
' +'
' +''; + h+='
Анимация
Обратная пропорция: постоянная площадь
' + +'
Двигай ширину $x$ — высота $y$ сама подстраивается так, что площадь $x\\cdot y$ остаётся постоянной. Это и есть обратная пропорциональность.
' + +'
' + +'
'; h+=secNav('p3','p5')+readBtn('p4'); box.innerHTML=h; renderMath(box); + (function(){ if(!window.Math6Anim) return; var car=Math6Anim.constAreaRect(document.getElementById('p4-carfig'),{area:12}); + var sl=document.getElementById('p4-cw'); sl.oninput=function(){ document.getElementById('p4-cwv').textContent=sl.value; car.set(+sl.value); }; })(); + (function(){ var Q=[ {t:'d',q:'Количество тетрадей и их общая стоимость (цена постоянна).'}, @@ -551,11 +565,12 @@ function buildP7(){ {name:'Оценки класса', segs:[{label:'«9–10»',value:30},{label:'«7–8»',value:45},{label:'«5–6»',value:25}]}, {name:'Сутки школьника', segs:[{label:'Сон',value:33},{label:'Учёба',value:25},{label:'Отдых',value:42}]} ]; - var pick=document.getElementById('p7-pick'), fig=document.getElementById('p7-fig'), leg=document.getElementById('p7-leg'); - var pal=['#4f46e5','#0891b2','#e11d48','#059669','#d97706']; + var pick=document.getElementById('p7-pick'), fig=document.getElementById('p7-fig'), leg=document.getElementById('p7-leg'), grow=null; + var pal=['#0891b2','#f59e0b','#e11d48','#059669','#7c3aed']; pick.innerHTML=DS.map(function(d,k){ return ''; }).join(''); - function render(k){ var d=DS[k]; fig.innerHTML=Math6.pie(d.segs,{size:200}); - leg.innerHTML=d.segs.map(function(s,j){ return '
'+s.label+' — '+s.value+'%
'; }).join(''); } + function render(k){ var d=DS[k]; var segs=d.segs.map(function(s,j){ return {label:s.label,value:s.value,color:pal[j%pal.length]}; }); + if(window.Math6Anim){ if(!grow) grow=Math6Anim.pieGrow(fig,{segs:segs}); else grow.set(segs); } else { fig.innerHTML=Math6.pie(d.segs,{size:200}); } + leg.innerHTML=segs.map(function(s){ return '
'+s.label+' — '+s.value+'%
'; }).join(''); } pick.querySelectorAll('[data-k]').forEach(function(b){ b.addEventListener('click',function(){ pick.querySelectorAll('button').forEach(function(x){x.classList.remove('primary');}); b.classList.add('primary'); render(+b.getAttribute('data-k')); }); }); render(0); })();