Files
Learn_System/frontend/js/exam9/app.js
T
Maxim Dolgolyov 6cff327e88 feat: exam9 — Экзамен 9 класс по математике (80 вариантов)
Новый отдельный модуль /exam9 в стиле LearnSpace:
- 80 вариантов × 10 заданий = 800 задач с разбором (KaTeX + SVG)
- Сайдбар: пункт «Экзамен 9 класс» (clipboard-check)
- Feature flag: feature_exam9_enabled (мигр. 002)
- Видим всем авторизованным; рендер на стороне клиента
- Прогресс в localStorage: подсветка вариантов (done/partial)
- Возобновление последнего варианта при возврате

Структура:
  frontend/exam9.html              — страница (LearnSpace layout)
  frontend/js/exam9/app.js         — рендерер
  frontend/js/exam9/variants/      — 80 файлов с данными
  frontend/img/exam9/              — 22 PNG/JPG фигур заданий

Картинки путей _tmp/ → /img/exam9/ переписаны автоматически.

Все маршруты проверены: 200 OK на /exam9, /js/exam9/*, /img/exam9/*.
2026-05-16 12:53:49 +03:00

177 lines
6.8 KiB
JavaScript

'use strict';
/* ──────────────────────────────────────────────────────────────────
Exam 9 — Math 2025 renderer
Variants loaded into window.VARIANTS by /js/exam9/variants/vNN.js
────────────────────────────────────────────────────────────────── */
const STORAGE_KEY = 'exam9_progress_v1';
let currentVariant = null;
let katexLoaded = false;
/* ── KaTeX bootstrap ────────────────────────────────────────────── */
function onKatexLoad() {
katexLoaded = true;
if (currentVariant !== null) runKatex(document.getElementById('ex-main'));
}
function runKatex(el) {
if (!katexLoaded || !el) return;
try {
renderMathInElement(el, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
],
throwOnError: false,
});
} catch {}
}
/* ── Progress in localStorage ───────────────────────────────────── */
function loadProgress() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
catch { return {}; }
}
function saveProgress(p) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch {}
}
function markSolutionViewed(variantNum, taskIdx) {
const p = loadProgress();
p[variantNum] = p[variantNum] || [];
if (!p[variantNum].includes(taskIdx)) {
p[variantNum].push(taskIdx);
saveProgress(p);
}
}
/* ── Variant picker ─────────────────────────────────────────────── */
function buildGrid() {
const grid = document.getElementById('variant-grid');
const progress = loadProgress();
grid.innerHTML = '';
Object.keys(VARIANTS).sort((a, b) => Number(a) - Number(b)).forEach(n => {
const v = VARIANTS[n];
const total = (v.tasks || []).length;
const viewed = (progress[n] || []).length;
let cls = '';
if (viewed === total && total > 0) cls = ' done';
else if (viewed > 0) cls = ' partial';
const isActive = Number(n) === currentVariant ? ' active' : '';
const btn = document.createElement('button');
btn.className = 'vg-btn' + cls + isActive;
btn.textContent = n;
btn.title = `${v.label}${viewed === total ? ' ✓' : viewed > 0 ? ` (${viewed}/${total})` : ''}`;
btn.onclick = () => { selectVariant(Number(n)); closePicker(); };
grid.appendChild(btn);
});
}
function togglePicker() {
const overlay = document.getElementById('picker-overlay');
const btn = document.getElementById('picker-btn');
if (overlay.classList.contains('visible')) closePicker();
else {
buildGrid();
overlay.classList.add('visible');
btn.classList.add('open');
document.addEventListener('keydown', onEsc);
}
}
function closePicker() {
document.getElementById('picker-overlay').classList.remove('visible');
document.getElementById('picker-btn').classList.remove('open');
document.removeEventListener('keydown', onEsc);
}
function onOverlayClick(e) {
if (e.target === document.getElementById('picker-overlay')) closePicker();
}
function onEsc(e) { if (e.key === 'Escape') closePicker(); }
/* ── Task rendering ─────────────────────────────────────────────── */
function buildOpts(opts) {
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
const cls = isLong ? 'opts-vertical' : 'opts';
return `<div class="${cls}">` +
opts.map(([l, t]) =>
`<span class="opt"><span class="opt-lbl">${l})</span><span>${t}</span></span>`
).join('') + `</div>`;
}
const SOL_ICON_CLOSED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
function renderVariant(num) {
const main = document.getElementById('ex-main');
const v = VARIANTS[num];
if (!v) {
main.innerHTML = '<div class="ex-empty">Вариант не найден</div>';
return;
}
main.innerHTML =
`<div class="variant-title">${v.label}<small>${v.tasks.length} заданий</small></div>` +
v.tasks.map((t, i) => `
<div class="task-card">
<div class="task-header">
<div class="task-num">${i + 1}</div>
<div class="task-label">Задание ${i + 1}</div>
</div>
<div class="task-body">
<div class="task-text">${t.text}</div>
${t.figure ? `<div class="task-figure">${t.figure}</div>` : ''}
${t.opts ? buildOpts(t.opts) : ''}
</div>
${t.sol ? `<div class="sol-wrap">
<button class="sol-btn" data-task="${i}" onclick="toggleSol(this, ${num}, ${i})">
${SOL_ICON_CLOSED}<span>Показать решение</span>
</button>
<div class="sol-panel">${t.sol}</div>
</div>` : ''}
</div>`
).join('');
runKatex(main);
}
function toggleSol(btn, variantNum, taskIdx) {
const panel = btn.nextElementSibling;
const open = panel.classList.contains('visible');
panel.classList.toggle('visible', !open);
btn.classList.toggle('open', !open);
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
if (!open) {
if (!panel.dataset.k) { runKatex(panel); panel.dataset.k = '1'; }
markSolutionViewed(variantNum, taskIdx);
}
}
function selectVariant(num) {
currentVariant = num;
document.getElementById('picker-label').textContent = VARIANTS[num].label;
document.querySelectorAll('.vg-btn').forEach(b => {
b.classList.toggle('active', Number(b.textContent) === num);
});
renderVariant(num);
// Persist last opened variant
try { localStorage.setItem('exam9_last_variant', String(num)); } catch {}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
/* ── Boot ───────────────────────────────────────────────────────── */
(function boot() {
const keys = Object.keys(VARIANTS);
if (!keys.length) {
document.getElementById('ex-main').innerHTML = '<div class="ex-empty">Варианты не загружены</div>';
return;
}
// Resume last opened variant or open first one
let initial = Number(keys[0]);
try {
const last = Number(localStorage.getItem('exam9_last_variant'));
if (last && VARIANTS[last]) initial = last;
} catch {}
selectVariant(initial);
})();