6cff327e88
Новый отдельный модуль /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/*.
177 lines
6.8 KiB
JavaScript
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);
|
|
})();
|