From 1ee16a3a38a9fe41257d93a6b863a62b1f5d3374 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 27 May 2026 12:08:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(textbooks):=20Wave=202=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BA=D0=B0=D1=87=D0=BA=D0=B0=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B3=D0=B5=D0=B1=D1=80=D1=8B=208=20(+422=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Боксёрский ринг (§1): SVG-канаты вокруг квадрата + 4 цветные угловые подушки + ковёр-pattern + bell-звук через Web Audio API при S=36 + анимация боксёра-победителя на 2с 2. Доказательство √(ab)=√a·√b (§3): кнопка 'Воспроизвести' запускает 5-шаговую анимацию (подсветка прямоугольника → разрез на единичные клетки → склейка в квадрат → бейдж 'Доказано!') 3. Drag&drop с инерцией (§4): pointer-based DnD с ghost-карточкой следующей за курсором, drop-zone подсветка, неверный → тряска и возврат с инерцией, кнопочный fallback для тача 4. Match-игра (§3): SVG-overlay рисует линии соединения между парами выражений (синяя dashed pending → зелёная при совпадении / красная мигающая при ошибке) 5. Real-time валидация (liveCheck): ✓/✗ индикатор появляется при вводе во всех числовых input'ах без нажатия 'Проверить' 6. Game-over modal (squares): красивая модалка с рекордом, SVG-кубком, confetti 7. Hover-preview карточек §§: tooltip с темами параграфа и прогресс-баром 8. Fade-переходы между секциями: 180ms fadeOut + 220ms fadeIn с translateY --- frontend/textbooks/algebra_8.html | 642 +++++++++++++++++++++++++----- 1 file changed, 548 insertions(+), 94 deletions(-) diff --git a/frontend/textbooks/algebra_8.html b/frontend/textbooks/algebra_8.html index e3d1fa1..1a075f8 100644 --- a/frontend/textbooks/algebra_8.html +++ b/frontend/textbooks/algebra_8.html @@ -410,6 +410,71 @@ input,select,textarea{font-family:inherit} .psel-card{flex:0 0 148px;scroll-snap-align:start} body{font-size:.94rem} } + +/* ═══════════════════════════════════════════════ + WAVE 2 — INTERACTIVE DEPTH + ═══════════════════════════════════════════════ */ + +/* Task 5: live-check indicators */ +.live-ind{display:inline-block;margin-left:6px;font-weight:900;font-size:1rem;transition:color .15s} +.live-ind.ok{color:var(--ok)} +.live-ind.fail{color:var(--fail)} + +/* Task 3: drag-and-drop DnD card */ +.dnd-card{display:inline-flex;align-items:center;justify-content:center;padding:8px 16px;background:linear-gradient(135deg,var(--acc),var(--acc2));color:#fff;border-radius:10px;font-weight:700;font-size:1rem;cursor:grab;user-select:none;font-family:'JetBrains Mono',monospace;box-shadow:var(--sh2);transition:transform .15s,box-shadow .15s;touch-action:none} +.dnd-card:active{cursor:grabbing;transform:scale(1.06);box-shadow:0 8px 24px rgba(3,169,244,.35)} +.dnd-card.dragging-active{opacity:.55;transform:scale(.95)} +.dnd-card.correct-dnd{background:linear-gradient(135deg,var(--ok),#059669);animation:dndCorrect .4s ease} +.dnd-card.wrong-dnd{animation:dndWrong .5s ease} +@keyframes dndCorrect{0%{transform:scale(1.15)}100%{transform:scale(1)}} +@keyframes dndWrong{0%,100%{transform:translateX(0)}25%{transform:translateX(-8px)}75%{transform:translateX(8px)}} +.dnd-dropzone{min-height:60px;border:2.5px dashed var(--sec-acc,var(--pri));border-radius:12px;background:var(--card);padding:10px 14px;display:flex;align-items:center;justify-content:center;transition:border-color .15s,background .15s;font-size:.88rem;color:var(--muted);font-weight:600} +.dnd-dropzone.over{border-color:var(--ok);background:var(--ok-bg)} + +/* Task 4: SVG connection lines for match */ +#match-svg-overlay{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible} +.match-line{stroke-width:2.5;fill:none;stroke-dasharray:none;transition:stroke .25s} +.match-line.pending{stroke:var(--acc);stroke-dasharray:6 3;animation:dashFlow .8s linear infinite} +.match-line.correct-line{stroke:var(--ok)} +.match-line.wrong-line{stroke:var(--fail);animation:lineFlash .3s ease 3} +@keyframes dashFlow{to{stroke-dashoffset:-18}} +@keyframes lineFlash{0%,100%{opacity:1}50%{opacity:.2}} + +/* Task 6: game-over modal */ +.game-over-modal{position:fixed;inset:0;z-index:2000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);backdrop-filter:blur(4px);animation:modalIn .3s ease} +@keyframes modalIn{from{opacity:0}to{opacity:1}} +.game-over-box{background:var(--card);border-radius:20px;padding:32px 36px;text-align:center;max-width:360px;width:90%;box-shadow:0 24px 64px rgba(0,0,0,.28);animation:boxIn .4s cubic-bezier(.34,1.56,.64,1)} +@keyframes boxIn{from{transform:scale(.75);opacity:0}to{transform:scale(1);opacity:1}} +.game-over-score{font-size:3rem;font-weight:900;color:var(--pri2);font-family:'Unbounded',sans-serif;margin:10px 0} +.game-over-record{display:inline-flex;align-items:center;gap:8px;padding:8px 16px;background:linear-gradient(135deg,#fcd34d,#f59e0b);color:#451a03;border-radius:10px;font-weight:800;font-size:.95rem;margin:10px 0;animation:recordPulse 1s ease infinite} +@keyframes recordPulse{0%,100%{box-shadow:0 0 0 0 rgba(245,158,11,.5)}50%{box-shadow:0 0 0 8px rgba(245,158,11,0)}} + +/* Task 7: psel-card hover preview */ +.psel-card{overflow:visible} +/* restore border-radius clip for the active stripe via pseudo-element only on non-preview */ +.psel-card-preview{display:none;position:absolute;left:105%;top:0;width:200px;background:var(--card);border:1.5px solid var(--pri);border-radius:12px;padding:12px 14px;z-index:100;box-shadow:var(--sh2);pointer-events:none;animation:previewIn .18s ease} +@keyframes previewIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}} +.psel-card:hover .psel-card-preview{display:block} +.psel-preview-title{font-size:.72rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px} +.psel-preview-topic{font-size:.78rem;color:var(--text);margin-bottom:2px;padding-left:8px;border-left:2px solid var(--sec-acc,var(--pri))} +.psel-preview-bar{height:5px;background:rgba(233,30,99,.12);border-radius:3px;overflow:hidden;margin-top:8px} +.psel-preview-fill{height:100%;background:var(--pri);border-radius:3px;transition:width .4s} +.psel-preview-pct{font-size:.72rem;color:var(--muted);margin-top:2px} +@media(max-width:768px){.psel-card-preview{display:none!important}} + +/* Task 8: section fade transitions */ +.sec.fade-out{animation:secFadeOut .18s ease forwards} +.sec.fade-in{animation:secFadeIn .22s ease forwards} +@keyframes secFadeOut{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-8px)}} +@keyframes secFadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} + +/* Task 1: boxer ring extras */ +.ring-boxer{animation:boxerBounce .4s ease infinite alternate} +@keyframes boxerBounce{from{transform:translateY(0)}to{transform:translateY(-4px)}} + +/* Task 2: geo proof animation badge */ +.proof-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:linear-gradient(135deg,var(--ok),#059669);color:#fff;border-radius:8px;font-weight:700;font-size:.85rem;animation:badgeIn .4s cubic-bezier(.34,1.56,.64,1)} +@keyframes badgeIn{from{transform:scale(.5);opacity:0}to{transform:scale(1);opacity:1}} @@ -636,13 +701,20 @@ function achievement(id, text){ PARA SELECTOR ════════════════════════════════════════════════════════ */ const PARAS = [ - { id:'p1', num:'§ 1', name:'Квадратный корень', sub:'Арифметический корень' }, - { id:'p2', num:'§ 2', name:'Действительные числа', sub:'Иррациональные числа' }, - { id:'p3', num:'§ 3', name:'Свойства корней', sub:'√(ab) = √a·√b' }, - { id:'p4', num:'§ 4', name:'Применение свойств', sub:'Преобразования' }, - { id:'p5', num:'§ 5', name:'Числовые промежутки', sub:'∪ и ∩' }, - { id:'p6', num:'§ 6', name:'Системы неравенств', sub:'Двойные неравенства' }, - { id:'final', num:'★', name:'Финал главы', sub:'Самооценка · Практика · Увлекательно', final:true }, + { id:'p1', num:'§ 1', name:'Квадратный корень', sub:'Арифметический корень', + topics:['Определение корня','Арифметический корень','Извлечение корня','Игра «Таблица квадратов»','Ринг: S=36'] }, + { id:'p2', num:'§ 2', name:'Действительные числа', sub:'Иррациональные числа', + topics:['ℕ ⊂ ℤ ⊂ ℚ ⊂ ℝ','Иррациональные числа','Числовая ось','Классификация чисел'] }, + { id:'p3', num:'§ 3', name:'Свойства корней', sub:'√(ab) = √a·√b', + topics:['√(ab) = √a·√b','√(a/b) = √a/√b','√(a²) = |a|','Match-игра','Тренажёр упрощения'] }, + { id:'p4', num:'§ 4', name:'Применение свойств', sub:'Преобразования', + topics:['Вынесение множителя','Внесение под корень','Освобождение от иррац.','Сравнение корней','Drag «упрости √»'] }, + { id:'p5', num:'§ 5', name:'Числовые промежутки', sub:'∪ и ∩', + topics:['9 типов промежутков','Конструктор промежутка','Объединение A ∪ B','Пересечение A ∩ B'] }, + { id:'p6', num:'§ 6', name:'Системы неравенств', sub:'Двойные неравенства', + topics:['Система неравенств','Совокупность неравенств','Двойные неравенства','Решение на числовой оси'] }, + { id:'final', num:'★', name:'Финал главы', sub:'Самооценка · Практика · Увлекательно', final:true, + topics:['10 задач самооценки','3 практические задачи','История знака √','Олимпиадные задачи'] }, ]; function buildParaSelector(){ @@ -653,10 +725,18 @@ function buildParaSelector(){ card.className = 'psel-card' + (p.final ? ' final' : ''); card.dataset.id = p.id; card.dataset.progCard = p.id; + const topicsHtml = (p.topics||[]).map(t=>`
${t}
`).join(''); + const progPct = STATE.progress[p.id] || 0; card.innerHTML = `
${p.num}
${p.name}
-
`; +
+
+
${p.name}
+ ${topicsHtml} +
+
${progPct}% пройдено
+
`; card.addEventListener('click', ()=>goTo(p.id)); g.appendChild(card); }); @@ -670,15 +750,29 @@ function ensureBuilt(id){ if(fn){ fn(); BUILT.add(id); } } function goTo(id){ + const prevEl = document.querySelector('.sec.active'); + if(prevEl && prevEl.id !== 'sec-' + id){ + prevEl.classList.add('fade-out'); + setTimeout(()=>{ + prevEl.classList.remove('active','fade-out'); + _goToFinish(id); + }, 180); + } else { + if(prevEl) prevEl.classList.remove('active'); + _goToFinish(id); + } +} +function _goToFinish(id){ STATE.current = id; ensureBuilt(id); - document.querySelectorAll('.sec').forEach(s=>s.classList.remove('active')); const el = document.getElementById('sec-' + id); - if(el) el.classList.add('active'); + if(el){ + el.classList.add('active','fade-in'); + setTimeout(()=>el.classList.remove('fade-in'), 250); + } document.querySelectorAll('.psel-card').forEach(c=>c.classList.toggle('active', c.dataset.id === id)); buildSidebar(id); window.scrollTo({top:0, behavior:'smooth'}); - // мин прогресс просто за вход if((STATE.progress[id]||0) < 10) bumpProgress(id, 10); if(window.renderMathInElement){ setTimeout(()=>renderMathInElement(el, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false}),0); @@ -1019,6 +1113,41 @@ function initMobileSidebar(){ window.addEventListener('resize', check); } +/* ════════════════════════════════════════════════════════ + WAVE 2 — UTILITIES + ════════════════════════════════════════════════════════ */ + +/* Task 5: live input validation */ +function liveCheck(inp, expectedFn){ + let ind = inp.nextElementSibling; + if(!ind || !ind.classList.contains('live-ind')){ + ind = document.createElement('span'); + ind.className = 'live-ind'; + inp.after(ind); + } + inp.addEventListener('input', ()=>{ + const v = inp.value.trim(); + if(!v){ ind.className = 'live-ind'; ind.innerHTML=''; return; } + const result = expectedFn(v); + if(result === true){ ind.className = 'live-ind ok'; ind.innerHTML='✓'; } + else if(result === false){ ind.className = 'live-ind fail'; ind.innerHTML='✗'; } + else { ind.className = 'live-ind'; ind.innerHTML='…'; } + }); +} + +/* Task 1: bell sound via Web Audio API */ +function playBell(){ + try{ + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const o = ctx.createOscillator(); const g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = 880; o.type = 'sine'; + g.gain.setValueAtTime(0.3, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5); + o.start(); o.stop(ctx.currentTime + 0.5); + }catch(e){} +} + /* Paragraph builders will be defined below */ @@ -1061,17 +1190,60 @@ function buildP1(){ ${widget('Боксёрский ринг — найди сторону', 'INTERACT', 'Тяните угол квадрата, чтобы изменить его сторону. Поставьте площадь точно 36 м² — получите бонус!', ` - - - сторона x = 0 м - S = 0 м² + + + + + + + + + + + + + + + + + + + + + + + + + + + + сторона x = 6.0 м + S = 36 м² + - ← тяните угол + ← тяните угол + +
-
Сторона: 0 м
-
Площадь: 0 м²
- +
Сторона: 6.0 м
+
Площадь: 36 м²
+
`)} @@ -1226,38 +1398,89 @@ function buildP1(){ function initRing(){ const svg = document.getElementById('ring-svg'); if(!svg) return; - const rect = document.getElementById('ring-rect'); + const ringRect = document.getElementById('ring-rect'); + const carpetRect = document.getElementById('ring-carpet-rect'); const handle = document.getElementById('ring-handle'); const sideLab = document.getElementById('ring-side-lab'); const areaLab = document.getElementById('ring-area-lab'); const sideChip = document.getElementById('ring-side-chip'); const areaChip = document.getElementById('ring-area-chip'); const fb = document.getElementById('ring-feedback'); + const boxer = document.getElementById('ring-boxer'); + const dimLine = document.getElementById('ring-dim-line'); let dragging = false; + let ringWon = false; const scale = 20; // 1 м = 20 px + const OX = 60, OY_BASE = 200; // origin x, bottom anchor + + function setRope(id, x1, x2, y){ const r = document.getElementById(id); if(r){ r.setAttribute('x1',x1); r.setAttribute('x2',x2); r.setAttribute('y1',y); r.setAttribute('y2',y); } } + function setPad(id, x, y){ const p = document.getElementById(id); if(p){ p.setAttribute('x',x); p.setAttribute('y',y); } } + function setVPost(id, x, y1, y2){ const p = document.getElementById(id); if(p){ p.setAttribute('x1',x); p.setAttribute('x2',x); p.setAttribute('y1',y1); p.setAttribute('y2',y2); } } function update(sideM){ sideM = Math.max(0.5, Math.min(10, sideM)); const sidePx = sideM * scale; - rect.setAttribute('width', sidePx); - rect.setAttribute('height', sidePx); - rect.setAttribute('y', 200 - sidePx - 30); - handle.setAttribute('cx', 60 + sidePx); - handle.setAttribute('cy', 200 - sidePx - 30); - sideLab.setAttribute('x', 60 + sidePx/2); - sideLab.setAttribute('y', 200 - 14); + const top = OY_BASE - sidePx - 30; + const right = OX + sidePx; + + [ringRect, carpetRect].forEach(r=>{ + r.setAttribute('width', sidePx); r.setAttribute('height', sidePx); + r.setAttribute('x', OX); r.setAttribute('y', top); + }); + handle.setAttribute('cx', right); + handle.setAttribute('cy', top); + + // ropes: 3 horizontal at top+offset, mid, near-bottom + const ropePad = 8; + setRope('rope-t1', OX - ropePad, right + ropePad, top + sidePx*0.2); + setRope('rope-t2', OX - ropePad, right + ropePad, top + sidePx*0.5); + setRope('rope-t3', OX - ropePad, right + ropePad, top + sidePx*0.8); + setVPost('rope-l', OX - ropePad, top, top + sidePx); + setVPost('rope-r', right + ropePad, top, top + sidePx); + + // corner pads + setPad('cp-tl', OX - ropePad + 2, top - 4); + setPad('cp-tr', right + ropePad - 10, top - 4); + setPad('cp-bl', OX - ropePad + 2, top + sidePx - 6); + setPad('cp-br', right + ropePad - 10, top + sidePx - 6); + + // boxer center position + const bx = OX + sidePx/2; + const by = top + sidePx/2; + if(boxer){ + boxer.querySelectorAll('circle,line,text').forEach((el,i)=>{ + // shift entire boxer group by adjusting first head circle + }); + // repositon via transform + boxer.setAttribute('transform', `translate(${bx - 120},${by - 100})`); + } + + // dimension line + if(dimLine){ + dimLine.setAttribute('x1', OX); dimLine.setAttribute('x2', right); + dimLine.setAttribute('y1', top + sidePx + 6); dimLine.setAttribute('y2', top + sidePx + 6); + } + sideLab.setAttribute('x', OX + sidePx/2); + sideLab.setAttribute('y', top + sidePx + 22); sideLab.textContent = 'сторона x = ' + sideM.toFixed(1) + ' м'; - areaLab.setAttribute('x', 60 + sidePx/2); - areaLab.setAttribute('y', 200 - sidePx/2 - 30); + areaLab.setAttribute('x', OX + sidePx/2); + areaLab.setAttribute('y', top + sidePx/2 + 6); const area = sideM * sideM; areaLab.textContent = 'S = ' + area.toFixed(1) + ' м²'; sideChip.textContent = sideM.toFixed(1); areaChip.textContent = area.toFixed(1); + if(Math.abs(area - 36) < 0.2){ - fb.style.display = 'inline'; - fb.textContent = '✓ Точно! Сторона = √36 = 6 м'; - achievement('ring36','Нашёл сторону ринга'); - bumpProgress('p1', 8); + if(!ringWon){ + ringWon = true; + playBell(); + fb.style.display = 'inline'; + fb.innerHTML = '✓ Точно! Сторона = √36 = 6 м'; + achievement('ring36','Нашёл сторону ринга'); + bumpProgress('p1', 8); + // show boxer for 2s + if(boxer){ boxer.style.display=''; setTimeout(()=>{ if(boxer) boxer.style.display='none'; ringWon=false; }, 2200); } + } } else { fb.style.display = 'none'; } @@ -1266,10 +1489,10 @@ function initRing(){ const onMove = (e)=>{ if(!dragging) return; - const rect = svg.getBoundingClientRect(); - const x = (e.clientX || (e.touches && e.touches[0].clientX) || 0) - rect.left; - const svgX = x / rect.width * 360; - const sideM = (svgX - 60) / scale; + const bbox = svg.getBoundingClientRect(); + const x = (e.clientX || (e.touches && e.touches[0].clientX) || 0) - bbox.left; + const svgX = x / bbox.width * 360; + const sideM = (svgX - OX) / scale; update(sideM); }; handle.addEventListener('mousedown', ()=>dragging=true); @@ -1360,8 +1583,8 @@ function squaresAnswer(picked, btn){ if(sqState.round > 10){ const t = ((performance.now()-sqState.t0)/1000).toFixed(1); const total = sqState.score + Math.max(0, Math.round((30 - +t)*2)); - feedback(fb, true, 'Игра окончена! Итого: ' + total + ' очков, время ' + t + 'с'); - if(total > (isFinite(STATE.squaresBest) ? STATE.squaresBest : 0)){ + const isRecord = total > (isFinite(STATE.squaresBest) ? STATE.squaresBest : 0); + if(isRecord){ STATE.squaresBest = total; saveProgress(); achievement('squares','Лучший результат «Таблица квадратов»'); @@ -1369,10 +1592,40 @@ function squaresAnswer(picked, btn){ bumpProgress('p1', 15); if(sqState.timer) clearInterval(sqState.timer); sqState = null; + showGameOverModal(total, t, isRecord); return; } setTimeout(squaresNext, 850); } + +function showGameOverModal(total, timeStr, isRecord){ + // remove existing + const old = document.getElementById('game-over-modal'); + if(old) old.remove(); + const best = isFinite(STATE.squaresBest) ? STATE.squaresBest : total; + const recHtml = isRecord + ? `
+ + НОВЫЙ РЕКОРД! +
` + : `
Рекорд: ${best} очков
`; + const modal = document.createElement('div'); + modal.id = 'game-over-modal'; + modal.className = 'game-over-modal'; + modal.innerHTML = `
+
Игра окончена!
+
${total}
+
очков  |  время ${timeStr} с
+ ${recHtml} +
+ + +
+
`; + document.body.appendChild(modal); + confetti(); + modal.addEventListener('click', e=>{ if(e.target === modal) modal.remove(); }); +} function squaresReset(){ if(sqState && sqState.timer) clearInterval(sqState.timer); sqState = null; @@ -1906,6 +2159,10 @@ function buildP3(){

Площади всегда равны → $\\sqrt{ab}$ — сторона эквивалентного квадрата

+
+ +
+
`)} ${makeCard('rule','Свойство 2: корень из частного',null,` @@ -1949,14 +2206,17 @@ function buildP3(){ `)} ${widget('Match: выражение ↔ ответ', 'GAME', 'Соедините каждое выражение с его упрощённым значением. Кликните по выражению, затем по ответу.', ` -
-
-
Выражения
-
-
-
-
Ответы
-
+
+ +
+
+
Выражения
+
+
+
+
Ответы
+
+
@@ -2045,6 +2305,42 @@ function initGeoProof(){ upd(); } +/* ──── 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: `Шаг 1. Прямоугольник ${aVal} × ${bVal} подсвечивается рамкой — его площадь S = a·b = ${ab}` }, + { delay:900, html: `Шаг 2. Прямоугольник можно представить как ${ab} единичных клеток (${aVal} строк × ${bVal} столбцов)` }, + { delay:2000, html: `Шаг 3. Все ${ab} клеток «перетекают» в квадрат — ищем сторону, при которой сторона² = ${ab}` }, + { delay:3200, html: `Шаг 4. Квадрат со стороной √${ab} ≈ ${side} имеет ту же площадь: S = (√${ab})² = ${ab}` }, + { delay:4400, html: ` Доказано!   √(${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); +} + /* ──── Property check slider ──── */ function initPropCheck(){ const a = document.getElementById('prop-a'); @@ -2074,11 +2370,38 @@ let matchState = null; function initMatch(){ matchReset(); } + +function _matchLineCoords(btnA, btnB, container){ + const cRect = container.getBoundingClientRect(); + const rA = btnA.getBoundingClientRect(); + const rB = btnB.getBoundingClientRect(); + return { + x1: rA.right - cRect.left, + y1: rA.top - cRect.top + rA.height/2, + x2: rB.left - cRect.left, + y2: rB.top - cRect.top + rB.height/2, + }; +} + +function _drawMatchLine(svg, container, btnL, btnR, cls, id){ + const c = _matchLineCoords(btnL, btnR, container); + const line = document.createElementNS('http://www.w3.org/2000/svg','line'); + line.setAttribute('x1', c.x1); line.setAttribute('y1', c.y1); + line.setAttribute('x2', c.x2); line.setAttribute('y2', c.y2); + line.className.baseVal = 'match-line ' + cls; + if(id) line.id = id; + svg.appendChild(line); + return line; +} + function matchReset(){ const L = document.getElementById('match-left'); const R = document.getElementById('match-right'); - L.innerHTML = ''; R.innerHTML = ''; - matchState = { selL:null, selR:null, done:[] }; + const svgEl = document.getElementById('match-svg-overlay'); + if(L) L.innerHTML = ''; + if(R) R.innerHTML = ''; + if(svgEl) svgEl.innerHTML = ''; + matchState = { selL:null, selR:null, done:[], pendingLine:null }; const lArr = [...MATCH_PAIRS].sort(()=>Math.random()-0.5); const rArr = [...MATCH_PAIRS].sort(()=>Math.random()-0.5); lArr.forEach(p=>{ @@ -2087,9 +2410,11 @@ function matchReset(){ b.dataset.expr = p.expr; b.addEventListener('click', ()=>{ if(b.disabled) return; - L.querySelectorAll('button').forEach(x=>x.style.background=''); + if(L) L.querySelectorAll('button').forEach(x=>{if(!x.disabled){x.style.background='';x.style.color='';}}); b.style.background='var(--acc-soft)'; matchState.selL = b; + // remove pending line + if(matchState.pendingLine){ matchState.pendingLine.remove(); matchState.pendingLine=null; } matchCheck(); }); L.appendChild(b); @@ -2100,9 +2425,15 @@ function matchReset(){ b.dataset.ans = p.ans; b.addEventListener('click', ()=>{ if(b.disabled) return; - R.querySelectorAll('button').forEach(x=>x.style.background=''); + if(R) R.querySelectorAll('button').forEach(x=>{if(!x.disabled){x.style.background='';x.style.color='';}}); b.style.background='var(--pri-soft)'; matchState.selR = b; + // draw pending line + if(matchState.selL && svgEl){ + const container = document.getElementById('match-container'); + if(matchState.pendingLine) matchState.pendingLine.remove(); + matchState.pendingLine = _drawMatchLine(svgEl, container, matchState.selL, b, 'pending', 'match-pending'); + } matchCheck(); }); R.appendChild(b); @@ -2110,34 +2441,54 @@ function matchReset(){ document.getElementById('match-cnt').textContent = '0'; document.getElementById('match-fb').className = 'feedback'; } + function matchCheck(){ if(!matchState.selL || !matchState.selR) return; const e = matchState.selL.dataset.expr; const a = matchState.selR.dataset.ans; const correct = MATCH_PAIRS.some(p=>p.expr===e && p.ans===a); const fb = document.getElementById('match-fb'); + const svgEl = document.getElementById('match-svg-overlay'); + const container = document.getElementById('match-container'); + // remove pending line + if(matchState.pendingLine){ matchState.pendingLine.remove(); matchState.pendingLine=null; } if(correct){ + // draw permanent green line + if(svgEl && container) _drawMatchLine(svgEl, container, matchState.selL, matchState.selR, 'correct-line'); + // mark buttons + matchState.selL.style.background='var(--ok)';matchState.selL.style.color='#fff'; + matchState.selL.style.borderColor='var(--ok)'; + matchState.selR.style.background='var(--ok)';matchState.selR.style.color='#fff'; + matchState.selR.style.borderColor='var(--ok)'; + // add checkmark + matchState.selL.innerHTML = '✓ ' + e; + matchState.selR.innerHTML = '✓ ' + a; matchState.selL.disabled = true; matchState.selR.disabled = true; - matchState.selL.style.background='var(--ok)';matchState.selL.style.color='#fff'; - matchState.selR.style.background='var(--ok)';matchState.selR.style.color='#fff'; matchState.done.push(e); document.getElementById('match-cnt').textContent = matchState.done.length; - feedback(fb, true, e + ' = ' + a + ' ✓'); + feedback(fb, true, e + ' = ' + a + ' ✓'); if(matchState.done.length === MATCH_PAIRS.length){ - feedback(fb, true, 'Все 5 соединены! Свойства корня — в кармане.'); - achievement('match','Match выражений'); - bumpProgress('p3', 15); - confetti(); + setTimeout(()=>{ + feedback(fb, true, 'Все 5 соединены! Свойства корня — в кармане.'); + achievement('match','Match выражений'); + bumpProgress('p3', 15); + confetti(); + }, 100); } } else { + // draw red flashing line then remove + if(svgEl && container){ + const wrongLine = _drawMatchLine(svgEl, container, matchState.selL, matchState.selR, 'wrong-line'); + setTimeout(()=>wrongLine.remove(), 900); + } matchState.selL.style.background='var(--fail)';matchState.selL.style.color='#fff'; matchState.selR.style.background='var(--fail)';matchState.selR.style.color='#fff'; feedback(fb, false, 'Не совпадает. Попробуйте другую пару.'); setTimeout(()=>{ if(matchState.selL && !matchState.selL.disabled){ matchState.selL.style.background=''; matchState.selL.style.color=''; } if(matchState.selR && !matchState.selR.disabled){ matchState.selR.style.background=''; matchState.selR.style.color=''; } - }, 700); + }, 750); } matchState.selL = null; matchState.selR = null; @@ -2165,11 +2516,18 @@ const SIMP_TASKS = [ {q:'√(2.25)', a:1.5}, {q:'√(0.16·9)', a:1.2}, {q:'√(196)', a:14}, ]; let simpIdx = 0; -function initSimpTrainer(){ simpIdx = 0; simpRender(); } +function initSimpTrainer(){ + simpIdx = 0; simpRender(); + const inp = document.getElementById('simp-ans'); + if(inp) liveCheck(inp, v=>{ const n=parseFloat(v.replace(',','.')); if(isNaN(n)) return null; return Math.abs(n - SIMP_TASKS[simpIdx].a) < 0.02; }); +} function simpRender(){ const t = SIMP_TASKS[simpIdx]; document.getElementById('simp-q').textContent = t.q; document.getElementById('simp-ans').value = ''; + // reset live-ind + const ind = document.querySelector('#simp-ans + .live-ind'); + if(ind){ ind.className='live-ind'; ind.innerHTML=''; } document.getElementById('simp-fb').className='feedback'; } function simpCheck(){ @@ -2224,21 +2582,26 @@ function buildP4(){ `)} - ${widget('Drag «упрости √»', 'GAME', 'Перетащите подходящий множитель за пределы корня. Подсказка: ищите точный квадрат среди множителей.', ` + ${widget('Drag «упрости √»', 'GAME', 'Перетащите подходящий множитель в зону слева от корня. На мобиле — просто нажмите нужную карточку.', `
Упростите:
-
√72
-
- Выберите множитель: -
+
√72
+
+
+ ? +
+ × √ + ?
-
- √36 · √2 = 62 -
-
+
Карточки множителей:
+
+
+
+ + `)} @@ -2362,59 +2725,133 @@ function buildP4(){ setTimeout(()=>{ initDragSimp(); initConverter(); initFracIrr(); initCompare(); initSimp4(); }, 50); } -/* ──── Drag simplify (visual) ──── */ +/* ──── Drag simplify — pointer-based DnD ──── */ const DRAG_TASKS = [ {n:72, sq:36, rest:2}, {n:50, sq:25, rest:2}, {n:48, sq:16, rest:3}, {n:200, sq:100, rest:2}, {n:75, sq:25, rest:3}, {n:18, sq:9, rest:2}, {n:32, sq:16, rest:2}, {n:98, sq:49, rest:2}, {n:128, sq:64, rest:2}, ]; let dragIdx = 0; +let _dndState = null; // active drag + function initDragSimp(){ dragIdx = 0; dragRender(); + // global pointer handlers for ghost + window.addEventListener('pointermove', _dndMove); + window.addEventListener('pointerup', _dndDrop); } + function dragRender(){ const t = DRAG_TASKS[dragIdx]; - document.getElementById('drag-q').textContent = '√' + t.n; - // generate multipliers — true sq + some fakes + const qEl = document.getElementById('drag-q'); + if(qEl) qEl.textContent = '√' + t.n; + // reset dropzone + const dz = document.getElementById('drag-dropzone'); + if(dz) dz.innerHTML = '?'; + const restEl = document.getElementById('drag-rest-num'); + if(restEl) restEl.textContent = '?'; + const resEl = document.getElementById('drag-result'); + if(resEl) resEl.textContent = ''; + // generate multipliers const mults = new Set([t.sq]); while(mults.size < 5){ - const m = [4, 9, 16, 25, 36, 49, 64, 81][Math.floor(Math.random()*8)]; + const m = [4,9,16,25,36,49,64,81][Math.floor(Math.random()*8)]; if(t.n % m === 0) mults.add(m); else if(mults.size < 3) mults.add(m); } const arr = [...mults].sort((a,b)=>a-b); const g = document.getElementById('drag-mults'); + if(!g) return; g.innerHTML = ''; arr.forEach(m=>{ - const b = el('button', {class:'btn'}, m); - b.addEventListener('click', ()=>dragPick(m, t, b)); - g.appendChild(b); + const card = document.createElement('div'); + card.className = 'dnd-card'; + card.textContent = String(m); + card.dataset.val = m; + // pointer down starts drag + card.addEventListener('pointerdown', e=>{ + e.preventDefault(); + _dndStart(e, card, m, t); + }); + // click fallback for mobile/accessibility + card.addEventListener('click', ()=>{ + if(!_dndState) dragPick(m, t, card); + }); + g.appendChild(card); }); - // reset visual - document.getElementById('drag-out-a').textContent = '?'; - document.getElementById('drag-out-b').textContent = '?'; - document.getElementById('drag-out-num').textContent = '?'; - document.getElementById('drag-out-rad').textContent = '?'; document.getElementById('drag-fb').className = 'feedback'; } -function dragPick(m, t, btn){ + +function _dndStart(e, card, val, task){ + const ghost = document.getElementById('dnd-ghost'); + if(!ghost) return; + _dndState = { card, val, task }; + card.classList.add('dragging-active'); + ghost.textContent = val; + ghost.className = 'dnd-card'; + ghost.style.display = 'flex'; + ghost.style.left = e.clientX + 'px'; + ghost.style.top = e.clientY + 'px'; + card.setPointerCapture(e.pointerId); +} + +function _dndMove(e){ + if(!_dndState) return; + const ghost = document.getElementById('dnd-ghost'); + if(ghost){ ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; } + const dz = document.getElementById('drag-dropzone'); + if(dz){ + const r = dz.getBoundingClientRect(); + const over = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; + dz.classList.toggle('over', over); + } +} + +function _dndDrop(e){ + if(!_dndState) return; + const ghost = document.getElementById('dnd-ghost'); + if(ghost){ ghost.style.display = 'none'; } + _dndState.card.classList.remove('dragging-active'); + const dz = document.getElementById('drag-dropzone'); + let droppedOnZone = false; + if(dz){ + const r = dz.getBoundingClientRect(); + droppedOnZone = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; + dz.classList.remove('over'); + } + if(droppedOnZone){ + dragPick(_dndState.val, _dndState.task, _dndState.card); + } + _dndState = null; +} + +function dragPick(m, t, card){ const fb = document.getElementById('drag-fb'); - if(t.n % m !== 0){ feedback(fb, false, m + ' не делит ' + t.n + ' нацело'); return; } - const rest = t.n / m; - if(m !== t.sq || rest !== t.rest){ - // valid but not optimal - feedback(fb, false, '√' + t.n + ' = √(' + m + '·' + rest + ') = ' + Math.sqrt(m).toFixed(2) + '·√' + rest + '. Это не самый компактный вид.'); + if(t.n % m !== 0){ + if(card){ card.classList.add('wrong-dnd'); setTimeout(()=>card.classList.remove('wrong-dnd'),600); } + feedback(fb, false, m + ' не делит ' + t.n + ' нацело'); return; } - document.getElementById('drag-out-a').textContent = m; - document.getElementById('drag-out-b').textContent = rest; - document.getElementById('drag-out-num').textContent = Math.sqrt(m); - document.getElementById('drag-out-rad').textContent = rest; - btn.style.background='var(--ok)'; btn.style.color='#fff'; - feedback(fb, true, '✓ √' + t.n + ' = ' + Math.sqrt(m) + '√' + rest); + const rest = t.n / m; + if(m !== t.sq || rest !== t.rest){ + if(card){ card.classList.add('wrong-dnd'); setTimeout(()=>card.classList.remove('wrong-dnd'),600); } + feedback(fb, false, '√'+t.n+' = √('+m+'·'+rest+') = '+Math.sqrt(m).toFixed(2)+'·√'+rest+'. Не самый компактный вид.'); + return; + } + // correct! + const dz = document.getElementById('drag-dropzone'); + if(dz){ dz.innerHTML = ''+Math.sqrt(m)+''; } + const restEl = document.getElementById('drag-rest-num'); + if(restEl) restEl.textContent = rest; + const resEl = document.getElementById('drag-result'); + if(resEl) resEl.innerHTML = '✓ √'+t.n+' = '+Math.sqrt(m)+'√'+rest+''; + if(card){ card.classList.add('correct-dnd'); } + feedback(fb, true, '✓ √'+t.n+' = '+Math.sqrt(m)+'√'+rest); + confetti(); bumpProgress('p4', 3); } + function dragNext(){ dragIdx = (dragIdx + 1) % DRAG_TASKS.length; dragRender(); @@ -2532,6 +2969,10 @@ let simp4State = { idx:0, score:0, cnt:0 }; function initSimp4(){ simp4State = { idx:0, score:0, cnt:0 }; simp4Render(); + const ia = document.getElementById('simp4-a'); + const ib = document.getElementById('simp4-b'); + if(ia) liveCheck(ia, v=>{ const n=+v; if(isNaN(n)||!v) return null; const t=SIMP4_TASKS[simp4State.idx]; return n===t.a ? null : false; }); + if(ib) liveCheck(ib, v=>{ const n=+v; if(isNaN(n)||!v) return null; const t=SIMP4_TASKS[simp4State.idx]; const a=+(document.getElementById('simp4-a').value); return (a===t.a && n===t.b) ? true : (n===t.b ? null : false); }); } function simp4Render(){ const t = SIMP4_TASKS[simp4State.idx]; @@ -3527,7 +3968,20 @@ function buildFinal(){ ${secNav('p6', null)} `; renderMath(body); - setTimeout(()=>{ buildAssessment(); renderMath(body); }, 50); + setTimeout(()=>{ + buildAssessment(); renderMath(body); + // liveCheck for practical tasks + const pr1 = document.getElementById('pr1-a'); + if(pr1) liveCheck(pr1, v=>{ const n=+v; return isNaN(n)?null:n===26?true:false; }); + const pr2 = document.getElementById('pr2-a'); + if(pr2) liveCheck(pr2, v=>{ const n=+v; return isNaN(n)?null:n===81?true:false; }); + // dec inputs + const decExpected = [5,18,21,8,2,1]; + ['dec-1','dec-2','dec-3','dec-4','dec-5','dec-6'].forEach((id,i)=>{ + const inp = document.getElementById(id); + if(inp) liveCheck(inp, v=>{ const n=+v; return isNaN(n)||!v?null:n===decExpected[i]?true:false; }); + }); + }, 50); // Повторный рендер с задержкой — на случай если KaTeX ещё не успел подгрузиться setTimeout(()=>renderMath(body), 300); }