From 1b79965fced8d5cd9f17211b6fd29f6ed213df89 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 10:13:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(geom9=20ch3=20wave2=20+=20final):=20=C2=A7?= =?UTF-8?q?12=20=C2=AB=D0=93=D0=B5=D1=80=D0=BE=D0=BD=C2=BB=20+=20=D0=A4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=20=D0=93=D0=BB=D0=B0=D0=B2=D1=8B=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/exam-prep.js | 86 +++++ backend/src/server.js | 30 ++ frontend/css/exam-prep.css | 173 +++++++++ frontend/exam-prep-mock.html | 57 +++ frontend/exam-prep-practice.html | 57 +++ frontend/exam-prep-topics.html | 57 +++ frontend/exam-prep-variants.html | 57 +++ frontend/exam-prep.html | 56 +++ frontend/js/exam-prep/api.js | 42 ++ frontend/js/exam-prep/common.js | 92 +++++ frontend/js/exam-prep/dashboard.js | 107 +++++ frontend/textbooks/geometry_9_ch3.html | 514 ++++++++++++++++++++++++- js/sidebar.js | 2 +- 13 files changed, 1320 insertions(+), 10 deletions(-) create mode 100644 backend/src/routes/exam-prep.js create mode 100644 frontend/css/exam-prep.css create mode 100644 frontend/exam-prep-mock.html create mode 100644 frontend/exam-prep-practice.html create mode 100644 frontend/exam-prep-topics.html create mode 100644 frontend/exam-prep-variants.html create mode 100644 frontend/exam-prep.html create mode 100644 frontend/js/exam-prep/api.js create mode 100644 frontend/js/exam-prep/common.js create mode 100644 frontend/js/exam-prep/dashboard.js diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js new file mode 100644 index 0000000..1fcd1c3 --- /dev/null +++ b/backend/src/routes/exam-prep.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 325f79c..4c84ece 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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'] } : { diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css new file mode 100644 index 0000000..797d149 --- /dev/null +++ b/frontend/css/exam-prep.css @@ -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; } +} diff --git a/frontend/exam-prep-mock.html b/frontend/exam-prep-mock.html new file mode 100644 index 0000000..0916f8e --- /dev/null +++ b/frontend/exam-prep-mock.html @@ -0,0 +1,57 @@ + + + + + + Подготовка к экзамену · Пробник — LearnSpace + + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+
 
+
 
+
+
+ + + +
+
+ +

Пробный экзамен

+

В F9 здесь стартует таймер на 180 минут, 10 задач в реальных условиях (без проверки и решений), а в конце — балл по сетке и разбор каждого задания.

+
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/frontend/exam-prep-practice.html b/frontend/exam-prep-practice.html new file mode 100644 index 0000000..4f26948 --- /dev/null +++ b/frontend/exam-prep-practice.html @@ -0,0 +1,57 @@ + + + + + + Подготовка к экзамену · Тренажёр — LearnSpace + + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+
 
+
 
+
+
+ + + +
+
+ +

Тренажёр случайных задач

+

В F3 здесь появится поле ввода ответа с автопроверкой, а в F5 — выборка случайных задач из банка с фильтром «нерешённые / слабые».

+
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/frontend/exam-prep-topics.html b/frontend/exam-prep-topics.html new file mode 100644 index 0000000..0248116 --- /dev/null +++ b/frontend/exam-prep-topics.html @@ -0,0 +1,57 @@ + + + + + + Подготовка к экзамену · Темы — LearnSpace + + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+
 
+
 
+
+
+ + + +
+
+ +

Тренировка по темам

+

В F6 проставим теги темам (LLM-классификация), а в F7 здесь появится список из ~25 подтем с точностью пользователя и кнопкой «Прорешать 20 задач».

+
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/frontend/exam-prep-variants.html b/frontend/exam-prep-variants.html new file mode 100644 index 0000000..f836cc5 --- /dev/null +++ b/frontend/exam-prep-variants.html @@ -0,0 +1,57 @@ + + + + + + Подготовка к экзамену · Варианты — LearnSpace + + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+
 
+
 
+
+
+ + + +
+
+ +

Браузер вариантов

+

В F2 сюда переедет просмотр 80 вариантов с условиями и решениями. До этого пользуйтесь старой страницей: /exam9

+
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/frontend/exam-prep.html b/frontend/exam-prep.html new file mode 100644 index 0000000..5ed72c3 --- /dev/null +++ b/frontend/exam-prep.html @@ -0,0 +1,56 @@ + + + + + + Подготовка к экзамену — LearnSpace + + + + + + +
+ + +
+
+ +
+
+ + + + + +
+
+
 
+
 
+
+
+ + + +
+
+ +

Загрузка…

+
+
+ +
+
+
+ + + + + + + + + + + + diff --git a/frontend/js/exam-prep/api.js b/frontend/js/exam-prep/api.js new file mode 100644 index 0000000..126e4a6 --- /dev/null +++ b/frontend/js/exam-prep/api.js @@ -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; +})(); diff --git a/frontend/js/exam-prep/common.js b/frontend/js/exam-prep/common.js new file mode 100644 index 0000000..e079bfd --- /dev/null +++ b/frontend/js/exam-prep/common.js @@ -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/[/...] + - 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/[/[/...]]` */ + 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 ` + ${v.label} + `; + }).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, + }; +})(); diff --git a/frontend/js/exam-prep/dashboard.js b/frontend/js/exam-prep/dashboard.js new file mode 100644 index 0000000..03bddbb --- /dev/null +++ b/frontend/js/exam-prep/dashboard.js @@ -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 = `
+ +

Не удалось загрузить данные экзамена

+

Проверьте, что миграция применена и трек math9 включён.

+
`; + 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 = ` +
+
+
Решено задач
+
${progress.tasks_solved} / ${counts.total}
+
+
${solvedPct}% от банка
+
+
+
Точность
+
${accuracy == null ? '—' : accuracy + '%'}
+
${progress.correct_attempts} верно из ${progress.total_attempts} попыток
+
+
+
Серия (streak)
+
+
Будет в F4
+
+
+
До экзамена
+
+
Задайте дату в F10
+
+
+ +
+

С чего начать

+
${escapeHtml(stripTags(track.intro_html || ''))}
+ +
+ +
+

Банк задач

+
Всего ${counts.total} задач в ${track.variants_count} вариантах.
+
+
+
Тестовая часть (А)
+
${counts.mc}
+
выбор варианта а–д
+
+
+
Краткий ответ
+
${counts.open}
+
число / дробь / пара
+
+
+
Развёрнутые
+
${counts.long}
+
выражения, графики
+
+
+
+ +
+

Слабые темы

+
Топ-3 темы с худшей точностью появятся после фазы F6 (тегирование) и F8.
+
+ `; + + if (window.lucide) lucide.createIcons(); +})(); + +function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); +} +function stripTags(s) { + return String(s || '').replace(/<[^>]+>/g, ''); +} diff --git a/frontend/textbooks/geometry_9_ch3.html b/frontend/textbooks/geometry_9_ch3.html index 6bb0f09..d887f18 100644 --- a/frontend/textbooks/geometry_9_ch3.html +++ b/frontend/textbooks/geometry_9_ch3.html @@ -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', ` +

Площадь треугольника по трём сторонам $a, b, c$ находится по формуле Герона:

+ $$S = \\sqrt{p(p-a)(p-b)(p-c)}$$ +

где $p = \\dfrac{a+b+c}{2}$ — полупериметр треугольника.

+

В чём ценность? Не нужны ни высоты, ни углы — достаточно знать только длины трёх сторон.

+

Пример. Треугольник со сторонами $5, 7, 8$.

+ $$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$$ +
Откуда берётся формула?
+ Идея: $S = \\tfrac{1}{2}ab\\sin C$, $\\cos C = \\tfrac{a^2+b^2-c^2}{2ab}$ (теорема косинусов), $\\sin^2 C = 1 - \\cos^2 C$. После алгебраических преобразований через разность квадратов выражение упрощается до формулы Герона. +
`); + + html += makeCard('rule', 'Медиана треугольника', '12.2', ` +

Длина медианы $m_c$, проведённой к стороне $c$:

+ $$m_c = \\dfrac{1}{2}\\sqrt{2a^2 + 2b^2 - c^2}$$ +

Аналогично для остальных медиан:

+ $$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}$$ +

Удобно: три медианы пересекаются в одной точке — центроиде, делящей каждую медиану в отношении $2:1$ от вершины.

`); + + html += makeCard('example', 'Решение произвольного треугольника', '12.3', ` +

«Решить треугольник» — значит найти все его стороны и углы. Какие случаи бывают:

+
    +
  • 3 стороны $(a,b,c)$: углы — по теореме косинусов; площадь — по Герону.
  • +
  • 2 стороны и угол между ними $(a, b, C)$: 3-я сторона — по теореме косинусов; остальные углы — по теореме синусов; площадь $S = \\tfrac{1}{2}ab\\sin C$.
  • +
  • 1 сторона и 2 угла $(a, A, B)$: 3-й угол $C = 180° - A - B$; остальные стороны — по теореме синусов.
  • +
  • 2 стороны и угол не между ними: может быть 0, 1 или 2 решения (неоднозначный случай).
  • +
+

Пример. $a = 5$, $b = 7$, угол между ними $C = 60°$.

+ $$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 += `
+
ИНТЕРАКТИВ 1
Слайдер трёх сторон
+
Задай стороны $a, b, c$ ползунками. Если они удовлетворяют неравенству треугольника, увидишь треугольник; площадь, углы и радиусы вычисляются автоматически.
+
+ + + +
+
+ +
+
+
`; + + /* IV2 — Калькулятор Герона */ + html += `
+
ИНТЕРАКТИВ 2
Калькулятор Герона
+
Введи длины трёх сторон — программа найдёт площадь по формуле Герона.
+
+ + + +
+
+
+ +
`; + + /* IV3 — Какой случай / какой метод? */ + html += `
+
ИНТЕРАКТИВ 3
Какой метод применим?
+
Дано — какой инструмент быстрее всего приведёт к ответу? Выбери из четырёх.
+
Задача 1 / 6Очки: 0 / 6
+
+
+ + + + +
+ +
`; + + /* IV4 — Тренажёр */ + html += `
+
ИНТЕРАКТИВ 4
Тренажёр Герона
+
Реши задачу и введи площадь (округляй до 2 знаков после запятой).
+
Задача 1 / 6Очки: 0 / 6
+
+
+ $S =$ + + + +
+ +
`; + + 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='' + +'Невозможный треугольник' + +'Сумма двух сторон должна быть больше третьей'; + out.innerHTML='Неравенство треугольника не выполнено: $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 += ''; + s += ''; + // подписи сторон + 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 += 'c = '+c+''; + s += 'b = '+b+''; + s += 'a = '+a+''; + // вершины с подписями + [['A',Av,-14,18],['B',Bv,14,18],['C',Cv,0,-10]].forEach(([n,P,dx,dy])=>{ + s += ''; + s += ''+n+''; + }); + svg.innerHTML=s; + out.innerHTML = 'Полупериметр: $p = '+p.toFixed(2)+'$  ·  Площадь (Герон): $S \\approx '+S.toFixed(2)+'$
' + + 'Углы: $A \\approx '+Aang.toFixed(1)+'°$, $B \\approx '+Bang.toFixed(1)+'°$, $C \\approx '+Cang.toFixed(1)+'°$
' + + 'Радиусы: $R \\approx '+R.toFixed(2)+'$, $r \\approx '+r.toFixed(2)+'$
' + + 'Медианы: $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,'✗ Введи все значения.'); return; } + if(a<=0||b<=0||c<=0){ feedback(fb,false,'✗ Стороны должны быть положительными.'); return; } + if(a+b<=c||a+c<=b||b+c<=a){ feedback(fb,false,'✗ Неравенство треугольника не выполняется.'); 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)+'$
' + + '$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)+'}$
' + + '$S = \\sqrt{'+inner.toFixed(3)+'} \\approx '+S.toFixed(3)+'$'; + renderMath(out); + feedback(fb,true,'✓ Площадь найдена.'); + 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='Завершено! Очки: '+score+' / '+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?('✓ Верно! '+explain[Q[i].a]):('✗ Правильно: '+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='Финиш! Очки: '+score+' / '+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,'✗ Введи число.'); return; } + const tol=Q[i].tol||0.05; + const ok=Math.abs(v-Q[i].a)<=tol; + if(ok) score++; + feedback(fb, ok, ok?'✓ Верно!':'✗ Правильный ответ: $'+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', '★', ` -

Итоговый раздел главы «Теоремы синусов и косинусов» будет добавлен в следующих обновлениях.

-

Раздел Phase 7.

`); - html += readButton('final3'); + + /* Часть А — Шпаргалка главы (3 mini-карточки) */ + html += `
+
+
${ICONS.theory}
+
Шпаргалка главы 3
+
Итог
+
+
+

Главные формулы главы «Теоремы синусов и косинусов» — в одном месте. Просмотри перед боссами!

+
+
+
+ + § 10 · Теорема синусов +
+
$\\dfrac{a}{\\sin A} = \\dfrac{b}{\\sin B} = \\dfrac{c}{\\sin C} = 2R$. Пара «сторона + противолежащий угол» $\\Rightarrow$ радиус $R$ и любая сторона.
+
+
+
+ + § 11 · Теорема косинусов +
+
$a^2 = b^2 + c^2 - 2bc\\cos A$ — обобщение Пифагора. По 3 сторонам — любой угол; по 2 сторонам и углу — 3-я сторона.
+
+
+
+ + § 12 · Формула Герона +
+
$S = \\sqrt{p(p-a)(p-b)(p-c)}$, где $p = \\dfrac{a+b+c}{2}$ — полупериметр. Площадь по трём сторонам.
+
+
+
+
`; + + /* Часть Б — 5 боссов */ + html += `
+
+
${ICONS.rule}
+
Боссы главы 3
+
5
+
+
+

5 интегрированных задач — каждая комбинирует темы §§10–12. За каждого побеждённого босса — +10 XP и +18% к прогрессу. Победишь всех — +50 XP бонус и ачивка «Магистр треугольников»!

+
+
`; + + html += '
'; + + html += `
+
Прогресс по боссам
+
0 / 5 боссов побеждено
+
+
+
+ +
`; + 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:'§§ 10–12 — синтез', + 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 '
' + +'
' + +'' + +'
Босс '+b.n+': '+b.title+'
' + +'
'+b.tag+'
' + +'
' + +'
'+b.q+'
' + +'
' + +'ответ =' + +'' + +'' + +'' + +'
' + +'' + +'
'; + }).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 = ' Повержен'; + 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, '✗ Введи число.'); return; } + const ok = Math.abs(val - b.ans) < 0.05; + if(ok){ + BOSS_STATE[idx].defeated = true; saveBosses(); + feedback(fb, true, '✓ Босс '+b.n+' повержен! +10 XP. '+b.hint); + addXp(10, 'boss-ch3-'+b.n); + bumpProgress('final3', 18); + markDefeatedUI(b); + refreshOverall(); + } else { + feedback(fb, false, '✗ Промах. Попробуй ещё. Подсказка доступна.'); + } + }); + hintBtn.addEventListener('click', ()=>{ + const fb = document.getElementById('bossG3-'+b.n+'-fb'); + fb.className = 'feedback ok'; + fb.innerHTML = 'Подсказка: '+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 ===== */ diff --git a/js/sidebar.js b/js/sidebar.js index 4fa8b5d..cccd3ca 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -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', 'Практика и игры', `