feat: WebSocket real-time + rAF render gate + guest board + screen picker

Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+426
View File
@@ -709,6 +709,124 @@
}
.sl-filter-select:focus { border-color: var(--violet); outline: none; }
.sl-count { font-size: 0.78rem; color: #8898AA; font-weight: 600; }
/* ══════════ CLASSROOM ADMIN TAB ══════════ */
.cr-admin-section { margin-bottom: 40px; }
.cr-admin-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em;
margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
}
.cr-admin-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* Active session card */
.cr-live-list { display: flex; flex-direction: column; gap: 10px; }
.cr-live-card {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1.5px solid var(--border);
border-left: 4px solid #EF4444; border-radius: 16px;
padding: 14px 18px; transition: box-shadow 0.15s, transform 0.15s;
}
.cr-live-card:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.1); transform: translateX(2px); }
.cr-live-pulse {
width: 10px; height: 10px; border-radius: 50%; background: #EF4444; flex-shrink: 0;
animation: pulse-live 1.4s ease-in-out infinite;
}
@keyframes pulse-live {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); }
}
.cr-live-info { flex: 1; min-width: 0; }
.cr-live-title { font-size: 0.96rem; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-live-meta { font-size: 0.81rem; color: var(--text-3); }
.cr-live-badges { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
.cr-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 99px; font-size: 0.76rem; font-weight: 700;
}
.cr-badge-online { background: rgba(6,214,100,0.12); color: #059652; }
.cr-badge-msgs { background: rgba(6,214,224,0.12); color: #05aab3; }
.cr-badge-dur { background: rgba(155,93,229,0.1); color: var(--violet); }
.cr-live-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* History session row */
.cr-hist-list { display: flex; flex-direction: column; gap: 8px; }
.cr-hist-row {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 12px 16px; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cr-hist-row:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,0.07); }
.cr-hist-row.open { border-color: var(--violet); background: rgba(155,93,229,0.03); border-radius: 14px 14px 0 0; border-bottom: none; }
.cr-hist-icon { width: 38px; height: 38px; border-radius: 10px; background: rgba(155,93,229,0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.cr-hist-main { flex: 1; min-width: 0; }
.cr-hist-title { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-hist-meta { font-size: 0.79rem; color: var(--text-3); }
.cr-hist-chips { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.cr-hist-chevron { width: 18px; height: 18px; color: var(--text-3); transition: transform 0.2s; flex-shrink: 0; }
.cr-hist-row.open .cr-hist-chevron { transform: rotate(180deg); color: var(--violet); }
/* Session detail drawer */
.cr-detail-drawer {
overflow: hidden; max-height: 0; transition: max-height 0.35s ease;
border: 1px solid var(--violet); border-top: none;
border-radius: 0 0 14px 14px; background: rgba(238,242,255,0.5);
margin-bottom: 0;
}
.cr-detail-drawer.open { max-height: 3000px; margin-bottom: 8px; }
.cr-detail-inner { padding: 20px 24px; }
.cr-detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media(max-width:700px) { .cr-detail-grid { grid-template-columns: repeat(2, 1fr); } }
.cr-detail-stat {
background: #fff; border: 1px solid var(--border); border-radius: 12px;
padding: 14px 16px; text-align: center;
}
.cr-detail-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--violet); margin-bottom: 4px; }
.cr-detail-label { font-size: 0.72rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; }
.cr-attend-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.cr-attend-row {
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: 10px; background: #fff;
font-size: 0.88rem;
}
.cr-attend-name { flex: 1; font-weight: 600; }
.cr-attend-time { color: var(--text-3); font-size: 0.8rem; }
.cr-attend-dur { color: var(--cyan); font-weight: 700; font-size: 0.8rem; min-width: 60px; text-align: right; }
.cr-pages-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap: 8px; margin-top: 10px; }
.cr-page-chip {
display: flex; align-items: center; justify-content: space-between;
background: #fff; border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; font-size: 0.82rem;
}
.cr-page-num { font-weight: 700; color: var(--violet); }
.cr-page-cnt { color: var(--text-3); font-size: 0.76rem; }
.cr-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.btn-cr-export { padding: 8px 18px; border: 1.5px solid var(--cyan); border-radius: 99px; background: rgba(6,214,224,0.06); color: #05aab3; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
.btn-cr-export:hover { background: rgba(6,214,224,0.15); }
.btn-cr-del { padding: 8px 18px; border: 1.5px solid rgba(241,91,181,0.4); border-radius: 99px; background: transparent; color: var(--pink); font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
.btn-cr-del:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
.btn-cr-end { padding: 8px 18px; border: none; border-radius: 99px; background: #EF4444; color: #fff; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: opacity 0.15s; }
.btn-cr-end:hover { opacity: 0.85; }
/* Pagination */
.cr-pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
.cr-page-btn {
min-width: 36px; height: 36px; padding: 0 12px; border: 1.5px solid var(--border);
border-radius: 10px; background: var(--surface); font-family:'Manrope',sans-serif;
font-size:0.85rem; font-weight:700; color:var(--text-2); cursor:pointer; transition:all 0.14s;
display:flex; align-items:center; justify-content:center;
}
.cr-page-btn:hover:not(:disabled) { border-color:var(--violet); color:var(--violet); }
.cr-page-btn.active { background:var(--violet); border-color:var(--violet); color:#fff; }
.cr-page-btn:disabled { opacity:0.4; cursor:default; }
.cr-page-info { font-size:0.82rem; color:var(--text-3); font-weight:600; }
/* toolbar for classroom history */
.cr-hist-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); }
.cr-hist-search:focus { outline:none; border-color:var(--violet); }
.cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; }
</style>
</head>
<body>
@@ -733,6 +851,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<span class="sb-link active"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></span>
</nav>
@@ -762,6 +881,9 @@
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
<i data-lucide="clock" style="width:15px;height:15px"></i> История сессий
</button>
<button class="admin-nav-item" data-tab="classroom" onclick="switchTab(this)">
<i data-lucide="video" style="width:15px;height:15px"></i> Онлайн-уроки
</button>
<div class="admin-nav-sep"></div>
<div class="admin-nav-label">Контент</div>
@@ -967,6 +1089,51 @@
<div id="t-body"><div class="spinner"></div></div>
</div>
<!-- ── Онлайн-уроки ── -->
<div class="tab-pane" id="tab-classroom">
<!-- Module master toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1.5px solid var(--border-h);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:32px">
<div>
<div style="font-size:0.97rem;font-weight:700;margin-bottom:4px">Модуль онлайн-уроков</div>
<div class="perm-desc" style="margin:0">Если отключить, учителя не смогут создавать новые уроки. Уже активные сессии продолжат работу до завершения.</div>
</div>
<label class="perm-toggle" id="cr-master-lbl" title="Включить / выключить модуль" style="margin-left:24px;flex-shrink:0">
<input type="checkbox" id="cr-master-chk" onchange="crMasterToggle(this.checked)" checked />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
<!-- Active sessions -->
<div class="cr-admin-section">
<div class="cr-admin-section-title">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
Активные уроки
<span id="cr-live-refresh-btn" style="font-size:0.76rem;font-weight:600;color:var(--violet);cursor:pointer;text-transform:none;letter-spacing:0;margin-left:-4px" onclick="loadCrActiveSessions()">
<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Обновить
</span>
</div>
<div id="cr-live-list"><div class="spinner"></div></div>
</div>
<!-- Session history -->
<div class="cr-admin-section">
<div class="cr-admin-section-title">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
История уроков
</div>
<div class="cr-hist-toolbar">
<input class="cr-hist-search" id="cr-hist-q" type="text" placeholder="Поиск по теме или учителю…" oninput="crHistDebounce()">
<span class="cr-hist-count" id="cr-hist-count"></span>
</div>
<div id="cr-hist-list"><div class="spinner"></div></div>
<div id="cr-hist-pagination"></div>
</div>
</div>
<!-- ── Права доступа ── -->
<div class="tab-pane" id="tab-permissions">
<div class="perm-header">
@@ -4321,9 +4488,13 @@
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
@@ -4814,6 +4985,260 @@
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
/* ════════════════════════════════════════════════
ОНЛАЙН-УРОКИ (classroom admin)
════════════════════════════════════════════════ */
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
let _crOpenDetailId = null, _crHistDebTimer = null;
async function loadCrModuleState() {
try {
const features = await LS.api('/api/admin/features');
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = features.classroom !== false;
} catch(e) { /* silent */ }
}
async function crMasterToggle(enabled) {
try {
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
} catch(e) {
LS.toast('Ошибка: ' + e.message, 'error');
// revert checkbox
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = !enabled;
}
}
function fmtDuration(sec) {
if (!sec || sec < 0) return '';
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
if (h) return `${h}ч ${m}м`;
if (m) return `${m} мин ${s} сек`;
return `${s} сек`;
}
function fmtLiveDuration(createdAt) {
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
return fmtDuration(sec);
}
async function loadCrActiveSessions() {
const el = document.getElementById('cr-live-list');
try {
const { sessions } = await LS.api('/api/classroom/admin/active');
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
return;
}
el.innerHTML = sessions.map(s => {
const dur = fmtLiveDuration(s.created_at);
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
return `<div class="cr-live-card">
<div class="cr-live-pulse"></div>
<div class="cr-live-info">
<div class="cr-live-title">${esc(title)}</div>
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
</div>
<div class="cr-live-badges">
<span class="cr-badge cr-badge-online">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
${s.online_count}
</span>
<span class="cr-badge cr-badge-msgs">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
${s.message_count}
</span>
<span class="cr-badge cr-badge-dur">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${dur}
</span>
</div>
<div class="cr-live-actions">
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
async function adminEndSession(id) {
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
try {
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
LS.toast('Урок завершён', 'success', 2500);
loadCrActiveSessions();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
function crHistDebounce() {
clearTimeout(_crHistDebTimer);
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
}
async function loadCrHistory(page) {
if (page) _crHistPage = page;
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
const el = document.getElementById('cr-hist-list');
el.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
if (_crHistSearch) params.set('search', _crHistSearch);
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
_crHistTotal = total; _crHistPages = pages;
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
renderCrPagination();
return;
}
el.innerHTML = sessions.map(s => {
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
return `<div>
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
<div class="cr-hist-icon">
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="cr-hist-main">
<div class="cr-hist-title">${esc(title)}</div>
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
</div>
<div class="cr-hist-chips">
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
<span class="cr-badge cr-badge-dur">${dur}</span>
</div>
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
<div class="spinner"></div>
</div>
</div>
</div>`;
}).join('');
if (_crOpenDetailId) {
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
if (dr) loadCrSessionDetail(_crOpenDetailId);
}
renderCrPagination();
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
function renderCrPagination() {
const el = document.getElementById('cr-hist-pagination');
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
const p = _crHistPage, total = _crHistPages;
let html = '<div class="cr-pagination">';
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
</button>`;
const range = [];
for (let i=1;i<=total;i++) {
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
else if (range[range.length-1]!=='…') range.push('…');
}
range.forEach(r => {
if (r==='…') html += `<span class="cr-page-info"></span>`;
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
});
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
</button></div>`;
el.innerHTML = html;
}
async function toggleCrDetail(id, rowEl) {
const wasOpen = _crOpenDetailId === id;
// close all
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
_crOpenDetailId = null;
if (wasOpen) return;
// open this one
rowEl.classList.add('open');
const dr = document.getElementById(`cr-detail-${id}`);
if (dr) { dr.classList.add('open'); }
_crOpenDetailId = id;
await loadCrSessionDetail(id);
}
async function loadCrSessionDetail(id) {
const inner = document.getElementById(`cr-detail-inner-${id}`);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
const dur = fmtDuration(stats.duration_sec);
inner.innerHTML = `
<div class="cr-detail-grid">
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
</div>
${attendance.length ? `
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
<div class="cr-attend-list">
${attendance.map(a => `
<div class="cr-attend-row">
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span class="cr-attend-name">${esc(a.user_name)}</span>
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
</div>
`).join('')}
</div>
` : ''}
${pages.length > 1 ? `
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
<div class="cr-pages-list">
${pages.map(p => `
<div class="cr-page-chip">
<span class="cr-page-num">Стр. ${p.page_num}</span>
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
</div>
`).join('')}
</div>
` : ''}
<div class="cr-detail-actions">
<button class="btn-cr-export" onclick="adminExportChat(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Экспорт чата
</button>
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
Удалить запись
</button>
</div>`;
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function adminExportChat(id) {
window.open(`/api/classroom/${id}/chat/export`, '_blank');
}
async function adminDeleteSession(id) {
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
try {
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
LS.toast('Урок удалён', 'success', 2500);
_crOpenDetailId = null;
loadCrHistory();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── wire tab loading ─── */
const _origSwitchTab = window.switchTab;
window.switchTab = function(btn) {
@@ -4823,6 +5248,7 @@
else if (tab === 'audit') loadAuditLog();
else if (tab === 'errors') loadErrorLog();
else if (tab === 'health') loadHealth();
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
};
/* ─── init ─── */
+1
View File
@@ -262,6 +262,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link nav-active"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -287,6 +287,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -367,6 +367,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -200,6 +200,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -323,6 +323,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -341,6 +341,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -277,6 +277,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -567,6 +567,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+3655 -209
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -432,6 +432,7 @@ function renderGrid() {
<p>Откройте виды в каталоге, чтобы они появились здесь</p>
<a href="/red-book.html" class="btn-rb-outline"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Исследовать виды</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
</div>`;
return;
}
+1
View File
@@ -241,6 +241,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -415,6 +415,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -203,6 +203,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -1193,6 +1193,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+20 -2
View File
@@ -7,6 +7,7 @@
<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="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; min-height: 100vh; }
.fc-wrap { max-width: 1100px; margin: 0 auto; padding: 28px 28px 80px; }
@@ -395,6 +396,8 @@
<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/search.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
(async () => {
/* ── auth ── */
@@ -664,13 +667,28 @@ async function startStudyForDeck(deckId) {
bindSwipe();
}
const _FC_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtmlFC(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _FC_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
function showStudyCard() {
const card = _studyCards[_studyIdx];
if (!card) { finishStudy(); return; }
const el = document.getElementById('study-card');
el.className = 'study-card-inner';
document.getElementById('study-front-text').textContent = card.front;
document.getElementById('study-back-text').textContent = card.back;
document.getElementById('study-front-text').innerHTML = mathHtmlFC(card.front);
document.getElementById('study-back-text').innerHTML = mathHtmlFC(card.back);
_studyFlipped = false;
document.getElementById('study-btns').classList.remove('visible');
document.getElementById('study-flip-hint').style.display = 'block';
+1
View File
@@ -215,6 +215,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+435
View File
@@ -0,0 +1,435 @@
<!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="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0b0920;
--bg2: #12093a;
--violet: #9B5DE5;
--cyan: #06D6E0;
--text: #e8e0f7;
--muted: rgba(232,224,247,0.45);
--border: rgba(155,93,229,0.2);
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Manrope', sans-serif; overflow: hidden; }
/* ── Name entry screen ── */
#guest-entry {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse 80% 60% at 50% 30%, rgba(155,93,229,0.12) 0%, transparent 70%), var(--bg);
}
.ge-box {
width: 100%; max-width: 400px; padding: 40px 36px 36px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 32px 80px rgba(0,0,0,0.55);
margin: 16px;
}
.ge-logo {
display: flex; align-items: center; gap: 10px;
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800;
color: var(--text); margin-bottom: 28px;
}
.ge-logo svg { width: 28px; height: 28px; }
.ge-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; margin-bottom: 6px; }
.ge-sub { font-size: 0.78rem; color: var(--muted); margin-bottom: 26px; line-height: 1.6; }
.ge-lesson-name {
font-size: 0.82rem; font-weight: 700; color: var(--violet);
margin-bottom: 22px; padding: 8px 12px;
background: rgba(155,93,229,0.08); border-radius: 8px;
border: 1px solid rgba(155,93,229,0.18);
display: none;
}
.ge-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
.ge-input {
width: 100%; padding: 12px 14px;
background: rgba(255,255,255,0.05); border: 1.5px solid rgba(255,255,255,0.12);
border-radius: 12px; color: var(--text); font-family: 'Manrope', sans-serif;
font-size: 0.9rem; outline: none; transition: border-color 0.15s;
}
.ge-input:focus { border-color: var(--violet); }
.ge-input::placeholder { color: rgba(255,255,255,0.22); }
.ge-btn {
width: 100%; margin-top: 18px; padding: 13px;
background: linear-gradient(135deg, var(--violet), #5e2fb5);
border: none; border-radius: 12px; color: #fff;
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
cursor: pointer; transition: opacity 0.15s, transform 0.12s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.ge-btn:hover { opacity: 0.88; transform: translateY(-1px); }
.ge-btn:disabled { opacity: 0.4; cursor: default; transform: none; }
.ge-disclaimer {
margin-top: 16px; font-size: 0.68rem; color: rgba(255,255,255,0.2);
text-align: center; line-height: 1.6;
}
.ge-error { margin-top: 12px; font-size: 0.75rem; color: #FF6B6B; text-align: center; display: none; }
/* ── Board layout ── */
#guest-board { display: none; flex-direction: column; height: 100vh; }
/* Header */
.gb-header {
height: 46px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px;
background: rgba(10,7,30,0.92); border-bottom: 1px solid rgba(255,255,255,0.07);
backdrop-filter: blur(8px);
z-index: 10;
}
.gb-header-left { display: flex; align-items: center; gap: 10px; }
.gb-header-right { display: flex; align-items: center; gap: 10px; }
.gb-logo { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--violet); }
.gb-title { font-size: 0.78rem; font-weight: 700; color: var(--text); max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gb-sep { width: 1px; height: 18px; background: rgba(255,255,255,0.1); }
.gb-badge {
display: flex; align-items: center; gap: 5px; padding: 3px 9px;
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.22);
border-radius: 99px; font-size: 0.63rem; font-weight: 700; color: var(--violet);
}
.gb-badge-dot { width: 5px; height: 5px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; }
@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
/* Page nav */
.gb-page-nav { display: flex; align-items: center; gap: 8px; }
.gb-page-btn {
width: 28px; height: 28px; border: 1px solid rgba(255,255,255,0.12);
border-radius: 7px; background: rgba(255,255,255,0.04);
color: var(--muted); cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.gb-page-btn:hover:not(:disabled) { border-color: rgba(155,93,229,0.4); color: var(--text); background: rgba(155,93,229,0.08); }
.gb-page-btn:disabled { opacity: 0.3; cursor: default; }
.gb-page-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
/* Canvas area */
.gb-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
background: #2d5a2d; /* chalkboard green — same as classroom */
}
#guest-canvas { display: block; width: 100%; height: 100%; }
/* Ended overlay */
#gb-ended {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(11,9,32,0.92); backdrop-filter: blur(8px);
flex-direction: column; align-items: center; justify-content: center;
gap: 14px; text-align: center;
}
#gb-ended svg { width: 48px; height: 48px; color: var(--violet); }
#gb-ended h2 { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; }
#gb-ended p { font-size: 0.8rem; color: var(--muted); }
/* Status toast */
#gb-toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
padding: 8px 18px; border-radius: 99px;
background: rgba(20,15,50,0.95); border: 1px solid rgba(155,93,229,0.3);
font-size: 0.76rem; font-weight: 600; color: var(--text);
pointer-events: none; opacity: 0; transition: opacity 0.25s;
white-space: nowrap; z-index: 500;
}
#gb-toast.show { opacity: 1; }
@media (max-width: 480px) {
.gb-logo { display: none; }
.gb-title { max-width: 140px; font-size: 0.72rem; }
}
</style>
</head>
<body>
<!-- ── Name entry screen ─────────────────────────────── -->
<div id="guest-entry">
<div class="ge-box">
<div class="ge-logo">
<svg viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="8" fill="#9B5DE5"/><path d="M8 22V12l8-4 8 4v10" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M13 22v-5h6v5" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
LearnSpace
</div>
<div class="ge-title">Гостевой просмотр</div>
<div class="ge-sub">Вы смотрите доску в режиме чтения. Рисовать нельзя.</div>
<div class="ge-lesson-name" id="ge-lesson-name"></div>
<div class="ge-label">Ваше имя</div>
<input class="ge-input" id="ge-name-input" type="text" placeholder="Введите ваше имя…" maxlength="40"
onkeydown="if(event.key==='Enter') guestJoin()">
<button class="ge-btn" id="ge-join-btn" onclick="guestJoin()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Войти как гость
</button>
<div class="ge-error" id="ge-error"></div>
<div class="ge-disclaimer">Ваше имя будет видно учителю в списке участников</div>
</div>
</div>
<!-- ── Board ─────────────────────────────────────────── -->
<div id="guest-board">
<header class="gb-header">
<div class="gb-header-left">
<span class="gb-logo">LearnSpace</span>
<div class="gb-sep"></div>
<span class="gb-title" id="gb-title">Онлайн-урок</span>
<div class="gb-badge">
<span class="gb-badge-dot"></span>
Гостевой просмотр
</div>
</div>
<div class="gb-header-right">
<div class="gb-page-nav">
<button class="gb-page-btn" id="gb-prev" onclick="gbPrevPage()" disabled title="Предыдущая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span class="gb-page-label" id="gb-page-label">1 / 1</span>
<button class="gb-page-btn" id="gb-next" onclick="gbNextPage()" disabled title="Следующая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
</header>
<div class="gb-canvas-wrap">
<canvas id="guest-canvas"></canvas>
</div>
</div>
<!-- ── Lesson ended overlay ─── -->
<div id="gb-ended">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>
<h2>Урок завершён</h2>
<p>Учитель завершил урок. Спасибо за участие!</p>
</div>
<div id="gb-toast"></div>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
<script src="/js/whiteboard.js"></script>
<script>
const _token = new URLSearchParams(location.search).get('token');
let _guestId = null;
let _sessionId = null;
let _wb = null;
let _curPage = 1;
let _totalPages = 1;
let _es = null;
let _wbMaxSeq = 0;
let _pollTimer = null;
/* ── toast ── */
let _toastTimer;
function showToast(msg, dur = 2800) {
const el = document.getElementById('gb-toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), dur);
}
/* ── pre-load session info ── */
async function init() {
if (!_token) { showError('Неверная ссылка'); return; }
try {
const r = await fetch(`/api/classroom/guest/${_token}`);
if (!r.ok) { showError('Ссылка недействительна или урок уже завершён'); return; }
const info = await r.json();
if (info.status !== 'active') {
showError('Урок ещё не начался. Попробуйте позже или обратитесь к учителю.');
return;
}
const nameEl = document.getElementById('ge-lesson-name');
nameEl.textContent = info.title || 'Онлайн-урок';
nameEl.style.display = 'block';
_totalPages = info.page_count || 1;
_curPage = info.current_page || 1;
} catch {
showError('Не удалось подключиться к серверу');
}
}
function showError(msg) {
const el = document.getElementById('ge-error');
el.textContent = msg;
el.style.display = 'block';
}
/* ── join ── */
async function guestJoin() {
const nameInput = document.getElementById('ge-name-input');
const btn = document.getElementById('ge-join-btn');
const name = nameInput.value.trim() || 'Гость';
btn.disabled = true;
try {
const r = await fetch(`/api/classroom/guest/${_token}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
showError(err.error || 'Не удалось войти');
btn.disabled = false;
return;
}
const data = await r.json();
_guestId = data.guestId;
_sessionId = data.sessionId;
_totalPages = data.page_count || 1;
_curPage = data.current_page || 1;
document.getElementById('gb-title').textContent = data.title || 'Онлайн-урок';
startBoard();
} catch {
showError('Ошибка соединения');
btn.disabled = false;
}
}
/* ── board startup ── */
function startBoard() {
document.getElementById('guest-entry').style.display = 'none';
const boardEl = document.getElementById('guest-board');
boardEl.style.display = 'flex';
const canvas = document.getElementById('guest-canvas');
_wb = new Whiteboard(canvas, { readOnly: true, bg: 'chalk' });
_wb.fit();
window.addEventListener('resize', () => _wb.fit());
loadPage(_curPage);
connectSSE();
// Send goodbye on tab close
window.addEventListener('pagehide', leaveGuest);
}
/* ── load strokes for a page ── */
async function loadPage(pageNum) {
_curPage = pageNum;
updatePageNav();
_wbMaxSeq = 0;
_wb.clearPage();
try {
const r = await fetch(`/api/classroom/guest/${_token}/strokes?page_num=${pageNum}`);
if (!r.ok) return;
const data = await r.json();
_wbMaxSeq = data.seq || 0;
_wb.loadStrokes(data.strokes || []);
if (data.template) _wb.setTemplate(data.template);
} catch {}
}
function updatePageNav() {
document.getElementById('gb-page-label').textContent = `${_curPage} / ${_totalPages}`;
document.getElementById('gb-prev').disabled = _curPage <= 1;
document.getElementById('gb-next').disabled = _curPage >= _totalPages;
}
function gbPrevPage() { if (_curPage > 1) loadPage(_curPage - 1); }
function gbNextPage() { if (_curPage < _totalPages) loadPage(_curPage + 1); }
/* ── SSE ── */
function connectSSE() {
const url = `/api/classroom/guest/${_token}/stream?guestId=${encodeURIComponent(_guestId || '')}`;
_es = new EventSource(url);
_es.onmessage = (e) => {
try { handleEvent(JSON.parse(e.data)); } catch {}
};
_es.onerror = () => {
// Auto-reconnects — just show brief toast
setTimeout(() => { if (_es.readyState === EventSource.CONNECTING) showToast('Переподключение…'); }, 3000);
};
}
function handleEvent(data) {
if (!data.type) return;
switch (data.type) {
case 'classroom_strokes':
if (data.pageNum == _curPage) {
_wbMaxSeq = Math.max(_wbMaxSeq, ...(data.strokes || []).map(s => s.seq || 0));
_wb.addStrokes(data.strokes || []);
}
break;
case 'classroom_stroke_preview':
if (data.pageNum == _curPage) {
if (data.cancel) _wb.removeLiveStroke(data.liveId);
else _wb.setLiveStroke(data.liveId, data.tool, data.data, data.userName, '#06D6E0');
}
break;
case 'classroom_stroke_deleted':
_wb.removeStroke(data.strokeId);
break;
case 'classroom_stroke_updated':
if (data.pageNum == _curPage)
_wb.updateStroke(data.strokeId, data.data);
break;
case 'classroom_page_added':
_totalPages++;
updatePageNav();
break;
case 'classroom_page_changed':
// Follow teacher
if (data.pageNum !== _curPage) loadPage(data.pageNum);
break;
case 'classroom_template_changed':
if (data.pageNum == _curPage) _wb.setTemplate(data.template);
break;
case 'classroom_page_cleared':
if (data.pageNum == _curPage) { _wbMaxSeq = 0; _wb.clearPage(); }
break;
case 'classroom_page_renamed':
// Nothing visible to guest
break;
case 'classroom_page_duplicated':
_totalPages++;
updatePageNav();
break;
case 'classroom_page_deleted':
_totalPages = Math.max(1, _totalPages - 1);
if (_curPage > _totalPages) loadPage(_totalPages);
else updatePageNav();
break;
case 'classroom_ended':
showEnded();
break;
}
}
function showEnded() {
if (_es) { _es.close(); _es = null; }
document.getElementById('gb-ended').style.display = 'flex';
}
function leaveGuest() {
if (!_guestId) return;
navigator.sendBeacon(`/api/classroom/guest/${_token}/leave`,
new Blob([JSON.stringify({ guestId: _guestId })], { type: 'application/json' }));
}
/* ── boot ── */
init();
</script>
</body>
</html>
+1
View File
@@ -285,6 +285,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -166,6 +166,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+27 -5
View File
@@ -20,12 +20,14 @@
* rtc.destroy();
*/
class ClassroomRTC {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive }) {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
this._sid = sessionId;
this._uid = userId;
this._onSignal = onSignal; // fn(targetUid, payload)
this._onScreen = onScreenStream; // fn(stream | null)
this._onMicActive = onMicActive; // fn(uid, bool) — optional
this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick
this._vadThreshold = vadThreshold ?? 12;
this._peers = new Map(); // uid → PeerState
this._localStream = null; // mic audio
@@ -62,15 +64,17 @@ class ClassroomRTC {
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
let speaking = false;
const THRESHOLD = 12;
const THRESHOLD = this._vadThreshold;
const timer = setInterval(() => {
analyser.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
const level = Math.min(100, Math.round((avg / 64) * 100));
const now = avg > THRESHOLD;
if (now !== speaking) {
speaking = now;
try { this._onMicActive(uid, speaking); } catch {}
try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
}
try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
}, 120);
this._vadTimers.set(uid, { ctx, timer });
} catch {}
@@ -307,10 +311,11 @@ class ClassroomRTC {
/* ── Screen sharing (teacher) ────────────────────────────────────────── */
async startScreenShare() {
async startScreenShare(constraints = {}) {
try {
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always' }, audio: false,
video: { cursor: 'always', ...constraints.video },
audio: constraints.audio ?? false,
});
} catch { return null; }
@@ -340,6 +345,23 @@ class ClassroomRTC {
}
}
/** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
async useExistingScreenStream(stream) {
if (this._screenStream) {
this._screenStream.getTracks().forEach(t => t.stop());
this._screenStream = null;
}
this._screenStream = stream;
const vt = this._screenStream.getVideoTracks()[0];
if (!vt) return stream;
for (const [, peer] of this._peers) {
if (!peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
}
}
return stream;
}
isSharing() { return !!this._screenStream; }
/* ── Cleanup ─────────────────────────────────────────────────────────── */
+1 -1
View File
@@ -792,7 +792,7 @@ class AngryBirdsSim {
/* Planet + g — second line, readable */
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillText(`${pl.label} g = ${pl.g} м/с²`, 14, 50);
ctx.fillText(`${pl.label.replace(/<svg[\s\S]*?<\/svg>/g, '').trim()} g = ${pl.g} м/с²`, 14, 50);
/* Wind reminder */
if (lvl?.wind) {
+1
View File
@@ -50,6 +50,7 @@ class BohrAtomSim {
this.W = w; this.H = h;
}
getParams() { return { level: this.level }; }
setParams({ level } = {}) {
if (level !== undefined) {
const n = Math.max(1, Math.min(6, Math.round(+level)));
+16 -4
View File
@@ -1,4 +1,16 @@
'use strict';
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
if (!s || !s.includes('<svg')) return s;
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
return '';
});
}
/*
ChemSandboxSim v2 «Химическая песочница»
Колба Эрленмейера с реалистичным стеклом
@@ -1089,7 +1101,7 @@ class ChemSandboxSim {
// ── Молекулярное уравнение ──
ctx.font = 'bold 17px "JetBrains Mono", monospace';
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
ctx.fillText(rx.eq, W / 2, y);
ctx.fillText(_csClean(rx.eq), W / 2, y);
y += 22;
// ── Тип реакции + пояснение ──
@@ -1109,7 +1121,7 @@ class ChemSandboxSim {
if (rx.why) {
ctx.font = '13px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText(rx.why, W / 2, y);
ctx.fillText(_csClean(rx.why), W / 2, y);
y += 17;
}
@@ -1117,7 +1129,7 @@ class ChemSandboxSim {
if (rx.ionFull) {
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(155,200,255,0.60)';
ctx.fillText('Полн.: ' + rx.ionFull, W / 2, y);
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
y += 16;
}
@@ -1125,7 +1137,7 @@ class ChemSandboxSim {
if (rx.ionNet) {
ctx.font = 'bold 13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(123,245,164,0.75)';
ctx.fillText('Сокр.: ' + rx.ionNet, W / 2, y);
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
y += 16;
}
+1
View File
@@ -74,6 +74,7 @@ class CollisionSim {
this.c.height = r.height || 420;
}
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
+1
View File
@@ -86,6 +86,7 @@ class ElectrolysisSim {
this._initIons();
}
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
setParams({ voltage, electrolyte } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (electrolyte !== undefined) {
+1
View File
@@ -48,6 +48,7 @@ class EquilibriumSim {
this.reset();
}
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
let needReset = false;
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
+2 -2
View File
@@ -1787,7 +1787,7 @@ class ForceSandboxSim {
// Угловая скорость ω — фиолетовая метка справа от тела
if (hasOmg) {
const sym = b.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
const sym = b.omega > 0 ? '\u21BB' : '\u21BA';
const labX = b.x + halfW + 7;
const labY = b.type === 'box' ? b.y - b.h * 0.12 : b.y - b.r * 0.15;
ctx.save();
@@ -1852,7 +1852,7 @@ class ForceSandboxSim {
ctx.fillStyle = '#EF476F';
ctx.fillText(`p=${(body.mass * spd / S).toFixed(1)} кг·м/с`, cx, cy + r + 12);
if (Math.abs(body.omega) > 0.05) {
const sym = body.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
const sym = body.omega > 0 ? '\u21BB' : '\u21BA';
ctx.fillStyle = '#9B5DE5';
ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22);
}
+1
View File
@@ -46,6 +46,7 @@ class GraphTransformSim {
this.W = w; this.H = h;
}
getParams() { return { a: this.a, k: this.k, b: this.b, c: this.c }; }
setParams({ a, k, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (k !== undefined) this.k = +k;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -56,6 +56,7 @@ class IsoprocessSim {
setProcess(p) { this.process = p; this.draw(); this._emit(); }
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
setParams({ P1, V1 } = {}) {
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
+1
View File
@@ -84,6 +84,7 @@ class MirrorSim {
this.draw(); this._emit();
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
+1
View File
@@ -34,6 +34,7 @@ class NormalDistSim {
this.W = w; this.H = h;
}
getParams() { return { mu: this.mu, sigma: this.sigma, shade: this.shade, zLow: this.zLow, zHigh: this.zHigh }; }
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
if (mu !== undefined) this.mu = +mu;
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
+3
View File
@@ -51,6 +51,9 @@ class PendulumSim {
this.W = w; this.H = h;
}
getParams() {
return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping };
}
setParams({ L, g, theta, damping } = {}) {
if (L !== undefined) this.L = +L;
if (g !== undefined) this.g = +g;
+1
View File
@@ -60,6 +60,7 @@ class ProbabilitySim {
this.W = w; this.H = h;
}
getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
setParams({ mode, trials, speed } = {}) {
if (mode !== undefined) this.mode = mode;
if (trials !== undefined) this.trials = Math.max(1, +trials);
+5
View File
@@ -88,6 +88,11 @@ class ProjectileSim {
this._cw = w; this._ch = h;
}
getParams() {
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
bounce: this.bounce, restitution: this.restitution };
}
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
+1
View File
@@ -43,6 +43,7 @@ class QuadraticSim {
this.W = w; this.H = h;
}
getParams() { return { a: this.a, b: this.b, c: this.c }; }
setParams({ a, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (b !== undefined) this.b = +b;
+1
View File
@@ -42,6 +42,7 @@ class RefractionSim {
this.W = w; this.H = h;
}
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
+1
View File
@@ -39,6 +39,7 @@ class ThinLensSim {
this.W = w; this.H = h;
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
+1
View File
@@ -67,6 +67,7 @@ class TitrationSim {
/* ── Public API ─────────────────────────────────────────── */
getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
+2 -2
View File
@@ -792,7 +792,7 @@ class TriangleSim {
// Formula
this._drawFormulaBox(ctx, this.W, this.H,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² ${adjSide1Name}·${adjSide2Name}·cos${angName} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${c2.toFixed(2)} = ${check.toFixed(2)}`,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² \u2212 2\u00B7${adjSide1Name}\u00B7${adjSide2Name}\u00B7cos${angName} \u2192 ${c2.toFixed(2)} = ${check.toFixed(2)}`,
'#fbbf24');
ctx.restore();
@@ -845,7 +845,7 @@ class TriangleSim {
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
const statusCol = isRight ? '#22d55e' : '#f59e0b';
const statusText = isRight
? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${leg1Name}² + ${leg2Name}² = ${hypName}² (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
? `\u2713 ${leg1Name}\u00B2 + ${leg2Name}\u00B2 = ${hypName}\u00B2 (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)}${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
+4
View File
@@ -59,6 +59,10 @@ class WavesSim {
this._emit();
}
getParams() {
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
n: this._n, speed: this._speed, mode: this._mode };
}
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
+1523 -192
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -380,6 +380,7 @@
<span class="sb-link active"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></span>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+366 -6
View File
@@ -136,11 +136,12 @@
.sim-zoom-btns { display: flex; gap: 4px; }
.zoom-btn {
width: 32px; height: 32px; border-radius: 10px;
min-width: 32px; width: auto; height: 32px; border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; font-size: 1.1rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: .8rem; font-weight: 700;
padding: 0 9px; white-space: nowrap;
display: flex; align-items: center; justify-content: center; gap: 4px;
transition: all .15s;
}
.zoom-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.07); }
@@ -668,6 +669,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -933,6 +935,54 @@
</button>
</div>
<!-- hydrostatics controls -->
<div id="ctrl-hydro" class="sim-zoom-btns" style="display:none">
<select id="hydro-mode-sel" onchange="hydroMode(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="pressure">Давление P=ρgh</option>
<option value="surface">Пов. натяжение</option>
<option value="communicating">Сообщ. сосуды</option>
<option value="archimedes">Архимед</option>
</select>
<select id="hydro-liq-sel" onchange="hydroSim&&hydroSim.setLiquid(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="water">Вода</option>
<option value="saltwater">Солёная вода</option>
<option value="oil">Масло</option>
<option value="alcohol">Спирт</option>
<option value="glycerin">Глицерин</option>
<option value="mercury">Ртуть</option>
</select>
<div id="hydro-arch-ctrl" style="display:none;gap:4px;align-items:center">
<select id="hydro-mat-sel" onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="styrofoam">Пенопласт</option>
<option value="cork">Пробка</option>
<option value="wood">Дерево</option>
<option value="ice">Лёд</option>
<option value="plastic">Пластик</option>
<option value="glass">Стекло</option>
<option value="aluminum">Алюминий</option>
<option value="iron">Железо</option>
<option value="gold">Золото</option>
</select>
<button class="zoom-btn" onclick="hydroSim&&hydroSim.addBody()" title="Добавить тело">+ Тело</button>
<button class="zoom-btn" onclick="hydroSim&&hydroSim.clearBodies()" title="Очистить">Очистить</button>
</div>
<div id="hydro-comm-ctrl" style="display:none;gap:4px;align-items:center">
<label style="font-size:.72rem;color:rgba(255,255,255,.5)">Сосудов:</label>
<select onchange="hydroSim&&hydroSim.setNumVessels(+this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 6px;font-size:.72rem;cursor:pointer">
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<button class="zoom-btn" id="hydro-valve-btn" onclick="hydroToggleValve()" title="Кран">Кран: откр.</button>
</div>
<div id="hydro-surf-ctrl" style="display:none;gap:4px;align-items:center">
<label style="font-size:.72rem;color:rgba(255,255,255,.5);white-space:nowrap">θ:</label>
<input type="range" min="0" max="160" value="20" step="5" style="width:72px;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.querySelector('#hydro-panel-theta input[type=range]').value=this.value">
<span id="hydro-theta-val" style="font-size:.72rem;color:#9B5DE5;min-width:28px;white-space:nowrap">20°</span>
<button class="zoom-btn" id="hydro-surf-toggle" onclick="hydroToggleSurface()" title="Переключить: капилляры / капля" style="white-space:nowrap">Капилляры</button>
</div>
</div>
<!-- theory toggle (all sims) -->
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
@@ -3537,6 +3587,97 @@
<div class="pstat"><div class="pstat-label">Диагональ</div><div class="pstat-val" id="stbar-d"></div></div>
</div>
<!-- ── HYDROSTATICS sim body ── -->
<div id="sim-hydro" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<!-- left panel -->
<div class="proj-panel" style="width:230px;gap:0;overflow-y:auto">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<!-- liquid -->
<div style="margin-bottom:10px">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Жидкость</div>
<select onchange="hydroSim&&hydroSim.setLiquid(this.value);document.getElementById('hydro-liq-sel').value=this.value" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
<option value="water">Вода (1000 кг/м³)</option>
<option value="saltwater">Солёная вода (1030)</option>
<option value="oil">Масло (900)</option>
<option value="alcohol">Спирт (790)</option>
<option value="glycerin">Глицерин (1260)</option>
<option value="mercury">Ртуть (13600)</option>
</select>
</div>
<!-- material (Archimedes only) -->
<div id="hydro-panel-mat" style="margin-bottom:10px;display:none">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Материал тела</div>
<select onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
<option value="styrofoam">Пенопласт (30 кг/м³)</option>
<option value="cork">Пробка (120)</option>
<option value="wood">Дерево (500)</option>
<option value="ice">Лёд (900)</option>
<option value="plastic">Пластик (1100)</option>
<option value="glass">Стекло (2500)</option>
<option value="aluminum">Алюминий (2700)</option>
<option value="iron">Железо (7800)</option>
<option value="gold">Золото (19300)</option>
</select>
<div style="display:flex;gap:5px;margin-top:6px">
<button class="gp-btn" onclick="hydroSim&&hydroSim.addBody()" style="flex:1">+ Тело</button>
<button class="gp-btn" onclick="hydroSim&&hydroSim.clearBodies()" style="flex:1">Очистить</button>
</div>
</div>
<!-- contact angle (surface tension) -->
<div id="hydro-panel-theta" style="margin-bottom:10px;display:none">
<div style="display:flex;justify-content:space-between;font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">
<span>Краевой угол θ</span>
<span id="hydro-theta-lbl" style="color:#9B5DE5">20°</span>
</div>
<input type="range" min="0" max="160" value="20" step="5" style="width:100%;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.querySelector('#hydro-surf-ctrl input[type=range]').value=this.value">
<div style="display:flex;justify-content:space-between;font-size:.65rem;color:rgba(255,255,255,.25);margin-top:2px">
<span>Смачивание</span><span>Несмачивание</span>
</div>
<div style="margin-top:6px">
<button class="gp-btn" id="hydro-surf-toggle-panel" onclick="hydroToggleSurface()" style="width:100%">Капилляры</button>
</div>
</div>
<!-- communicating vessels -->
<div id="hydro-panel-comm" style="margin-bottom:10px;display:none">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Сосудов</div>
<div style="display:flex;gap:5px">
<button class="gp-btn hydro-nv active" onclick="hydroSetVessels(2,this)" style="flex:1">2</button>
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(3,this)" style="flex:1">3</button>
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(4,this)" style="flex:1">4</button>
</div>
<div style="margin-top:8px">
<button class="gp-btn" id="hydro-valve-panel-btn" onclick="hydroToggleValve()" style="width:100%;color:#06D6A0;border-color:rgba(6,214,160,.3)">Кран: открыт</button>
</div>
<div style="margin-top:6px;display:flex;gap:5px">
<button class="gp-btn" onclick="hydroSim&&hydroSim.addLiquid(0)" style="flex:1">+ Жидкость</button>
<button class="gp-btn" onclick="hydroSim&&hydroSim.removeLiquid()" style="flex:1">- Жидкость</button>
</div>
</div>
<!-- formula display -->
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Формулы</div>
<div id="hydro-formulas" style="font-size:.72rem;font-family:'JetBrains Mono',monospace;color:rgba(255,255,255,.6);line-height:1.7;background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px;min-height:80px"></div>
<!-- result badge -->
<div id="hydro-result" style="margin-top:8px;font-size:.82rem;font-weight:700;text-align:center;padding:8px;border-radius:8px;display:none"></div>
</div><!-- /.proj-panel -->
<!-- canvas area -->
<div style="flex:1;min-width:0;position:relative">
<canvas id="hydro-canvas" style="width:100%;height:100%;display:block"></canvas>
</div>
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-hydro -->
<!-- ── Theory panel (overlay right) ── -->
<div class="theory-panel" id="theory-panel">
<div class="theory-panel-inner" id="theory-content"></div>
@@ -4152,6 +4293,10 @@
title: 'Закон Кулона',
desc: 'Силовые линии и эквипотенциальные поверхности для системы точечных зарядов.',
preview: P_FIELD },
{ id: 'hydrostatics', cat: 'phys',
title: 'Гидростатика',
desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.',
preview: P_SANDBOX },
{ id: 'dynamics', cat: 'phys',
title: 'Динамика',
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
@@ -4269,11 +4414,11 @@
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration',
'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
'sim-waves'];
'sim-waves','sim-hydro'];
const ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag',
'ctrl-molphys',
'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves'];
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro'];
/* ── sim routing ── */
@@ -4324,6 +4469,8 @@
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
}
function _simShow(elId) {
@@ -4423,6 +4570,21 @@
_simShow('sim-graph');
_simShow('ctrl-graph');
_registerSimState('graph',
() => ({
fns: [0,1,2].map(i => ({ expr: document.getElementById(`fn${i}`)?.value || '', color: FN_COLORS[i] }))
}),
(st) => {
if (!Array.isArray(st.fns)) return;
st.fns.forEach((fn, i) => {
const el = document.getElementById(`fn${i}`);
if (el) { el.value = fn.expr; }
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
});
}
);
if (_embedMode) _startStateEmit('graph');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gSim) {
gSim = new GraphSim(document.getElementById('graph-canvas'));
@@ -4446,6 +4608,8 @@
document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
_simShow('sim-proj');
_simShow('ctrl-proj');
_registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
if (_embedMode) _startStateEmit('projectile');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pSim) {
@@ -4629,6 +4793,8 @@
document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
_simShow('sim-coll');
_simShow('ctrl-coll');
_registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
if (_embedMode) _startStateEmit('collision');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!cSim) {
@@ -6535,6 +6701,8 @@
function _openQuadratic() {
document.getElementById('sim-topbar-title').textContent = 'Корни квадратного уравнения';
_simShow('sim-quadratic');
_registerSimState('quadratic', () => quadSim?.getParams(), st => quadSim?.setParams(st));
if (_embedMode) _startStateEmit('quadratic');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!quadSim) {
quadSim = new QuadraticSim(document.getElementById('quadratic-canvas'));
@@ -6573,6 +6741,8 @@
function _openNormalDist() {
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
_simShow('sim-normaldist');
_registerSimState('normaldist', () => ndSim?.getParams(), st => ndSim?.setParams(st));
if (_embedMode) _startStateEmit('normaldist');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!ndSim) {
ndSim = new NormalDistSim(document.getElementById('normaldist-canvas'));
@@ -6617,6 +6787,8 @@
function _openGraphTransform() {
document.getElementById('sim-topbar-title').textContent = 'Трансформации графиков';
_simShow('sim-graphtransform');
_registerSimState('graphtransform', () => gtSim?.getParams(), st => gtSim?.setParams(st));
if (_embedMode) _startStateEmit('graphtransform');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gtSim) {
gtSim = new GraphTransformSim(document.getElementById('graphtransform-canvas'));
@@ -6663,6 +6835,8 @@
function _openPendulum() {
document.getElementById('sim-topbar-title').textContent = 'Маятник';
_simShow('sim-pendulum');
_registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st));
if (_embedMode) _startStateEmit('pendulum');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pendSim) {
pendSim = new PendulumSim(document.getElementById('pendulum-canvas'));
@@ -6705,6 +6879,8 @@
function _openEquilibrium() {
document.getElementById('sim-topbar-title').textContent = 'Химическое равновесие';
_simShow('sim-equilibrium');
_registerSimState('equilibrium', () => eqSim?.getParams(), st => eqSim?.setParams(st));
if (_embedMode) _startStateEmit('equilibrium');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!eqSim) {
eqSim = new EquilibriumSim(document.getElementById('equilibrium-canvas'));
@@ -6746,6 +6922,8 @@
function _openThinLens() {
document.getElementById('sim-topbar-title').textContent = 'Тонкая линза';
_simShow('sim-thinlens');
_registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st));
if (_embedMode) _startStateEmit('thinlens');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!lensSim) {
lensSim = new ThinLensSim(document.getElementById('thinlens-canvas'));
@@ -6787,6 +6965,8 @@
function _openMirror() {
document.getElementById('sim-topbar-title').textContent = 'Зеркала';
_simShow('sim-mirrors');
_registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st));
if (_embedMode) _startStateEmit('mirrors');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!mirrorSim) {
mirrorSim = new MirrorSim(document.getElementById('mirror-canvas'));
@@ -6877,6 +7057,9 @@
function _openIsoprocess() {
document.getElementById('sim-topbar-title').textContent = 'Изопроцессы';
_simShow('sim-isoprocess');
_registerSimState('isoprocess', () => isoSim?.getParams(),
st => { if (isoSim) { isoSim.setParams(st); if (st.process) isoSim.setProcess(st.process); } });
if (_embedMode) _startStateEmit('isoprocess');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!isoSim) {
isoSim = new IsoprocessSim(document.getElementById('isoprocess-canvas'));
@@ -6941,6 +7124,8 @@
function _openTitration() {
document.getElementById('sim-topbar-title').textContent = 'pH и кривая титрования';
_simShow('sim-titration');
_registerSimState('titration', () => titrSim?.getParams(), st => titrSim?.setParams(st));
if (_embedMode) _startStateEmit('titration');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!titrSim) {
titrSim = new TitrationSim(document.getElementById('titration-canvas'));
@@ -6989,6 +7174,8 @@
function _openRefraction() {
document.getElementById('sim-topbar-title').textContent = 'Преломление света';
_simShow('sim-refraction');
_registerSimState('refraction', () => refrSim?.getParams(), st => refrSim?.setParams(st));
if (_embedMode) _startStateEmit('refraction');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!refrSim) {
refrSim = new RefractionSim(document.getElementById('refraction-canvas'));
@@ -7028,6 +7215,8 @@
function _openProbability() {
document.getElementById('sim-topbar-title').textContent = 'Теория вероятностей';
_simShow('sim-probability');
_registerSimState('probability', () => probSim?.getParams(), st => probSim?.setParams(st));
if (_embedMode) _startStateEmit('probability');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!probSim) {
probSim = new ProbabilitySim(document.getElementById('probability-canvas'));
@@ -7066,6 +7255,8 @@
function _openBohrAtom() {
document.getElementById('sim-topbar-title').textContent = 'Атом Бора';
_simShow('sim-bohratom');
_registerSimState('bohratom', () => bohrSim?.getParams(), st => bohrSim?.setParams(st));
if (_embedMode) _startStateEmit('bohratom');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!bohrSim) {
bohrSim = new BohrAtomSim(document.getElementById('bohratom-canvas'));
@@ -7102,6 +7293,8 @@
function _openElectrolysis() {
document.getElementById('sim-topbar-title').textContent = 'Электролиз';
_simShow('sim-electrolysis');
_registerSimState('electrolysis', () => elecSim?.getParams(), st => elecSim?.setParams(st));
if (_embedMode) _startStateEmit('electrolysis');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!elecSim) {
elecSim = new ElectrolysisSim(document.getElementById('electrolysis-canvas'));
@@ -7141,6 +7334,9 @@
document.getElementById('sim-topbar-title').textContent = 'Волны и звук';
document.getElementById('ctrl-waves').style.display = '';
_simShow('sim-waves');
_registerSimState('waves', () => wavesSim?.getParams(),
st => { if (wavesSim) { if (st.mode) wavesSim.setMode(st.mode); wavesSim.setParams(st); } });
if (_embedMode) _startStateEmit('waves');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!wavesSim) {
wavesSim = new WavesSim(document.getElementById('waves-canvas'));
@@ -7887,6 +8083,123 @@
},
};
/* ══════════════════════════════════════════════
HYDROSTATICS
══════════════════════════════════════════════ */
let hydroSim = null;
let _hydroValveOpen = true;
function _openHydro(preset) {
document.getElementById('sim-topbar-title').textContent = 'Гидростатика';
_simShow('sim-hydro');
document.getElementById('ctrl-hydro').style.display = '';
_registerSimState('hydrostatics',
() => ({ mode: hydroSim?.mode, liq: hydroSim?.liquidKey }),
st => { if (st?.mode && hydroSim) hydroMode(st.mode); });
if (_embedMode) _startStateEmit('hydrostatics');
window.addEventListener('load', () => {}, { once: true });
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('hydro-canvas');
const mode = preset || 'pressure';
if (!hydroSim) {
hydroSim = new HydroSim(canvas, mode);
hydroSim.onUpdate = _hydroUpdateUI;
} else {
hydroSim.fit();
hydroSim.play();
}
hydroMode(mode);
}));
}
function hydroMode(mode) {
if (!hydroSim) return;
hydroSim.setMode(mode);
const sel = document.getElementById('hydro-mode-sel');
if (sel) sel.value = mode;
// show/hide sub-controls
['arch','comm','surf','mat'].forEach(k => {
const el = document.getElementById('hydro-panel-' + k);
const el2 = document.getElementById('hydro-' + k + '-ctrl');
if (el) el.style.display = 'none';
if (el2) el2.style.display = 'none';
});
if (mode === 'archimedes') {
const a = document.getElementById('hydro-panel-mat');
const b = document.getElementById('hydro-arch-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
if (mode === 'surface') {
const a = document.getElementById('hydro-panel-theta');
const b = document.getElementById('hydro-surf-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
if (mode === 'communicating') {
const a = document.getElementById('hydro-panel-comm');
const b = document.getElementById('hydro-comm-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
}
function hydroToggleSurface() {
if (!hydroSim) return;
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
hydroSim._stMode = next;
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
['hydro-surf-toggle','hydro-surf-toggle-panel'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = label;
});
}
function hydroToggleValve() {
if (!hydroSim) return;
_hydroValveOpen = !_hydroValveOpen;
hydroSim.setValve(_hydroValveOpen);
const label = _hydroValveOpen ? 'Кран: открыт' : 'Кран: закрыт';
const color = _hydroValveOpen ? '#06D6A0' : '#F15BB5';
['hydro-valve-btn','hydro-valve-panel-btn'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.textContent = label; el.style.color = color; el.style.borderColor = _hydroValveOpen ? 'rgba(6,214,160,.3)' : 'rgba(241,91,181,.3)'; }
});
}
function hydroSetVessels(n, btn) {
if (hydroSim) hydroSim.setNumVessels(n);
document.querySelectorAll('.hydro-nv').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function _hydroUpdateUI(info) {
if (!info) return;
const el = document.getElementById('hydro-formulas');
if (!el) return;
const lines = [];
if (info.formula) lines.push(`<span style="color:#FFD166">${info.formula}</span>`);
if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
if (info.matName) lines.push(`Материал: ${info.matName}`);
if (info.FA) lines.push(`<span style="color:#06D6E0">F_A = ${info.FA} Н</span>`);
if (info.mg) lines.push(`<span style="color:#F15BB5">mg = ${info.mg} Н</span>`);
if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
el.innerHTML = lines.join('<br>');
// result badge
const rb = document.getElementById('hydro-result');
if (rb && info.state) {
const colors = { 'ВСПЛЫВАЕТ': '#06D6A0', 'ТОНЕТ': '#F15BB5', 'ВЗВЕШЕНО': '#FFD166' };
rb.style.display = '';
rb.style.color = colors[info.state] || '#fff';
rb.style.background = (colors[info.state] || '#9B5DE5') + '18';
rb.style.border = '1px solid ' + (colors[info.state] || '#9B5DE5') + '44';
rb.textContent = info.state;
} else if (rb) {
rb.style.display = 'none';
}
}
let _theoryOpen = false;
function toggleTheory() {
_theoryOpen = !_theoryOpen;
@@ -7923,6 +8236,51 @@
const _embedMode = _qp.get('embed') === '1';
const _autoSim = _qp.get('sim');
/* ── Sim state relay (embed mode only) ──────────────────────────────── */
// Map simId → { getState, applyState } registered by openSim handlers
const _simStateRegistry = {};
function _registerSimState(simId, getState, applyState) {
_simStateRegistry[simId] = { getState, applyState };
}
let _lastEmittedState = null;
let _stateEmitInterval = null;
function _startStateEmit(simId) {
if (_stateEmitInterval) clearInterval(_stateEmitInterval);
_lastEmittedState = null;
_stateEmitInterval = setInterval(() => {
const reg = _simStateRegistry[simId];
if (!reg) return;
try {
const state = reg.getState();
const json = JSON.stringify(state);
if (json === _lastEmittedState) return;
_lastEmittedState = json;
window.parent.postMessage({ type: 'sim_state', simId, state }, '*');
} catch {}
}, 400);
}
function _stopStateEmit() {
if (_stateEmitInterval) { clearInterval(_stateEmitInterval); _stateEmitInterval = null; }
_lastEmittedState = null;
}
// Receive apply_sim_state from parent (students)
window.addEventListener('message', e => {
if (!_embedMode) return;
const d = e.data;
if (!d || d.type !== 'apply_sim_state') return;
const reg = _simStateRegistry[_autoSim];
if (!reg) return;
try {
reg.applyState(d.state);
_lastEmittedState = JSON.stringify(d.state); // suppress echo
} catch {}
});
if (_embedMode) {
document.querySelector('.sidebar').style.display = 'none';
document.querySelector('.sb-content').style.marginLeft = '0';
@@ -7932,7 +8290,8 @@
if (_autoSim) {
document.getElementById('lab-sim').classList.add('open');
document.querySelector('.sim-topbar').style.display = 'none';
openSim(_autoSim);
// defer until all external scripts are loaded
window.addEventListener('load', () => openSim(_autoSim));
}
} else {
/* init — fetch sim settings + permissions in parallel, then render */
@@ -8006,5 +8365,6 @@
<script src="/js/labs/probability.js"></script>
<script src="/js/labs/bohratom.js"></script>
<script src="/js/labs/electrolysis.js"></script>
<script src="/js/labs/hydrostatics.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -787,6 +787,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -211,6 +211,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+178 -47
View File
@@ -8,6 +8,7 @@
<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" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
@@ -135,10 +136,52 @@
/* question pick cards */
.lq-q-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
/* question filters */
.lq-filter-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.lq-filter-select {
flex: 1; padding: 8px 10px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 500;
color: #0F172A; background: #fff; outline: none; cursor: pointer;
transition: border-color 0.15s; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
}
.lq-filter-select:focus { border-color: var(--violet); }
.lq-q-count { font-size: 0.7rem; color: #8898AA; font-weight: 600; white-space: nowrap; margin-bottom: 12px; }
/* load more */
.btn-load-more {
width: 100%; padding: 10px; border: 1.5px dashed rgba(155,93,229,0.3);
border-radius: 12px; background: transparent; margin-bottom: 24px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
color: var(--violet); cursor: pointer; transition: all 0.15s;
}
.btn-load-more:hover { background: rgba(155,93,229,0.05); border-color: var(--violet); }
.btn-load-more:disabled { opacity: 0.5; cursor: default; }
/* result stat cards */
.lq-result-stats { display: flex; gap: 8px; margin-bottom: 14px; }
.lq-result-stat { flex: 1; padding: 10px 12px; border-radius: 12px; background: rgba(15,23,42,0.04); text-align: center; }
.lq-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 900; color: #0F172A; }
.lq-result-stat-lbl { font-size: 0.64rem; color: #8898AA; margin-top: 2px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.lq-result-stat.rs-correct { background: rgba(6,214,160,0.08); }
.lq-result-stat.rs-correct .lq-result-stat-val { color: #059652; }
.lq-result-stat.rs-wrong { background: rgba(239,71,111,0.07); }
.lq-result-stat.rs-wrong .lq-result-stat-val { color: #EF476F; }
/* explanation box */
.lq-explanation {
margin-top: 14px; padding: 13px 15px;
background: rgba(6,214,224,0.06); border: 1.5px solid rgba(6,214,224,0.2);
border-radius: 12px; font-size: 0.82rem; color: #0F172A; line-height: 1.6;
}
.lq-explanation-label { font-size: 0.64rem; font-weight: 700; color: #0891B2; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.lq-q-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 14px; padding: 14px 16px;
display: flex; align-items: center; gap: 12px;
display: flex; align-items: flex-start; gap: 12px;
box-shadow: 0 1px 4px rgba(15,23,42,0.04); transition: all 0.15s;
}
.lq-q-card:hover { border-color: rgba(155,93,229,0.25); }
@@ -146,9 +189,10 @@
.lq-q-body { flex: 1; min-width: 0; }
.lq-q-text {
font-size: 0.84rem; font-weight: 600; color: #0F172A;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; line-height: 1.5; min-height: 1.5em;
}
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; }
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 5px; display: flex; gap: 8px; flex-wrap: wrap; }
.btn-launch {
padding: 7px 16px; border: none; border-radius: 999px;
background: var(--grad-1); color: #fff;
@@ -228,7 +272,7 @@
}
.lq-results-title {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #0F172A; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;
color: #0F172A; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
.result-bars { display: flex; flex-direction: column; gap: 10px; }
.result-bar-row { display: flex; align-items: center; gap: 10px; }
@@ -313,6 +357,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -421,12 +466,27 @@
<i data-lucide="search" class="lq-search-icon"></i>
<input class="lq-search" id="q-search" type="text" placeholder="Поиск вопросов…" />
</div>
<div class="lq-filter-row">
<select class="lq-filter-select" id="topic-filter" onchange="onTopicFilter()">
<option value="">Все темы</option>
</select>
<select class="lq-filter-select" id="diff-filter" onchange="onDiffFilter()" style="max-width:130px">
<option value="">Любой уровень</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
</div>
<div class="lq-q-count" id="q-count" style="display:none"></div>
<div class="lq-q-list" id="q-list">
<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">
<div class="spinner" style="margin:0 auto 10px"></div>
Загрузка вопросов…
</div>
</div>
<button class="btn-load-more" id="btn-load-more" style="display:none" onclick="loadMoreQuestions()">
Загрузить ещё
</button>
</div>
</div>
@@ -435,6 +495,8 @@
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
@@ -461,6 +523,22 @@
'fill-blank': 'Заполни пробел', ordering: 'Порядок',
};
/* ── math rendering ── */
const MATH_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: MATH_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
@@ -513,8 +591,12 @@
let answerCount = 0;
let sseSource = null;
let allQuestions = [];
let filteredQuestions = [];
let searchTimeout = null;
let _topicFilter = '';
let _diffFilter = '';
let _qPage = 0;
let _totalQ = 0;
const Q_LIMIT = 30;
/* ── load classes ── */
async function loadClasses() {
@@ -593,9 +675,24 @@
document.getElementById('session-header-label').innerHTML =
'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg> Активна · ' + selectedClass.name;
updateStudentCounter(0);
loadTopics();
lucide.createIcons();
}
async function loadTopics() {
try {
const topics = await LS.api('/api/topics');
const sel = document.getElementById('topic-filter');
sel.innerHTML = '<option value="">Все темы</option>';
(topics || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
sel.appendChild(opt);
});
} catch {}
}
function updateStudentCounter(count) {
answerCount = count;
document.getElementById('as-students-text').textContent =
@@ -658,34 +755,56 @@
}
/* ── load questions ── */
async function loadQuestions() {
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
async function loadQuestions(reset = true) {
if (reset) { _qPage = 0; allQuestions = []; }
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
const params = new URLSearchParams({ limit: Q_LIMIT, offset: _qPage * Q_LIMIT });
if (_topicFilter) params.set('topic_id', _topicFilter);
if (_diffFilter) params.set('difficulty', _diffFilter);
const sq = document.getElementById('q-search')?.value.trim();
if (sq) params.set('search', sq);
try {
const data = await LS.api('/api/questions?limit=50');
allQuestions = data.rows || [];
filteredQuestions = allQuestions;
const data = await LS.api('/api/questions?' + params.toString());
const rows = data.rows || [];
_totalQ = data.total ?? (reset ? rows.length : allQuestions.length + rows.length);
allQuestions = reset ? rows : [...allQuestions, ...rows];
renderQuestionList();
const btnMore = document.getElementById('btn-load-more');
const countEl = document.getElementById('q-count');
if (btnMore) btnMore.style.display = allQuestions.length < _totalQ ? '' : 'none';
if (countEl) { countEl.textContent = `Показано ${allQuestions.length} из ${_totalQ}`; countEl.style.display = ''; }
} catch {
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
}
}
async function loadMoreQuestions() {
const btn = document.getElementById('btn-load-more');
if (btn) { btn.disabled = true; btn.textContent = 'Загрузка…'; }
_qPage++;
await loadQuestions(false);
if (btn) { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
}
function onTopicFilter() { _topicFilter = document.getElementById('topic-filter').value; loadQuestions(true); }
function onDiffFilter() { _diffFilter = document.getElementById('diff-filter').value; loadQuestions(true); }
function renderQuestionList() {
const list = document.getElementById('q-list');
if (!filteredQuestions.length) {
if (!allQuestions.length) {
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Вопросов не найдено</div>';
lucide.createIcons();
return;
}
let html = '';
filteredQuestions.forEach(q => {
allQuestions.forEach(q => {
const diffCls = 'badge-diff-' + (q.difficulty || 1);
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
const typeLabel = TYPE_LABELS[q.type] || q.type || '';
const isLaunched = currentQuestion && currentQuestion.id === q.id;
html += `<div class="lq-q-card${isLaunched ? ' launched' : ''}">
<div class="lq-q-body">
<div class="lq-q-text">${esc(q.text)}</div>
<div class="lq-q-text" data-text="${esc(q.text)}"></div>
<div class="lq-q-meta">
<span class="badge ${diffCls}">${diffLabel}</span>
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
@@ -698,35 +817,28 @@
</div>`;
});
list.innerHTML = html;
list.querySelectorAll('.lq-q-text[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
}
/* search questions */
/* search questions — server-side */
document.getElementById('q-search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = document.getElementById('q-search').value.trim().toLowerCase();
filteredQuestions = q
? allQuestions.filter(item => item.text.toLowerCase().includes(q) || (item.topic || '').toLowerCase().includes(q))
: allQuestions;
renderQuestionList();
}, 280);
searchTimeout = setTimeout(() => loadQuestions(true), 350);
});
/* ── launch question ── */
async function launchQuestion(questionId) {
if (!activeSession) return;
const q = allQuestions.find(x => x.id === questionId);
if (!q) return;
try {
await LS.api('/api/live/' + activeSession.id + '/question', {
const resp = await LS.api('/api/live/' + activeSession.id + '/question', {
method: 'PUT',
body: JSON.stringify({ question_id: questionId }),
});
currentQuestion = q;
currentQuestion = resp.question || allQuestions.find(x => x.id === questionId) || { id: questionId };
answerCount = 0;
updateStudentCounter(0);
renderActiveQuestion(q);
renderActiveQuestion(currentQuestion);
renderQuestionList();
} catch (e) {
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
@@ -750,7 +862,7 @@
const letter = String.fromCharCode(65 + idx);
optionsHtml += `<div class="lq-active-opt${opt.is_correct ? ' correct' : ''}">
<div class="lq-opt-letter">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
<span>${esc(opt.text)}</span>
<span data-text="${esc(opt.text)}"></span>
</div>`;
});
optionsHtml += '</div>';
@@ -762,7 +874,7 @@
В эфире · <span class="badge ${diffCls}">${diffLabel}</span>
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
</div>
<div class="lq-active-text">${esc(q.text)}</div>
<div class="lq-active-text" data-text="${esc(q.text)}"></div>
${optionsHtml}
<div class="lq-counter">
<div>
@@ -779,6 +891,7 @@
</button>
<div id="results-area"></div>
`;
card.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -798,38 +911,48 @@
}
function renderResults(data, container) {
const { answers = [], options = [] } = data;
// count answers per option
const countMap = {};
answers.forEach(a => {
const key = a.option_id ?? a.answer ?? 'other';
countMap[key] = (countMap[key] || 0) + 1;
});
const totalAnswers = answers.length;
const maxCount = Math.max(...Object.values(countMap), 1);
// use options from current question if not in results
const opts = options.length ? options : (currentQuestion?.options || []);
const opts = data.options || [];
const q = data.question || {};
const stats = data.stats || {};
const total = stats.total || 0;
const correct= stats.correct|| 0;
const maxCount = Math.max(...opts.map(o => o.chosen_count || 0), 1);
if (!opts.length) {
container.innerHTML = '<div style="padding:16px;text-align:center;color:#8898AA;font-size:0.84rem">Нет данных о вариантах ответа</div>';
return;
}
const pctCorrect = total > 0 ? Math.round(correct / total * 100) : 0;
const pctWrong = total > 0 ? 100 - pctCorrect : 0;
let html = `<div class="lq-results-wrap">
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты · ${totalAnswers} ответов</div>
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты</div>
<div class="lq-result-stats">
<div class="lq-result-stat">
<div class="lq-result-stat-val">${total}</div>
<div class="lq-result-stat-lbl">Ответов</div>
</div>
<div class="lq-result-stat rs-correct">
<div class="lq-result-stat-val">${pctCorrect}%</div>
<div class="lq-result-stat-lbl">Верно</div>
</div>
<div class="lq-result-stat rs-wrong">
<div class="lq-result-stat-val">${pctWrong}%</div>
<div class="lq-result-stat-lbl">Неверно</div>
</div>
</div>
<div class="result-bars">`;
opts.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
const key = opt.id ?? idx;
const count = countMap[key] || 0;
const pct = Math.round((count / Math.max(maxCount, 1)) * 100);
const count = opt.chosen_count || 0;
const pct = Math.round(count / maxCount * 100);
const isCorrect = opt.is_correct;
html += `<div class="result-bar-row">
<div class="result-bar-label${isCorrect ? ' correct-lbl' : ''}">
${isCorrect ? '<span class="rb-correct-marker"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : letter + '.'}
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px">${esc(opt.text)}</span>
<span class="rb-opt-text" data-text="${esc(opt.text)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px"></span>
</div>
<div class="result-bar-track">
<div class="result-bar-fill${isCorrect ? ' correct-fill' : ''}" style="width:${pct}%"></div>
@@ -838,8 +961,16 @@
</div>`;
});
html += '</div></div>';
html += '</div>';
if (q.explanation) {
html += `<div class="lq-explanation">
<div class="lq-explanation-label">Объяснение</div>
<div class="lq-exp-text" data-text="${esc(q.explanation)}"></div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -351,6 +351,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -550,6 +550,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+24 -4
View File
@@ -8,6 +8,7 @@
<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" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
@@ -243,6 +244,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link nav-active"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -382,6 +384,8 @@
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
@@ -409,6 +413,22 @@
};
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика', other: 'Другое' };
/* ── math rendering ── */
const _MATH_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _MATH_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
@@ -570,7 +590,7 @@
<span class="badge ${diffCls}">${diffLabel}</span>
<span class="badge badge-type">${esc(typeLabel)}</span>
</div>
<div class="qc-text">${esc(q.text)}</div>
<div class="qc-text">${mathHtml(q.text)}</div>
<div class="qc-footer">
<span class="qc-topic"><i data-lucide="tag" style="width:11px;height:11px"></i> ${esc(q.topic || SUBJECT_NAMES[q.subject_slug] || '—')}</span>
${optsCount ? `<span class="qc-opts-count"><i data-lucide="list" style="width:11px;height:11px"></i> ${optsCount} вар.</span>` : ''}
@@ -578,20 +598,20 @@
if (isExpanded) {
html += `<div class="qc-preview">
<div class="qc-preview-text">${esc(q.text)}</div>`;
<div class="qc-preview-text">${mathHtml(q.text)}</div>`;
if ((q.options || []).length) {
html += '<div class="qc-options">';
q.options.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
html += `<div class="qc-option${opt.is_correct ? ' correct' : ''}">
<div class="qc-option-marker">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
<span>${esc(opt.text)}</span>
<span>${mathHtml(opt.text)}</span>
</div>`;
});
html += '</div>';
}
if (q.explanation) {
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`;
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${mathHtml(q.explanation)}</div>`;
}
html += '</div>';
}
+1
View File
@@ -167,6 +167,7 @@
<div class="bio-topbar">
<a href="/red-book.html" style="color:var(--rb-muted);text-decoration:none;font-size:12px;border:1px solid var(--rb-border);padding:6px 12px;border-radius:8px;"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg> Биомы Беларуси</h1>
<div class="biome-tabs" id="biome-tabs"></div>
<button id="sound-btn" onclick="toggleSound()" title="Звуки биома" style="display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук</button>
+1
View File
@@ -170,6 +170,7 @@
<div class="eco-topbar">
<a href="/red-book.html" class="eco-back"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg> Пищевые сети · Симулятор экосистем</h1>
<span style="margin-left:auto;font-size:12px;color:var(--rb-muted)" id="node-count"></span>
<button onclick="toggleEnergyFlow()" id="btn-energy" title="Анимация потока энергии" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;padding:6px 12px;border-radius:8px;cursor:pointer;transition:all .2s"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Поток</button>
+1
View File
@@ -197,6 +197,7 @@
<p class="rb-sb-section">РАЗДЕЛЫ</p>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
+1
View File
@@ -517,6 +517,7 @@
<p class="rb-sb-section">РАЗДЕЛЫ</p>
<a href="/red-book.html" class="sb-link active"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
+1
View File
@@ -305,6 +305,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>