feat(math6): умножение-прыжки (Гл.4 §7) + координатный тир (Гл.5 §1)

Math6Anim.numberLineJumps — a·b как a прыжков-дуг по b на числовой прямой
(зелёные вправо, красные влево, приземление на произведение); ползунки a,b.
Math6Anim.coordGame — «поставь точку (x;y)»: клик по узлу сетки, проверка,
счёт, при промахе показывает верную точку. План: 3D-тела исключены.
Headless-safe. Тесты math6: 20/20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-02 21:53:47 +03:00
parent f4ece6f5b1
commit 555f701b57
5 changed files with 95 additions and 3 deletions
+4
View File
@@ -186,6 +186,8 @@ test('анимации: canvas-демо монтируются (headless-safe)',
assert.ok(r4.doc.querySelector('#p4-walk canvas'), 'canvas «прыжки по прямой» §4.4');
r4.doc.defaultView.goTo('p1'); await wait(100);
assert.ok(r4.doc.querySelector('#p1-therm-fig canvas'), 'canvas «термометр» §4.1');
r4.doc.defaultView.goTo('p7'); await wait(100);
assert.ok(r4.doc.querySelector('#p7-jumpfig canvas'), 'canvas «умножение-прыжки» §4.7');
assert.deepEqual(r4.errors, [], 'ch4 без ошибок: ' + r4.errors.join(' | '));
// Глава 5 §2: машинка + график
const r5 = await loadDom('math_6_ch5.html');
@@ -194,6 +196,8 @@ test('анимации: canvas-демо монтируются (headless-safe)',
r5.doc.defaultView.goTo('p3'); await wait(100);
assert.ok(r5.doc.querySelector('#p3-livefig canvas'), 'canvas «живой график y=kx/k÷x» §5.3');
assert.ok(r5.doc.querySelectorAll('#p3-live [data-m]').length === 2, 'переключатель прямая/обратная §5.3');
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(' | '));
});
+73
View File
@@ -305,6 +305,79 @@ M.thermometer = function (host, opts) {
return { stop: L.stop, set: function (v) { st.target = v; setCap(v); } };
};
function _riA(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
/* ============================ ДЕМО 8: УМНОЖЕНИЕ КАК ПРЫЖКИ (a · b) ============================ */
M.numberLineJumps = function (host, opts) {
opts = opts || {}; var a = opts.a != null ? opts.a : 3, b = opts.b != null ? opts.b : -2; // a прыжков по b
var prod = a * b, W0 = 540, H0 = 150; var sc = sceneCanvas(host, W0, H0); var cap = caption(host, '');
var min = Math.min(-2, 0, prod) - 1, max = Math.max(2, 0, prod) + 1, period = Math.max(3.5, a * 0.8 + 1.6);
function X(v) { var pad = 30; return pad + (v - min) / (max - min) * (W0 - 2 * pad); }
function hop(ctx, x1, x2, baseY, col) {
var mx = (x1 + x2) / 2; ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x1, baseY); ctx.quadraticCurveTo(mx, baseY - 24, x2, baseY); ctx.stroke();
var d = x2 >= x1 ? 1 : -1; ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x2, baseY); ctx.lineTo(x2 - d * 7, baseY - 6); ctx.lineTo(x2 - d * 7, baseY + 2); 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 - 46; 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, prog = p * (a + 0.5), doneJumps = Math.min(a, Math.floor(prog)), partial = Math.min(1, prog - doneJumps);
var col = b >= 0 ? '#059669' : '#e11d48', prev = 0;
for (var j = 0; j < doneJumps; j++) { hop(ctx, X(prev), X(prev + b), axisY, col); prev += b; }
if (doneJumps < a) { hop(ctx, X(prev), X(prev + b * partial), axisY, col); }
var pos = doneJumps >= a ? prod : prev;
ctx.fillStyle = '#e11d48'; ctx.beginPath(); ctx.arc(X(pos), axisY, 5, 0, 2 * Math.PI); ctx.fill();
if (doneJumps >= a) { ctx.fillStyle = acc; ctx.font = '14px Unbounded, sans-serif'; ctx.fillText('' + prod, X(prod), axisY - 30); }
}
var L = loop(host, draw);
var bp = b < 0 ? '(' + b + ')' : '' + b;
cap.innerHTML = '$' + a + ' \\cdot ' + bp + ' = ' + prod + '$ — это ' + a + ' ' + (a === 1 ? 'прыжок' : (a < 5 ? 'прыжка' : 'прыжков')) + ' по ' + b + ' от нуля.';
if (W.renderMathInElement) try { W.renderMathInElement(cap, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
return { stop: L.stop };
};
/* ============================ ДЕМО 9: КООРДИНАТНЫЙ ТИР («поставь точку») ============================ */
M.coordGame = function (host, opts) {
var W0 = 320, H0 = 320; var sc = sceneCanvas(host, W0, H0);
var ui = D.createElement('div'); ui.style.cssText = 'text-align:center;margin-top:8px';
ui.innerHTML = '<div class="qbox" id="cg-q" style="margin-bottom:6px"></div><div style="color:var(--muted);font-size:.9rem">Очки: <b id="cg-s">0</b> · кликни по узлу сетки</div>';
host.appendChild(ui);
var XMIN = -5, XMAX = 5, YMIN = -5, YMAX = 5, 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); }
var st = { tx: 2, ty: 3, score: 0, placed: null, ok: false, reveal: 0 };
function setQ() { var q = ui.querySelector('#cg-q'); if (q) { q.innerHTML = 'Поставь точку $(' + st.tx + ';\\,' + st.ty + ')$'; if (W.renderMathInElement) try { W.renderMathInElement(q, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} } }
function newTarget() { st.tx = _riA(-5, 5); st.ty = _riA(-5, 5); st.placed = null; st.reveal = 0; setQ(); }
function draw() {
var ctx = sc.ctx; if (!ctx) return; var bd = cssVar('--border', '#e2e8f0'), axc = cssVar('--text', '#0f172a'), mut = cssVar('--muted', '#64748b');
ctx.clearRect(0, 0, W0, H0);
ctx.strokeStyle = bd; ctx.lineWidth = 0.8;
for (var gx = XMIN; gx <= XMAX; gx++) { ctx.beginPath(); ctx.moveTo(X(gx), Y(YMIN)); ctx.lineTo(X(gx), Y(YMAX)); ctx.stroke(); }
for (var gy = YMIN; gy <= YMAX; gy++) { 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 12px serif'; ctx.fillText('x', X(XMAX) - 2, Y(0) + 14); ctx.fillText('y', X(0) + 7, Y(YMAX) + 11);
if (st.reveal > 0) { ctx.fillStyle = '#059669'; ctx.beginPath(); ctx.arc(X(st.tx), Y(st.ty), 7, 0, 2 * Math.PI); ctx.fill(); }
if (st.placed) { ctx.fillStyle = st.ok ? '#059669' : '#e11d48'; ctx.beginPath(); ctx.arc(X(st.placed.x), Y(st.placed.y), 6, 0, 2 * Math.PI); ctx.fill(); }
}
var L = loop(host, draw);
if (!HEADLESS && sc.ctx) {
sc.cv.style.cursor = 'crosshair';
sc.cv.addEventListener('pointerdown', function (e) {
var r = sc.cv.getBoundingClientRect();
var dx = Math.round(XMIN + (e.clientX - r.left) / r.width * (XMAX - XMIN));
var dy = Math.round(YMIN + (r.height - (e.clientY - r.top)) / r.height * (YMAX - YMIN));
dx = Math.max(XMIN, Math.min(XMAX, dx)); dy = Math.max(YMIN, Math.min(YMAX, dy));
st.placed = { x: dx, y: dy }; st.ok = (dx === st.tx && dy === st.ty);
if (st.ok) { st.score++; var s = ui.querySelector('#cg-s'); if (s) s.textContent = st.score; setTimeout(newTarget, 700); }
else { st.reveal = 1; setTimeout(function () { st.reveal = 0; st.placed = null; }, 1100); }
});
}
setQ();
return { stop: L.stop };
};
/* ============================ КОМПОНЕНТ: ПОШАГОВЫЙ ПЛЕЕР (DOM, не canvas) ============================ */
M.stepPlayer = function (host, opts) {
opts = opts || {}; var steps = opts.steps || []; if (!steps.length) return { stop: function () {} };
+9
View File
@@ -500,9 +500,18 @@ function buildP7(){
+'<div id="p7-q" class="qbox"></div>'
+'<div style="display:flex;gap:10px;justify-content:center;align-items:center;flex-wrap:wrap"><input type="number" id="p7-a" class="tinp" style="width:90px;text-align:center"><button class="btn primary" id="p7-go">Проверить</button></div>'
+'<div class="feedback" id="p7-fb"></div></div>';
h+='<div class="wg" id="p7-jumps"><div class="wg-header"><span class="wg-badge">Анимация</span><div class="wg-title">Умножение — это прыжки</div></div>'
+'<div class="wg-help">$a \\cdot b$ — сделать $a$ прыжков по $b$ от нуля. Зелёные прыжки — вправо ($b>0$), красные — влево ($b<0$).</div>'
+'<div class="sliders"><label>$a$ (сколько прыжков) = <b id="p7-jav">3</b><input type="range" id="p7-ja" min="1" max="5" value="3"></label>'
+'<label>$b$ (длина прыжка) = <b id="p7-jbv">-2</b><input type="range" id="p7-jb" min="-5" max="5" value="-2"></label></div>'
+'<div id="p7-jumpfig"></div></div>';
h+=secNav('p6','p8')+readBtn('p7');
box.innerHTML=h; renderMath(box);
(function(){ if(!window.Math6Anim) return; var ja=document.getElementById('p7-ja'), jb=document.getElementById('p7-jb'), jump=null;
function r(){ var a=+ja.value,b=+jb.value; document.getElementById('p7-jav').textContent=a; document.getElementById('p7-jbv').textContent=b; if(jump)jump.stop(); jump=Math6Anim.numberLineJumps(document.getElementById('p7-jumpfig'),{a:a,b:b}); }
ja.oninput=r; jb.oninput=r; r(); })();
(function(){
var out=document.getElementById('p7-out');
function render(s){ var map={pp:['$(+)\\cdot(+)=+$','плюс'],pn:['$(+)\\cdot(-)=-$','минус'],np:['$(-)\\cdot(+)=-$','минус'],nn:['$(-)\\cdot(-)=+$','плюс']};
+5
View File
@@ -137,9 +137,14 @@ function buildP1(){
+'<div id="p1-tq" class="qbox" style="font-size:1.1rem;text-align:center;padding:14px 0"></div>'
+'<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap"><button class="btn primary" data-tq="1">I</button><button class="btn primary" data-tq="2">II</button><button class="btn primary" data-tq="3">III</button><button class="btn primary" data-tq="4">IV</button></div>'
+'<div class="feedback" id="p1-tfb"></div></div>';
h+='<div class="wg" id="p1-game-wg"><div class="wg-header"><span class="wg-badge">Игра</span><div class="wg-title">Координатный тир: поставь точку</div></div>'
+'<div class="wg-help">Тебе называют координаты — кликни по нужному узлу сетки. Попал — очко и новая цель; промахнулся — покажу, где была точка.</div>'
+'<div id="p1-game"></div></div>';
h+=secNav(null,'p2')+readBtn('p1');
box.innerHTML=h; renderMath(box);
(function(){ if(window.Math6Anim) Math6Anim.coordGame(document.getElementById('p1-game'),{}); })();
(function(){
var i=0,score=0,cur=null;
function gen(){ cur={x:_ri(-5,5), y:_ri(-5,5)}; }
+4 -3
View File
@@ -27,7 +27,7 @@
| 11 | **`thermometer`** (canvas) | Столбик термометра ↑↓, ±числа, **`|x|` как измеренное расстояние до 0**, противоположные — зеркально. | 4.1, 4.2 |
| 12 | **`numberLineJumps`** (canvas) | Умножение как **повторные прыжки** ($3\cdot(-2)$ = три прыжка по −2); вычитание = прыжок противоположного. | 4.5, 4.7, 4.8 |
| 13 | **`coordGame`** (canvas) | «Морской бой/клад»: поставь точку по координатам; перекрестье от осей; четверти подсвечиваются. | 5.1 |
| 14 | **`solid3d` + `unfoldNet`** (canvas) | Вращение тел (куб/призма/пирамида/цилиндр/конус) + **разворачивание развёртки** и сборка обратно. | 6.1 |
| 14 | ~~`solid3d` + `unfoldNet`~~ | **ИСКЛЮЧЕНО** (по решению). Гл.6 §1 остаётся со статичной SVG-галереей тел + развёртки + квизы. | |
| 15 | **`triangleDrag`** (SVG) | Тащишь вершину — тип треугольника **пересчитывается вживую**, штрихи равных сторон и дуги углов обновляются. | 6.3 |
| 16 | **`reflectFold`** (canvas) | **Складывание** фигуры через ось (осевая симметрия) и **поворот на 180°** вокруг точки (центральная). | 6.4, 6.5 |
@@ -83,7 +83,7 @@
- **§3** y=kx / y=k/x → `plotLive` ✓.
### Глава 6 — Наглядная геометрия
- **§1** Тела/развёртки → **`solid3d` + `unfoldNet`** (вращение + раскрытие/сборка развёртки).
- **§1** Тела/развёртки → **без 3D-анимации (исключено)**: статичная SVG-галерея тел + развёртки + квизы «грани/рёбра/вершины» и «какое тело из развёртки» (уже есть).
- **§2** Окружность/круг → `rollingCircle` + `sweepArea` ✓.
- **§3** Виды треугольников → **`triangleDrag`** (тащишь вершину — тип пересчитывается).
- **§4** Центральная симметрия → **`reflectFold`** (поворот на 180° вокруг $O$).
@@ -103,7 +103,8 @@
---
## D. Подход к раскатке
1. **Opus строит реюзабельные компоненты** (раздел A) — это canvas/3D, риск, нет авто-визуальной проверки, нужен headless-guard + тест «монтируется». Приоритет: `stepPlayer` `columnOp` `longDivision` `barModel``thermometer`/`numberLineJumps``vennDrag``solid3d/unfoldNet``triangleDrag``reflectFold` → остальное.
1. **Opus строит реюзабельные компоненты** (раздел A) — это canvas/3D, риск, нет авто-визуальной проверки, нужен headless-guard + тест «монтируется». Сделано: `stepPlayer`, `thermometer`, `plotLive`, `carGraph`, `rollingCircle`, `sweepArea`, `areaModel`, `numberLineWalk`.
Остаток приоритетно: `numberLineJumps``coordGame``triangleDrag``reflectFold``barModel`/`pieGrow``vennDrag`/`setFilter``balanceScale`/`constAreaRect`. (3D-тела исключены.)
2. **Sonnet-воркфлоу вшивает** компоненты в §§ по карте (раздел B), по главе на агента, через `if(window.Math6Anim){…}`, с само-проверкой тестом (как делали при обогащении).
3. Каждый компонент → запись в тест «canvas-демо монтируются (headless-safe)».