Files
Learn_System/frontend/trainer.html
T
Maxim Dolgolyov 277bddf1fd feat(trainer): P7 пошаговое решение (репетитор) + P8 мат-клавиатура
- движок checkStep(problem, line): шаг = равносильное уравнение (держится во всех корнях И не выполняется в не-корнях) → ловит арифметику, потерю корня, тождество; статусы equivalent/solved/wrong/identity/parse
- страница: тумблер «Решить по шагам» (kind solve), ввод и проверка каждого шага, список принятых шагов (KaTeX + галочка), подсказка следующего шага, завершение по solved-форме; общий onSolved; stepPref между задачами
- P8: экранная мат-клавиатура (( ) x / ^ √ ; ⌫, вставка в курсор, без либ) + live-превью KaTeX; для поля ответа и поля шага
- ROADMAP_V2: P7+P8 → DONE; смоук движка 300/300 (T14 checkStep), страница 33/33 (шаг-сценарии)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:06:46 +03:00

1003 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Тренажёр — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"/>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ═══════════════ Тренажёр — «рабочий лист в клетку» ═══════════════
Фон — ambient-клетка (материал предмета: тетрадь в клетку). Задача —
чистая карточка-герой с акцентной «решающей» полосой. Акцент индиго→
фиолет, успех — изумруд, мастерство — золото. Логика/классы не менялись. */
:root {
--ink: #1b1f38; --ink-soft: #5b6378; --ink-faint: #98a1b8;
--card: #ffffff;
--g1: #6366f1; --g2: #8b5cf6;
--accent-ink: #4338ca; --accent-soft: #eef0ff;
--ok: #10b981; --ok-ink: #047857; --ok-soft: #dcfce7;
--bad: #ef4444; --bad-soft: #fee2e2; --warn: #d97706;
--gold: #f59e0b;
--r-lg: 24px;
--sh: 0 16px 40px rgba(27,31,56,.09), 0 2px 6px rgba(27,31,56,.04);
--sh-lg: 0 30px 70px rgba(27,31,56,.20);
--ease: cubic-bezier(.22,.61,.36,1);
}
.sb-content {
background-color: #f5f6fb;
background-image:
radial-gradient(1000px 600px at 86% -10%, rgba(139,92,246,.10), transparent 60%),
radial-gradient(820px 560px at 2% -6%, rgba(99,102,241,.09), transparent 55%),
linear-gradient(rgba(99,102,241,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(99,102,241,.05) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 26px 26px, 26px 26px;
background-attachment: fixed;
}
.tr-wrap { max-width: 740px; margin: 0 auto; padding: 34px 20px 90px; }
@keyframes trUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.tr-head, .tr-overall, .tr-mode, .tr-topics, .tr-skills, .tr-card { animation: trUp .5s var(--ease) both; }
.tr-overall { animation-delay: .04s; } .tr-mode { animation-delay: .06s; }
.tr-topics { animation-delay: .1s; } .tr-skills { animation-delay: .13s; } .tr-card { animation-delay: .16s; }
.tr-head { margin-bottom: 20px; }
.tr-h1 {
font-family: 'Manrope', sans-serif; font-weight: 800; font-size: clamp(1.6rem, 4.4vw, 2.1rem);
letter-spacing: -.02em; color: var(--ink); margin: 0 0 6px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
.tr-sub { color: var(--ink-soft); font-size: .95rem; line-height: 1.5; }
.tr-pill {
display: inline-block; font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em;
padding: 5px 11px; border-radius: 99px; color: #fff; vertical-align: middle;
background: linear-gradient(135deg, var(--g1), var(--g2)); box-shadow: 0 6px 16px rgba(99,102,241,.30);
}
/* ── выбор темы (вкладки) ── */
.tr-topics { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 12px; }
.tr-chip {
font: inherit; font-size: .86rem; font-weight: 700; cursor: pointer; color: var(--ink-soft);
padding: 9px 16px; border-radius: 99px; border: 1px solid rgba(99,102,241,.18);
background: rgba(255,255,255,.7); backdrop-filter: blur(6px); transition: .18s var(--ease);
}
.tr-chip:hover { border-color: var(--g1); color: var(--accent-ink); transform: translateY(-1px); }
.tr-chip.on { color: #fff; border-color: transparent; background: linear-gradient(135deg, var(--g1), var(--g2)); box-shadow: 0 8px 20px rgba(99,102,241,.34); }
/* ── карточка задачи (герой) ── */
.tr-card {
position: relative; overflow: hidden; background: var(--card);
border: 1px solid rgba(99,102,241,.10); border-radius: var(--r-lg);
padding: 34px 30px 30px; box-shadow: var(--sh); transition: box-shadow .3s var(--ease), transform .3s var(--ease);
}
.tr-card::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 5px; background: linear-gradient(90deg, var(--g1), var(--g2)); }
.tr-card.tr-correct { box-shadow: 0 20px 54px rgba(16,185,129,.24); animation: trPop .5s var(--ease); }
.tr-card.tr-correct::before { height: 6px; background: linear-gradient(90deg, var(--ok), #34d399); }
.tr-card.tr-wrong { animation: trShake .42s var(--ease); }
.tr-card.tr-wrong::before { background: linear-gradient(90deg, var(--bad), #fb7185); }
@keyframes trPop { 0% { transform: scale(1); } 32% { transform: scale(1.014); } 100% { transform: scale(1); } }
@keyframes trShake { 0%,100% { transform: translateX(0); } 18% { transform: translateX(-7px); } 38% { transform: translateX(6px); } 58% { transform: translateX(-4px); } 78% { transform: translateX(2px); } }
#tr-skill {
color: var(--accent-ink); font-family: 'Manrope', sans-serif; font-size: .74rem; font-weight: 800;
text-transform: uppercase; letter-spacing: .07em; margin-bottom: 16px;
}
.tr-eq {
font-family: 'Cambria Math', 'Times New Roman', Georgia, serif;
font-size: clamp(1.8rem, 5.2vw, 2.5rem); font-weight: 600; letter-spacing: .01em;
color: var(--ink); text-align: center; padding: 8px 0 26px; user-select: none;
}
.tr-inrow { display: flex; gap: 10px; align-items: stretch; max-width: 440px; margin: 0 auto; }
#tr-eqx { font-family: 'Cambria Math', serif; font-size: 1.5rem; font-weight: 600; color: var(--accent-ink); align-self: center; padding-left: 4px; }
.tr-input {
flex: 1; min-width: 0; font: inherit; font-size: 1.2rem; font-weight: 600; text-align: center; color: var(--ink);
padding: 13px 16px; border-radius: 14px; border: 2px solid rgba(99,102,241,.22); background: #fff; outline: none; transition: .18s var(--ease);
}
.tr-input::placeholder { color: var(--ink-faint); font-weight: 500; }
.tr-input:focus { border-color: var(--g1); box-shadow: 0 0 0 4px rgba(99,102,241,.16); }
.tr-input:disabled { background: #f3f4fb; color: var(--ink-soft); }
.tr-btn {
font: inherit; font-weight: 700; cursor: pointer; border: none; border-radius: 14px;
padding: 13px 22px; transition: .18s var(--ease); display: inline-flex; align-items: center; gap: 7px;
}
.tr-btn .ic { width: 17px; height: 17px; }
.tr-primary { color: #fff; background: linear-gradient(135deg, var(--g1), var(--g2)); box-shadow: 0 10px 24px rgba(99,102,241,.34); }
.tr-primary:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(99,102,241,.42); }
.tr-primary:active { transform: translateY(0); }
.tr-ghost { background: rgba(99,102,241,.08); color: var(--accent-ink); }
.tr-ghost:hover { background: rgba(99,102,241,.15); }
.tr-feedback {
width: fit-content; margin: 20px auto 2px; min-height: 28px; padding: 7px 16px; border-radius: 99px;
font-weight: 700; font-size: 1rem; display: flex; align-items: center; justify-content: center; gap: 9px; transition: .2s var(--ease);
}
.tr-feedback:empty { padding: 0; min-height: 0; }
.tr-feedback .ic { width: 19px; height: 19px; }
.tr-feedback.ok { color: var(--ok-ink); background: var(--ok-soft); }
.tr-feedback.bad { color: #b91c1c; background: var(--bad-soft); }
.tr-feedback.warn { color: var(--warn); background: #fef3c7; font-weight: 600; }
.tr-actions { display: flex; flex-wrap: wrap; gap: 9px; justify-content: center; margin-top: 16px; }
/* ── мат-клавиатура + live-превью (P8) ── */
.tr-keypad { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; max-width: 440px; margin: 10px auto 0; }
.tr-key { font: inherit; font-size: .95rem; font-weight: 700; font-family: 'Cambria Math', serif; cursor: pointer; min-width: 40px; padding: 7px 10px; border-radius: 10px; border: 1px solid rgba(99,102,241,.18); background: rgba(255,255,255,.8); color: var(--accent-ink); transition: .14s var(--ease); }
.tr-key:hover { border-color: var(--g1); background: var(--accent-soft); transform: translateY(-1px); }
.tr-key:active { transform: translateY(0); }
.tr-key .ic { width: 16px; height: 16px; }
.tr-preview { text-align: center; margin: 12px auto 0; color: var(--ink-soft); }
.tr-preview:empty { display: none; }
.tr-preview .katex { font-size: 1.12em; }
/* ── пошаговое решение / репетитор (P7) ── */
.tr-steps { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.tr-steps:empty { display: none; }
.tr-step-line { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 12px; background: linear-gradient(180deg, #f4fbf7, #ecf9f1); border: 1px solid rgba(16,185,129,.22); animation: trUp .25s var(--ease) both; }
.tr-step-ic { flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%; background: var(--ok); color: #fff; display: inline-flex; align-items: center; justify-content: center; }
.tr-step-ic .ic { width: 14px; height: 14px; }
.tr-step-tex { font-family: 'Cambria Math', serif; font-size: 1.12rem; color: var(--ink); }
.tr-solution {
margin-top: 22px; padding: 18px 20px; border-radius: 16px;
background: linear-gradient(180deg, #fbfbff, #f4f5fd); border: 1px solid rgba(99,102,241,.14);
animation: trUp .35s var(--ease) both;
}
.tr-solution h4 { margin: 0 0 12px; font-family: 'Manrope', sans-serif; font-size: .74rem; text-transform: uppercase; letter-spacing: .07em; color: var(--accent-ink); font-weight: 800; }
.tr-step { color: #334155; padding: 12px 0; }
.tr-step + .tr-step { border-top: 1px dashed rgba(99,102,241,.2); }
.tr-step-note { display: block; color: var(--ink-soft); font-family: 'Manrope', sans-serif; font-size: .92rem; line-height: 1.6; margin-bottom: 7px; }
.tr-step-math { display: block; font-family: 'Cambria Math', serif; font-size: 1.18rem; color: var(--ink); margin-left: 30px; }
.tr-step-n { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: linear-gradient(135deg, var(--g1), var(--g2)); color: #fff; font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 800; margin-right: 9px; vertical-align: 1px; }
.tr-eq .katex-display { margin: 0; }
.tr-eq .katex { font-size: 1.12em; }
/* текстовый prompt (проценты/упрощение) — компактнее уравнения */
.tr-eq.tr-eq-text { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: clamp(1.15rem, 3.4vw, 1.6rem); line-height: 1.45; color: var(--ink); }
/* выбор навыка внутри темы */
.tr-skills { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 24px; }
.tr-skills .tr-skill {
font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; font-family: 'Cambria Math', 'Times New Roman', serif;
padding: 7px 13px; border-radius: 11px; border: 1px solid rgba(99,102,241,.16); background: rgba(255,255,255,.7);
color: var(--ink-soft); transition: .16s var(--ease); display: inline-flex; align-items: center;
}
.tr-skills .tr-skill:hover { border-color: var(--g1); color: var(--accent-ink); transform: translateY(-1px); }
.tr-skills .tr-skill.on { background: var(--accent-soft); border-color: var(--g1); color: var(--accent-ink); }
.tr-pool-info { font-size: .82rem; color: var(--ink-soft); align-self: center; margin-right: 6px; }
#tr-gen-btn { border-style: dashed; color: var(--accent-ink); }
/* бейджи прогресса на чипах */
.tr-badge { display: inline-flex; margin-left: 7px; color: var(--ok); vertical-align: middle; }
.tr-badge .ic { width: 14px; height: 14px; }
.tr-chip.on .tr-badge { color: #fff; }
.tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: var(--ink-faint); background: rgba(99,102,241,.12); border-radius: 99px; padding: 1px 7px; }
.tr-chip.on .tr-badge-n { color: #fff; background: rgba(255,255,255,.24); }
/* ── общий прогресс (лёгкая геймификация) ── */
.tr-overall { display: inline-flex; align-items: center; gap: 8px; color: var(--accent-ink); font-size: .82rem; font-weight: 700; margin: 0 0 14px; padding: 6px 13px; border-radius: 99px; background: rgba(99,102,241,.08); }
.tr-overall:empty { display: none; }
/* ── модалка ── */
.tr-modal { position: fixed; inset: 0; z-index: 50; background: rgba(20,22,45,.55); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; padding: 20px; animation: trFade .2s ease; }
@keyframes trFade { from { opacity: 0; } to { opacity: 1; } }
.tr-modal-card { background: #fff; border-radius: 20px; max-width: 920px; width: 100%; max-height: 86vh; overflow: auto; box-shadow: var(--sh-lg); animation: trUp .3s var(--ease) both; }
.tr-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid rgba(99,102,241,.12); font-weight: 800; font-family: 'Manrope', sans-serif; font-size: 1.06rem; color: var(--ink); position: sticky; top: 0; background: #fff; z-index: 1; }
.tr-modal-x { background: none; border: none; cursor: pointer; color: var(--ink-soft); padding: 5px; border-radius: 9px; transition: .15s; }
.tr-modal-x:hover { background: rgba(99,102,241,.1); color: var(--ink); }
.tr-modal-x .ic { width: 18px; height: 18px; }
#tr-an-body, #tr-tch-body { padding: 20px 22px; }
.tr-an-picker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.tr-an-cls { font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; padding: 8px 14px; border-radius: 99px; border: 1px solid rgba(99,102,241,.2); background: #fff; color: var(--ink-soft); transition: .15s; }
.tr-an-cls:hover, .tr-an-cls.on { border-color: var(--g1); color: var(--accent-ink); background: var(--accent-soft); }
.tr-an-empty { color: var(--ink-faint); padding: 24px; text-align: center; }
.tr-hm-wrap { overflow-x: auto; border-radius: 12px; border: 1px solid rgba(99,102,241,.12); }
table.tr-hm { border-collapse: collapse; font-size: .8rem; width: 100%; }
table.tr-hm th, table.tr-hm td { border: 1px solid rgba(99,102,241,.1); padding: 7px 9px; text-align: center; white-space: nowrap; }
table.tr-hm th { background: #f6f7fd; color: var(--ink-soft); font-weight: 700; position: sticky; top: 0; }
.tr-hm-name { text-align: left !important; font-weight: 600; color: var(--ink); background: #f6f7fd; position: sticky; left: 0; }
.tr-hm-none { color: #cbd5e1; }
.tr-hm-cell { font-weight: 700; color: #334155; }
.tr-hm-cell .ic { width: 14px; height: 14px; color: #fff; }
.tr-hm-sum { font-weight: 800; color: var(--accent-ink); background: var(--accent-soft); }
/* форма авторинга задачи */
.tr-form { display: flex; flex-direction: column; gap: 13px; }
.tr-form label { display: flex; flex-direction: column; gap: 5px; font-size: .85rem; font-weight: 700; color: var(--ink-soft); }
.tr-form input, .tr-form textarea { font: inherit; padding: 10px 12px; border: 1px solid rgba(99,102,241,.22); border-radius: 11px; outline: none; resize: vertical; color: var(--ink); transition: .15s; }
.tr-form input:focus, .tr-form textarea:focus { border-color: var(--g1); box-shadow: 0 0 0 3px rgba(99,102,241,.14); }
.tr-form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.tr-form-row label { flex: 1; min-width: 110px; }
.tr-form-hint { font-size: .8rem; color: var(--ink-soft); line-height: 1.5; }
.tr-form-err { color: #dc2626; font-size: .85rem; font-weight: 600; min-height: 18px; }
/* ── режим (умная тренировка) ── */
.tr-mode { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; flex-wrap: wrap; }
.tr-mode-btn {
font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 7px;
padding: 9px 15px; border-radius: 99px; border: 1px solid rgba(99,102,241,.18); background: rgba(255,255,255,.7); color: var(--ink-soft); transition: .16s var(--ease);
}
.tr-mode-btn .ic { width: 16px; height: 16px; }
.tr-mode-btn:hover { border-color: var(--g1); color: var(--accent-ink); transform: translateY(-1px); }
.tr-mode-btn.on { color: #fff; border-color: transparent; background: linear-gradient(135deg, var(--g1), var(--g2)); box-shadow: 0 8px 20px rgba(99,102,241,.32); }
.tr-session { font-size: .82rem; font-weight: 800; color: var(--accent-ink); padding: 4px 12px; border-radius: 99px; background: rgba(99,102,241,.1); }
.tr-session:empty { display: none; }
/* ── итог сессии ── */
.tr-summary {
position: relative; overflow: hidden; background: #fff; border: 1px solid rgba(99,102,241,.1); border-radius: var(--r-lg);
padding: 30px 26px; box-shadow: var(--sh); text-align: center; animation: trPop .5s var(--ease);
}
.tr-summary::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 6px; background: linear-gradient(90deg, var(--gold), var(--g2)); }
.tr-sum-h { margin: 6px 0 18px; font-family: 'Manrope', sans-serif; font-weight: 800; font-size: 1.35rem; color: var(--ink); }
.tr-sum-row { display: inline-flex; flex-direction: column; align-items: center; margin: 0 18px 12px; }
.tr-sum-row b { font-size: 1.9rem; font-weight: 800; font-family: 'Manrope', sans-serif; line-height: 1.1; background: linear-gradient(135deg, var(--g1), var(--g2)); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.tr-sum-row span { font-size: .72rem; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .06em; font-weight: 700; }
.tr-sum-weak { margin: 10px 0 20px; color: var(--warn); font-weight: 600; font-size: .92rem; }
.tr-sum-weak.tr-sum-good { color: var(--ok-ink); }
/* ── статистика ── */
.tr-stats { display: flex; gap: 28px; justify-content: center; margin: 24px 0 4px; }
.tr-stat { text-align: center; }
.tr-stat b { display: block; font-size: 1.7rem; font-weight: 800; font-family: 'Manrope', sans-serif; line-height: 1.1; background: linear-gradient(135deg, var(--g1), var(--g2)); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.tr-stat span { font-size: .72rem; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .06em; font-weight: 700; }
.tr-note { margin-top: 26px; text-align: center; color: var(--ink-faint); font-size: .78rem; line-height: 1.5; }
@media (max-width: 560px) {
.tr-wrap { padding: 22px 14px 70px; }
.tr-card { padding: 26px 18px 24px; }
.tr-inrow { flex-wrap: wrap; }
.tr-inrow .tr-btn { width: 100%; justify-content: center; }
.tr-stats { gap: 22px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="tr-wrap">
<div class="tr-head">
<h1 class="tr-h1">Тренажёр<span class="tr-pill" id="tr-subject">Алгебра · 78 класс</span></h1>
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
</div>
<div class="tr-overall" id="tr-overall"></div>
<div class="tr-mode">
<button class="tr-mode-btn on" id="tr-smart-btn" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>
Умная тренировка
</button>
<span class="tr-session" id="tr-session"></span>
<button class="tr-mode-btn" id="tr-analytics-btn" type="button" style="display:none;margin-left:auto">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><rect x="7" y="10" width="3" height="7"/><rect x="13" y="6" width="3" height="11"/></svg>
Аналитика класса
</button>
</div>
<div class="tr-modal" id="tr-analytics" style="display:none">
<div class="tr-modal-card">
<div class="tr-modal-head">
<span>Аналитика класса</span>
<button class="tr-modal-x" id="tr-an-close" type="button" aria-label="Закрыть">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="tr-an-body"></div>
</div>
</div>
<div class="tr-modal" id="tr-teacher" style="display:none">
<div class="tr-modal-card" style="max-width:560px">
<div class="tr-modal-head">
<span id="tr-tch-title">Своя задача</span>
<button class="tr-modal-x" id="tr-tch-close" type="button" aria-label="Закрыть">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="tr-tch-body"></div>
</div>
</div>
<div class="tr-topics" id="tr-topics"></div>
<div class="tr-skills" id="tr-skills"></div>
<div class="tr-card">
<div class="tr-skill" id="tr-skill"></div>
<div class="tr-eq" id="tr-eq"></div>
<div id="tr-answerbox">
<div class="tr-inrow">
<span class="tr-eqx" id="tr-eqx">x =</span>
<input class="tr-input" id="tr-input" type="text" inputmode="text" autocomplete="off"
placeholder="ответ" aria-label="Ваш ответ"/>
<button class="tr-btn tr-primary" id="tr-check" type="button">Проверить</button>
</div>
<div class="tr-keypad" id="tr-keypad"></div>
<div class="tr-preview" id="tr-preview"></div>
<div class="tr-feedback" id="tr-feedback"></div>
</div>
<div id="tr-stepbox" style="display:none">
<div class="tr-steps" id="tr-steps"></div>
<div class="tr-inrow">
<input class="tr-input" id="tr-stepin" type="text" autocomplete="off"
placeholder="следующий шаг, напр. 3x = 15" aria-label="Следующий шаг"/>
<button class="tr-btn tr-primary" id="tr-stepcheck" type="button">Шаг</button>
</div>
<div class="tr-keypad" id="tr-keypad2"></div>
<div class="tr-preview" id="tr-prev2"></div>
<div class="tr-feedback" id="tr-stepfb"></div>
</div>
<div class="tr-actions">
<button class="tr-btn tr-ghost" id="tr-step-toggle" type="button" style="display:none">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
Решить по шагам
</button>
<button class="tr-btn tr-ghost" id="tr-hint" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1h6c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2Z"/></svg>
Подсказка
</button>
<button class="tr-btn tr-ghost" id="tr-solve" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Решение
</button>
<button class="tr-btn tr-ghost" id="tr-skip" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 12 8-12 8z"/><path d="M20 4v16"/></svg>
Другая
</button>
</div>
<div class="tr-solution" id="tr-solution" style="display:none"></div>
</div>
<div class="tr-summary" id="tr-summary" style="display:none"></div>
<div class="tr-stats">
<div class="tr-stat"><b id="tr-solved">0</b><span>решено</span></div>
<div class="tr-stat"><b id="tr-streak">0</b><span>серия</span></div>
</div>
<div class="tr-note" id="tr-note"></div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- безопасный вычислитель + ядро тренажёра (тот же путь, что lab/sim-builder) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/trainer/_trainer_engine.js"></script>
<script src="/js/trainer/generators.js"></script>
<script src="/js/trainer/adaptive.js"></script>
<!-- KaTeX для рендера уравнений и шагов решения -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script>
(function () {
'use strict';
// initPage редиректит на /login, если не авторизован
if (typeof LS === 'undefined') return;
var ip = LS.initPage();
if (!ip) return;
// Фича-гейт: «Тренажёр» можно отключить в админке (feature_trainer_enabled).
// Сайдбар/доступ скрываются через FEATURE_HREFS; здесь — редирект при прямом заходе.
// Админ имеет доступ всегда — он управляет модулями.
if (LS.loadFeatures && !ip.isAdmin) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.trainer === false) { if (LS.toast) LS.toast('Тренажёр отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
}
var TE = window.TrainerEngine, TG = window.TrainerGenerators, TA = window.TrainerAdaptive;
var gens = TG.list();
var ordered = gens; // прогрессия = порядок объявления (темы по order, навыки по order)
var ICON = {
ok: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
bad: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
star: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
};
function $(id) { return document.getElementById(id); }
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function randSeed() { return (Math.random() * 2147483647) | 0; }
function fmt(v) { return TE.prettyMath(String(v)); }
// KaTeX-рендер выражения → html-строка или null (мягкий фолбэк на текст)
function kat(latex, display) {
if (window.katex && latex) {
try { return window.katex.renderToString(latex, { displayMode: !!display, throwOnError: false }); }
catch (e) {}
}
return null;
}
function setMath(el, latex, fallbackText, display) {
var h = kat(latex, display);
if (h) el.innerHTML = h; else el.textContent = fallbackText;
}
var topics = (TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]).concat([{ key: 'word', label: 'Текстовые задачи', word: true }]);
var isTeacher = !!(ip && ip.isTeacher);
function skillKey(g) { return g.skill || g.id; }
function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; }
function isWord() { return curTopic === 'word'; }
function currentSkill() { return (cur && cur.kind === 'word') ? (cur.skill || 'word-linear') : skillKey(curGen); }
// ── пул текстовых задач (Уровень 1, LLM + серверная проверка) ──
var wordPool = [], wordIdx = 0, wordLoading = false;
function toWordProblem(p) {
return {
kind: 'word', skill: p.skill || 'word-linear', title: 'Текстовая задача',
display: p.story, latex: null,
lhsExpr: p.lhsExpr, rhsExpr: p.rhsExpr, answerVar: p.answerVar || 'x', answer: p.answer,
solution: (p.solution || []).map(function (st) {
var tex = st.tex || '';
return { note: st.note || '', tex: tex ? TE.prettyMath(tex) : '', latex: tex ? TE.exprToLatex(tex) : null };
})
};
}
function loadWordPool(done) {
if (!LS.practicePool) { wordPool = []; if (done) done(); return; }
wordLoading = true; renderSkills();
LS.practicePool('word-linear').then(function (r) {
wordPool = ((r && r.problems) || []).map(toWordProblem); wordIdx = 0;
}).catch(function () { wordPool = []; }).then(function () {
wordLoading = false; renderSkills(); if (done) done();
});
}
function serveWordProblem() {
var eq = $('tr-eq'); eq.classList.add('tr-eq-text');
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
if (!wordPool.length) {
cur = null;
$('tr-skill').textContent = 'Текстовые задачи';
eq.textContent = wordLoading ? 'Загрузка…' : (isTeacher ? 'Банк пуст. Нажмите «Сгенерировать задачу».' : 'Здесь появятся текстовые задачи.');
$('tr-input').disabled = true; setMode(false);
return;
}
cur = wordPool[wordIdx % wordPool.length]; wordIdx++;
$('tr-skill').textContent = cur.title;
setMath(eq, null, cur.display, true); // условие как текст
applyInputMode();
var inp = $('tr-input'); inp.value = ''; inp.disabled = false;
var card = $('tr-card'); if (card) { card.classList.remove('tr-correct'); card.classList.remove('tr-wrong'); }
var pv = $('tr-preview'); if (pv) pv.innerHTML = '';
setMode(false); inp.focus();
setStepMode(false); // текстовые задачи — без пошагового режима
}
function genWordProblem() {
var gb = $('tr-gen-btn'); if (gb) { gb.disabled = true; gb.textContent = 'Генерирую…'; }
LS.practiceGenerate('word-linear').then(function (r) {
if (r && r.ok && r.problem) {
wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0;
if (LS.toast) LS.toast('Задача добавлена (проверена за ' + r.attempts + ' попыт.)', 'success');
serveWordProblem();
}
renderSkills();
}).catch(function () {
if (LS.toast) LS.toast('Не удалось сгенерировать (LLM-провайдер не настроен?)', 'error');
renderSkills();
});
}
var curTopic = topics[0] ? topics[0].key : null;
var curGen = skillsOf(curTopic)[0] || gens[0];
var cur = null;
var solved = 0, streak = 0;
var answered = false; // задача решена (верно/неверно/показано решение) → «Проверить» становится «Дальше»
var prog = {}; // skill → строка прогресса с сервера
// адаптивная сессия
var smart = true, GOAL = 10;
var sessAnswered = 0, sessEvents = [], reviewQ = [], summaryShown = false;
// пошаговый режим (P7)
var stepMode = false, stepPref = false, stepList = [];
function topicMastered(topicKey) {
var ss = skillsOf(topicKey);
return ss.length > 0 && ss.every(function (g) { var p = prog[skillKey(g)]; return p && p.mastered; });
}
function skillBadge(g) {
var p = prog[skillKey(g)];
if (p && p.mastered) return '<span class="tr-badge" title="Освоено">' + ICON.star + '</span>';
if (p && p.solved) return '<span class="tr-badge-n">' + p.solved + '</span>';
return '';
}
function renderTopics() {
$('tr-topics').innerHTML = topics.map(function (t, i) {
var done = topicMastered(t.key) ? '<span class="tr-badge" title="Тема освоена">' + ICON.star + '</span>' : '';
return '<button class="tr-chip' + (t.key === curTopic ? ' on' : '') + '" type="button" data-ti="' + i + '">' + esc(t.label) + done + '</button>';
}).join('');
}
function renderSkills() {
if (isWord()) {
var tb = isTeacher
? '<button class="tr-skill" id="tr-gen-btn" type="button">+ ИИ-задача</button>'
+ '<button class="tr-skill" id="tr-author-btn" type="button">Своя задача</button>'
+ '<button class="tr-skill" id="tr-assign-btn" type="button">Выдать классу</button>'
: '';
$('tr-skills').innerHTML = '<span class="tr-pool-info">' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '</span>' + tb;
var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem);
var ab = $('tr-author-btn'); if (ab) ab.addEventListener('click', openAuthor);
var asg = $('tr-assign-btn'); if (asg) asg.addEventListener('click', openAssign);
return;
}
var ss = skillsOf(curTopic);
$('tr-skills').innerHTML = ss.map(function (g, i) {
return '<button class="tr-skill' + (g === curGen ? ' on' : '') + '" type="button" data-si="' + i + '">' + esc(g.title) + skillBadge(g) + '</button>';
}).join('');
}
function setMode(done) {
answered = done;
$('tr-check').textContent = done ? 'Дальше' : 'Проверить';
var sc = $('tr-stepcheck'); if (sc) sc.textContent = done ? 'Дальше' : 'Шаг';
}
// общие эффекты «задача решена» (из обычного ответа и из пошагового режима)
function onSolved() {
solved++; streak++;
var card = $('tr-card'); if (card) card.classList.add('tr-correct');
recordAnswer(true); submitAttempt(true);
setMode(true); updateStats();
}
// ── мат-клавиатура + live-превью (P8) ──
var KEYS = [
{ t: '(', ins: '(' }, { t: ')', ins: ')' }, { t: 'x', ins: 'x' },
{ t: '/', ins: '/' }, { t: '^', ins: '^' }, { t: '√', ins: 'sqrt(' }, { t: ';', ins: '; ' },
{ bksp: true }
];
function buildKeypad(container, inputId, previewId) {
if (!container) return;
container.innerHTML = KEYS.map(function (k, i) {
if (k.bksp) return '<button class="tr-key" type="button" data-k="' + i + '" aria-label="Стереть"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8L2 12l6 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><path d="m18 9-6 6M12 9l6 6"/></svg></button>';
return '<button class="tr-key" type="button" data-k="' + i + '">' + esc(k.t) + '</button>';
}).join('');
container.addEventListener('click', function (e) {
var b = e.target.closest('.tr-key'); if (!b) return;
var inp = $(inputId); if (!inp || inp.disabled) return;
var k = KEYS[+b.getAttribute('data-k')];
if (k.bksp) backspaceAt(inp); else insertAt(inp, k.ins);
renderPreview(inp, $(previewId));
});
}
function insertAt(inp, text) {
var s = inp.selectionStart, e = inp.selectionEnd, v = inp.value;
if (s == null || e == null) { inp.value = v + text; inp.focus(); return; }
inp.value = v.slice(0, s) + text + v.slice(e);
var pos = s + text.length; inp.focus();
try { inp.setSelectionRange(pos, pos); } catch (err) {}
}
function backspaceAt(inp) {
var s = inp.selectionStart, e = inp.selectionEnd, v = inp.value, pos;
if (s == null) { inp.value = v.slice(0, -1); inp.focus(); return; }
if (s !== e) { inp.value = v.slice(0, s) + v.slice(e); pos = s; }
else if (s > 0) { inp.value = v.slice(0, s - 1) + v.slice(s); pos = s - 1; }
else { inp.focus(); return; }
inp.focus(); try { inp.setSelectionRange(pos, pos); } catch (err) {}
}
function renderPreview(inp, prev) {
if (!prev) return;
var raw = (inp.value || '').trim();
if (!raw) { prev.innerHTML = ''; return; }
var latex = TE.exprToLatex(raw);
prev.innerHTML = latex ? (kat(latex, false) || '') : '';
}
// ── пошаговое решение / репетитор (P7) ──
function canStep() { return !!(cur && cur.kind === 'solve'); }
function setStepMode(on) {
stepMode = !!(on && canStep());
var ab = $('tr-answerbox'), sb = $('tr-stepbox');
if (ab) ab.style.display = stepMode ? 'none' : '';
if (sb) sb.style.display = stepMode ? '' : 'none';
var tog = $('tr-step-toggle'); if (tog) tog.classList.toggle('on', stepMode);
if (stepMode) {
stepList = []; renderSteps();
var fb = $('tr-stepfb'); fb.className = 'tr-feedback'; fb.textContent = '';
var si = $('tr-stepin'); si.value = ''; si.disabled = false;
$('tr-prev2').innerHTML = '';
setMode(false);
si.focus();
}
}
function renderSteps() {
$('tr-steps').innerHTML = stepList.map(function (s) {
var latex = TE.exprToLatex(s);
var math = latex ? (kat(latex, false) || esc(TE.prettyMath(s))) : esc(TE.prettyMath(s));
return '<div class="tr-step-line"><span class="tr-step-ic">' + ICON.ok + '</span><span class="tr-step-tex">' + math + '</span></div>';
}).join('');
}
function checkStepNow() {
if (answered) { advance(); return; }
var inp = $('tr-stepin'), fb = $('tr-stepfb');
var r = TE.checkStep(cur, inp.value);
if (!r.ok) {
fb.className = 'tr-feedback ' + (r.status === 'wrong' ? 'bad' : 'warn');
fb.innerHTML = (r.status === 'wrong' ? ICON.bad + ' ' : '') + esc(r.message);
return;
}
stepList.push(inp.value.trim());
renderSteps();
inp.value = ''; $('tr-prev2').innerHTML = '';
if (r.status === 'solved') {
fb.className = 'tr-feedback ok'; fb.innerHTML = ICON.ok + ' <span>Готово!</span>';
inp.disabled = true; onSolved();
} else {
fb.className = 'tr-feedback ok'; fb.innerHTML = ICON.ok + ' Верный шаг — продолжай.';
inp.focus();
}
}
// Префикс «x =» и подсказка ввода зависят от типа задачи.
function applyInputMode() {
var k = cur && cur.kind;
var multi = (k === 'roots' || k === 'simplify');
var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : '';
$('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ';
var tog = $('tr-step-toggle'); if (tog) tog.style.display = canStep() ? '' : 'none';
}
// Текст ответа в фидбеке/раскрытии — по типу задачи.
function answerLabel() {
if (cur.kind === 'roots' && cur.answers) return 'Корни: ' + cur.answers.map(fmt).join('; ');
if (cur.kind === 'simplify') return '= ' + (cur.answerExpr ? fmt(cur.answerExpr) : '');
return 'x = ' + fmt(cur.answer);
}
function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; }
function stepHtml(st, n) {
if (!st) return '';
var num = '<span class="tr-step-n">' + n + '</span>';
var note = st.note ? '<span class="tr-step-note">' + num + esc(st.note) + '</span>'
: '<span class="tr-step-note">' + num + '</span>';
var math = '';
if (st.latex) { var h = kat(st.latex, false); math = '<span class="tr-step-math">' + (h || esc(st.tex || '')) + '</span>'; }
else if (st.tex) { math = '<span class="tr-step-math">' + esc(st.tex) + '</span>'; }
return '<div class="tr-step">' + note + math + '</div>';
}
function newProblem() {
if (isWord()) { serveWordProblem(); return; }
// strict:false + несколько попыток на случай редкой неудачи с ограничениями
cur = null;
for (var i = 0; i < 6 && !cur; i++) cur = TE.instantiate(curGen, { seed: randSeed(), strict: false });
if (!cur) { $('tr-eq').textContent = 'Не удалось сгенерировать задачу'; return; }
$('tr-skill').textContent = curGen.title;
var eq = $('tr-eq');
eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты/упрощение) — другим шрифтом
setMath(eq, cur.latex, cur.display, true);
applyInputMode();
var inp = $('tr-input');
inp.value = ''; inp.disabled = false;
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
var card = $('tr-card'); if (card) { card.classList.remove('tr-correct'); card.classList.remove('tr-wrong'); }
var pv = $('tr-preview'); if (pv) pv.innerHTML = '';
setMode(false);
inp.focus();
setStepMode(stepPref); // сохраняем выбор «по шагам» между задачами (для kind solve)
}
// фоновая отправка попытки на сервер (прогресс/мастерство)
function submitAttempt(correct) {
if (!LS.practiceSubmit) return;
LS.practiceSubmit(currentSkill(), correct).then(function (r) {
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); updateOverall(); }
}).catch(function () {});
}
function solutionHtml(title) {
var steps = (cur.solution || []).map(function (st, i) { return stepHtml(st, i + 1); }).join('');
return '<h4>' + title + '</h4>' + (steps || '<div class="tr-step"><span class="tr-step-math">x = ' + esc(fmt(cur.answer)) + '</span></div>');
}
function revealSolution() {
var s = $('tr-solution');
s.innerHTML = solutionHtml('Решение');
s.style.display = 'block';
}
// ── адаптивная сессия ──
function updateSession() {
$('tr-session').textContent = smart ? ('Сессия: ' + Math.min(sessAnswered, GOAL) + ' / ' + GOAL) : '';
}
function pickNext(lastSkill) {
if (!TA) return;
var last = (lastSkill !== undefined) ? lastSkill : (curGen ? skillKey(curGen) : null);
var id = TA.nextSkill({ ordered: ordered, progress: prog, queue: reviewQ, answered: sessAnswered, last: last });
var g = id ? gens.filter(function (x) { return skillKey(x) === id; })[0] : null;
if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); }
}
function recordAnswer(correct) {
var sk = currentSkill();
sessEvents.push({ skill: sk, correct: correct });
sessAnswered++;
if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered);
updateSession();
}
function advance() {
if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; }
if (isWord()) { serveWordProblem(); return; } // банк — без адаптивного подбора
if (smart) pickNext();
newProblem();
}
function showSummary() {
summaryShown = true;
var st = TA ? TA.sessionStats(sessEvents) : { total: sessAnswered, correct: solved, accuracy: 0, skills: [], weak: [] };
var weak = st.weak.map(function (s) { var g = gens.filter(function (x) { return skillKey(x) === s; })[0]; return g ? g.title : s; });
$('tr-summary').innerHTML =
'<h3 class="tr-sum-h">Итог сессии</h3>' +
'<div class="tr-sum-row"><b>' + st.correct + ' / ' + st.total + '</b><span>верно</span></div>' +
'<div class="tr-sum-row"><b>' + st.accuracy + '%</b><span>точность</span></div>' +
'<div class="tr-sum-row"><b>' + st.skills.length + '</b><span>навыков</span></div>' +
(weak.length ? '<div class="tr-sum-weak">Стоит повторить: ' + esc(weak.join(', ')) + '</div>'
: '<div class="tr-sum-weak tr-sum-good">Отличная сессия — без ошибок!</div>') +
'<button class="tr-btn tr-primary" id="tr-sum-go" type="button">Продолжить</button>';
$('tr-card').style.display = 'none';
$('tr-summary').style.display = 'block';
$('tr-sum-go').addEventListener('click', function () {
$('tr-summary').style.display = 'none';
$('tr-card').style.display = '';
sessAnswered = 0; sessEvents = []; summaryShown = false;
updateSession();
if (smart) pickNext();
newProblem();
});
}
// «Решение» до ответа = сдаться (засчитывается как неверно один раз)
function revealAnswer(giveUp) {
revealSolution();
if (giveUp && !answered) {
streak = 0;
$('tr-input').disabled = true;
var si = $('tr-stepin'); if (si) si.disabled = true;
var fb = $('tr-feedback'); fb.className = 'tr-feedback';
if (cur.kind === 'roots' || cur.kind === 'simplify') fb.textContent = 'Ответ: ' + answerLabel();
else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
setMode(true);
recordAnswer(false); submitAttempt(false);
updateStats();
}
}
function check() {
if (answered) { advance(); return; }
var r = TE.checkStudentAnswer(cur, $('tr-input').value);
var fb = $('tr-feedback');
if (r.reason === 'empty' || r.reason === 'parse' || r.reason === 'nan') {
fb.className = 'tr-feedback warn'; fb.textContent = r.message; return; // не решено, можно поправить ввод
}
$('tr-input').disabled = true;
setMode(true);
if (r.ok) {
fb.className = 'tr-feedback ok';
var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel())
: (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
fb.innerHTML = ICON.ok + ' <span>Верно!</span>&nbsp;' + lbl;
$('tr-input').disabled = true;
onSolved();
} else {
streak = 0;
fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Неверно. Разбери решение и реши похожую.';
$('tr-card').classList.add('tr-wrong');
recordAnswer(false); submitAttempt(false);
revealSolution();
}
updateStats();
}
// ── общий прогресс (лёгкая геймификация) ──
function updateOverall() {
var solvedTotal = 0, mastered = 0;
for (var k in prog) {
if (k === '__ms' || !Object.prototype.hasOwnProperty.call(prog, k)) continue;
var p = prog[k]; if (!p) continue;
solvedTotal += (p.solved || 0);
if (p.mastered) mastered++;
}
var el = $('tr-overall');
if (el) el.textContent = solvedTotal ? ('Освоено навыков: ' + mastered + ' из ' + gens.length + ' · решено всего: ' + solvedTotal) : '';
}
// ── учительская аналитика класса ──
var _anClasses = [], _anCur = null;
function skillTitle(id) { var g = TG.get ? TG.get(id) : null; return g ? g.title : id; }
function anPicker() {
if (_anClasses.length <= 1) return '';
return '<div class="tr-an-picker">' + _anClasses.map(function (c) {
return '<button class="tr-an-cls' + (c.id === _anCur ? ' on' : '') + '" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
}).join('') + '</div>';
}
function renderHeatmap(data) {
if (!data.skills || !data.skills.length) return '<div class="tr-an-empty">Пока нет данных — ученики ещё не решали задачи.</div>';
var head = '<tr><th>Ученик</th>' + data.skills.map(function (s) { return '<th title="' + esc(s) + '">' + esc(skillTitle(s)) + '</th>'; }).join('') + '</tr>';
var rows = data.students.map(function (st) {
var cells = data.skills.map(function (s) {
var c = st.perSkill[s];
if (!c) return '<td class="tr-hm-none">—</td>';
if (c.mastered) return '<td class="tr-hm-cell" style="background:#16a34a" title="освоено">' + ICON.star + '</td>';
var bg = c.accuracy >= 70 ? '#bbf7d0' : c.accuracy >= 40 ? '#fef9c3' : '#fecaca';
return '<td class="tr-hm-cell" style="background:' + bg + '" title="' + c.solved + ' из ' + c.attempts + '">' + c.accuracy + '%</td>';
}).join('');
return '<tr><td class="tr-hm-name">' + esc(st.name) + '</td>' + cells + '</tr>';
}).join('');
var sumCells = data.skills.map(function (s) {
var ps = data.perSkill.filter(function (x) { return x.skill === s; })[0];
return '<td class="tr-hm-sum">' + (ps ? ps.accuracy + '%' : '') + '</td>';
}).join('');
return '<div class="tr-hm-wrap"><table class="tr-hm">' + head + rows + '<tr><td class="tr-hm-name">Класс</td>' + sumCells + '</tr></table></div>';
}
function showStats(classId) {
_anCur = classId;
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Загрузка…</div>';
LS.practiceClassStats(classId).then(function (data) {
$('tr-an-body').innerHTML = anPicker() + renderHeatmap(data);
}).catch(function () {
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Не удалось загрузить аналитику.</div>';
});
}
function openAnalytics() {
$('tr-analytics').style.display = 'flex';
$('tr-an-body').innerHTML = '<div class="tr-an-empty">Загрузка…</div>';
(LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) {
var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || [];
_anClasses = list;
if (!list.length) { $('tr-an-body').innerHTML = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
showStats(list[0].id);
}).catch(function () { $('tr-an-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
}
// ── авторинг своей задачи (учитель) ──
function openAuthor() {
$('tr-tch-title').textContent = 'Своя задача';
$('tr-tch-body').innerHTML =
'<div class="tr-form">' +
'<label>Условие<textarea id="tr-f-story" rows="3" placeholder="Текст задачи словами"></textarea></label>' +
'<div class="tr-form-row">' +
'<label>Левая часть<input id="tr-f-lhs" placeholder="2*x + 1"></label>' +
'<label>Правая часть<input id="tr-f-rhs" placeholder="7"></label>' +
'<label>Ответ x<input id="tr-f-ans" placeholder="3"></label>' +
'</div>' +
'<div class="tr-form-hint">Сервер проверит подстановкой: при этом x левая часть должна равняться правой.</div>' +
'<div class="tr-form-err" id="tr-f-err"></div>' +
'<button class="tr-btn tr-primary" id="tr-f-save" type="button">Проверить и добавить</button>' +
'</div>';
$('tr-teacher').style.display = 'flex';
$('tr-f-save').addEventListener('click', submitAuthor);
}
function submitAuthor() {
var data = { topic: 'word-linear', story: $('tr-f-story').value, lhs: $('tr-f-lhs').value, rhs: $('tr-f-rhs').value, answer: Number($('tr-f-ans').value) };
var err = $('tr-f-err'); err.textContent = '';
var btn = $('tr-f-save'); btn.disabled = true; btn.textContent = 'Проверяю…';
LS.practiceAuthor(data).then(function (r) {
if (r && r.ok && r.problem) {
wordPool.unshift(toWordProblem(r.problem)); wordIdx = 0;
if (LS.toast) LS.toast('Задача добавлена в банк', 'success');
$('tr-teacher').style.display = 'none';
if (isWord()) { renderSkills(); serveWordProblem(); }
} else { err.textContent = 'Не удалось добавить.'; btn.disabled = false; btn.textContent = 'Проверить и добавить'; }
}).catch(function () {
err.textContent = 'Проверка не прошла: при этом x левая часть не равна правой. Исправьте уравнение или ответ.';
btn.disabled = false; btn.textContent = 'Проверить и добавить';
});
}
// ── выдать тему классу (учитель) ──
function openAssign() {
$('tr-tch-title').textContent = 'Выдать классу';
$('tr-tch-body').innerHTML = '<div class="tr-an-empty">Загрузка классов…</div>';
$('tr-teacher').style.display = 'flex';
(LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) {
var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || [];
if (!list.length) { $('tr-tch-body').innerHTML = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
$('tr-tch-body').innerHTML = '<div class="tr-form-hint">Ученики выбранного класса получат уведомление со ссылкой на тренажёр.</div>' +
'<div class="tr-an-picker" id="tr-assign-list">' + list.map(function (c) {
return '<button class="tr-an-cls" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
}).join('') + '</div>';
$('tr-assign-list').addEventListener('click', function (e) {
var b = e.target.closest('.tr-an-cls'); if (!b) return;
b.disabled = true;
LS.practiceAssign(+b.getAttribute('data-cid'), 'word-linear', 'Текстовые задачи').then(function (res) {
if (LS.toast) LS.toast('Выдано классу (' + ((res && res.notified) || 0) + ' ученикам)', 'success');
$('tr-teacher').style.display = 'none';
}).catch(function () { if (LS.toast) LS.toast('Не удалось выдать классу', 'error'); b.disabled = false; });
});
}).catch(function () { $('tr-tch-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
}
// ── события ──
$('tr-tch-close').addEventListener('click', function () { $('tr-teacher').style.display = 'none'; });
$('tr-teacher').addEventListener('click', function (e) { if (e.target === $('tr-teacher')) $('tr-teacher').style.display = 'none'; });
$('tr-analytics-btn').addEventListener('click', openAnalytics);
$('tr-an-close').addEventListener('click', function () { $('tr-analytics').style.display = 'none'; });
$('tr-analytics').addEventListener('click', function (e) { if (e.target === $('tr-analytics')) $('tr-analytics').style.display = 'none'; });
$('tr-an-body').addEventListener('click', function (e) {
var b = e.target.closest('.tr-an-cls'); if (!b) return;
showStats(+b.getAttribute('data-cid'));
});
$('tr-topics').addEventListener('click', function (e) {
var b = e.target.closest('.tr-chip'); if (!b) return;
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
curTopic = t.key;
renderTopics();
if (t.word) { renderSkills(); loadWordPool(function () { serveWordProblem(); }); return; }
var ss = skillsOf(curTopic);
// первый неосвоенный навык темы, иначе первый
curGen = ss[0] || curGen;
for (var i = 0; i < ss.length; i++) { var p = prog[skillKey(ss[i])]; if (!(p && p.mastered)) { curGen = ss[i]; break; } }
renderSkills(); newProblem();
});
$('tr-skills').addEventListener('click', function (e) {
var b = e.target.closest('.tr-skill'); if (!b) return;
var ss = skillsOf(curTopic);
curGen = ss[+b.getAttribute('data-si')] || curGen;
renderSkills(); newProblem();
});
$('tr-smart-btn').addEventListener('click', function () {
smart = !smart;
$('tr-smart-btn').classList.toggle('on', smart);
updateSession();
});
$('tr-check').addEventListener('click', check);
$('tr-skip').addEventListener('click', newProblem);
$('tr-hint').addEventListener('click', function () {
if (!cur) return;
if (stepMode) {
var sol = cur.solution || [];
var idx = Math.min(stepList.length, Math.max(0, sol.length - 1));
var st = sol[idx];
var fb = $('tr-stepfb'); fb.className = 'tr-feedback warn';
fb.innerHTML = 'Подсказка: ' + (st && st.latex ? (kat(st.latex, false) || esc(st.tex || '')) : esc((st && (st.tex || st.note)) || ('x = ' + fmt(cur.answer))));
return;
}
var s = $('tr-solution');
s.innerHTML = '<h4>Подсказка</h4>' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null }, 1);
s.style.display = 'block';
});
$('tr-solve').addEventListener('click', function () { if (cur) revealAnswer(true); });
$('tr-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); check(); } });
// P8 — мат-клавиатуры + live-превью
buildKeypad($('tr-keypad'), 'tr-input', 'tr-preview');
buildKeypad($('tr-keypad2'), 'tr-stepin', 'tr-prev2');
$('tr-input').addEventListener('input', function () { renderPreview($('tr-input'), $('tr-preview')); });
$('tr-stepin').addEventListener('input', function () { renderPreview($('tr-stepin'), $('tr-prev2')); });
// P7 — пошаговый режим
$('tr-step-toggle').addEventListener('click', function () { stepPref = !stepMode; setStepMode(!stepMode); });
$('tr-stepcheck').addEventListener('click', checkStepNow);
$('tr-stepin').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); checkStepNow(); } });
$('tr-note').textContent = gens.length + ' навыков в ' + topics.length + ' темах · умная тренировка ведёт от простого к сложному и возвращает ошибки · прогресс сохраняется.';
// загрузка прогресса → старт (умный режим: адаптивный первый навык)
function boot() {
for (var ti = 0; ti < topics.length; ti++) {
if (!topicMastered(topics[ti].key)) { curTopic = topics[ti].key; break; }
}
var ss = skillsOf(curTopic);
curGen = ss[0] || gens[0];
for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } }
if (smart) pickNext(null); // адаптивный первый навык (last=null — можно взять текущий)
renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem();
if (isTeacher) $('tr-analytics-btn').style.display = '';
}
(LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null))
.then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); })
.catch(function () {})
.then(boot);
})();
</script>
</body>
</html>