fix(textbooks): KaTeX распознаёт \[…\] + переделать «Упрости √» в пошаговую игру

1. KaTeX: в config delimiters добавлены '\['/'\]' (display) и '\('/'\)' (inline) во всех 6 местах вызова renderMathInElement. Раньше initFracIrr использовал \[…\] в template literal — выводилось raw LaTeX. Теперь рендерится математически.

2. «Упрости √» переделан с нуля:
   Было: непонятный drag-and-drop с пустой drop-zone и техническим хинтом
   Стало: явный вопрос 'Выберите точный квадрат, который делит подкоренное'
   - Карточки кандидатов крупные (с подписью "= N²" под числом)
   - Не делит → красная тряска + объяснение
   - Делит но не максимальный → жёлтое предупреждение
   - Максимальный квадрат → зелёная анимация pop + пошаговый вывод KaTeX:
     √72 = √(36·2) = √36·√2 = 6√2
   - confetti + XP +8
   - Кнопка 'Подсказка' даёт намёк
   - На правильном ответе остальные карточки блокируются
This commit is contained in:
Maxim Dolgolyov
2026-05-27 13:14:54 +03:00
parent 6864db5b94
commit aebdc47e4f
+82 -117
View File
@@ -10,7 +10,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false})"></script>
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
<style>
:root{
@@ -454,6 +454,18 @@ input,select,textarea{font-family:inherit}
/* hover-preview карточек выключен — мешал перекрытием соседних рядов */
.psel-card-preview{display:none!important}
/* Карточки-кандидаты для «Упрости корень» (§4) */
.simp-card-btn{padding:10px 6px;border-radius:11px;border:2px solid var(--border);background:var(--card);cursor:pointer;font-family:'JetBrains Mono',monospace;transition:transform .12s,box-shadow .12s,border-color .12s,background .12s;display:flex;flex-direction:column;align-items:center;gap:2px;min-height:60px}
.simp-card-btn:hover{transform:translateY(-2px);border-color:var(--pri);box-shadow:0 4px 14px rgba(233,30,99,.18)}
.simp-card-btn .scb-num{font-size:1.4rem;font-weight:900;color:var(--text);line-height:1}
.simp-card-btn .scb-sub{font-size:.72rem;color:var(--muted);font-weight:600}
.simp-card-btn.correct-dnd{background:var(--ok-bg);border-color:var(--ok);color:#065f46;animation:simpPop .35s ease}
.simp-card-btn.correct-dnd .scb-num{color:#065f46}
.simp-card-btn.wrong-dnd{background:var(--fail-bg);border-color:var(--fail);animation:simpShake .35s ease}
.simp-card-btn.locked{opacity:.4;pointer-events:none}
@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)}}
/* Конвейер 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)}
@@ -1100,7 +1112,7 @@ function _goToFinish(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);
setTimeout(()=>renderMathInElement(el, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false}),0);
}
}
@@ -1236,7 +1248,7 @@ function buildSidebar(id){
box.innerHTML = html;
// render KaTeX inside sidebar
if(window.renderMathInElement){
try{ renderMathInElement(box, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false}); }catch(e){}
try{ renderMathInElement(box, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false}); }catch(e){}
}
}
@@ -1294,7 +1306,7 @@ function el(tag, attrs, html){
}
function renderMath(root){
if(window.renderMathInElement){
renderMathInElement(root, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false});
renderMathInElement(root, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false});
}
}
function feedback(elm, ok, text){
@@ -2131,7 +2143,7 @@ function initDual(){
formula.classList.remove('mod-active');
}
if(window.renderMathInElement){
try{ renderMathInElement(formula, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false}); }catch(e){}
try{ renderMathInElement(formula, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false}); }catch(e){}
}
}
}
@@ -3034,26 +3046,23 @@ function buildP4(){
</ul>
`)}
${widget('Drag «упрости √»', 'GAME', 'Перетащите подходящий множитель в зону слева от корня. На мобиле — просто нажмите нужную карточку.', `
<div id="drag-task" style="padding:14px;background:var(--card);border-radius:9px;text-align:center;margin-bottom:12px">
<div class="lab">Упростите:</div>
<div id="drag-q" style="font-size:2rem;font-weight:800;color:var(--pri2);margin:10px 0;font-family:'JetBrains Mono',monospace">√72</div>
<div style="display:flex;gap:12px;align-items:center;justify-content:center;margin:14px 0;flex-wrap:wrap">
<div id="drag-dropzone" class="dnd-dropzone" style="min-width:80px;min-height:54px">
<span style="font-size:1.2rem;font-family:'JetBrains Mono',monospace;color:var(--muted)">?</span>
</div>
<span style="font-size:1.6rem;color:var(--muted);font-family:'JetBrains Mono',monospace">×</span>
<span id="drag-rest-num" style="font-size:1.6rem;font-weight:700;color:var(--acc2);font-family:'JetBrains Mono',monospace">?</span>
${widget('Упрости корень — найди точный квадрат', 'GAME', 'Нажмите на число, которое является точным квадратом И делит подкоренное число. Например, для √72: точные квадраты — 4, 9, 16, 25, 36; делит ли 72 какой-то из них? Да, 36 (72 = 36·2).', `
<div id="drag-task" style="padding:18px;background:var(--card);border-radius:11px;margin-bottom:12px">
<div class="lab" style="text-align:center;margin-bottom:6px">Шаг 1. Упрости выражение</div>
<div id="drag-q" style="font-size:2.6rem;font-weight:900;color:var(--pri2);text-align:center;margin:14px 0;font-family:'JetBrains Mono',monospace">$\\sqrt{72}$</div>
<div style="text-align:center;margin-bottom:14px">
<div class="lab" style="margin-bottom:4px">Выберите точный квадрат, который делит подкоренное число:</div>
<div id="drag-mults" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(74px,1fr));gap:8px;max-width:520px;margin:10px auto 0"></div>
</div>
<div class="lab" style="margin-bottom:8px">Карточки множителей:</div>
<div id="drag-mults" style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;min-height:48px"></div>
<div id="drag-result" style="margin-top:14px;font-size:1.15rem;font-family:'JetBrains Mono',monospace;min-height:28px"></div>
<div class="row-c" style="margin-top:12px">
<button class="btn primary" onclick="dragNext()">Следующая</button>
<div id="drag-pipeline" style="display:none;margin-top:16px;padding:14px;background:linear-gradient(135deg,var(--pri-soft),var(--acc-soft));border-radius:9px;text-align:center;font-size:1.1rem;line-height:2"></div>
<div class="row-c" style="margin-top:14px">
<button class="btn primary" onclick="dragNext()">
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
Следующая задача
</button>
<button class="btn" onclick="dragHint()">Подсказка</button>
</div>
</div>
<!-- floating drag ghost -->
<div id="dnd-ghost" style="position:fixed;pointer-events:none;z-index:8000;display:none;transform:translate(-50%,-50%)"></div>
<div id="drag-fb" class="feedback"></div>
`)}
@@ -3178,135 +3187,85 @@ function buildP4(){
setTimeout(()=>{ initDragSimp(); initConverter(); initFracIrr(); initCompare(); initSimp4(); }, 50);
}
/* ──── 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];
const qEl = document.getElementById('drag-q');
if(qEl) qEl.textContent = '√' + t.n;
// reset dropzone
const dz = document.getElementById('drag-dropzone');
if(dz) dz.innerHTML = '<span style="font-size:1.2rem;font-family:\'JetBrains Mono\',monospace;color:var(--muted)">?</span>';
const restEl = document.getElementById('drag-rest-num');
if(restEl) restEl.textContent = '?';
const resEl = document.getElementById('drag-result');
if(resEl) resEl.textContent = '';
// generate multipliers — сначала ВСЕ делители-квадраты из набора, потом добавки
const CAND = [4,9,16,25,36,49,64,81,100,121];
const mults = new Set([t.sq]);
// 1) добавим все настоящие делители-квадраты
for(const m of CAND){ if(t.n % m === 0) mults.add(m); }
// 2) добавим случайные «отвлекающие» до 5 штук, max 30 попыток (страховка от инф.цикла)
let safety = 0;
while(mults.size < 5 && safety++ < 30){
const m = CAND[Math.floor(Math.random()*CAND.length)];
mults.add(m);
if(qEl){
qEl.innerHTML = '$\\sqrt{' + t.n + '}$';
renderMath(qEl);
}
const arr = [...mults].slice(0,5).sort((a,b)=>a-b);
// Генерируем 5 кандидатов: точный квадрат + другие из набора
const CAND = [4, 9, 16, 25, 36, 49, 64, 81, 100, 121];
const mults = new Set([t.sq]);
for(const m of CAND){ if(t.n % m === 0) mults.add(m); }
let safety = 0;
while(mults.size < 5 && safety++ < 30){ mults.add(CAND[Math.floor(Math.random()*CAND.length)]); }
const arr = [...mults].slice(0, 5).sort((a, b) => a - b);
const g = document.getElementById('drag-mults');
if(!g) return;
g.innerHTML = '';
arr.forEach(m=>{
const card = document.createElement('div');
card.className = 'dnd-card';
card.textContent = String(m);
arr.forEach(m => {
const card = document.createElement('button');
card.className = 'simp-card-btn';
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);
});
const sqRt = Math.sqrt(m);
card.innerHTML = `<div class="scb-num">${m}</div><div class="scb-sub">= ${sqRt}<sup>2</sup></div>`;
card.addEventListener('click', () => dragPick(m, t, card));
g.appendChild(card);
});
const pipe = document.getElementById('drag-pipeline');
if(pipe){ pipe.style.display = 'none'; pipe.innerHTML = ''; }
document.getElementById('drag-fb').className = 'feedback';
}
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');
const pipe = document.getElementById('drag-pipeline');
// 1) не делит → нельзя так раскладывать
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 + ' нацело');
card.classList.add('wrong-dnd');
setTimeout(()=>card.classList.remove('wrong-dnd'), 600);
feedback(fb, false, '✗ ' + m + ' не делит ' + t.n + ' нацело — этот вариант не подходит');
return;
}
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+'. Не самый компактный вид.');
// 2) делит, но НЕ максимальный точный квадрат → подходит, но не оптимально
if(m !== t.sq){
card.classList.add('wrong-dnd');
setTimeout(()=>card.classList.remove('wrong-dnd'), 600);
feedback(fb, false, '⚠ ' + m + ' делит ' + t.n + ' (' + t.n + ' = ' + m + '·' + rest + '), но ' + t.sq + ' ещё больше. Попробуй ещё.');
return;
}
// correct!
const dz = document.getElementById('drag-dropzone');
if(dz){ dz.innerHTML = '<span style="font-family:\'JetBrains Mono\',monospace;font-weight:900;color:var(--ok);font-size:1.2rem">'+Math.sqrt(m)+'</span>'; }
const restEl = document.getElementById('drag-rest-num');
if(restEl) restEl.textContent = rest;
const resEl = document.getElementById('drag-result');
if(resEl) resEl.innerHTML = '<span style="color:var(--ok);font-weight:700">&#10003; √'+t.n+' = '+Math.sqrt(m)+'√'+rest+'</span>';
if(card){ card.classList.add('correct-dnd'); }
feedback(fb, true, '&#10003; √'+t.n+' = '+Math.sqrt(m)+'√'+rest);
// 3) Идеально! Показываем пошаговый вывод
document.querySelectorAll('.simp-card-btn').forEach(b => b.classList.add('locked'));
card.classList.remove('locked');
card.classList.add('correct-dnd');
if(pipe){
pipe.style.display = 'block';
pipe.innerHTML = `
<div style="margin-bottom:6px;font-size:.86rem;color:var(--muted)">Решение пошагово:</div>
<div>$\\sqrt{${t.n}} = \\sqrt{${m} \\cdot ${rest}} = \\sqrt{${m}} \\cdot \\sqrt{${rest}} = ${Math.sqrt(m)}\\sqrt{${rest}}$</div>
`;
renderMath(pipe);
}
feedback(fb, true, '✓ Верно! $\\sqrt{' + t.n + '} = ' + Math.sqrt(m) + '\\sqrt{' + rest + '}$');
setTimeout(()=>renderMath(fb), 50);
confetti();
bumpProgress('p4', 3);
addXp(8, 'simp');
}
function dragNext(){
@@ -3314,6 +3273,12 @@ function dragNext(){
dragRender();
}
function dragHint(){
const t = DRAG_TASKS[dragIdx];
const fb = document.getElementById('drag-fb');
feedback(fb, true, 'Подсказка: ищите наибольший точный квадрат, который делит ' + t.n + ' нацело. Ответ начинается с ' + t.sq + '.');
}
/* ──── Converter a√b ⇄ √c ──── */
function initConverter(){
const a = document.getElementById('conv-a');
@@ -5400,7 +5365,7 @@ function _renderDailyChallenge(){
setTimeout(()=>{
if(window.renderMathInElement && box){
try{ renderMathInElement(box, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false}); }catch(e){}
try{ renderMathInElement(box, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false}); }catch(e){}
}
}, 30);
}