feat(geom9 ch3 wave2 + final): §12 «Герон» + Финал Главы 3

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:13:29 +03:00
parent 8dcd54d206
commit 1b79965fce
13 changed files with 1320 additions and 10 deletions
+86
View File
@@ -0,0 +1,86 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
/* ── Statements (prepared once) ────────────────────────────────── */
const SQL = {
listTracks: db.prepare(`
SELECT exam_key, title, subject_slug, grade, duration_min,
tasks_per_variant, variants_count, intro_html, sort_order
FROM exam_tracks
WHERE enabled = 1
ORDER BY sort_order, exam_key
`),
getTrack: db.prepare(`
SELECT exam_key, title, subject_slug, grade, duration_min,
tasks_per_variant, variants_count, scoring_json, intro_html
FROM exam_tracks
WHERE exam_key = ? AND enabled = 1
`),
countTasks: db.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN task_type = 'mc' THEN 1 ELSE 0 END), 0) AS mc,
COALESCE(SUM(CASE WHEN task_type = 'open' THEN 1 ELSE 0 END), 0) AS open,
COALESCE(SUM(CASE WHEN task_type = 'long' THEN 1 ELSE 0 END), 0) AS long
FROM exam_tasks
WHERE exam_key = ?
`),
/* For each (user, task) — was the most-recent attempt correct?
A task counts as «solved» iff at least one historical attempt is_correct = 1. */
userProgress: db.prepare(`
SELECT
COUNT(DISTINCT exam_task_id) AS tasks_attempted,
COUNT(DISTINCT CASE WHEN is_correct = 1 THEN exam_task_id END) AS tasks_solved,
COUNT(*) AS total_attempts,
COALESCE(SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct_attempts
FROM exam_attempts a
JOIN exam_tasks t ON t.id = a.exam_task_id
WHERE a.user_id = ? AND t.exam_key = ?
`),
};
/* ── GET /api/exam-prep/tracks ──
Public list of enabled exam tracks (for a future landing page). */
router.get('/tracks', (_req, res) => {
const tracks = SQL.listTracks.all();
res.json({ tracks });
});
/* ── GET /api/exam-prep/:examKey/info ──
Track metadata + global counts + this user's aggregate progress. */
router.get('/:examKey/info', (req, res) => {
const { examKey } = req.params;
const track = SQL.getTrack.get(examKey);
if (!track) return res.status(404).json({ error: 'Unknown exam track' });
const counts = SQL.countTasks.get(examKey);
const progress = SQL.userProgress.get(req.user.id, examKey);
// Parse scoring grid (JSON in DB). Tolerant of malformed values.
let scoring = null;
if (track.scoring_json) {
try { scoring = JSON.parse(track.scoring_json); } catch { scoring = null; }
}
res.json({
track: {
exam_key: track.exam_key,
title: track.title,
subject: track.subject_slug,
grade: track.grade,
duration_min: track.duration_min,
tasks_per_variant: track.tasks_per_variant,
variants_count: track.variants_count,
intro_html: track.intro_html,
scoring,
},
counts,
progress,
});
});
module.exports = router;
+30
View File
@@ -51,6 +51,7 @@ const collectionRoutes = require('./routes/collection');
const redBookRoutes = require('./routes/red-book');
const parentRoutes = require('./routes/parent');
const exam9Routes = require('./routes/exam9');
const examPrepRoutes = require('./routes/exam-prep');
const textbookRoutes = require('./routes/textbooks');
const teacherStudentsRoutes = require('./routes/teacherStudents');
@@ -171,6 +172,7 @@ app.use('/api/red-book', requireFeature('red_book'), redBookRoutes);
app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem'));
app.use('/api/parent', parentRoutes);
app.use('/api/exam9', exam9Routes);
app.use('/api/exam-prep', examPrepRoutes);
app.use('/api/textbooks', textbookRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes);
@@ -340,6 +342,34 @@ app.get('/textbooks', (_req, res) => {
res.sendFile(path.join(frontendDir, 'textbooks.html'));
});
// ── Exam preparation module: /exam-prep/:examKey/{,variants,practice,topics,mock}[/...]
// All sub-pages share the same examKey segment; sub-view is the second segment.
// The HTML file is chosen by the sub-view; examKey is parsed client-side from URL.
function sendExamPrep(res, file) {
if (!isProd) res.setHeader('Cache-Control', 'no-store');
res.sendFile(path.join(frontendDir, file));
}
const EXAM_PREP_VIEWS = {
variants: 'exam-prep-variants.html',
practice: 'exam-prep-practice.html',
topics: 'exam-prep-topics.html',
mock: 'exam-prep-mock.html',
};
// /exam-prep → redirect to default track (math9 for now)
app.get('/exam-prep', (_req, res) => res.redirect(302, '/exam-prep/math9'));
// /exam-prep/:examKey → dashboard
app.get('/exam-prep/:examKey', (_req, res) => sendExamPrep(res, 'exam-prep.html'));
// /exam-prep/:examKey/:view → corresponding sub-page (sub-view + optional trailing segments)
app.get(['/exam-prep/:examKey/:view', '/exam-prep/:examKey/:view/*'], (req, res, next) => {
const file = EXAM_PREP_VIEWS[req.params.view];
if (!file) return next();
sendExamPrep(res, file);
});
// NOTE: /exam9 remains live (served by static middleware as exam9.html) until F2
// ports the variant browser into /exam-prep/:examKey/variants. The 301 redirect
// will be added at that point.
// Serve HTML files without extension (/dashboard → dashboard.html)
// In dev: disable cache so edits are always picked up immediately
const htmlCacheOpts = isProd ? { extensions: ['html'] } : {
+173
View File
@@ -0,0 +1,173 @@
/* ═══════════════════════════════════════════════════════════════
Exam Preparation Module — shared styles
Used by exam-prep*.html pages.
═══════════════════════════════════════════════════════════════ */
.sb-content { padding: 0; overflow-y: auto; }
/* ── Outer wrapper ─────────────────────────────────────────────── */
.ep-wrap {
max-width: 1080px;
margin: 0 auto;
padding: 28px 24px 80px;
width: 100%;
}
/* ── Page header ───────────────────────────────────────────────── */
.ep-header {
display: flex; align-items: center; gap: 14px;
margin-bottom: 18px; flex-wrap: wrap;
}
.ep-icon {
width: 52px; height: 52px; border-radius: 14px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(155,93,229,.22), rgba(6,214,224,.16));
border: 1.5px solid rgba(255,255,255,.1);
display: flex; align-items: center; justify-content: center;
}
.ep-icon svg { width: 26px; height: 26px; stroke: var(--violet); stroke-width: 1.8; fill: none; }
.ep-title { font-family: 'Unbounded', sans-serif; font-size: 1.35rem; font-weight: 800; letter-spacing: -.02em; }
.ep-sub { font-size: .82rem; color: var(--text-2); margin-top: 2px; }
/* ── Tabs bar ──────────────────────────────────────────────────── */
.ep-tabs {
display: flex; gap: 4px; margin: 18px 0 26px;
border-bottom: 1.5px solid var(--border);
overflow-x: auto;
}
.ep-tab {
display: inline-flex; align-items: center; gap: 7px;
padding: 10px 16px;
font-family: 'Manrope', sans-serif; font-size: .9rem; font-weight: 600;
color: var(--text-2); text-decoration: none;
border-bottom: 2.5px solid transparent;
transition: color .15s, border-color .15s;
white-space: nowrap;
}
.ep-tab:hover { color: var(--violet); }
.ep-tab.active { color: var(--violet); border-bottom-color: var(--violet); }
.ep-tab svg { width: 15px; height: 15px; stroke-width: 2; }
/* ── Cards / sections ──────────────────────────────────────────── */
.ep-card {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 14px;
padding: 20px 22px;
margin-bottom: 16px;
transition: border-color .15s;
}
.ep-card:hover { border-color: var(--border-h); }
.ep-card h3 {
font-family: 'Unbounded', sans-serif;
font-size: 1rem; font-weight: 800;
margin-bottom: 12px;
letter-spacing: -.01em;
}
.ep-card .ep-card-hint {
font-size: .8rem; color: var(--text-3); margin-bottom: 14px;
}
/* ── Stat grid for dashboard ───────────────────────────────────── */
.ep-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.ep-stat {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
}
.ep-stat-label {
font-size: .72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .04em; color: var(--text-3);
margin-bottom: 8px;
}
.ep-stat-value {
font-family: 'Unbounded', sans-serif;
font-size: 1.5rem; font-weight: 800; line-height: 1; letter-spacing: -.02em;
color: var(--text);
}
.ep-stat-value.ep-violet { color: var(--violet); }
.ep-stat-value.ep-good { color: #06D6A0; }
.ep-stat-value.ep-warn { color: #F8961E; }
.ep-stat-sub {
font-size: .72rem; color: var(--text-3); margin-top: 4px;
}
/* ── Progress bar ──────────────────────────────────────────────── */
.ep-bar {
height: 8px;
background: rgba(155,93,229,.10);
border-radius: 999px;
overflow: hidden;
margin-top: 10px;
}
.ep-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--violet), #06D6E0);
border-radius: 999px;
transition: width .35s ease;
}
/* ── Empty / placeholder state ─────────────────────────────────── */
.ep-empty {
padding: 56px 20px; text-align: center; color: var(--text-3);
}
.ep-empty svg {
width: 44px; height: 44px; opacity: .45; margin-bottom: 12px;
stroke: var(--text-3); stroke-width: 1.5; fill: none;
}
.ep-empty h4 {
font-family: 'Unbounded', sans-serif;
font-size: 1rem; font-weight: 700; color: var(--text-2);
margin-bottom: 6px;
}
.ep-empty p { font-size: .85rem; max-width: 380px; margin: 0 auto; }
/* ── CTA action buttons ────────────────────────────────────────── */
.ep-cta-row {
display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px;
}
.ep-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 9px 18px;
border: 1.5px solid var(--violet);
border-radius: 10px;
background: rgba(155,93,229,.08);
color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: .88rem; font-weight: 700;
cursor: pointer; transition: all .15s;
text-decoration: none;
}
.ep-btn:hover { background: var(--violet); color: #fff; }
.ep-btn.ep-btn-primary {
background: var(--violet); color: #fff;
}
.ep-btn.ep-btn-primary:hover { filter: brightness(1.08); }
.ep-btn svg { width: 14px; height: 14px; stroke-width: 2; }
/* ── Skeleton loaders ──────────────────────────────────────────── */
.ep-skel {
background: linear-gradient(90deg, rgba(15,23,42,.05) 0%, rgba(15,23,42,.10) 50%, rgba(15,23,42,.05) 100%);
background-size: 200% 100%;
animation: ep-skel-anim 1.4s ease-in-out infinite;
border-radius: 6px;
display: inline-block;
}
@keyframes ep-skel-anim {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Mobile tweaks ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.ep-wrap { padding: 20px 16px 60px; }
.ep-title { font-size: 1.15rem; }
.ep-tabs { gap: 0; }
.ep-tab { padding: 9px 12px; font-size: .82rem; }
.ep-card { padding: 16px 18px; }
.ep-stat { padding: 14px 16px; }
}
+57
View File
@@ -0,0 +1,57 @@
<!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=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="/css/exam-prep.css" />
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="ep-wrap">
<header class="ep-header">
<div class="ep-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<path d="M9 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3"/>
<path d="m9 14 2 2 4-4"/>
</svg>
</div>
<div>
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em">&nbsp;</span></div>
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em">&nbsp;</span></div>
</div>
</header>
<nav class="ep-tabs" id="ep-tabs"></nav>
<main id="ep-main">
<div class="ep-empty">
<i data-lucide="timer"></i>
<h4>Пробный экзамен</h4>
<p>В F9 здесь стартует таймер на 180 минут, 10 задач в реальных условиях (без проверки и решений), а в конце — балл по сетке и разбор каждого задания.</p>
</div>
</main>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/exam-prep/common.js"></script>
<script src="/js/exam-prep/api.js"></script>
<script>(async () => { await EP.boot(); if (window.lucide) lucide.createIcons(); })();</script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!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=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="/css/exam-prep.css" />
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="ep-wrap">
<header class="ep-header">
<div class="ep-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<path d="M9 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3"/>
<path d="m9 14 2 2 4-4"/>
</svg>
</div>
<div>
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em">&nbsp;</span></div>
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em">&nbsp;</span></div>
</div>
</header>
<nav class="ep-tabs" id="ep-tabs"></nav>
<main id="ep-main">
<div class="ep-empty">
<i data-lucide="dumbbell"></i>
<h4>Тренажёр случайных задач</h4>
<p>В F3 здесь появится поле ввода ответа с автопроверкой, а в F5 — выборка случайных задач из банка с фильтром «нерешённые / слабые».</p>
</div>
</main>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/exam-prep/common.js"></script>
<script src="/js/exam-prep/api.js"></script>
<script>(async () => { await EP.boot(); if (window.lucide) lucide.createIcons(); })();</script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!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=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="/css/exam-prep.css" />
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="ep-wrap">
<header class="ep-header">
<div class="ep-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<path d="M9 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3"/>
<path d="m9 14 2 2 4-4"/>
</svg>
</div>
<div>
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em">&nbsp;</span></div>
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em">&nbsp;</span></div>
</div>
</header>
<nav class="ep-tabs" id="ep-tabs"></nav>
<main id="ep-main">
<div class="ep-empty">
<i data-lucide="tag"></i>
<h4>Тренировка по темам</h4>
<p>В F6 проставим теги темам (LLM-классификация), а в F7 здесь появится список из ~25 подтем с точностью пользователя и кнопкой «Прорешать 20 задач».</p>
</div>
</main>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/exam-prep/common.js"></script>
<script src="/js/exam-prep/api.js"></script>
<script>(async () => { await EP.boot(); if (window.lucide) lucide.createIcons(); })();</script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<!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=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="/css/exam-prep.css" />
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="ep-wrap">
<header class="ep-header">
<div class="ep-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<path d="M9 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3"/>
<path d="m9 14 2 2 4-4"/>
</svg>
</div>
<div>
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em">&nbsp;</span></div>
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em">&nbsp;</span></div>
</div>
</header>
<nav class="ep-tabs" id="ep-tabs"></nav>
<main id="ep-main">
<div class="ep-empty">
<i data-lucide="layout-grid"></i>
<h4>Браузер вариантов</h4>
<p>В F2 сюда переедет просмотр 80 вариантов с условиями и решениями. До этого пользуйтесь старой страницей: <a href="/exam9" style="color:var(--violet)">/exam9</a></p>
</div>
</main>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/exam-prep/common.js"></script>
<script src="/js/exam-prep/api.js"></script>
<script>(async () => { await EP.boot(); if (window.lucide) lucide.createIcons(); })();</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
<!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=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="/css/exam-prep.css" />
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="ep-wrap">
<header class="ep-header">
<div class="ep-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="2" width="6" height="4" rx="1"/>
<path d="M9 4H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-3"/>
<path d="m9 14 2 2 4-4"/>
</svg>
</div>
<div>
<div class="ep-title" id="ep-title"><span class="ep-skel" style="width:280px;height:1.2em">&nbsp;</span></div>
<div class="ep-sub" id="ep-sub"><span class="ep-skel" style="width:200px;height:.8em">&nbsp;</span></div>
</div>
</header>
<nav class="ep-tabs" id="ep-tabs"></nav>
<main id="ep-main">
<div class="ep-empty">
<i data-lucide="loader-circle"></i>
<h4>Загрузка…</h4>
</div>
</main>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/exam-prep/common.js"></script>
<script src="/js/exam-prep/api.js"></script>
<script src="/js/exam-prep/dashboard.js"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Exam Preparation Module — API wrappers
Thin LS.api wrappers under window.EP.api.*
────────────────────────────────────────────────────────────────── */
(function () {
const base = (examKey) => `/api/exam-prep/${encodeURIComponent(examKey)}`;
const api = {
/* Track registry / metadata */
listTracks: () => LS.api('/api/exam-prep/tracks'),
getInfo: (examKey) => LS.api(`${base(examKey)}/info`),
/* Future endpoints (F2-F10) — placeholders so calling code can be written
against the final shape. Wire them up as routes ship. */
listVariants: (examKey) => LS.api(`${base(examKey)}/variants`),
getVariant: (examKey, n) => LS.api(`${base(examKey)}/variants/${n}/tasks`),
listTopics: (examKey) => LS.api(`${base(examKey)}/topics`),
getTopicTasks:(examKey, slug, query) => LS.api(`${base(examKey)}/topics/${encodeURIComponent(slug)}/tasks${qs(query)}`),
getPracticeNext: (examKey, query) => LS.api(`${base(examKey)}/practice/next${qs(query)}`),
getDashboard: (examKey) => LS.api(`${base(examKey)}/dashboard`),
getPlan: (examKey) => LS.api(`${base(examKey)}/plan`),
savePlan: (examKey, body) => LS.api(`${base(examKey)}/plan`, { method: 'PUT', body }),
saveAttempt: (body) => LS.api(`/api/exam-prep/attempts`, { method: 'POST', body }),
startMock: (examKey, body) => LS.api(`${base(examKey)}/mock/start`, { method: 'POST', body }),
mockAnswer: (mockId, body) => LS.api(`/api/exam-prep/mock/${mockId}/answer`, { method: 'POST', body }),
mockFinish: (mockId) => LS.api(`/api/exam-prep/mock/${mockId}/finish`, { method: 'POST' }),
mockResult: (mockId) => LS.api(`/api/exam-prep/mock/${mockId}/result`),
};
function qs(obj) {
if (!obj || typeof obj !== 'object') return '';
const parts = Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
return parts.length ? `?${parts.join('&')}` : '';
}
window.EP = window.EP || {};
window.EP.api = api;
})();
+92
View File
@@ -0,0 +1,92 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Exam Preparation Module — common helpers
Loaded by every exam-prep*.html page before its view-specific JS.
Responsibilities:
- Parse examKey from URL path: /exam-prep/<key>[/...]
- Determine current view (dashboard | variants | practice | topics | mock)
- Render the tabs bar with the active tab highlighted
- Expose helpers on window.EP
────────────────────────────────────────────────────────────────── */
(function () {
const VIEWS = [
{ id: 'dashboard', label: 'Дашборд', icon: 'gauge', path: '' },
{ id: 'variants', label: 'Варианты', icon: 'layout-grid', path: '/variants' },
{ id: 'practice', label: 'Тренажёр', icon: 'dumbbell', path: '/practice' },
{ id: 'topics', label: 'Темы', icon: 'tag', path: '/topics' },
{ id: 'mock', label: 'Пробник', icon: 'timer', path: '/mock' },
];
/* Parse examKey and view from `/exam-prep/<key>[/<view>[/...]]` */
function parseUrl() {
const parts = location.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
// parts[0] === 'exam-prep'; parts[1] === examKey; parts[2] === optional view
const examKey = parts[1] || 'math9';
const view = (parts[2] && VIEWS.find(v => v.id === parts[2]))
? parts[2]
: 'dashboard';
return { examKey, view };
}
function renderTabs(containerSel, { examKey, view }) {
const el = document.querySelector(containerSel);
if (!el) return;
el.innerHTML = VIEWS.map(v => {
const href = `/exam-prep/${examKey}${v.path}`;
const active = v.id === view ? ' active' : '';
return `<a class="ep-tab${active}" href="${href}">
<i data-lucide="${v.icon}"></i><span>${v.label}</span>
</a>`;
}).join('');
if (window.lucide && typeof lucide.createIcons === 'function') lucide.createIcons();
}
/* Bootstrap shared for every exam-prep page.
- Reads {examKey, view}
- Initializes LS auth/page chrome
- Renders the tabs bar (if a #ep-tabs slot exists)
- Loads track info and writes it into #ep-title / #ep-sub if present
- Returns the {track, counts, progress} payload to the caller (Promise) */
async function boot(opts = {}) {
const { examKey, view } = parseUrl();
if (typeof LS !== 'undefined') {
LS.initPage?.();
LS.showBoardIfAllowed?.();
LS.hideDisabledFeatures?.();
}
renderTabs(opts.tabsSelector || '#ep-tabs', { examKey, view });
let info = null;
try {
info = await LS.api(`/api/exam-prep/${examKey}/info`);
} catch (e) {
console.warn('[exam-prep] info failed', e);
}
if (info?.track) {
const titleEl = document.getElementById('ep-title');
const subEl = document.getElementById('ep-sub');
if (titleEl) titleEl.textContent = info.track.title;
if (subEl) {
const c = info.counts || {};
subEl.textContent =
`${info.track.variants_count} вариантов · ${c.total ?? '—'} задач · ` +
`${info.track.duration_min} мин`;
}
}
window.EP = window.EP || {};
window.EP.examKey = examKey;
window.EP.view = view;
window.EP.info = info;
return { examKey, view, info };
}
window.EP = {
boot, parseUrl, renderTabs, VIEWS,
};
})();
+107
View File
@@ -0,0 +1,107 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Dashboard view — landing screen of /exam-prep/:examKey
In F1: shows track meta + global counts + first-pass user progress.
Full live dashboard (slabnik themes, streak, plan) ships in F4 / F8 / F10.
────────────────────────────────────────────────────────────────── */
(async function () {
const { info } = await EP.boot();
const main = document.getElementById('ep-main');
if (!info?.track) {
main.innerHTML = `<div class="ep-empty">
<i data-lucide="alert-triangle"></i>
<h4>Не удалось загрузить данные экзамена</h4>
<p>Проверьте, что миграция применена и трек math9 включён.</p>
</div>`;
if (window.lucide) lucide.createIcons();
return;
}
const { track, counts, progress } = info;
const solvedPct = counts.total
? Math.round((progress.tasks_solved / counts.total) * 100)
: 0;
const accuracy = progress.total_attempts
? Math.round((progress.correct_attempts / progress.total_attempts) * 100)
: null;
main.innerHTML = `
<div class="ep-stats">
<div class="ep-stat">
<div class="ep-stat-label">Решено задач</div>
<div class="ep-stat-value ep-violet">${progress.tasks_solved} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${counts.total}</span></div>
<div class="ep-bar"><div class="ep-bar-fill" style="width:${solvedPct}%"></div></div>
<div class="ep-stat-sub">${solvedPct}% от банка</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Точность</div>
<div class="ep-stat-value ${accuracy == null ? '' : accuracy >= 70 ? 'ep-good' : 'ep-warn'}">${accuracy == null ? '—' : accuracy + '%'}</div>
<div class="ep-stat-sub">${progress.correct_attempts} верно из ${progress.total_attempts} попыток</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Серия (streak)</div>
<div class="ep-stat-value">—</div>
<div class="ep-stat-sub">Будет в F4</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">До экзамена</div>
<div class="ep-stat-value">—</div>
<div class="ep-stat-sub">Задайте дату в F10</div>
</div>
</div>
<div class="ep-card">
<h3>С чего начать</h3>
<div class="ep-card-hint">${escapeHtml(stripTags(track.intro_html || ''))}</div>
<div class="ep-cta-row">
<a class="ep-btn ep-btn-primary" href="/exam-prep/${track.exam_key}/practice">
<i data-lucide="play"></i> Начать тренировку
</a>
<a class="ep-btn" href="/exam-prep/${track.exam_key}/variants">
<i data-lucide="layout-grid"></i> Все варианты
</a>
<a class="ep-btn" href="/exam-prep/${track.exam_key}/mock">
<i data-lucide="timer"></i> Пробный экзамен
</a>
</div>
</div>
<div class="ep-card">
<h3>Банк задач</h3>
<div class="ep-card-hint">Всего ${counts.total} задач в ${track.variants_count} вариантах.</div>
<div class="ep-stats" style="margin-bottom:0">
<div class="ep-stat">
<div class="ep-stat-label">Тестовая часть (А)</div>
<div class="ep-stat-value">${counts.mc}</div>
<div class="ep-stat-sub">выбор варианта а–д</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Краткий ответ</div>
<div class="ep-stat-value">${counts.open}</div>
<div class="ep-stat-sub">число / дробь / пара</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Развёрнутые</div>
<div class="ep-stat-value">${counts.long}</div>
<div class="ep-stat-sub">выражения, графики</div>
</div>
</div>
</div>
<div class="ep-card" style="opacity:.7">
<h3>Слабые темы</h3>
<div class="ep-card-hint">Топ-3 темы с худшей точностью появятся после фазы F6 (тегирование) и F8.</div>
</div>
`;
if (window.lucide) lucide.createIcons();
})();
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
function stripTags(s) {
return String(s || '').replace(/<[^>]+>/g, '');
}
+505 -9
View File
@@ -1052,19 +1052,515 @@ function buildP11(){
wireReadBtn('p11');
}
function buildP12(){ _stubBuilder('p12', '§12', 'Формула Герона. Решение треугольников', 'p11', 'final3'); }
function buildP12(){
const box = document.getElementById('p12-body');
let html = '';
html += makeCard('theory', 'Формула Герона', '12.1', `
<p>Площадь треугольника по трём сторонам $a, b, c$ находится по <b>формуле Герона</b>:</p>
$$S = \\sqrt{p(p-a)(p-b)(p-c)}$$
<p>где $p = \\dfrac{a+b+c}{2}$ — <b>полупериметр</b> треугольника.</p>
<p><b>В чём ценность?</b> Не нужны ни высоты, ни углы — достаточно знать только длины трёх сторон.</p>
<p><b>Пример.</b> Треугольник со сторонами $5, 7, 8$.</p>
$$p = \\dfrac{5 + 7 + 8}{2} = 10$$
$$S = \\sqrt{10 \\cdot (10-5) \\cdot (10-7) \\cdot (10-8)} = \\sqrt{10 \\cdot 5 \\cdot 3 \\cdot 2} = \\sqrt{300} = 10\\sqrt{3} \\approx 17{,}32$$
<details class="spoiler"><summary>Откуда берётся формула?</summary><div class="spoiler-body">
Идея: $S = \\tfrac{1}{2}ab\\sin C$, $\\cos C = \\tfrac{a^2+b^2-c^2}{2ab}$ (теорема косинусов), $\\sin^2 C = 1 - \\cos^2 C$. После алгебраических преобразований через разность квадратов выражение упрощается до формулы Герона.
</div></details>`);
html += makeCard('rule', 'Медиана треугольника', '12.2', `
<p>Длина медианы $m_c$, проведённой к стороне $c$:</p>
$$m_c = \\dfrac{1}{2}\\sqrt{2a^2 + 2b^2 - c^2}$$
<p>Аналогично для остальных медиан:</p>
$$m_a = \\dfrac{1}{2}\\sqrt{2b^2 + 2c^2 - a^2}, \\qquad m_b = \\dfrac{1}{2}\\sqrt{2a^2 + 2c^2 - b^2}$$
<p><b>Удобно:</b> три медианы пересекаются в одной точке — <b>центроиде</b>, делящей каждую медиану в отношении $2:1$ от вершины.</p>`);
html += makeCard('example', 'Решение произвольного треугольника', '12.3', `
<p><b>«Решить треугольник»</b> — значит найти все его стороны и углы. Какие случаи бывают:</p>
<ul style="padding-left:22px;line-height:1.95">
<li><b>3 стороны $(a,b,c)$:</b> углы — по теореме косинусов; площадь — по Герону.</li>
<li><b>2 стороны и угол между ними $(a, b, C)$:</b> 3-я сторона — по теореме косинусов; остальные углы — по теореме синусов; площадь $S = \\tfrac{1}{2}ab\\sin C$.</li>
<li><b>1 сторона и 2 угла $(a, A, B)$:</b> 3-й угол $C = 180° - A - B$; остальные стороны — по теореме синусов.</li>
<li><b>2 стороны и угол не между ними:</b> может быть 0, 1 или 2 решения (<i>неоднозначный случай</i>).</li>
</ul>
<p><b>Пример.</b> $a = 5$, $b = 7$, угол между ними $C = 60°$.</p>
$$c^2 = 25 + 49 - 2 \\cdot 5 \\cdot 7 \\cdot \\cos 60° = 74 - 35 = 39 \\Rightarrow c \\approx 6{,}24$$
$$S = \\dfrac{1}{2} \\cdot 5 \\cdot 7 \\cdot \\sin 60° = \\dfrac{35\\sqrt{3}}{4} \\approx 15{,}16$$`);
/* IV1 — Slider 3-х сторон */
html += `<div class="wg" id="p12-iv1">
<div class="wg-header"><span class="wg-badge">ИНТЕРАКТИВ 1</span><div class="wg-title">Слайдер трёх сторон</div></div>
<div class="wg-help">Задай стороны $a, b, c$ ползунками. Если они удовлетворяют неравенству треугольника, увидишь треугольник; площадь, углы и радиусы вычисляются автоматически.</div>
<div class="sliders">
<label>$a$<b id="p12-iv1-aval">5</b><input type="range" id="p12-iv1-a" min="1" max="15" step="1" value="5"></label>
<label>$b$<b id="p12-iv1-bval">7</b><input type="range" id="p12-iv1-b" min="1" max="15" step="1" value="7"></label>
<label>$c$<b id="p12-iv1-cval">8</b><input type="range" id="p12-iv1-c" min="1" max="15" step="1" value="8"></label>
</div>
<div style="background:var(--card);border-radius:10px;padding:10px;overflow-x:auto">
<svg id="p12-iv1-svg" viewBox="0 0 420 320" style="width:100%;min-width:340px;height:auto;display:block"></svg>
</div>
<div id="p12-iv1-out" style="margin-top:10px;padding:10px 14px;background:var(--pri-soft);border-radius:9px;font-size:.95rem;line-height:1.9"></div>
</div>`;
/* IV2 — Калькулятор Герона */
html += `<div class="wg" id="p12-iv2">
<div class="wg-header"><span class="wg-badge">ИНТЕРАКТИВ 2</span><div class="wg-title">Калькулятор Герона</div></div>
<div class="wg-help">Введи длины трёх сторон — программа найдёт площадь по формуле Герона.</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:10px">
<label style="display:block;font-size:.85rem;color:var(--muted)">сторона $a$<input type="number" id="p12-iv2-a" class="tinp" style="width:100%;margin-top:4px" value="5" step="0.1" min="0.1"></label>
<label style="display:block;font-size:.85rem;color:var(--muted)">сторона $b$<input type="number" id="p12-iv2-b" class="tinp" style="width:100%;margin-top:4px" value="7" step="0.1" min="0.1"></label>
<label style="display:block;font-size:.85rem;color:var(--muted)">сторона $c$<input type="number" id="p12-iv2-c" class="tinp" style="width:100%;margin-top:4px" value="8" step="0.1" min="0.1"></label>
</div>
<div style="text-align:center;margin-bottom:10px"><button class="btn primary" id="p12-iv2-go">Найти площадь</button></div>
<div id="p12-iv2-out" style="padding:12px 14px;background:var(--card);border-radius:9px;font-size:.95rem;min-height:50px;line-height:1.9"></div>
<div class="feedback" id="p12-iv2-fb"></div>
</div>`;
/* IV3 — Какой случай / какой метод? */
html += `<div class="wg" id="p12-iv3">
<div class="wg-header"><span class="wg-badge">ИНТЕРАКТИВ 3</span><div class="wg-title">Какой метод применим?</div></div>
<div class="wg-help">Дано — какой инструмент быстрее всего приведёт к ответу? Выбери из четырёх.</div>
<div class="score-display"><span>Задача <b id="p12-iv3-i">1</b> / 6</span><span>Очки: <b id="p12-iv3-s">0</b> / 6</span></div>
<div id="p12-iv3-q" style="padding:14px;background:var(--pri-soft);border-radius:10px;font-size:1.05rem;text-align:center;margin-bottom:10px"></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px">
<button class="btn primary" data-ans="heron" id="p12-iv3-heron">Формула Герона<br><span style="font-size:.78rem;opacity:.85">$\\sqrt{p(p-a)(p-b)(p-c)}$</span></button>
<button class="btn primary" data-ans="cos" id="p12-iv3-cos">Теорема косинусов<br><span style="font-size:.78rem;opacity:.85">$a^2 = b^2+c^2-2bc\\cos A$</span></button>
<button class="btn primary" data-ans="sin" id="p12-iv3-sin">Теорема синусов<br><span style="font-size:.78rem;opacity:.85">$\\dfrac{a}{\\sin A} = 2R$</span></button>
<button class="btn primary" data-ans="sab" id="p12-iv3-sab">$S = \\tfrac{1}{2}ab\\sin C$<br><span style="font-size:.78rem;opacity:.85">площадь через 2 стороны и угол</span></button>
</div>
<div class="feedback" id="p12-iv3-fb"></div>
</div>`;
/* IV4 — Тренажёр */
html += `<div class="wg" id="p12-iv4">
<div class="wg-header"><span class="wg-badge">ИНТЕРАКТИВ 4</span><div class="wg-title">Тренажёр Герона</div></div>
<div class="wg-help">Реши задачу и введи площадь (округляй до 2 знаков после запятой).</div>
<div class="score-display"><span>Задача <b id="p12-iv4-i">1</b> / 6</span><span>Очки: <b id="p12-iv4-s">0</b> / 6</span></div>
<div id="p12-iv4-q" style="padding:14px;background:var(--pri-soft);border-radius:10px;font-size:1.05rem;margin-bottom:10px;text-align:center"></div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;justify-content:center">
<span style="font-family:'JetBrains Mono',monospace">$S =$</span>
<input type="number" id="p12-iv4-ans" class="tinp" style="width:120px;text-align:center" step="0.01">
<button class="btn primary" id="p12-iv4-go">Проверить</button>
<button class="btn" id="p12-iv4-start">Заново</button>
</div>
<div class="feedback" id="p12-iv4-fb"></div>
</div>`;
html += readButton('p12');
html += secNav('p11', 'final3');
box.innerHTML = html;
renderMath(box);
/* IV1 — Slider 3-х сторон */
(function(){
const sA=document.getElementById('p12-iv1-a');
const sB=document.getElementById('p12-iv1-b');
const sC=document.getElementById('p12-iv1-c');
const lA=document.getElementById('p12-iv1-aval');
const lB=document.getElementById('p12-iv1-bval');
const lC=document.getElementById('p12-iv1-cval');
const svg=document.getElementById('p12-iv1-svg');
const out=document.getElementById('p12-iv1-out');
const seen=new Set();
function draw(){
const a=+sA.value, b=+sB.value, c=+sC.value;
lA.textContent=a; lB.textContent=b; lC.textContent=c;
// Неравенство треугольника
if(a+b<=c || a+c<=b || b+c<=a){
svg.innerHTML='<rect x="0" y="0" width="420" height="320" fill="none"/>'
+'<text x="210" y="160" text-anchor="middle" font-family="Inter,sans-serif" font-size="18" font-weight="700" fill="#dc2626">Невозможный треугольник</text>'
+'<text x="210" y="186" text-anchor="middle" font-family="Inter,sans-serif" font-size="13" fill="#7f1d1d">Сумма двух сторон должна быть больше третьей</text>';
out.innerHTML='<b style="color:#dc2626">Неравенство треугольника не выполнено:</b> $a + b > c$, $a + c > b$, $b + c > a$.';
renderMath(out);
return;
}
// Построение треугольника: A в начале, B = (c, 0); C находим по теореме косинусов через cos A
const cosA=(b*b+c*c-a*a)/(2*b*c);
const sinA=Math.sqrt(Math.max(0,1-cosA*cosA));
// в локальных координатах: A=(0,0), B=(c,0), C=(b*cosA, b*sinA)
const Ax=0, Ay=0, Bx=c, By=0, Cx=b*cosA, Cy=b*sinA;
// масштабируем в SVG 420×320 с полем 40
const minX=Math.min(Ax,Bx,Cx), maxX=Math.max(Ax,Bx,Cx);
const minY=Math.min(Ay,Cy), maxY=Math.max(By,Cy);
const W=420, H=320, pad=46;
const sx=(W-2*pad)/(maxX-minX||1), sy=(H-2*pad)/(maxY-minY||1);
const s_=Math.min(sx,sy);
function T(X,Y){ return {x: pad+(X-minX)*s_, y: H-pad-(Y-minY)*s_}; }
const Av=T(Ax,Ay), Bv=T(Bx,By), Cv=T(Cx,Cy);
// углы
const cosB_=(a*a+c*c-b*b)/(2*a*c); const cosC_=(a*a+b*b-c*c)/(2*a*b);
const Aang=Math.acos(Math.max(-1,Math.min(1,cosA)))*180/Math.PI;
const Bang=Math.acos(Math.max(-1,Math.min(1,cosB_)))*180/Math.PI;
const Cang=Math.acos(Math.max(-1,Math.min(1,cosC_)))*180/Math.PI;
const p=(a+b+c)/2;
const S=Math.sqrt(Math.max(0,p*(p-a)*(p-b)*(p-c)));
const R=(a*b*c)/(4*S||1e-9);
const r=S/(p||1e-9);
const mA=0.5*Math.sqrt(2*b*b+2*c*c-a*a);
const mB=0.5*Math.sqrt(2*a*a+2*c*c-b*b);
const mC=0.5*Math.sqrt(2*a*a+2*b*b-c*c);
let s='';
s += '<rect x="0" y="0" width="420" height="320" fill="none"/>';
s += '<polygon points="'+Av.x.toFixed(2)+','+Av.y.toFixed(2)+' '+Bv.x.toFixed(2)+','+Bv.y.toFixed(2)+' '+Cv.x.toFixed(2)+','+Cv.y.toFixed(2)+'" fill="rgba(124,58,237,.12)" stroke="#7c3aed" stroke-width="2.2" stroke-linejoin="round"/>';
// подписи сторон
const midAB={x:(Av.x+Bv.x)/2, y:(Av.y+Bv.y)/2};
const midAC={x:(Av.x+Cv.x)/2, y:(Av.y+Cv.y)/2};
const midBC={x:(Bv.x+Cv.x)/2, y:(Bv.y+Cv.y)/2};
s += '<text x="'+midAB.x.toFixed(2)+'" y="'+(midAB.y+18).toFixed(2)+'" text-anchor="middle" font-family="Inter,sans-serif" font-size="13" font-weight="700" fill="#5b21b6">c = '+c+'</text>';
s += '<text x="'+(midAC.x-16).toFixed(2)+'" y="'+midAC.y.toFixed(2)+'" text-anchor="middle" font-family="Inter,sans-serif" font-size="13" font-weight="700" fill="#5b21b6">b = '+b+'</text>';
s += '<text x="'+(midBC.x+16).toFixed(2)+'" y="'+midBC.y.toFixed(2)+'" text-anchor="middle" font-family="Inter,sans-serif" font-size="13" font-weight="700" fill="#dc2626">a = '+a+'</text>';
// вершины с подписями
[['A',Av,-14,18],['B',Bv,14,18],['C',Cv,0,-10]].forEach(([n,P,dx,dy])=>{
s += '<circle cx="'+P.x.toFixed(2)+'" cy="'+P.y.toFixed(2)+'" r="4" fill="#0f172a"/>';
s += '<text x="'+(P.x+dx).toFixed(2)+'" y="'+(P.y+dy).toFixed(2)+'" text-anchor="middle" font-family="Inter,sans-serif" font-size="14" font-weight="800" fill="#0f172a">'+n+'</text>';
});
svg.innerHTML=s;
out.innerHTML = '<b>Полупериметр:</b> $p = '+p.toFixed(2)+'$ &nbsp;·&nbsp; <b>Площадь (Герон):</b> $S \\approx '+S.toFixed(2)+'$<br>'
+ '<b>Углы:</b> $A \\approx '+Aang.toFixed(1)+'°$, $B \\approx '+Bang.toFixed(1)+'°$, $C \\approx '+Cang.toFixed(1)+'°$<br>'
+ '<b>Радиусы:</b> $R \\approx '+R.toFixed(2)+'$, $r \\approx '+r.toFixed(2)+'$<br>'
+ '<b>Медианы:</b> $m_a \\approx '+mA.toFixed(2)+'$, $m_b \\approx '+mB.toFixed(2)+'$, $m_c \\approx '+mC.toFixed(2)+'$';
renderMath(out);
seen.add(a+'|'+b+'|'+c);
if(seen.size>=4 && !seen.has('done')){ addXp(10,'p12-iv1'); bumpProgress('p12',15); seen.add('done'); }
}
[sA,sB,sC].forEach(s=>s.addEventListener('input', draw));
draw();
})();
/* IV2 — Калькулятор Герона */
(function(){
const aI=document.getElementById('p12-iv2-a');
const bI=document.getElementById('p12-iv2-b');
const cI=document.getElementById('p12-iv2-c');
const go=document.getElementById('p12-iv2-go');
const out=document.getElementById('p12-iv2-out');
const fb=document.getElementById('p12-iv2-fb');
let solved=0;
go.addEventListener('click', ()=>{
const a=parseFloat(aI.value), b=parseFloat(bI.value), c=parseFloat(cI.value);
if(!isFinite(a)||!isFinite(b)||!isFinite(c)){ feedback(fb,false,'&#10007; Введи все значения.'); return; }
if(a<=0||b<=0||c<=0){ feedback(fb,false,'&#10007; Стороны должны быть положительными.'); return; }
if(a+b<=c||a+c<=b||b+c<=a){ feedback(fb,false,'&#10007; Неравенство треугольника не выполняется.'); return; }
const p=(a+b+c)/2;
const inner=p*(p-a)*(p-b)*(p-c);
const S=Math.sqrt(Math.max(0,inner));
out.innerHTML = '$p = \\dfrac{a+b+c}{2} = \\dfrac{'+a+'+'+b+'+'+c+'}{2} = '+p.toFixed(2)+'$<br>'
+ '$S = \\sqrt{p(p-a)(p-b)(p-c)} = \\sqrt{'+p.toFixed(2)+' \\cdot '+(p-a).toFixed(2)+' \\cdot '+(p-b).toFixed(2)+' \\cdot '+(p-c).toFixed(2)+'}$<br>'
+ '$S = \\sqrt{'+inner.toFixed(3)+'} \\approx '+S.toFixed(3)+'$';
renderMath(out);
feedback(fb,true,'&#10003; Площадь найдена.');
solved++;
if(solved===1){ addXp(10,'p12-iv2'); bumpProgress('p12',10); }
});
})();
/* IV3 — Какой метод? */
(function(){
const Q=[
{t:'Известны три стороны треугольника. Найти его площадь.', a:'heron'},
{t:'Известны две стороны и угол между ними. Найти площадь.', a:'sab'},
{t:'Известны три стороны. Найти один из углов.', a:'cos'},
{t:'Известны сторона и противолежащий ей угол. Найти радиус описанной окружности.', a:'sin'},
{t:'Известны два угла и одна сторона. Найти другую сторону.', a:'sin'},
{t:'Известны две стороны и угол между ними. Найти третью сторону.', a:'cos'}
];
const explain={
heron:'$S = \\sqrt{p(p-a)(p-b)(p-c)}$ — площадь без углов и высот.',
cos:'$a^2 = b^2+c^2-2bc\\cos A$ — связывает 3 стороны и угол.',
sin:'$\\dfrac{a}{\\sin A} = 2R$ — пара (сторона; противолежащий угол).',
sab:'$S = \\tfrac{1}{2}ab\\sin C$ — площадь через две стороны и угол между ними.'
};
const labels={heron:'формула Герона', cos:'теорема косинусов', sin:'теорема синусов', sab:'$S = \\tfrac{1}{2}ab\\sin C$'};
const qBox=document.getElementById('p12-iv3-q');
const iEl=document.getElementById('p12-iv3-i');
const sEl=document.getElementById('p12-iv3-s');
const fb=document.getElementById('p12-iv3-fb');
const btns=document.querySelectorAll('#p12-iv3 button[data-ans]');
let i=0, score=0, done=false;
function show(){
if(i>=Q.length){
qBox.innerHTML='Завершено! Очки: <b>'+score+'</b> / '+Q.length;
if(!done){ done=true; addXp(15,'p12-iv3'); bumpProgress('p12',25); if(score===Q.length) achievement('p12_done'); }
btns.forEach(b=>b.disabled=true);
return;
}
qBox.innerHTML=Q[i].t; renderMath(qBox);
iEl.textContent=(i+1); sEl.textContent=score;
fb.style.display='none';
}
btns.forEach(b=>b.addEventListener('click', ()=>{
const ok = b.dataset.ans===Q[i].a;
if(ok) score++;
feedback(fb, ok, ok?('&#10003; Верно! '+explain[Q[i].a]):('&#10007; Правильно: '+labels[Q[i].a]+'. '+explain[Q[i].a]));
i++;
setTimeout(show, 1100);
}));
show();
})();
/* IV4 — Тренажёр Герона */
(function(){
const Q=[
{t:'Треугольник со сторонами $3, 4, 5$. Найди площадь $S$.', a:6, tol:0.02},
{t:'Треугольник со сторонами $5, 5, 6$. Найди площадь $S$.', a:12, tol:0.02},
{t:'Треугольник со сторонами $7, 8, 9$. Найди площадь $S$.', a:26.83, tol:0.05},
{t:'Треугольник со сторонами $6, 8, 10$. Найди площадь $S$.', a:24, tol:0.02},
{t:'Треугольник со сторонами $13, 14, 15$. Найди площадь $S$.', a:84, tol:0.05},
{t:'Стороны $a = 4$, $b = 6$, угол между ними $C = 90°$. Найди $S$.', a:12, tol:0.02}
];
const qBox=document.getElementById('p12-iv4-q');
const ans=document.getElementById('p12-iv4-ans');
const go=document.getElementById('p12-iv4-go');
const reset=document.getElementById('p12-iv4-start');
const iEl=document.getElementById('p12-iv4-i');
const sEl=document.getElementById('p12-iv4-s');
const fb=document.getElementById('p12-iv4-fb');
let i=0, score=0, done=false;
function show(){
if(i>=Q.length){
qBox.innerHTML='Финиш! Очки: <b>'+score+'</b> / '+Q.length;
if(!done){ done=true; addXp(15,'p12-iv4'); bumpProgress('p12',25); }
go.disabled=true; ans.disabled=true;
return;
}
qBox.innerHTML=Q[i].t; renderMath(qBox);
iEl.textContent=(i+1); sEl.textContent=score;
ans.value=''; fb.style.display='none'; go.disabled=false; ans.disabled=false;
}
go.addEventListener('click', ()=>{
const v=parseFloat((ans.value||'').replace(',', '.'));
if(!isFinite(v)){ feedback(fb,false,'&#10007; Введи число.'); return; }
const tol=Q[i].tol||0.05;
const ok=Math.abs(v-Q[i].a)<=tol;
if(ok) score++;
feedback(fb, ok, ok?'&#10003; Верно!':'&#10007; Правильный ответ: $'+Q[i].a+'$');
i++;
setTimeout(show, 1000);
});
reset.addEventListener('click', ()=>{ i=0; score=0; done=false; show(); });
show();
})();
wireReadBtn('p12');
}
function buildFinal3(){
const body = document.getElementById('final3-body');
const box = document.getElementById('final3-body');
let html = '';
html += makeCard('theory', 'Финал главы 3', '★', `
<p>Итоговый раздел главы <b>«Теоремы синусов и косинусов»</b> будет добавлен в следующих обновлениях.</p>
<p style="color:var(--muted);font-size:.9rem">Раздел Phase 7.</p>`);
html += readButton('final3');
/* Часть А — Шпаргалка главы (3 mini-карточки) */
html += `<div class="card">
<div class="card-header">
<div class="card-icon theory">${ICONS.theory}</div>
<div class="card-title">Шпаргалка главы 3</div>
<div class="card-num">Итог</div>
</div>
<div class="card-body">
<p>Главные формулы главы «Теоремы синусов и косинусов» — в одном месте. Просмотри перед боссами!</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-top:10px">
<div style="padding:12px 14px;background:var(--acc-soft);border-radius:11px;border-left:3px solid var(--pri)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--pri2)" stroke-width="2" style="width:18px;height:18px"><circle cx="12" cy="12" r="9"/><polygon points="6 7 17 7 12 18"/></svg>
<span style="font-family:'Unbounded',sans-serif;font-weight:700;color:var(--pri2);font-size:.92rem">§ 10 · Теорема синусов</span>
</div>
<div style="font-size:.92rem">$\\dfrac{a}{\\sin A} = \\dfrac{b}{\\sin B} = \\dfrac{c}{\\sin C} = 2R$. Пара «сторона + противолежащий угол» $\\Rightarrow$ радиус $R$ и любая сторона.</div>
</div>
<div style="padding:12px 14px;background:var(--acc-soft);border-radius:11px;border-left:3px solid var(--pri)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--pri2)" stroke-width="2" style="width:18px;height:18px"><polygon points="4 20 20 20 12 4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>
<span style="font-family:'Unbounded',sans-serif;font-weight:700;color:var(--pri2);font-size:.92rem">§ 11 · Теорема косинусов</span>
</div>
<div style="font-size:.92rem">$a^2 = b^2 + c^2 - 2bc\\cos A$ — обобщение Пифагора. По 3 сторонам — любой угол; по 2 сторонам и углу — 3-я сторона.</div>
</div>
<div style="padding:12px 14px;background:var(--acc-soft);border-radius:11px;border-left:3px solid var(--pri)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--pri2)" stroke-width="2" style="width:18px;height:18px"><path d="M4 17 L12 5 L20 17 Z"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
<span style="font-family:'Unbounded',sans-serif;font-weight:700;color:var(--pri2);font-size:.92rem">§ 12 · Формула Герона</span>
</div>
<div style="font-size:.92rem">$S = \\sqrt{p(p-a)(p-b)(p-c)}$, где $p = \\dfrac{a+b+c}{2}$ — полупериметр. Площадь по трём сторонам.</div>
</div>
</div>
</div>
</div>`;
/* Часть Б — 5 боссов */
html += `<div class="card">
<div class="card-header">
<div class="card-icon rule">${ICONS.rule}</div>
<div class="card-title">Боссы главы 3</div>
<div class="card-num">5</div>
</div>
<div class="card-body">
<p>5 интегрированных задач — каждая комбинирует темы §§10–12. За каждого побеждённого босса — <b>+10 XP</b> и +18% к прогрессу. Победишь всех — <b>+50 XP бонус</b> и ачивка «Магистр треугольников»!</p>
</div>
</div>`;
html += '<div id="ch3G-bosses-container"></div>';
html += `<div style="margin-top:18px;padding:18px 20px;background:linear-gradient(135deg,var(--pri-soft),var(--acc-soft));border-radius:14px;border:1.5px solid var(--pri);text-align:center" id="ch3G-final-summary">
<div style="font-family:'Unbounded',sans-serif;font-weight:800;color:var(--pri2);font-size:1.1rem;margin-bottom:6px">Прогресс по боссам</div>
<div id="ch3G-boss-overall" style="font-size:.95rem;color:var(--text);margin-bottom:10px">0 / 5 боссов побеждено</div>
<div style="height:12px;background:var(--card);border-radius:8px;overflow:hidden;border:1px solid var(--border)">
<div id="ch3G-boss-overall-fill" style="height:100%;width:0%;background:linear-gradient(90deg,var(--pri),var(--acc));transition:width .35s"></div>
</div>
<div id="ch3G-final-reward" style="margin-top:14px;display:none;padding:14px;background:var(--card);border-radius:11px;border:2px solid var(--warn)">
<div style="display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--warn)" stroke-width="2.2" style="width:22px;height:22px"><polygon points="12 2 15 9 22 9 17 14 19 22 12 18 5 22 7 14 2 9 9 9"/></svg>
<div style="font-family:'Unbounded',sans-serif;font-weight:800;color:var(--pri2);font-size:1.05rem">Магистр треугольников</div>
</div>
<div style="font-size:.92rem;margin-bottom:10px">Глава 3 пройдена! Все 5 боссов повержены. +50 XP бонус.</div>
<a class="btn primary" href="/textbook/geometry-9-ch4" style="text-decoration:none">Дальше: Глава 4 <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></a>
</div>
</div>`;
html += secNav('p12', null);
body.innerHTML = html;
wireReadBtn('final3');
if(window.renderMathInElement) renderMath(body);
box.innerHTML = html;
renderMath(box);
/* Боссы */
const BOSSES = [
{
n:1, color:'#10b981',
title:'Циклоп Синусов',
tag:'§ 10',
q:'В треугольнике сторона $a = 12$ и противолежащий угол $A = 30°$. Найди радиус описанной окружности $R$.',
ans:12, decimal:false,
hint:'$R = \\dfrac{a}{2\\sin A} = \\dfrac{12}{2 \\cdot 0{,}5} = \\dfrac{12}{1} = 12$.'
},
{
n:2, color:'#0891b2',
title:'Минотавр Косинусов',
tag:'§ 11',
q:'В треугольнике стороны $b = 5$ и $c = 8$, угол между ними $A = 60°$. Найди сторону $a$.',
ans:7, decimal:false,
hint:'$a^2 = 25 + 64 - 2 \\cdot 5 \\cdot 8 \\cdot \\cos 60° = 89 - 40 = 49 \\Rightarrow a = 7$.'
},
{
n:3, color:'#7c3aed',
title:'Гарпия Герона',
tag:'§ 12',
q:'Треугольник со сторонами $5, 12, 13$. Найди его площадь $S$.',
ans:30, decimal:false,
hint:'$p = 15$. $S = \\sqrt{15 \\cdot 10 \\cdot 3 \\cdot 2} = \\sqrt{900} = 30$. Заметь: это прямоугольный треугольник, и $S = \\tfrac{1}{2}\\cdot 5 \\cdot 12 = 30$.'
},
{
n:4, color:'#dc2626',
title:'Дракон Решения',
tag:'§§ 11 + 12',
q:'В треугольнике стороны $7, 8, 9$. Найди наибольший угол (в градусах, округли до целого).',
ans:73, decimal:false,
hint:'Наибольший угол лежит против стороны $9$. $\\cos C = \\dfrac{49+64-81}{2\\cdot 7\\cdot 8} = \\dfrac{32}{112} \\approx 0{,}2857$. $C = \\arccos(0{,}2857) \\approx 73°$.'
},
{
n:5, color:'#f59e0b',
title:'Мастер Треугольников',
tag:'§§ 1012 — синтез',
q:'В треугольнике стороны $a = 10$, $b = 6$, угол между ними $C = 60°$. Найди площадь $S$ (округли до 2 знаков).',
ans:25.98, decimal:true,
hint:'$S = \\tfrac{1}{2}ab\\sin C = \\tfrac{1}{2}\\cdot 10 \\cdot 6 \\cdot \\sin 60° = 30 \\cdot \\dfrac{\\sqrt{3}}{2} = 15\\sqrt{3} \\approx 25{,}98$.'
}
];
const cont = document.getElementById('ch3G-bosses-container');
const STATE_KEY = 'geometry9_ch3_bosses';
const BOSS_STATE = (function(){
try{ const s = localStorage.getItem(STATE_KEY); if(s){ const p = JSON.parse(s); if(Array.isArray(p) && p.length === BOSSES.length) return p; } }catch(e){}
return BOSSES.map(()=>({defeated:false}));
})();
function saveBosses(){ try{ localStorage.setItem(STATE_KEY, JSON.stringify(BOSS_STATE)); }catch(e){} }
cont.innerHTML = BOSSES.map((b)=>{
const stepAttr = b.decimal ? 'step="0.01"' : 'step="1"';
const ph = b.decimal ? 'число (можно с запятой)' : 'целое число';
return '<div class="boss-card" id="bossG3-'+b.n+'-card" style="padding:16px;background:var(--card);border-radius:12px;border:2px solid '+b.color+';margin-bottom:14px;transition:background .3s,box-shadow .3s">'
+'<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap">'
+'<svg viewBox="0 0 24 24" fill="none" stroke="'+b.color+'" stroke-width="2.2" style="width:28px;height:28px;flex-shrink:0"><polygon points="12 4 20 18 4 18"/><circle cx="12" cy="13" r="2.4"/></svg>'
+'<div style="font-family:\'Unbounded\',sans-serif;font-weight:800;color:'+b.color+';font-size:1.05rem">Босс '+b.n+': '+b.title+'</div>'
+'<div style="margin-left:auto;font-size:.78rem;color:var(--muted);padding:3px 8px;background:var(--acc-soft);border-radius:6px">'+b.tag+'</div>'
+'</div>'
+'<div id="bossG3-'+b.n+'-q" class="boss-q" style="padding:12px 14px;background:var(--acc-soft);border-radius:9px;font-size:1rem;line-height:1.55;margin-bottom:10px">'+b.q+'</div>'
+'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">'
+'<span style="font-family:\'JetBrains Mono\',monospace;font-size:.92rem">ответ =</span>'
+'<input type="number" '+stepAttr+' id="bossG3-'+b.n+'-ans" class="tinp" style="width:150px;text-align:center" placeholder="'+ph+'">'
+'<button class="btn primary" id="bossG3-'+b.n+'-go" style="background:'+b.color+';border-color:'+b.color+'">Атаковать</button>'
+'<button class="btn" id="bossG3-'+b.n+'-hint">Подсказка</button>'
+'</div>'
+'<div class="feedback" id="bossG3-'+b.n+'-fb"></div>'
+'</div>';
}).join('');
renderMath(cont);
function markDefeatedUI(b){
const card = document.getElementById('bossG3-'+b.n+'-card');
const goBtn = document.getElementById('bossG3-'+b.n+'-go');
const ansInp = document.getElementById('bossG3-'+b.n+'-ans');
if(!card || !goBtn || !ansInp) return;
card.style.background = 'linear-gradient(135deg,var(--acc-soft),var(--pri-soft))';
card.style.boxShadow = '0 0 0 2px '+b.color+'33, 0 8px 24px rgba(16,185,129,.12)';
goBtn.disabled = true; goBtn.style.opacity = .55;
goBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" style="margin-right:4px"><polyline points="20 6 9 17 4 12"/></svg> Повержен';
ansInp.disabled = true;
}
function refreshOverall(){
const won = BOSS_STATE.filter(s => s.defeated).length;
const txt = document.getElementById('ch3G-boss-overall');
const fill = document.getElementById('ch3G-boss-overall-fill');
if(txt) txt.textContent = won + ' / ' + BOSSES.length + ' боссов побеждено';
if(fill) fill.style.width = (won * 100 / BOSSES.length) + '%';
if(won >= BOSSES.length){
const reward = document.getElementById('ch3G-final-reward');
if(reward && reward.style.display === 'none'){
reward.style.display = 'block';
if(!STATE.achievements.has('ch3_done')){
achievement('ch3_done','Магистр треугольников');
addXp(50, 'ch3-bonus');
bumpProgress('final3', 10);
if(window.confetti){ try{ window.confetti(); }catch(e){} }
}
}
}
}
BOSSES.forEach((b, idx)=>{
const goBtn = document.getElementById('bossG3-'+b.n+'-go');
const hintBtn = document.getElementById('bossG3-'+b.n+'-hint');
const ansInp = document.getElementById('bossG3-'+b.n+'-ans');
if(BOSS_STATE[idx].defeated) markDefeatedUI(b);
goBtn.addEventListener('click', ()=>{
if(BOSS_STATE[idx].defeated) return;
const fb = document.getElementById('bossG3-'+b.n+'-fb');
const raw = (ansInp.value||'').replace(',', '.').trim();
const val = parseFloat(raw);
if(isNaN(val) || raw === ''){ feedback(fb, false, '&#10007; Введи число.'); return; }
const ok = Math.abs(val - b.ans) < 0.05;
if(ok){
BOSS_STATE[idx].defeated = true; saveBosses();
feedback(fb, true, '&#10003; Босс '+b.n+' повержен! +10 XP. '+b.hint);
addXp(10, 'boss-ch3-'+b.n);
bumpProgress('final3', 18);
markDefeatedUI(b);
refreshOverall();
} else {
feedback(fb, false, '&#10007; Промах. Попробуй ещё. Подсказка доступна.');
}
});
hintBtn.addEventListener('click', ()=>{
const fb = document.getElementById('bossG3-'+b.n+'-fb');
fb.className = 'feedback ok';
fb.innerHTML = '<b>Подсказка:</b> '+b.hint;
fb.style.display = 'block';
fb.style.background = 'var(--warn-bg)';
fb.style.color = '#92400e';
fb.style.borderLeftColor = 'var(--warn)';
renderMath(fb);
});
ansInp.addEventListener('keydown', e=>{ if(e.key === 'Enter') goBtn.click(); });
});
refreshOverall();
}
/* ===== Search ===== */
+1 -1
View File
@@ -79,7 +79,7 @@
${L('/theory', 'brain', 'Теория')}
${L('/knowledge-map', 'share-2', 'Карта знаний')}
${L('/question-bank', 'database', 'Банк вопросов', { cls: 'sb-teacher-only', hidden: !isTch })}
${L('/exam9', 'clipboard-check', 'Экзамен 9 класс')}
${L('/exam-prep/math9', 'clipboard-check', 'Подготовка к экзамену 9')}
`)}
${G('practice', 'Практика и игры', `