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:
@@ -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: 'Правило 68–95–99.7' },
|
||||
{ q: 'Какая доля попадает в ±2σ?', a: 0.95, tol: 0.03, hint: 'Правило 68–95–99.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 ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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, '"') + '"></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);
|
||||
@@ -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: чип «Связано с программой» ──────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user