feat(textbooks): красивая анимация доказательства √(ab)=√a·√b

Старая версия: два статичных прямоугольника бок о бок (синий a×b и розовый √(ab)×√(ab)) с текстовым описанием. Зритель не видел РАВЕНСТВА площадей.

Новая версия — настоящее визуальное доказательство:
- Один большой SVG-канвас (600×280) с двумя зонами и стрелкой между ними
- Слева: прямоугольник a×b из единичных клеток (синих). Каждая клетка отдельный <rect> (всего a·b штук)
- Справа: пунктирная рамка квадрата √(ab)×√(ab) (заполнится анимацией)
- При нажатии 'Анимировать':
  * Шаг 1: волна подсветки клеток жёлтым по очереди (20мс задержка)
  * Шаг 2: клетки 'летят' (CSS transition 550мс на x/y) к новой позиции в квадрате,
    меняя цвет с синего на розовый
  * Шаг 3: финальная пульсация + KaTeX-формула с числами и бейдж 'Доказано!'
- KaTeX-формула под канвасом обновляется живо: $\sqrt{a·b}$ = ... + $\sqrt{a}·\sqrt{b}$ = ...
- 'Сбросить' возвращает в исходное положение

Бонус: для непрямого квадрата (a·b не точный квадрат) анимация всё равно работает, клетки плотно укладываются в столбцы по ceil(√ab), визуально показывая что суммарная площадь одинакова.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 13:18:41 +03:00
parent aebdc47e4f
commit aed820c2d1
+169 -76
View File
@@ -466,6 +466,12 @@ input,select,textarea{font-family:inherit}
@keyframes simpPop{0%{transform:scale(1)}50%{transform:scale(1.1)}100%{transform:scale(1)}}
@keyframes simpShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
/* Геометрическое доказательство §3 */
.geo-canvas-wrap{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:8px;margin-top:14px}
.geo-cell{filter:drop-shadow(0 1px 1px rgba(0,0,0,.12))}
.geo-formula{text-align:center;font-size:.98rem;line-height:2;padding:10px 14px;background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border-radius:9px;border:1px solid var(--border)}
.proof-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--ok);color:#fff;border-radius:99px;font-size:.85rem;font-weight:700}
/* Конвейер x → x² → √(x²) в §1 */
.dual-pipeline{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:wrap;margin-top:14px;padding:14px 8px;background:var(--card);border-radius:12px;border:1px solid var(--border)}
.dual-step{flex:1;min-width:90px;text-align:center;padding:10px 8px;border-radius:10px;background:rgba(233,30,99,0.04);border:1.5px solid var(--border)}
@@ -2595,38 +2601,30 @@ function buildP3(){
<p><b>Пример:</b> $\\sqrt{144 \\cdot 625} = \\sqrt{144} \\cdot \\sqrt{625} = 12 \\cdot 25 = 300$.</p>
`)}
${widget('Геометрическое доказательство √(a·b) = √a · √b', 'VISUAL', 'Прямоугольник a × b имеет ту же площадь, что и квадрат со стороной √(ab). Меняйте a и b — площади всегда совпадают.', `
<div class="row">
<span class="lab">a =</span>
<input id="geo-a" type="range" class="slider" min="1" max="9" step="1" value="4" style="max-width:160px">
<span id="geo-a-v" class="lab-mono">4</span>
<span class="lab" style="margin-left:14px">b =</span>
<input id="geo-b" type="range" class="slider" min="1" max="9" step="1" value="9" style="max-width:160px">
<span id="geo-b-v" class="lab-mono">9</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px">
<div style="text-align:center">
<div class="lab">Прямоугольник a × b</div>
<svg viewBox="0 0 180 180" style="width:100%;max-width:170px;margin-top:6px">
<rect id="geo-rect" x="20" y="20" width="160" height="80" fill="rgba(3,169,244,0.22)" stroke="#03a9f4" stroke-width="2"/>
<text x="100" y="65" text-anchor="middle" fill="#0288d1" font-size="14" font-weight="700">S = a·b</text>
</svg>
<div class="chip acc">S = <b id="geo-rect-s">36</b></div>
${widget('Геометрическое доказательство √(a·b) = √a·√b', 'VISUAL', 'Прямоугольник a × b разбивается на a·b единичных клеток. Те же клетки могут собраться в квадрат со стороной √(a·b). Нажмите «Анимировать» — увидите как клетки перетекают.', `
<div class="row" style="justify-content:center;flex-wrap:wrap;gap:18px">
<div class="row" style="margin:0">
<span class="lab">a =</span>
<input id="geo-a" type="range" class="slider" min="1" max="9" step="1" value="4" style="max-width:130px">
<span id="geo-a-v" class="lab-mono" style="font-size:1.1rem;color:var(--pri2);min-width:18px">4</span>
</div>
<div style="text-align:center">
<div class="lab">Квадрат со стороной √(ab)</div>
<svg viewBox="0 0 180 180" style="width:100%;max-width:170px;margin-top:6px">
<rect id="geo-sq" x="40" y="40" width="100" height="100" fill="rgba(233,30,99,0.22)" stroke="#e91e63" stroke-width="2"/>
<text x="90" y="95" text-anchor="middle" fill="#c2185b" font-size="14" font-weight="700">S = (√ab)²</text>
</svg>
<div class="chip">сторона = <b id="geo-side">6.00</b></div>
<div class="row" style="margin:0">
<span class="lab">b =</span>
<input id="geo-b" type="range" class="slider" min="1" max="9" step="1" value="9" style="max-width:130px">
<span id="geo-b-v" class="lab-mono" style="font-size:1.1rem;color:var(--pri2);min-width:18px">9</span>
</div>
</div>
<p style="margin-top:12px;text-align:center;color:var(--ok);font-weight:700">Площади всегда равны → $\\sqrt{ab}$ — сторона эквивалентного квадрата</p>
<div class="geo-canvas-wrap">
<svg id="geo-svg" viewBox="0 0 600 280" style="width:100%;max-width:640px;display:block;margin:14px auto 0"></svg>
</div>
<div class="geo-formula" id="geo-formula" style="margin-top:8px"></div>
<div class="row-c" style="margin-top:14px">
<button class="btn primary" onclick="geoProofAnimate()">&#9654; Воспроизвести доказательство</button>
<button class="btn primary" onclick="geoProofAnimate()" id="geo-play-btn">
<svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg>
Анимировать
</button>
<button class="btn" onclick="geoProofReset()">Сбросить</button>
</div>
<div id="geo-proof-anim" style="min-height:36px;margin-top:10px;text-align:center;font-size:.92rem;color:var(--acc2);font-weight:600"></div>
`)}
${makeCard('rule','Свойство 2: корень из частного',null,`
@@ -2744,67 +2742,162 @@ function buildP3(){
}
/* ──── Geometric proof √(ab) ──── */
/* ──── Geometric proof √(ab)=√a·√b — анимация клеток ──── */
const GEO_STATE = { animating: false };
function initGeoProof(){
const a = document.getElementById('geo-a');
if(!a) return;
const b = document.getElementById('geo-b');
function upd(){
const av = +a.value, bv = +b.value;
document.getElementById('geo-a-v').textContent = av;
document.getElementById('geo-b-v').textContent = bv;
const r = document.getElementById('geo-rect');
// rect: width ~ a, height ~ b (scaled)
const maxDim = 160, base = Math.max(av, bv);
r.setAttribute('width', av/base*maxDim);
r.setAttribute('height', bv/base*maxDim*0.6);
const sq = document.getElementById('geo-sq');
const s = Math.sqrt(av * bv);
sq.setAttribute('width', s/base*maxDim);
sq.setAttribute('height', s/base*maxDim);
document.getElementById('geo-rect-s').textContent = (av*bv).toFixed(0);
document.getElementById('geo-side').textContent = s.toFixed(2);
if(GEO_STATE.animating) return;
geoRenderRect();
}
a.addEventListener('input', upd);
b.addEventListener('input', upd);
upd();
geoRenderRect();
}
/* ──── Geo Proof Animation ──── */
function geoProofAnimate(){
const box = document.getElementById('geo-proof-anim');
if(!box) return;
const aVal = +(document.getElementById('geo-a') ? document.getElementById('geo-a').value : 4);
const bVal = +(document.getElementById('geo-b') ? document.getElementById('geo-b').value : 9);
const ab = aVal * bVal;
const side = Math.sqrt(ab).toFixed(2);
const steps = [
{ delay:0, html: `<b>Шаг 1.</b> Прямоугольник <span style="color:var(--acc2)">${aVal} × ${bVal}</span> подсвечивается рамкой — его площадь <b>S = a·b = ${ab}</b>` },
{ delay:900, html: `<b>Шаг 2.</b> Прямоугольник можно представить как ${ab} единичных клеток (<span style="color:var(--acc2)">${aVal} строк × ${bVal} столбцов</span>)` },
{ delay:2000, html: `<b>Шаг 3.</b> Все ${ab} клеток «перетекают» в квадрат — ищем сторону, при которой сторона² = ${ab}` },
{ delay:3200, html: `<b>Шаг 4.</b> Квадрат со стороной <b style="color:var(--pri2)">√${ab} ≈ ${side}</b> имеет ту же площадь: S = (√${ab})² = ${ab}` },
{ delay:4400, html: `<span class="proof-badge"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px;stroke:#fff"><polyline points="20 6 9 17 4 12"/></svg> Доказано!</span> &nbsp; √(${aVal}·${bVal}) = √${aVal}·√${bVal} = ${Math.sqrt(aVal).toFixed(2)}·${Math.sqrt(bVal).toFixed(2)} = ${side}` },
];
const rectEl = document.getElementById('geo-rect');
const sqEl = document.getElementById('geo-sq');
// highlight steps visually
if(rectEl){
rectEl.style.transition = 'stroke-width .3s';
rectEl.setAttribute('stroke-width','4');
setTimeout(()=>rectEl.setAttribute('stroke-width','2'), 900);
}
if(sqEl){
setTimeout(()=>{
sqEl.style.transition = 'stroke-width .3s';
sqEl.setAttribute('stroke-width','4');
setTimeout(()=>sqEl.setAttribute('stroke-width','2'), 800);
}, 3200);
}
steps.forEach(s=>{
setTimeout(()=>{ box.innerHTML = s.html; renderMath(box); }, s.delay);
});
setTimeout(()=>bumpProgress('p3', 5), 4500);
function geoCurrentAB(){
const a = +document.getElementById('geo-a').value;
const b = +document.getElementById('geo-b').value;
return [a, b];
}
function geoRenderRect(){
const [aVal, bVal] = geoCurrentAB();
document.getElementById('geo-a-v').textContent = aVal;
document.getElementById('geo-b-v').textContent = bVal;
const svg = document.getElementById('geo-svg');
if(!svg) return;
svg.innerHTML = '';
const ab = aVal * bVal;
const sqSide = Math.sqrt(ab);
// sizing: cell size based on max dimensions
const W = 600, H = 280;
const halfW = 290, gap = 20;
const maxCol = Math.max(aVal, Math.ceil(sqSide));
const maxRow = Math.max(bVal, Math.ceil(sqSide));
const cell = Math.floor(Math.min(halfW/maxCol, (H-50)/maxRow, 30));
const rectW = aVal * cell, rectH = bVal * cell;
const rectX = (halfW - rectW) / 2;
const rectY = (H - 30 - rectH) / 2;
const sqW = sqSide * cell;
const sqX = halfW + gap + (halfW - sqW) / 2;
const sqY = (H - 30 - sqW) / 2;
// Labels
svg.appendChild(svgEl('text', {x:halfW/2, y:H-8, 'text-anchor':'middle', fill:'#03a9f4', 'font-size':14, 'font-weight':700, 'font-family':'Inter,sans-serif'}, `${aVal} × ${bVal} = ${ab} клеток`));
svg.appendChild(svgEl('text', {x:halfW + gap + halfW/2, y:H-8, 'text-anchor':'middle', fill:'#e91e63', 'font-size':14, 'font-weight':700, 'font-family':'Inter,sans-serif'}, `сторона ≈ ${sqSide.toFixed(2)} → S = ${ab}`));
// Arrow between
svg.appendChild(svgEl('path', {d:`M ${halfW + 2} ${H/2 - 18} L ${halfW + gap - 2} ${H/2 - 18}`, stroke:'#7c3aed', 'stroke-width':2.5, fill:'none'}));
svg.appendChild(svgEl('polyline', {points:`${halfW + gap - 10},${H/2 - 24} ${halfW + gap - 2},${H/2 - 18} ${halfW + gap - 10},${H/2 - 12}`, stroke:'#7c3aed', 'stroke-width':2.5, fill:'none'}));
// Hint outline rectangle on the right (where square would be)
svg.appendChild(svgEl('rect', {x:sqX, y:sqY, width:sqW, height:sqW, fill:'none', stroke:'#e91e63', 'stroke-width':2, 'stroke-dasharray':'4,4', opacity:0.5}));
// Cells in rectangle layout
for(let i = 0; i < ab; i++){
const col = i % aVal;
const row = Math.floor(i / aVal);
const cellEl = svgEl('rect', {
class:'geo-cell',
'data-i':i,
x: rectX + col*cell + 1,
y: rectY + row*cell + 1,
width: cell - 2,
height: cell - 2,
fill: i % 2 === 0 ? '#03a9f4' : '#0288d1',
opacity: 0.85,
rx: 2,
});
cellEl.style.transition = 'x .55s cubic-bezier(.4,1.3,.5,1), y .55s cubic-bezier(.4,1.3,.5,1), fill .3s';
svg.appendChild(cellEl);
}
// Stash layout info
GEO_STATE.cell = cell;
GEO_STATE.rectX = rectX; GEO_STATE.rectY = rectY;
GEO_STATE.sqX = sqX; GEO_STATE.sqY = sqY;
GEO_STATE.sqSide = sqSide;
GEO_STATE.aVal = aVal; GEO_STATE.bVal = bVal; GEO_STATE.ab = ab;
// Formula
geoUpdateFormula(false);
}
function svgEl(tag, attrs, text){
const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
if(attrs) for(const k in attrs) e.setAttribute(k, attrs[k]);
if(text != null) e.textContent = text;
return e;
}
function geoUpdateFormula(done){
const fb = document.getElementById('geo-formula');
if(!fb) return;
const a = GEO_STATE.aVal, b = GEO_STATE.bVal, ab = GEO_STATE.ab;
const ra = Math.sqrt(a), rb = Math.sqrt(b);
const rab = Math.sqrt(ab);
const aRound = Number.isInteger(ra) ? ra : ra.toFixed(2);
const bRound = Number.isInteger(rb) ? rb : rb.toFixed(2);
const abRound = Number.isInteger(rab) ? rab : rab.toFixed(2);
fb.innerHTML = `$$\\sqrt{${a} \\cdot ${b}} = \\sqrt{${ab}} = ${abRound}$$
$$\\sqrt{${a}} \\cdot \\sqrt{${b}} = ${aRound} \\cdot ${bRound} = ${abRound}$$`
+ (done ? `<div class="proof-badge" style="margin-top:8px"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;stroke:#fff;stroke-width:3"><polyline points="20 6 9 17 4 12"/></svg> Доказано: $\\sqrt{${a} \\cdot ${b}} = \\sqrt{${a}} \\cdot \\sqrt{${b}}$</div>` : '');
renderMath(fb);
}
async function geoProofAnimate(){
if(GEO_STATE.animating) return;
GEO_STATE.animating = true;
const btn = document.getElementById('geo-play-btn');
if(btn) btn.disabled = true;
const svg = document.getElementById('geo-svg');
if(!svg){ GEO_STATE.animating = false; if(btn) btn.disabled = false; return; }
const cells = [...svg.querySelectorAll('.geo-cell')];
const cell = GEO_STATE.cell;
const sqX = GEO_STATE.sqX, sqY = GEO_STATE.sqY;
const sqSide = GEO_STATE.sqSide;
// Шаг 1: волна подсветки прямоугольника
for(let i = 0; i < cells.length; i++){
cells[i].setAttribute('fill', '#fbbf24');
cells[i].setAttribute('opacity', '1');
await sleep(20);
}
await sleep(280);
// Шаг 2: клетки летят в квадрат
// если ab - точный квадрат, плотная упаковка
const isSq = Number.isInteger(sqSide);
const cols = isSq ? sqSide : Math.ceil(sqSide);
const targetCellSize = isSq ? cell : (sqSide * cell) / cols;
for(let i = 0; i < cells.length; i++){
const col = i % cols;
const row = Math.floor(i / cols);
cells[i].setAttribute('x', sqX + col*targetCellSize + 1);
cells[i].setAttribute('y', sqY + row*targetCellSize + 1);
cells[i].setAttribute('width', targetCellSize - 2);
cells[i].setAttribute('height', targetCellSize - 2);
cells[i].setAttribute('fill', i % 2 === 0 ? '#e91e63' : '#c2185b');
await sleep(35);
}
await sleep(500);
// Шаг 3: пульс на финальном квадрате
cells.forEach(c => { c.setAttribute('opacity', '1'); });
await sleep(100);
geoUpdateFormula(true);
bumpProgress('p3', 6);
addXp(10, 'geo-proof');
confetti();
await sleep(2400);
GEO_STATE.animating = false;
if(btn) btn.disabled = false;
}
function geoProofReset(){
GEO_STATE.animating = false;
const btn = document.getElementById('geo-play-btn');
if(btn) btn.disabled = false;
geoRenderRect();
}
function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
/* ──── Property check slider ──── */
function initPropCheck(){
const a = document.getElementById('prop-a');