diff --git a/frontend/textbooks/algebra_8_ch2.html b/frontend/textbooks/algebra_8_ch2.html index 0fbf1f0..751d6dc 100644 --- a/frontend/textbooks/algebra_8_ch2.html +++ b/frontend/textbooks/algebra_8_ch2.html @@ -266,6 +266,23 @@ input,select,textarea{font-family:inherit} .eq-show{font-family:'JetBrains Mono',monospace} .pipe-tabs .btn.active{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))} +/* DRAG & DROP — sortable chips */ +.dnd-pool{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px;padding:10px;border:1.5px dashed var(--border);border-radius:10px;min-height:54px;transition:border-color .18s,background .18s} +.dnd-pool.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid} +.dnd-pool.col{flex-direction:column;align-items:stretch} +.dnd-pool.col .dnd-chip{width:auto} +.dnd-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--card);border:1.5px solid var(--border);border-radius:10px;cursor:grab;user-select:none;font-size:.92rem;line-height:1.4;transition:transform .12s,box-shadow .12s,border-color .12s;touch-action:none;max-width:100%} +.dnd-chip:hover{transform:translateY(-1px);border-color:var(--sec-acc,var(--pri));box-shadow:var(--sh)} +.dnd-chip:active{cursor:grabbing} +.dnd-chip.armed{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));box-shadow:0 0 0 3px rgba(244,63,94,.22);transform:translateY(-1px)} +.dnd-chip.dragging{opacity:.28} +.dnd-chip.placed{background:var(--sec-acc-soft,var(--pri-soft));border-color:var(--sec-acc,var(--pri))} +.dnd-chip .dnd-x{padding:0 5px;color:var(--muted);font-weight:700;font-size:1.05rem;border-radius:4px;cursor:pointer} +.dnd-chip .dnd-x:hover{color:var(--bad,var(--fail));background:var(--fail-bg)} +.drop-box.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid;transform:scale(1.015)} +.dnd-hint{font-size:.78rem;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;gap:6px} +.dnd-hint svg{width:14px;height:14px;flex-shrink:0} + /* SIDEBAR DRAWER for narrow viewports */ .col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none;animation:fadeIn .18s ease} .col-side-backdrop.show{display:block} @@ -712,6 +729,137 @@ function confetti(){ frame(); } +/* ============================================================ + DRAG & DROP SORTER — shared helper + ============================================================ + - desktop: drag chip → drop on .drop-box (or back on .dnd-pool) + - mobile/tap: tap chip → armed; tap a box → placed; tap × → remove + - re-rendering keeps it simple; setupSorter returns { placed, render } */ +function setupSorter(cfg){ + // cfg: { poolId, cats:[...], items:[{id,html,cat}], scopeSelector, columnLayout?:bool } + const placed = {}; + const pool = document.getElementById(cfg.poolId); + const scope = document.querySelector(cfg.scopeSelector); + if(!pool || !scope) return { placed, render: ()=>{} }; + pool.classList.add('dnd-pool'); + if(cfg.columnLayout) pool.classList.add('col'); + let armed = null; + + function buildChip(it, isPlaced){ + const el = document.createElement('div'); + el.className = 'dnd-chip' + (isPlaced ? ' placed' : ''); + el.dataset.id = it.id; + el.innerHTML = '' + it.html + '×'; + attachHandlers(el, it.id); + return el; + } + + function attachHandlers(el, itId){ + let ghost = null, dragging = false, startX = 0, startY = 0, captured = false; + el.addEventListener('pointerdown', ev => { + if(ev.button !== undefined && ev.button !== 0) return; + if(ev.target.classList && ev.target.classList.contains('dnd-x')){ + ev.stopPropagation(); + if(placed[itId]){ delete placed[itId]; render(); } + else if(armed === itId){ armed = null; render(); } + return; + } + startX = ev.clientX; startY = ev.clientY; + const rect = el.getBoundingClientRect(); + const ox = ev.clientX - rect.left, oy = ev.clientY - rect.top; + try { el.setPointerCapture(ev.pointerId); captured = true; } catch(e){} + function onMove(e){ + const dx = e.clientX - startX, dy = e.clientY - startY; + if(!dragging && Math.hypot(dx, dy) > 8){ + dragging = true; + ghost = el.cloneNode(true); + ghost.classList.remove('armed'); + ghost.style.cssText = 'position:fixed;z-index:9999;pointer-events:none;opacity:.9;transform:rotate(-2.5deg);box-shadow:0 14px 36px rgba(0,0,0,.32);width:' + rect.width + 'px;left:' + (e.clientX - ox) + 'px;top:' + (e.clientY - oy) + 'px'; + document.body.appendChild(ghost); + el.classList.add('dragging'); + } + if(dragging && ghost){ + ghost.style.left = (e.clientX - ox) + 'px'; + ghost.style.top = (e.clientY - oy) + 'px'; + const under = document.elementsFromPoint(e.clientX, e.clientY); + scope.querySelectorAll('.drop-box.over, .dnd-pool.over').forEach(n => n.classList.remove('over')); + const tgt = under.find(n => n.classList && (n.classList.contains('drop-box') || n.classList.contains('dnd-pool'))); + if(tgt) tgt.classList.add('over'); + } + } + function onUp(e){ + el.removeEventListener('pointermove', onMove); + el.removeEventListener('pointerup', onUp); + el.removeEventListener('pointercancel', onUp); + if(captured){ try { el.releasePointerCapture(ev.pointerId); } catch(_){} } + el.classList.remove('dragging'); + if(ghost){ ghost.remove(); ghost = null; } + scope.querySelectorAll('.drop-box.over, .dnd-pool.over').forEach(n => n.classList.remove('over')); + if(dragging){ + const under = document.elementsFromPoint(e.clientX, e.clientY); + const box = under.find(n => n.classList && n.classList.contains('drop-box')); + const pl = under.find(n => n.classList && n.classList.contains('dnd-pool')); + if(box){ + const di = box.querySelector('[data-cat]'); + if(di){ placed[itId] = di.dataset.cat; armed = null; render(); return; } + } else if(pl){ delete placed[itId]; armed = null; render(); return; } + // fell outside — revert + } else { + // tap fallback + if(placed[itId]){ delete placed[itId]; armed = null; render(); } + else { armed = (armed === itId) ? null : itId; render(); } + } + dragging = false; + } + el.addEventListener('pointermove', onMove); + el.addEventListener('pointerup', onUp); + el.addEventListener('pointercancel', onUp); + }); + } + + function attachBoxTaps(){ + scope.querySelectorAll('.drop-box').forEach(box => { + box.addEventListener('click', ev => { + if(!armed) return; + if(ev.target.closest('.dnd-chip')) return; + const di = box.querySelector('[data-cat]'); + if(di){ placed[armed] = di.dataset.cat; armed = null; render(); } + }); + }); + pool.addEventListener('click', ev => { + if(!armed) return; + if(ev.target.closest('.dnd-chip')) return; + // empty pool click also de-arms + armed = null; render(); + }); + } + + function render(){ + pool.innerHTML = ''; + cfg.items.forEach(it => { + if(placed[it.id]) return; + const chip = buildChip(it, false); + if(armed === it.id) chip.classList.add('armed'); + pool.appendChild(chip); + }); + cfg.cats.forEach(cat => { + const box = scope.querySelector('.drop-items[data-cat="' + cat + '"]'); + if(!box) return; + box.innerHTML = ''; + cfg.items.forEach(it => { + if(placed[it.id] !== cat) return; + box.appendChild(buildChip(it, true)); + }); + }); + if(window.renderMathInElement) try { renderMath(scope); } catch(_){} + } + + attachBoxTaps(); + render(); + return { placed, render, reset(){ for(const k in placed) delete placed[k]; armed = null; render(); } }; +} +const DND_HINT_HTML = '
Перетащите карточку или нажмите её, затем — на нужный ящик.
'; + /* INIT */ function initSidebarToggle(){ const side = document.getElementById('col-side'); @@ -1068,8 +1216,9 @@ function buildP10(){ `); /* INT 5 — Drag: разложимо или нет */ - html += widget('Разложимо или нет?','INTERACT 5','По знаку дискриминанта разнесите трёхчлены: раскладываются на множители или нет.',` -
+ html += widget('Разложимо или нет?','INTERACT 5','По знаку дискриминанта разнесите трёхчлены в нужные ящики.',` + ${DND_HINT_HTML} +
Раскладывается ($D \\geq 0$)
Не раскладывается ($D < 0$)
@@ -1246,64 +1395,26 @@ function buildP10(){ /* INIT 5 — Drag */ (function(){ const items = [ - { id:1, txt:'$x^2 - 5x + 6$', cat:'yes' }, - { id:2, txt:'$x^2 + 1$', cat:'no' }, - { id:3, txt:'$2x^2 + 5x - 3$', cat:'yes' }, - { id:4, txt:'$x^2 - 2x + 5$', cat:'no' }, - { id:5, txt:'$x^2 - 9$', cat:'yes' }, - { id:6, txt:'$3x^2 + x + 1$', cat:'no' }, - { id:7, txt:'$x^2 + 6x + 9$', cat:'yes' }, - { id:8, txt:'$x^2 + 4$', cat:'no' }, + { id:1, html:'$x^2 - 5x + 6$', cat:'yes' }, + { id:2, html:'$x^2 + 1$', cat:'no' }, + { id:3, html:'$2x^2 + 5x - 3$', cat:'yes' }, + { id:4, html:'$x^2 - 2x + 5$', cat:'no' }, + { id:5, html:'$x^2 - 9$', cat:'yes' }, + { id:6, html:'$3x^2 + x + 1$', cat:'no' }, + { id:7, html:'$x^2 + 6x + 9$', cat:'yes' }, + { id:8, html:'$x^2 + 4$', cat:'no' }, ]; - const cats = ['yes','no']; - const labels = { yes:'Раскл.', no:'Не раскл.' }; - let placed = {}; - function makeChip(it, where){ - const wrap = document.createElement('div'); - wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;background:var(--sec-acc-soft);border-radius:8px;padding:3px 6px;margin:2px'; - const sp = document.createElement('span'); - sp.innerHTML = it.txt; sp.style.cssText = 'padding:2px 4px'; - wrap.appendChild(sp); - if(where === 'pool'){ - cats.forEach(cat=>{ - const b = document.createElement('button'); - b.className = 'btn small'; b.textContent = labels[cat]; - b.style.cssText = 'padding:3px 7px;font-size:.72rem'; - b.addEventListener('click', ()=>{ placed[it.id] = cat; render(); }); - wrap.appendChild(b); - }); - } else { - const b = document.createElement('button'); - b.className = 'btn small'; b.textContent = '×'; - b.style.cssText = 'padding:2px 7px'; - b.addEventListener('click', ()=>{ delete placed[it.id]; render(); }); - wrap.appendChild(b); - } - return wrap; - } - function render(){ - const pool = document.getElementById('p10z-pool'); - pool.innerHTML = ''; - items.forEach(it=>{ if(!placed[it.id]) pool.appendChild(makeChip(it, 'pool')); }); - cats.forEach(cat=>{ - const box = document.querySelector('#p10-body .drop-items[data-cat="' + cat + '"]'); - if(!box) return; - box.innerHTML = ''; - items.forEach(it=>{ if(placed[it.id] === cat) box.appendChild(makeChip(it, 'placed')); }); - }); - if(window.renderMathInElement) renderMath(pool.parentElement); - } + const sorter = setupSorter({ poolId:'p10z-pool', cats:['yes','no'], items, scopeSelector:'#p10-body' }); document.getElementById('p10z-check').addEventListener('click', ()=>{ const fb = document.getElementById('p10z-fb'); fb.style.display = 'block'; - const placedCount = Object.keys(placed).length; + const placedCount = Object.keys(sorter.placed).length; if(placedCount < items.length){ feedback(fb, false, '⚠ Разложите все ' + items.length + ' трёхчленов.'); return; } - let ok = 0; items.forEach(it=>{ if(placed[it.id] === it.cat) ok++; }); + let ok = 0; items.forEach(it=>{ if(sorter.placed[it.id] === it.cat) ok++; }); if(ok === items.length){ feedback(fb, true, '✓ Все ' + items.length + ' верно!'); achievement('p10_sort'); bumpProgress('p10', 14); confetti(); } else feedback(fb, false, 'Верно ' + ok + ' из ' + items.length); }); - document.getElementById('p10z-reset').addEventListener('click', ()=>{ placed = {}; document.getElementById('p10z-fb').style.display='none'; render(); }); - render(); + document.getElementById('p10z-reset').addEventListener('click', ()=>{ sorter.reset(); document.getElementById('p10z-fb').style.display='none'; }); })(); } function buildP11stub(){ buildP11(); } @@ -1372,8 +1483,9 @@ function buildP11(){ `); /* INT 5 — Drag: типы задач */ - html += widget('Классифицируем тип задачи','INTERACT 5','Прочитайте задачу — кликом отнесите её к одной из четырёх категорий.',` -
+ html += widget('Классифицируем тип задачи','INTERACT 5','Прочитайте задачу и перетащите её в нужный ящик.',` + ${DND_HINT_HTML} +
Движение
Работа
@@ -1513,57 +1625,24 @@ function buildP11(){ /* INIT 5 — Drag типы */ (function(){ const items = [ - { id:1, txt:'Лодка прошла 24 км по течению и обратно.', cat:'dv' }, - { id:2, txt:'Двое рабочих вместе закончили работу за 6 ч.', cat:'wk' }, - { id:3, txt:'Произведение последовательных чисел равно 90.', cat:'nm' }, - { id:4, txt:'Сторона квадрата увеличена на 3 см, площадь увеличилась на 33 см².', cat:'gm' }, - { id:5, txt:'Автомобиль и автобус выехали навстречу из A и B.', cat:'dv' }, - { id:6, txt:'Сумма квадратов цифр двузначного числа = 25.', cat:'nm' }, - { id:7, txt:'Бассейн наполняется одной трубой на 2 ч быстрее, чем другой.', cat:'wk' }, - { id:8, txt:'Площадь прямоугольника 48 см², а периметр 28 см.', cat:'gm' }, + { id:1, html:'Лодка прошла 24 км по течению и обратно.', cat:'dv' }, + { id:2, html:'Двое рабочих вместе закончили работу за 6 ч.', cat:'wk' }, + { id:3, html:'Произведение последовательных чисел равно 90.', cat:'nm' }, + { id:4, html:'Сторона квадрата увеличена на 3 см, площадь увеличилась на 33 см².', cat:'gm' }, + { id:5, html:'Автомобиль и автобус выехали навстречу из A и B.', cat:'dv' }, + { id:6, html:'Сумма квадратов цифр двузначного числа = 25.', cat:'nm' }, + { id:7, html:'Бассейн наполняется одной трубой на 2 ч быстрее, чем другой.', cat:'wk' }, + { id:8, html:'Площадь прямоугольника 48 см², а периметр 28 см.', cat:'gm' }, ]; - const cats = ['dv','wk','nm','gm']; - const labels = { dv:'Движ.', wk:'Раб.', nm:'Числа', gm:'Геом.' }; - let placed = {}; - function makeChip(it, where){ - const wrap = document.createElement('div'); - wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;background:var(--sec-acc-soft);border-radius:8px;padding:4px 6px;font-size:.86rem;flex-wrap:wrap;width:100%'; - const sp = document.createElement('span'); sp.textContent = it.txt; sp.style.cssText = 'padding:2px 4px;flex:1;min-width:140px'; - wrap.appendChild(sp); - if(where === 'pool'){ - cats.forEach(cat=>{ - const b = document.createElement('button'); b.className = 'btn small'; b.textContent = labels[cat]; - b.style.cssText = 'padding:3px 7px;font-size:.7rem'; - b.addEventListener('click', ()=>{ placed[it.id] = cat; render(); }); - wrap.appendChild(b); - }); - } else { - const b = document.createElement('button'); b.className = 'btn small'; b.textContent = '×'; - b.style.cssText = 'padding:2px 7px'; b.addEventListener('click', ()=>{ delete placed[it.id]; render(); }); - wrap.appendChild(b); - } - return wrap; - } - function render(){ - const pool = document.getElementById('p11c-pool'); - pool.innerHTML = ''; - items.forEach(it=>{ if(!placed[it.id]) pool.appendChild(makeChip(it, 'pool')); }); - cats.forEach(cat=>{ - const box = document.querySelector('#p11-body .drop-items[data-cat="' + cat + '"]'); - if(!box) return; - box.innerHTML = ''; - items.forEach(it=>{ if(placed[it.id] === cat) box.appendChild(makeChip(it, 'placed')); }); - }); - } + const sorter = setupSorter({ poolId:'p11c-pool', cats:['dv','wk','nm','gm'], items, scopeSelector:'#p11-body', columnLayout:true }); document.getElementById('p11c-check').addEventListener('click', ()=>{ const fb = document.getElementById('p11c-fb'); fb.style.display = 'block'; - if(Object.keys(placed).length < items.length){ feedback(fb, false, '⚠ Разложите все ' + items.length + ' задач.'); return; } - let ok = 0; items.forEach(it=>{ if(placed[it.id] === it.cat) ok++; }); + if(Object.keys(sorter.placed).length < items.length){ feedback(fb, false, '⚠ Разложите все ' + items.length + ' задач.'); return; } + let ok = 0; items.forEach(it=>{ if(sorter.placed[it.id] === it.cat) ok++; }); if(ok === items.length){ feedback(fb, true, '✓ Все верно!'); achievement('p11_class'); bumpProgress('p11', 14); confetti(); } else feedback(fb, false, 'Верно ' + ok + ' из ' + items.length); }); - document.getElementById('p11c-reset').addEventListener('click', ()=>{ placed = {}; document.getElementById('p11c-fb').style.display='none'; render(); }); - render(); + document.getElementById('p11c-reset').addEventListener('click', ()=>{ sorter.reset(); document.getElementById('p11c-fb').style.display='none'; }); })(); } function buildP12stub(){ buildP12(); } @@ -2203,12 +2282,13 @@ function buildP7(){
`); /* ===== INTERACTIVE 2: Drag-сортировка ===== */ - html += widget('Сортировка уравнений по типу','INTERACT 2','Кликом отправьте каждое уравнение в нужный ящик. Серый — лишнее (не квадратное).',` -
+ html += widget('Сортировка уравнений по типу','INTERACT 2','Перетащите каждое уравнение в нужный ящик. На тач-экранах: тап по карточке, затем тап по ящику.',` + ${DND_HINT_HTML} +
-
Полное
-
Неполное
-
Не квадратное
+
Полное
+
Неполное
+
Не квадратное
`); @@ -2352,76 +2432,27 @@ function buildP7(){ /* ===== INIT INTERACTIVE 2 ===== */ (function initSort(){ const items = [ - { id:1, txt:'$2x^2 + 3x - 5 = 0$', cat:'full' }, - { id:2, txt:'$x^2 - 16 = 0$', cat:'incomplete' }, - { id:3, txt:'$3x^2 + 6x = 0$', cat:'incomplete' }, - { id:4, txt:'$x + 5 = 0$', cat:'notquad' }, - { id:5, txt:'$5x^2 - 2x + 1 = 0$', cat:'full' }, - { id:6, txt:'$7x^2 = 0$', cat:'incomplete' }, - { id:7, txt:'$x^3 - x = 0$', cat:'notquad' }, - { id:8, txt:'$x^2 + x + 1 = 0$', cat:'full' }, + { id:1, html:'$2x^2 + 3x - 5 = 0$', cat:'full' }, + { id:2, html:'$x^2 - 16 = 0$', cat:'incomplete' }, + { id:3, html:'$3x^2 + 6x = 0$', cat:'incomplete' }, + { id:4, html:'$x + 5 = 0$', cat:'notquad' }, + { id:5, html:'$5x^2 - 2x + 1 = 0$', cat:'full' }, + { id:6, html:'$7x^2 = 0$', cat:'incomplete' }, + { id:7, html:'$x^3 - x = 0$', cat:'notquad' }, + { id:8, html:'$x^2 + x + 1 = 0$', cat:'full' }, ]; - const pool = document.getElementById('p7s-pool'); - const cats = ['full','incomplete','notquad']; - const labels = { full:'Полное', incomplete:'Неполное', notquad:'Не квадр.' }; - let placed = {}; - function makeChip(it, where){ - const wrap = document.createElement('div'); - wrap.className = 'chip-wrap'; - wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;background:var(--sec-acc-soft);border-radius:8px;padding:3px 6px;margin:2px'; - const sp = document.createElement('span'); - sp.className = 'chip'; - sp.style.cssText = 'background:transparent;padding:2px 4px'; - sp.innerHTML = it.txt; - wrap.appendChild(sp); - if(where === 'pool'){ - cats.forEach(cat=>{ - const b = document.createElement('button'); - b.className = 'btn small'; - b.textContent = labels[cat]; - b.style.cssText = 'padding:3px 7px;font-size:.7rem'; - b.addEventListener('click', ()=>{ placed[it.id] = cat; renderAll(); }); - wrap.appendChild(b); - }); - } else { - const b = document.createElement('button'); - b.className = 'btn small'; - b.textContent = '×'; - b.style.cssText = 'padding:2px 7px'; - b.addEventListener('click', ()=>{ delete placed[it.id]; renderAll(); }); - wrap.appendChild(b); - } - return wrap; - } - function render(){ - pool.innerHTML = ''; - items.forEach(it=>{ - if(placed[it.id]) return; - pool.appendChild(makeChip(it, 'pool')); - }); - cats.forEach(cat=>{ - const box = document.querySelector('.drop-items[data-cat="' + cat + '"]'); - box.innerHTML = ''; - items.forEach(it=>{ - if(placed[it.id] !== cat) return; - box.appendChild(makeChip(it, 'placed')); - }); - }); - if(window.renderMathInElement) renderMath(document.getElementById('p7s-pool').parentElement); - } - function renderAll(){ render(); } + const sorter = setupSorter({ poolId:'p7s-pool', cats:['full','incomplete','notquad'], items, scopeSelector:'#p7-body' }); document.getElementById('p7s-check').addEventListener('click', ()=>{ const total = items.length; - const placedCount = Object.keys(placed).length; - if(placedCount < total){ feedback(document.getElementById('p7s-fb'), false, '⚠ Разложите ВСЕ уравнения по ящикам.'); document.getElementById('p7s-fb').style.display='block'; return; } - let ok = 0; items.forEach(it=>{ if(placed[it.id] === it.cat) ok++; }); + const placedCount = Object.keys(sorter.placed).length; const fb = document.getElementById('p7s-fb'); fb.style.display = 'block'; + if(placedCount < total){ feedback(fb, false, '⚠ Разложите ВСЕ уравнения по ящикам.'); return; } + let ok = 0; items.forEach(it=>{ if(sorter.placed[it.id] === it.cat) ok++; }); if(ok === total){ feedback(fb, true, '✓ Идеально! Все ' + total + ' верно.'); achievement('p7_sort'); bumpProgress('p7', 14); confetti(); } else feedback(fb, false, 'Верно ' + ok + ' из ' + total + '. Попробуйте перепроверить.'); }); - document.getElementById('p7s-reset').addEventListener('click', ()=>{ placed = {}; document.getElementById('p7s-fb').style.display='none'; renderAll(); }); - renderAll(); + document.getElementById('p7s-reset').addEventListener('click', ()=>{ sorter.reset(); document.getElementById('p7s-fb').style.display='none'; }); })(); /* ===== INIT INTERACTIVE 3 ===== */