feat(labs): Фаза1 — фреймворк учебных заданий (LabTasks)

Превращает песочницы в учебные инструменты: задание → ответ числом с допуском →
проверка/подсказка/прогресс (по образцу race.js, но переиспользуемо).
- _tasks.js: LabTasks (панель, прогресс-точки, проверка с tol, KaTeX в условии).
- Интеграция в loadTheory (одна точка): панель «Задания» дописывается в теорию,
  бейдж на кнопке теории когда задания есть.
- Данные на 5 симуляций: quadratic, trigcircle, normaldist, projectile, pendulum.
Проверка на клиенте (учебные, не оценочные). XP — отдельным инкрементом.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 10:42:17 +03:00
parent 28db2de74f
commit 15282c50b3
3 changed files with 168 additions and 3 deletions
+155
View File
@@ -0,0 +1,155 @@
'use strict';
/* LabTasks — лёгкий фреймворк учебных заданий для симуляций (Фаза 1 плана).
* Превращает «песочницу» в учебный инструмент: задание → ответ числом с допуском →
* проверка/подсказка/прогресс. Данные — LAB_TASKS[simId] = [{q, formula?, a, tol, unit?, hint?}].
* Рендерится панелью в теорию (loadTheory дописывает LabTasks.mountHtml). Ответы
* проверяются на клиенте (учебные, не оценочные) — без обращения к серверу. */
(function (global) {
/* ── Данные заданий (наполняется по симуляциям) ── */
var LAB_TASKS = {
quadratic: [
{ q: 'Найди дискриминант уравнения ниже.', formula: 'x^2 - 5x + 6 = 0', a: 1, tol: 0.01, hint: 'D = b² 4ac' },
{ q: 'Сколько действительных корней у уравнения x² + 2x + 5 = 0?', a: 0, tol: 0.01, hint: 'Если D < 0 — корней нет' },
{ q: 'Чему равна абсцисса вершины параболы y = x² − 4x + 1?', a: 2, tol: 0.01, hint: 'x вершины = b / (2a)' },
],
trigcircle: [
{ q: 'Чему равен sin 30°? (десятичной дробью)', a: 0.5, tol: 0.02, hint: 'sin — это ордината (y) точки на окружности' },
{ q: 'Чему равен cos 90°?', a: 0, tol: 0.02, hint: 'cos — это абсцисса (x) точки' },
{ q: 'Чему равен tg 45°?', a: 1, tol: 0.02, hint: 'tg = sin / cos' },
],
normaldist: [
{ q: 'Какая доля значений попадает в интервал ±1σ? (например 0.68)', a: 0.68, tol: 0.03, hint: 'Правило 689599.7' },
{ q: 'Какая доля попадает в ±2σ?', a: 0.95, tol: 0.03, hint: 'Правило 689599.7' },
],
projectile: [
{ q: 'При каком угле броска дальность максимальна (без сопротивления)? В градусах.', a: 45, tol: 1, hint: 'Дальность ∝ sin(2α), максимум при 2α = 90°' },
],
pendulum: [
{ q: 'Период математического маятника длиной 1 м при g ≈ 9.8 м/с²? В секундах.', a: 2.0, tol: 0.15, formula: 'T = 2\\pi\\sqrt{L/g}', hint: 'Подставь L = 1, g = 9.8' },
],
};
var ICON_CHECK = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg>';
var ICON_X = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;vertical-align:-2px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
var ICON_TASK = '<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;vertical-align:-2px"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
var ICON_TROPHY = '<svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg>';
function esc(s) { return (global.LS && LS.esc) ? LS.esc(s) : String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
/* состояние прохождения (сбрасывается при перезагрузке — задел под persist в Фазе 2) */
var state = {};
function st(simId) {
if (!state[simId]) state[simId] = { idx: 0, done: LAB_TASKS[simId].map(function () { return false; }) };
return state[simId];
}
function ensureStyle() {
if (document.getElementById('lt-style')) return;
var s = document.createElement('style'); s.id = 'lt-style';
s.textContent = [
'.lt-host{margin-top:18px;}',
'.lt-panel{border:1.5px solid rgba(155,93,229,.25);border-radius:14px;padding:14px 15px;background:rgba(155,93,229,.05);}',
'.lt-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px;}',
'.lt-lbl{display:inline-flex;align-items:center;gap:6px;font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--violet,#9B5DE5);}',
'.lt-dots{display:inline-flex;gap:5px;}',
'.lt-dot{width:7px;height:7px;border-radius:50%;background:rgba(155,93,229,.25);}',
'.lt-dot.ok{background:var(--green,#06D664);}',
'.lt-dot.cur{box-shadow:0 0 0 2px rgba(155,93,229,.35);}',
'.lt-q{font-size:.9rem;line-height:1.5;color:var(--text,#0f172a);margin-bottom:8px;}',
'.lt-formula{margin:6px 0 10px;}',
'.lt-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}',
'.lt-input{width:120px;padding:8px 11px;border:1.5px solid var(--border-h,#cbd5e1);border-radius:9px;font:inherit;font-size:.88rem;background:var(--surface,#fff);color:var(--text,#0f172a);}',
'.lt-unit{font-size:.82rem;color:var(--text-3,#56687A);}',
'.lt-btn{padding:8px 16px;border:none;border-radius:9px;background:var(--violet,#9B5DE5);color:#fff;font:700 .82rem Manrope,sans-serif;cursor:pointer;}',
'.lt-btn:hover{background:#8347d4;}',
'.lt-fb{margin-top:9px;font-size:.84rem;font-weight:600;min-height:1px;}',
'.lt-fb.ok{color:var(--green,#06A552);}',
'.lt-fb.err{color:var(--pink,#E23C8E);}',
'.lt-fb.warn{color:var(--amber,#D9831A);}',
'.lt-done{text-align:center;padding:20px 14px;}',
'.lt-done .ic{color:var(--violet,#9B5DE5);}',
'.lt-done-t{font-weight:800;font-size:.95rem;margin-top:6px;}',
'.lt-done-s{font-size:.8rem;color:var(--text-3,#56687A);margin-top:2px;}',
'#theory-toggle.lt-has{position:relative;}',
'#theory-toggle.lt-has::after{content:"";position:absolute;top:5px;right:5px;width:7px;height:7px;border-radius:50%;background:var(--violet,#9B5DE5);box-shadow:0 0 0 2px var(--bg,#EEF2FF);}',
].join('');
document.head.appendChild(s);
}
function inner(simId) {
var list = LAB_TASKS[simId]; if (!list) return '';
var s = st(simId);
var total = list.length, solved = s.done.filter(Boolean).length;
if (solved === total) {
return '<div class="lt-panel lt-done">' + ICON_TROPHY +
'<div class="lt-done-t">Все задания выполнены!</div>' +
'<div class="lt-done-s">' + total + ' из ' + total + '</div></div>';
}
var i = s.idx, t = list[i];
var dots = list.map(function (_, k) { return '<span class="lt-dot' + (s.done[k] ? ' ok' : '') + (k === i ? ' cur' : '') + '"></span>'; }).join('');
return '<div class="lt-panel">' +
'<div class="lt-head"><span class="lt-lbl">' + ICON_TASK + ' Задание ' + (i + 1) + ' из ' + total + '</span><span class="lt-dots">' + dots + '</span></div>' +
'<div class="lt-q">' + esc(t.q) + '</div>' +
(t.formula ? '<div class="lt-formula" data-formula="' + t.formula.replace(/"/g, '&quot;') + '"></div>' : '') +
'<div class="lt-row">' +
'<input class="lt-input" id="lt-in-' + simId + '" type="number" step="any" placeholder="ответ" onkeydown="if(event.key===\'Enter\')LabTasks.check(\'' + simId + '\')">' +
(t.unit ? '<span class="lt-unit">' + esc(t.unit) + '</span>' : '') +
'<button class="lt-btn" onclick="LabTasks.check(\'' + simId + '\')">Проверить</button>' +
'</div>' +
'<div class="lt-fb" id="lt-fb-' + simId + '"></div>' +
'</div>';
}
function renderFormulas(host) {
if (!global.katex) return;
host.querySelectorAll('.lt-formula[data-formula]').forEach(function (d) {
try { katex.render(d.dataset.formula, d, { displayMode: true, throwOnError: false }); }
catch (e) { d.textContent = d.dataset.formula; }
});
}
function rerender(simId) {
var host = document.getElementById('lt-host-' + simId);
if (!host) return;
host.innerHTML = inner(simId);
renderFormulas(host);
}
global.LabTasks = {
has: function (simId) { return !!(LAB_TASKS[simId] && LAB_TASKS[simId].length); },
/** HTML панели заданий для вставки в теорию */
mountHtml: function (simId) {
if (!this.has(simId)) return '';
ensureStyle();
return '<div class="lt-host" id="lt-host-' + simId + '">' + inner(simId) + '</div>';
},
/** отрисовать формулы после вставки в DOM */
afterMount: function (simId) {
var host = document.getElementById('lt-host-' + simId);
if (host) renderFormulas(host);
},
check: function (simId) {
var list = LAB_TASKS[simId]; if (!list) return;
var s = st(simId), t = list[s.idx];
var inp = document.getElementById('lt-in-' + simId);
var fb = document.getElementById('lt-fb-' + simId);
if (!inp || !fb) return;
var v = parseFloat(String(inp.value || '').replace(',', '.'));
if (isNaN(v)) { fb.className = 'lt-fb warn'; fb.textContent = 'Введи число'; return; }
if (Math.abs(v - t.a) <= (t.tol || 0)) {
s.done[s.idx] = true;
fb.className = 'lt-fb ok'; fb.innerHTML = ICON_CHECK + ' Верно!';
setTimeout(function () {
// перейти к следующему невыполненному
var next = -1;
for (var k = 1; k <= list.length; k++) { var j = (s.idx + k) % list.length; if (!s.done[j]) { next = j; break; } }
if (next !== -1) s.idx = next;
rerender(simId);
}, 650);
} else {
fb.className = 'lt-fb err'; fb.innerHTML = ICON_X + ' Не совсем.' + (t.hint ? ' Подсказка: ' + esc(t.hint) : '');
}
},
};
})(window);
+12 -3
View File
@@ -242,7 +242,15 @@ const SIMS = [
const _rm = window.LabRegistry ? window.LabRegistry.get(simId) : null;
const t = (_rm && _rm.theory) ? _rm.theory : THEORY[simId];
const el = document.getElementById('theory-content');
if (!t) { el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>'; return; }
// Фаза 1: панель учебных заданий (если есть для этой симуляции)
const tasksHtml = (window.LabTasks && LabTasks.has(simId)) ? LabTasks.mountHtml(simId) : '';
const tbtn = document.getElementById('theory-toggle');
if (tbtn) tbtn.classList.toggle('lt-has', !!tasksHtml);
if (!t) {
el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>' + tasksHtml;
if (tasksHtml) LabTasks.afterMount(simId);
return;
}
let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`;
for (const s of t.sections) {
html += '<div class="tp-section">';
@@ -252,12 +260,13 @@ const SIMS = [
if (s.vars) html += `<div class="tp-var-list">${s.vars.map(([v,d]) => `<div class="tp-var"><b>${v}</b> — ${d}</div>`).join('')}</div>`;
html += '</div>';
}
el.innerHTML = html;
// render KaTeX formulas
el.innerHTML = html + tasksHtml;
// render KaTeX formulas (теория)
el.querySelectorAll('.tp-formula[data-formula]').forEach(div => {
try { katex.render(div.dataset.formula, div, { displayMode: true, throwOnError: false }); }
catch(e) { div.textContent = div.dataset.formula; }
});
if (tasksHtml) LabTasks.afterMount(simId);
}
/* ── Контент-движок, Фаза 5: чип «Связано с программой» ──────────────────
+1
View File
@@ -430,6 +430,7 @@
<script src="/js/labs/_fx_motion.js"></script>
<script src="/js/labs/_fx_sound.js"></script>
<script src="/js/labs/_graph_panel.js"></script>
<script src="/js/labs/_tasks.js"></script>
<script src="/js/labs/_phys_visuals.js"></script>
<script src="/js/labs/_chem_visuals.js"></script>
<script src="/js/labs/_util.js"></script>