From fd29acbbddb253b05a1f7f79acda182b587cf83b Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Mon, 13 Apr 2026 18:04:59 +0300 Subject: [PATCH] feat: WebSocket real-time + rAF render gate + guest board + screen picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/settings.json | 26 +- backend/package-lock.json | 24 +- backend/package.json | 5 +- backend/src/controllers/adminController.js | 2 +- .../src/controllers/classroomController.js | 571 ++- backend/src/db/migrate.js | 9 +- backend/src/routes/classroom.js | 34 +- backend/src/routes/guestClassroom.js | 168 + backend/src/server.js | 17 +- backend/src/sse.js | 39 +- backend/src/ws-server.js | 159 + frontend/admin.html | 426 ++ frontend/analytics.html | 1 + frontend/biochem-library.html | 1 + frontend/biochem-pathways.html | 1 + frontend/biochem-properties.html | 1 + frontend/biochem-reactions.html | 1 + frontend/biochem.html | 1 + frontend/board.html | 1 + frontend/classes.html | 1 + frontend/classroom.html | 3864 ++++++++++++++++- frontend/collection-rb.html | 1 + frontend/collection.html | 1 + frontend/course.html | 1 + frontend/crossword.html | 1 + frontend/dashboard.html | 1 + frontend/flashcards.html | 22 +- frontend/gradebook.html | 1 + frontend/guest-board.html | 435 ++ frontend/hangman.html | 1 + frontend/homework.html | 1 + frontend/js/classroom-rtc.js | 32 +- frontend/js/labs/angrybirds.js | 2 +- frontend/js/labs/bohratom.js | 1 + frontend/js/labs/chemsandbox.js | 20 +- frontend/js/labs/collision.js | 1 + frontend/js/labs/electrolysis.js | 1 + frontend/js/labs/equilibrium.js | 1 + frontend/js/labs/forcesandbox.js | 4 +- frontend/js/labs/graphtransform.js | 1 + frontend/js/labs/hydrostatics.js | 1350 ++++++ frontend/js/labs/isoprocess.js | 1 + frontend/js/labs/mirror.js | 1 + frontend/js/labs/normaldist.js | 1 + frontend/js/labs/pendulum.js | 3 + frontend/js/labs/probability.js | 1 + frontend/js/labs/projectile.js | 5 + frontend/js/labs/quadratic.js | 1 + frontend/js/labs/refraction.js | 1 + frontend/js/labs/thinlens.js | 1 + frontend/js/labs/titration.js | 1 + frontend/js/labs/triangle.js | 4 +- frontend/js/labs/waves.js | 4 + frontend/js/whiteboard.js | 1715 +++++++- frontend/knowledge-map.html | 1 + frontend/lab.html | 372 +- frontend/lesson-history.html | 1894 ++++++++ frontend/lesson.html | 1 + frontend/library.html | 1 + frontend/live-quiz.html | 225 +- frontend/my-lessons.html | 1176 +++++ frontend/pet.html | 1 + frontend/profile.html | 1 + frontend/question-bank.html | 28 +- frontend/red-book-biomes.html | 1 + frontend/red-book-ecosystem.html | 1 + frontend/red-book-games.html | 1 + frontend/red-book.html | 1 + frontend/theory.html | 1 + js/api.js | 55 +- 70 files changed, 12231 insertions(+), 498 deletions(-) create mode 100644 backend/src/routes/guestClassroom.js create mode 100644 backend/src/ws-server.js create mode 100644 frontend/guest-board.html create mode 100644 frontend/js/labs/hydrostatics.js create mode 100644 frontend/lesson-history.html create mode 100644 frontend/my-lessons.html diff --git a/.claude/settings.json b/.claude/settings.json index ebe9fdd..2d45084 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -154,7 +154,31 @@ "Bash(powershell -Command \"Stop-Process -Id 69696 -Force\")", "Bash(powershell -Command \"Start-Sleep 1\")", "Bash(powershell -Command \"\\(Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess\")", - "Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")" + "Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")", + "Bash(grep -v '\\\\.js$')", + "Bash(curl -s -X POST http://localhost:3000/api/auth/login -H \"Content-Type: application/json\" -d '{\"login\":\"admin\",\"password\":\"admin123\"}')", + "Bash(curl -s -w '\\\\nHTTP_STATUS:%{http_code}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwidmVyc2lvbiI6MCwiaWF0IjoxNzc1OTgyNzc2LCJleHAiOjE3NzYwNjkxNzZ9.FJ3Ya9X_Qg5fEUagPc1l8KrDnj2BaKrXarA-KRVr_QM' http://localhost:3000/api/classroom/6/pages)", + "Bash(curl -s -w '\\\\nHTTP:%{http_code}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwidmVyc2lvbiI6MCwiaWF0IjoxNzc1OTgyNzc2LCJleHAiOjE3NzYwNjkxNzZ9.FJ3Ya9X_Qg5fEUagPc1l8KrDnj2BaKrXarA-KRVr_QM' 'http://localhost:3000/api/classroom/6/strokes?page_num=1')", + "Bash(wmic process:*)", + "Bash(taskkill /F /PID 67276)", + "Bash(cmd /c \"taskkill /F /PID 67276\")", + "Bash(cmd /c \"taskkill /F /PID 67276 && echo killed\")", + "Bash(cmd /c \"wmic process where ProcessId=67276 delete\")", + "Bash(powershell -Command \"Stop-Process -Id 67276 -Force\")", + "Bash(powershell -Command \"Start-Sleep -Milliseconds 1500; \\(Invoke-WebRequest -Uri 'http://localhost:3000/api/health' -UseBasicParsing\\).Content\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/lesson-history)", + "Bash(curl -s http://localhost:3000/api/classroom/my/history -H \"Authorization: Bearer test\")", + "Bash(curl -s -X DELETE http://localhost:3000/api/classroom/999/history -H \"Authorization: Bearer bad\")", + "Bash(pkill -f \"node.*server\")", + "Bash(grep -n \".*app-layout\\\\| +
+ + +
+
+
Модуль онлайн-уроков
+
Если отключить, учителя не смогут создавать новые уроки. Уже активные сессии продолжат работу до завершения.
+
+ +
+ + +
+
+ + Активные уроки + + + Обновить + +
+
+
+ + +
+
+ + История уроков +
+
+ + +
+
+
+
+ +
+
@@ -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 = `
${esc(e.message)}
`; } } + /* ════════════════════════════════════════════════ + ОНЛАЙН-УРОКИ (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 = '
Нет активных уроков
'; + 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 `
+
+
+
${esc(title)}
+
${esc(s.teacher_name)} · ${cls}
+
+
+ + + ${s.online_count} + + + + ${s.message_count} + + + + ${dur} + +
+
+ +
+
`; + }).join(''); + } catch(e) { + el.innerHTML = `
Ошибка: ${esc(e.message)}
`; + } + 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 = '
'; + 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 = '
Нет завершённых уроков
'; + 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 `
+
+
+ +
+
+
${esc(title)}
+
${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}
+
+
+ ${s.participant_count} уч. + ${s.message_count} сообщ. + ${dur} +
+ +
+
+
+
+
+
+
`; + }).join(''); + if (_crOpenDetailId) { + const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`); + if (dr) loadCrSessionDetail(_crOpenDetailId); + } + renderCrPagination(); + } catch(e) { + el.innerHTML = `
Ошибка: ${esc(e.message)}
`; + } + 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 = '
'; + html += ``; + 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 += ``; + else html += ``; + }); + html += `
`; + 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 = '
'; + try { + const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`); + const dur = fmtDuration(stats.duration_sec); + inner.innerHTML = ` +
+
${stats.participant_count}
Участников
+
${stats.message_count}
Сообщений
+
${stats.page_count}
Страниц
+
${dur}
Длительность
+
+ ${attendance.length ? ` +
Посещаемость
+
+ ${attendance.map(a => ` +
+ + ${esc(a.user_name)} + ${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'} + ${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : 'онлайн')} +
+ `).join('')} +
+ ` : ''} + ${pages.length > 1 ? ` +
Страницы доски
+
+ ${pages.map(p => ` +
+ Стр. ${p.page_num} + ${p.stroke_count} штр. +
+ `).join('')} +
+ ` : ''} +
+ + +
`; + } catch(e) { + inner.innerHTML = `
Ошибка: ${esc(e.message)}
`; + } + } + + 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 ─── */ diff --git a/frontend/analytics.html b/frontend/analytics.html index ea7521a..a322aa3 100644 --- a/frontend/analytics.html +++ b/frontend/analytics.html @@ -262,6 +262,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
Аналитика Банк вопросов diff --git a/frontend/biochem-library.html b/frontend/biochem-library.html index c83a284..830da3f 100644 --- a/frontend/biochem-library.html +++ b/frontend/biochem-library.html @@ -287,6 +287,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/biochem-pathways.html b/frontend/biochem-pathways.html index 846c015..927034b 100644 --- a/frontend/biochem-pathways.html +++ b/frontend/biochem-pathways.html @@ -367,6 +367,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/biochem-properties.html b/frontend/biochem-properties.html index 29619f2..18e9948 100644 --- a/frontend/biochem-properties.html +++ b/frontend/biochem-properties.html @@ -200,6 +200,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/biochem-reactions.html b/frontend/biochem-reactions.html index 098dccb..f6ab0d8 100644 --- a/frontend/biochem-reactions.html +++ b/frontend/biochem-reactions.html @@ -323,6 +323,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/biochem.html b/frontend/biochem.html index 2822617..b82df3a 100644 --- a/frontend/biochem.html +++ b/frontend/biochem.html @@ -341,6 +341,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/board.html b/frontend/board.html index 74d1171..42bcc8d 100644 --- a/frontend/board.html +++ b/frontend/board.html @@ -277,6 +277,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/classes.html b/frontend/classes.html index 93522eb..9ce81c7 100644 --- a/frontend/classes.html +++ b/frontend/classes.html @@ -567,6 +567,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/classroom.html b/frontend/classroom.html index 953b5b9..7204d06 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -5,7 +5,7 @@ Онлайн-урок — LearnSpace - + @@ -1091,6 +2094,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
@@ -1138,11 +2142,20 @@ Экран + + +
+
-
+
@@ -1211,6 +2227,39 @@
+ +
+
+ +
+ +
+
+ + +
+ +
+
+
+ +
+ Симуляция + + + +
+ + +
@@ -1226,6 +2275,11 @@ Авто +
- +
@@ -1318,26 +2400,16 @@ - - + +
- - - - - - - - - +
Линейка - +
- -
@@ -1357,13 +2429,148 @@
- -
- - 1/1 - - +
+ +
+ +
+
+ +
+ +
+
+ + + + + + + +
+
+
+ + +
+ +
+
+
+
+ +
+ +
+ + + +
+ + + + +
+
+
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ Цвет: + + + + + + +
+ + +
+ Стрелки: + + +
+ + +
+ Выровнять: + + + +
+ + +
@@ -1371,24 +2578,35 @@ -
+
- - - + + + +
+
+
@@ -1411,12 +2629,84 @@ + + + @@ -1437,23 +2727,28 @@
- - - - - +
+ + + + + +
- - - - + + + + +
@@ -1509,12 +2804,336 @@
+ +
+
+
Гостевая ссылка
+
Поделитесь ссылкой — гость увидит доску без регистрации (только просмотр)
+ + +
+
+ +
+
+ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
Трансляция экрана
+
Ученики увидят выбранное содержимое в реальном времени
+
+ +
+ + +
+ + + +
+ + +
+
+ + +
+
+ +
+
Экран не выбран
+
Нажмите кнопку ниже — откроется диалог выбора источника
+ +
+ + +
+ +
+
+ +
+ Экран 1 + + +
+
+ +
+
+ +
+ + +
+
+ Разрешение +
+ + + +
+
+
+ Частота кадров +
+ + + +
+
+ +
+ +
+ + + +
+
Звук системы
+
Передавать системный звук
+
+ +
+ +
+ + + +
+
Курсор
+
Показывать указатель мыши
+
+ +
+
+
+ + + + +
+
+ + +
+
+
+ Файлы из библиотеки + +
+ +
+
Загрузка...
+
+
+
+ + + + + + + + diff --git a/frontend/hangman.html b/frontend/hangman.html index 280f918..3bd198e 100644 --- a/frontend/hangman.html +++ b/frontend/hangman.html @@ -285,6 +285,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/homework.html b/frontend/homework.html index a027e95..9ae5937 100644 --- a/frontend/homework.html +++ b/frontend/homework.html @@ -166,6 +166,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/js/classroom-rtc.js b/frontend/js/classroom-rtc.js index 98f80e0..cddbe2d 100644 --- a/frontend/js/classroom-rtc.js +++ b/frontend/js/classroom-rtc.js @@ -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 ─────────────────────────────────────────────────────────── */ diff --git a/frontend/js/labs/angrybirds.js b/frontend/js/labs/angrybirds.js index fc70da6..f3005f7 100644 --- a/frontend/js/labs/angrybirds.js +++ b/frontend/js/labs/angrybirds.js @@ -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(//g, '').trim()} g = ${pl.g} м/с²`, 14, 50); /* Wind reminder */ if (lvl?.wind) { diff --git a/frontend/js/labs/bohratom.js b/frontend/js/labs/bohratom.js index cd61bf0..9e365ad 100644 --- a/frontend/js/labs/bohratom.js +++ b/frontend/js/labs/bohratom.js @@ -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))); diff --git a/frontend/js/labs/chemsandbox.js b/frontend/js/labs/chemsandbox.js index 6c2a06b..3e07ef1 100644 --- a/frontend/js/labs/chemsandbox.js +++ b/frontend/js/labs/chemsandbox.js @@ -1,4 +1,16 @@ 'use strict'; + +/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */ +function _csClean(s) { + if (!s || !s.includes('/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; } diff --git a/frontend/js/labs/collision.js b/frontend/js/labs/collision.js index 082926f..d49bda6 100644 --- a/frontend/js/labs/collision.js +++ b/frontend/js/labs/collision.js @@ -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; diff --git a/frontend/js/labs/electrolysis.js b/frontend/js/labs/electrolysis.js index a06bc54..404b92d 100644 --- a/frontend/js/labs/electrolysis.js +++ b/frontend/js/labs/electrolysis.js @@ -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) { diff --git a/frontend/js/labs/equilibrium.js b/frontend/js/labs/equilibrium.js index 3237a4e..2009caf 100644 --- a/frontend/js/labs/equilibrium.js +++ b/frontend/js/labs/equilibrium.js @@ -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)); diff --git a/frontend/js/labs/forcesandbox.js b/frontend/js/labs/forcesandbox.js index b362cd9..c65f4e6 100644 --- a/frontend/js/labs/forcesandbox.js +++ b/frontend/js/labs/forcesandbox.js @@ -1787,7 +1787,7 @@ class ForceSandboxSim { // Угловая скорость ω — фиолетовая метка справа от тела if (hasOmg) { - const sym = b.omega > 0 ? '' : ''; + 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 ? '' : ''; + const sym = body.omega > 0 ? '\u21BB' : '\u21BA'; ctx.fillStyle = '#9B5DE5'; ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22); } diff --git a/frontend/js/labs/graphtransform.js b/frontend/js/labs/graphtransform.js index 50d0482..9c900d4 100644 --- a/frontend/js/labs/graphtransform.js +++ b/frontend/js/labs/graphtransform.js @@ -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; diff --git a/frontend/js/labs/hydrostatics.js b/frontend/js/labs/hydrostatics.js new file mode 100644 index 0000000..f3e3d0d --- /dev/null +++ b/frontend/js/labs/hydrostatics.js @@ -0,0 +1,1350 @@ +'use strict'; +/* ═══════════════════════════════════════════════════════════════════ + HydroSim v2 — Гидростатика + Модули: давление · поверхностное натяжение · сообщающиеся сосуды · Архимед + Canvas 2D, pure-JS physics (no external libraries) + ═══════════════════════════════════════════════════════════════════ */ + +class HydroSim { + static G = 9.81; + + static LIQUIDS = { + water: { name: 'Вода', rho: 1000, color: '#2979FF', sigma: 0.073 }, + saltwater: { name: 'Солёная вода', rho: 1030, color: '#1565C0', sigma: 0.074 }, + oil: { name: 'Масло', rho: 900, color: '#FFA000', sigma: 0.033 }, + alcohol: { name: 'Спирт', rho: 790, color: '#CE93D8', sigma: 0.022 }, + glycerin: { name: 'Глицерин', rho: 1260, color: '#FFCC02', sigma: 0.063 }, + mercury: { name: 'Ртуть', rho: 13600, color: '#B0BEC5', sigma: 0.500 }, + }; + + static MATERIALS = { + styrofoam: { name: 'Пенопласт', rho: 30, color: '#F5F5F5' }, + cork: { name: 'Пробка', rho: 120, color: '#8D6E63' }, + wood: { name: 'Дерево', rho: 500, color: '#A1887F' }, + ice: { name: 'Лёд', rho: 900, color: '#B3E5FC' }, + plastic: { name: 'Пластик', rho: 1100, color: '#EF5350' }, + glass: { name: 'Стекло', rho: 2500, color: '#90CAF9' }, + aluminum: { name: 'Алюминий', rho: 2700, color: '#CFD8DC' }, + iron: { name: 'Железо', rho: 7800, color: '#78909C' }, + gold: { name: 'Золото', rho: 19300, color: '#FFD700' }, + }; + + constructor(canvas, mode = 'pressure') { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.mode = mode; + this._raf = null; + this._running = false; + this.onUpdate = null; + this._lastNotify = 0; + this._t = 0; + + this.liquidKey = 'water'; + this.materialKey = 'wood'; + this.g = HydroSim.G; + + /* pressure */ + this._probe = { vy: 0.5, dragging: false }; + + /* surface */ + this._stMode = 'capillary'; + this._contactAngle = 20; + + /* communicating */ + this._numVessels = 2; + this._liquidFrac = 0.45; + this._valveOpen = true; + this._vesselShapes = ['rect', 'wide']; + this._animLevel = null; + this._targetLevel = null; + this._recalcVessels(); + + /* archimedes */ + this._archReady = false; + this._bodies = []; + this._waterLevel = 0.60; + this._bodyShape = 'rect'; + + this._bindEvents(); + this._ro = new ResizeObserver(() => this.fit()); + this._ro.observe(canvas.parentElement || canvas); + requestAnimationFrame(() => { this.fit(); this.play(); }); + } + + /* ── Public API ── */ + setMode(m) { + this.mode = m; + if (m === 'archimedes') this._initArch(); + else this._destroyArch(); + this._notify(); + } + setLiquid(key) { this.liquidKey = key; this._notify(); } + setMaterial(key) { this.materialKey = key; if (this.mode === 'archimedes') this._archReset(); this._notify(); } + setContactAngle(deg) { this._contactAngle = deg; } + setNumVessels(n) { + this._numVessels = n; + this._vesselShapes = Array.from({ length: n }, (_, i) => ['rect','wide','narrow','trapezoid'][i % 4]); + this._recalcVessels(); this._notify(); + } + setValve(open) { this._valveOpen = open; this._recalcVessels(); this._notify(); } + addLiquid() { this._liquidFrac = Math.min(0.85, this._liquidFrac + 0.05); this._recalcVessels(); } + removeLiquid() { this._liquidFrac = Math.max(0.05, this._liquidFrac - 0.05); this._recalcVessels(); } + setBodyShape(s){ this._bodyShape = s; if (this.mode === 'archimedes') this._archReset(); } + addBody() { this._archAddBody(); } + clearBodies() { this._archClear(); this._notify(); } + + getInfo() { + const liq = HydroSim.LIQUIDS[this.liquidKey]; + const mat = HydroSim.MATERIALS[this.materialKey]; + switch (this.mode) { + case 'pressure': { + const h = this._probe.vy * this._tankH_m(); + const P = liq.rho * this.g * h; + return { h: h.toFixed(3), rho: liq.rho, P: Math.round(P), liqName: liq.name, + formula: `P = ρ·g·h = ${liq.rho}·${this.g.toFixed(1)}·${h.toFixed(3)} = ${Math.round(P)} Па` }; + } + case 'surface': { + const r = 0.001, theta = this._contactAngle * Math.PI / 180; + const h = 2 * liq.sigma * Math.cos(theta) / (liq.rho * this.g * r); + return { sigma: liq.sigma, theta: this._contactAngle, h: (h * 1000).toFixed(1), liqName: liq.name, + formula: `h = 2σ·cos(θ)/(ρgr) = ${(h*1000).toFixed(1)} мм (r=1мм)` }; + } + case 'communicating': { + const lvl = this._animLevel ?? this._targetLevel ?? this._liquidFrac * 0.8; + const h = lvl * this._tankH_m(), P = liq.rho * this.g * h; + return { h: h.toFixed(3), rho: liq.rho, P: Math.round(P), liqName: liq.name, + vessels: this._numVessels, valve: this._valveOpen, + formula: `h₁ = h₂ = ${h.toFixed(3)} м; P = ${Math.round(P)} Па` }; + } + case 'archimedes': { + if (!this._bodies.length) return { liqName: liq.name, matName: mat.name }; + const b = this._bodies[0]; + const Vs = b.submergedFrac * b.volume; + const FA = liq.rho * this.g * Vs, mg = mat.rho * b.volume * this.g; + const state = mat.rho < liq.rho * 0.99 ? 'ВСПЛЫВАЕТ' + : mat.rho > liq.rho * 1.01 ? 'ТОНЕТ' : 'ВЗВЕШЕНО'; + return { FA: FA.toFixed(4), mg: mg.toFixed(4), state, liqName: liq.name, matName: mat.name, + rhoMat: mat.rho, rhoLiq: liq.rho, + formula: `Fₐ = ρж·g·Vпог = ${liq.rho}·${this.g.toFixed(1)}·${Vs.toFixed(5)} = ${FA.toFixed(4)} Н` }; + } + } + return {}; + } + + fit() { + const el = this.canvas.parentElement || this.canvas; + const w = el.clientWidth || 600, h = el.clientHeight || 400; + if (w < 50 || h < 50) return; + this.canvas.width = w; this.canvas.height = h; + this.W = w; this.H = h; + /* archimedes: no walls to rebuild — pure JS physics */ + } + + play() { if (!this._running) { this._running = true; requestAnimationFrame(t => this._loop(t)); } } + stop() { this._running = false; if (this._raf) cancelAnimationFrame(this._raf); } + destroy() { + this.stop(); this._destroyArch(); this._ro?.disconnect(); + this.canvas.removeEventListener('pointerdown', this._onPD); + this.canvas.removeEventListener('pointermove', this._onPM); + window.removeEventListener('pointerup', this._onPU); + } + + /* ── Loop ── */ + _loop(t) { + if (!this._running) return; + this._raf = requestAnimationFrame(ts => this._loop(ts)); + this._t = t; + this._update(t); + this._draw(t); + if (t - this._lastNotify > 120) { this._lastNotify = t; this._notify(); } + } + + _update(t) { + if (this.mode === 'communicating' && this._targetLevel !== null) { + if (this._animLevel === null) this._animLevel = this._targetLevel; + const d = this._targetLevel - this._animLevel; + this._animLevel += d * 0.07; + if (Math.abs(d) < 0.001) this._animLevel = this._targetLevel; + } + this._waveT = ((this._waveT || 0) + 0.025); + if (this.mode === 'archimedes' && this._archReady) this._archPhysStep(); + } + + _draw(t) { + if (!this.W || !this.H) return; + this.ctx.clearRect(0, 0, this.W, this.H); + /* opaque background — prevents page bg colour bleeding through transparent canvas */ + this.ctx.fillStyle = '#0d0920'; + this.ctx.fillRect(0, 0, this.W, this.H); + switch (this.mode) { + case 'pressure': this._drawPressure(t); break; + case 'surface': this._drawSurface(t); break; + case 'communicating': this._drawCommunicating(t); break; + case 'archimedes': this._drawArchimedes(t); break; + } + } + + /* ═══════════════════════════════════════════════════ + МОДУЛЬ 1 — ДАВЛЕНИЕ + ═══════════════════════════════════════════════════ */ + _drawPressure(t) { + const ctx = this.ctx, W = this.W, H = this.H; + const liq = HydroSim.LIQUIDS[this.liquidKey]; + + /* tank */ + const tw = Math.min(W * 0.30, 200), th = H * 0.72; + const tx = W * 0.12, ty = H * 0.10; + + /* pressure at probe */ + const h = this._probe.vy * this._tankH_m(); + const P = liq.rho * this.g * h; + const maxP = liq.rho * this.g * this._tankH_m(); + + /* draw pressure-gradient zones inside tank */ + this._drawPressureZones(ctx, tx, ty, tw, th, maxP, liq.color); + + /* vessel over zones */ + this._drawGlassVessel(ctx, tx, ty, tw, th); + + /* wave surface */ + const liqY = ty; + this._drawWaveSurface(ctx, tx + 3, liqY, tw - 6, liq.color, this._waveT); + + /* depth ruler */ + this._drawRuler(ctx, tx - 36, ty, th, '0 м', '1 м'); + + /* probe */ + const pX = tx + tw * 0.5, pY = ty + this._probe.vy * th; + + /* depth dashed line */ + ctx.save(); + ctx.setLineDash([5, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(tx - 36, pY); ctx.lineTo(tx + tw + 80, pY); ctx.stroke(); + ctx.setLineDash([]); ctx.restore(); + + /* depth label */ + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = '12px Manrope,sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('h = ' + h.toFixed(2) + ' м', tx - 40, pY + 4); + ctx.restore(); + + /* pressure arrows */ + const maxLen = Math.min(60, tw * 0.42); + const arLen = maxP > 0 ? (P / maxP) * maxLen : 0; + const arCol = this._lerpColor('#FFD166', '#F15BB5', P / Math.max(maxP, 1)); + const dirs = [{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }]; + for (const d of dirs) this._drawArrow(ctx, pX, pY, d.dx * arLen, d.dy * arLen, arCol, 4); + + /* pressure value label */ + if (P > 5) { + ctx.save(); + ctx.fillStyle = arCol; ctx.font = 'bold 14px "JetBrains Mono",monospace'; + ctx.textAlign = 'center'; ctx.shadowColor = arCol; ctx.shadowBlur = 8; + ctx.fillText(Math.round(P) + ' Па', pX, pY - 22); + ctx.shadowBlur = 0; ctx.restore(); + } + + /* probe dot */ + ctx.save(); + ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = this._probe.dragging ? 24 : 14; + ctx.fillStyle = '#06D6E0'; + ctx.beginPath(); ctx.arc(pX, pY, 12, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#fff'; ctx.lineWidth = 2.5; ctx.stroke(); + ctx.shadowBlur = 0; + ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('P', pX, pY); + ctx.textBaseline = 'alphabetic'; ctx.restore(); + + /* drag hint */ + ctx.fillStyle = 'rgba(6,214,224,0.4)'; ctx.font = '10px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.fillText('↕ тяни', pX, pY + 28); + + /* gauge */ + const gR = Math.min(48, H * 0.11); + const gCx = tx + tw + 22 + gR, gCy = ty + th * 0.50; + this._drawGauge(ctx, gCx, gCy, P, maxP, 'Па', gR); + + /* right-side info panel */ + this._drawInfoPanel(ctx, tx + tw + 22 + gR * 2 + 12, ty + 4, W - (tx + tw + 22 + gR * 2 + 16), th - 8, [ + { label: 'Жидкость', value: liq.name }, + { label: 'ρ', value: liq.rho + ' кг/м³' }, + { label: 'h', value: h.toFixed(3) + ' м' }, + { label: 'P', value: Math.round(P) + ' Па', color: arCol }, + ]); + + /* formula strip */ + this._drawFormula(ctx, W / 2, H - 12, + `P = ρ·g·h = ${liq.rho}·${this.g.toFixed(1)}·${h.toFixed(2)} = ${Math.round(P)} Па`, + '#FFD166'); + } + + _drawPressureZones(ctx, x, y, w, h, maxP, color) { + ctx.save(); + ctx.beginPath(); ctx.rect(x + 3, y, w - 6, h); ctx.clip(); + /* deep-color gradient */ + const g = ctx.createLinearGradient(0, y, 0, y + h); + g.addColorStop(0, color + '22'); + g.addColorStop(0.4, color + '55'); + g.addColorStop(1, color + 'CC'); + ctx.fillStyle = g; ctx.fillRect(x + 3, y, w - 6, h); + /* isobar lines */ + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 6]); + for (let i = 1; i < 10; i++) { + const iy = y + (i / 10) * h; + ctx.beginPath(); ctx.moveTo(x + 3, iy); ctx.lineTo(x + w - 3, iy); ctx.stroke(); + } + ctx.setLineDash([]); + /* caustic shimmer */ + ctx.globalAlpha = 0.06; + for (let i = 0; i < 6; i++) { + const cx2 = x + 10 + ((i * 47 + (this._waveT || 0) * 15) % (w - 20)); + const cy2 = y + h * 0.3 + Math.sin(i + (this._waveT || 0) * 0.5) * h * 0.15; + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.ellipse(cx2, cy2, 12, 5, 0.4, 0, Math.PI * 2); ctx.fill(); + } + ctx.globalAlpha = 1; ctx.restore(); + } + + /* ═══════════════════════════════════════════════════ + МОДУЛЬ 2 — ПОВЕРХНОСТНОЕ НАТЯЖЕНИЕ + ═══════════════════════════════════════════════════ */ + _drawSurface(t) { + if (this._stMode === 'capillary') + this._drawCapillaries(t); + else + this._drawDrop(t); + } + + _drawCapillaries(t) { + const ctx = this.ctx, W = this.W, H = this.H; + const liq = HydroSim.LIQUIDS[this.liquidKey]; + + const diameters = [0.5, 1.0, 2.0, 4.0]; + const n = diameters.length; + const tW = W * 0.76, tX = W * 0.12; + const tY = H * 0.55, tH = H * 0.28; + + /* wide container */ + this._fillLiquidRect(ctx, tX + 3, tY, tW - 6, tH, liq.color); + this._drawGlassVessel(ctx, tX, tY, tW, tH); + this._drawWaveSurface(ctx, tX + 3, tY, tW - 6, liq.color, this._waveT); + + const capTubeW = Math.max(26, Math.min(W * 0.055, 40)); + const step = tW / (n + 1); + const theta = this._contactAngle * Math.PI / 180; + const cosT = Math.cos(theta); + const capH = H * 0.52; + + for (let i = 0; i < n; i++) { + const r_m = diameters[i] * 0.5 * 0.001; + const h_m = 2 * liq.sigma * cosT / (liq.rho * this.g * r_m); + const cx = tX + step * (i + 1); + const capBot = tY; + const capTop = tY - capH; + const inner = capTubeW - 10; + + /* tube walls — rendered as filled rects with gradient */ + const wallG = ctx.createLinearGradient(cx - capTubeW / 2, 0, cx + capTubeW / 2, 0); + wallG.addColorStop(0, 'rgba(140,180,255,0.55)'); + wallG.addColorStop(0.18, 'rgba(200,225,255,0.25)'); + wallG.addColorStop(0.82, 'rgba(200,225,255,0.12)'); + wallG.addColorStop(1, 'rgba(140,180,255,0.45)'); + ctx.save(); + ctx.fillStyle = wallG; + ctx.fillRect(cx - capTubeW / 2, capTop, capTubeW, capH); + /* hollow out centre */ + ctx.clearRect(cx - inner / 2, capTop, inner, capH); + ctx.restore(); + + /* glass highlight on left wall interior */ + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.fillRect(cx - inner / 2, capTop, 2, capH); + ctx.restore(); + + /* liquid rise height (visual scale: 5cm = full tube) */ + const scaledH = (h_m / 0.05) * capH * 0.9; + const liqH = Math.min(Math.max(scaledH, 0), capH); + const liqTop = capBot - liqH; + + if (liqH > 1) { + /* cylindrical-look radial gradient */ + const rg = ctx.createRadialGradient(cx, liqTop + liqH / 2, 0, cx, liqTop + liqH / 2, inner / 2); + rg.addColorStop(0, liq.color + 'EE'); + rg.addColorStop(0.6, liq.color + 'BB'); + rg.addColorStop(1, liq.color + '55'); + ctx.save(); + ctx.beginPath(); ctx.rect(cx - inner / 2, liqTop, inner, liqH); ctx.clip(); + ctx.fillStyle = rg; ctx.fillRect(cx - inner / 2, liqTop, inner, liqH); + ctx.restore(); + } + + /* meniscus */ + this._drawMeniscus(ctx, cx, liqTop, inner, this._contactAngle, liq.color); + + /* meniscus specular highlight */ + if (this._contactAngle < 90 && liqH > 3) { + const dip = (inner / 2) * Math.cos(theta) * 1.1; + ctx.save(); + ctx.globalAlpha = 0.28; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.ellipse(cx - inner * 0.15, liqTop - dip * 0.3, inner * 0.22, Math.max(2, dip * 0.35), 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + /* tube top cap */ + ctx.save(); + ctx.strokeStyle = 'rgba(140,180,255,0.5)'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx - capTubeW / 2, capTop); + ctx.lineTo(cx + capTubeW / 2, capTop); + ctx.stroke(); ctx.restore(); + + /* diameter label */ + ctx.fillStyle = 'rgba(255,255,255,0.60)'; ctx.font = '12px Manrope,sans-serif'; + ctx.textAlign = 'center'; ctx.fillText('d=' + diameters[i] + ' мм', cx, capTop - 22); + + /* rise height label */ + if (liqH > 14) { + ctx.fillStyle = liq.color; ctx.font = 'bold 13px monospace'; + ctx.textAlign = 'center'; ctx.fillText((h_m * 1000).toFixed(1) + ' мм', cx, liqTop - 10); + } + + /* vertical bracket showing rise */ + if (liqH > 28) { + ctx.save(); + ctx.strokeStyle = liq.color + '88'; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(cx + capTubeW / 2 + 6, capBot); + ctx.lineTo(cx + capTubeW / 2 + 6, liqTop); + ctx.stroke(); ctx.setLineDash([]); ctx.restore(); + } + } + + /* contact angle status */ + const isH = this._contactAngle < 90; + ctx.fillStyle = isH ? '#06D6E0' : '#F15BB5'; + ctx.font = '13px Manrope,sans-serif'; ctx.textAlign = 'left'; + ctx.fillText('θ = ' + this._contactAngle + '° — ' + (isH ? 'смачивание ↑' : 'несмачивание ↓'), tX, H - 34); + + this._drawFormula(ctx, W / 2, H - 12, + 'h = 2σ·cos(θ) / (ρgr) σ=' + liq.sigma + ' Н/м θ=' + this._contactAngle + '°', '#06D6E0'); + } + + _drawMeniscus(ctx, cx, topY, innerW, angleDeg, color) { + const r = innerW / 2; + ctx.save(); + if (angleDeg < 90) { + const dip = r * Math.cos(angleDeg * Math.PI / 180) * 1.1; + ctx.beginPath(); ctx.moveTo(cx - r, topY); ctx.quadraticCurveTo(cx, topY - dip, cx + r, topY); + } else { + const bulge = r * 0.5; + ctx.beginPath(); ctx.moveTo(cx - r, topY); ctx.quadraticCurveTo(cx, topY + bulge, cx + r, topY); + } + ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.stroke(); + ctx.restore(); + } + + _drawDrop(t) { + const ctx = this.ctx, W = this.W, H = this.H; + const liq = HydroSim.LIQUIDS[this.liquidKey]; + const theta = this._contactAngle * Math.PI / 180; + const isHydrophobic = this._contactAngle >= 90; + const surfY = H * 0.60; + + /* surface plane */ + ctx.save(); + const sg = ctx.createLinearGradient(0, surfY - 4, 0, surfY + H * 0.18); + sg.addColorStop(0, isHydrophobic ? 'rgba(155,93,229,0.55)' : 'rgba(6,214,224,0.55)'); + sg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = sg; ctx.fillRect(W * 0.05, surfY, W * 0.90, H * 0.18); + ctx.strokeStyle = isHydrophobic ? '#9B5DE5' : '#06D6E0'; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.moveTo(W * 0.05, surfY); ctx.lineTo(W * 0.95, surfY); ctx.stroke(); + ctx.restore(); + + /* surface label */ + ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.font = '12px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(isHydrophobic ? 'Гидрофобная поверхность' : 'Гидрофильная поверхность', W / 2, surfY + 20); + + /* drop shape */ + const dropVol = 0.0002; + const cos3 = Math.cos(theta); + const Rpow = 2 - 3 * cos3 + cos3 * cos3 * cos3; + const R = Math.pow((3 * dropVol) / (Math.PI * Math.max(Rpow, 0.01)), 1 / 3); + const Rpx = Math.min(R * 2200, W * 0.24); + const contactR = Rpx * Math.sin(theta); + const centerY = surfY - Rpx * Math.cos(theta); + const wobble = 1 + Math.sin(t * 0.002) * 0.012; /* slight oscillation */ + + ctx.save(); + ctx.shadowColor = liq.color; ctx.shadowBlur = 22; + ctx.beginPath(); ctx.rect(0, 0, W, surfY + 1); ctx.clip(); + const dg = ctx.createRadialGradient( + W / 2 - Rpx * 0.28, centerY - Rpx * 0.28, Rpx * 0.04, + W / 2, centerY, Rpx * wobble + ); + dg.addColorStop(0, liq.color + 'EE'); + dg.addColorStop(0.5, liq.color + 'BB'); + dg.addColorStop(1, liq.color + '44'); + ctx.fillStyle = dg; + ctx.beginPath(); + ctx.ellipse(W / 2, centerY, Rpx * wobble, Rpx / wobble, 0, 0, Math.PI * 2); + ctx.fill(); + /* specular highlight */ + ctx.globalAlpha = 0.50; ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.ellipse(W / 2 - Rpx * 0.28, centerY - Rpx * 0.28, Rpx * 0.20, Rpx * 0.10, -0.5, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + /* contact angle arc */ + ctx.save(); + ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2.5; + const arcR = 34, tangAngle = Math.PI - theta; + ctx.beginPath(); + ctx.arc(W / 2 - contactR, surfY, arcR, -Math.PI * 0.5, tangAngle - Math.PI * 0.5); + ctx.stroke(); + ctx.fillStyle = '#FFD166'; ctx.font = 'bold 13px monospace'; ctx.textAlign = 'left'; + ctx.fillText('θ = ' + this._contactAngle + '°', W / 2 - contactR + arcR + 6, surfY - 8); + ctx.restore(); + + this._drawFormula(ctx, W / 2, H - 12, + 'σ = ' + liq.sigma + ' Н/м ΔP = 2σ/R ≈ ' + (2 * liq.sigma / (R || 0.001)).toFixed(1) + ' Па', + '#F15BB5'); + } + + /* ═══════════════════════════════════════════════════ + МОДУЛЬ 3 — СООБЩАЮЩИЕСЯ СОСУДЫ + ═══════════════════════════════════════════════════ */ + _drawCommunicating(t) { + const ctx = this.ctx, W = this.W, H = this.H; + const liq = HydroSim.LIQUIDS[this.liquidKey]; + const n = this._numVessels; + + /* ── Geometry ──────────────────────────────────────── + One unified container, internal partitions stop + PIPE_H above the floor → shared bottom pipe zone. + Vessels = sections between outer walls + partitions. */ + const CX = W * 0.09, CW = W * 0.82; // container left + width + const CY = H * 0.09, CH = H * 0.73; // container top + height + const PIPE_H = Math.max(22, CH * 0.07); // connecting pipe zone height + const PIPE_Y = CY + CH - PIPE_H; // pipe zone top Y + const FLOOR = CY + CH; // container floor Y + const PART_W = 5; // partition thickness (px) + const SEC_W = (CW - (n - 1) * PART_W) / n; // each section width + + const isOpen = this._valveOpen; + const liqFrac = isOpen + ? (this._animLevel ?? this._targetLevel ?? this._liquidFrac * 0.8) + : this._liquidFrac * 0.8; + + /* usable height for liquid level display (above pipe zone) */ + const USABLE = CH - PIPE_H; + const liqTopY = PIPE_Y - liqFrac * USABLE; // liquid surface Y + const liqH = FLOOR - liqTopY; // total liquid column height + + /* ── 1. Liquid fill in each section ─────────────────── */ + for (let i = 0; i < n; i++) { + const sx = CX + i * (SEC_W + PART_W); + const lg = ctx.createLinearGradient(0, liqTopY, 0, FLOOR); + lg.addColorStop(0, liq.color + '42'); + lg.addColorStop(0.4, liq.color + '88'); + lg.addColorStop(1, liq.color + 'CC'); + ctx.fillStyle = lg; + ctx.fillRect(sx, liqTopY, SEC_W, liqH); + } + + /* ── 2. Pipe zone fill (open = liquid, closed = dark) ── */ + for (let i = 0; i < n - 1; i++) { + const px = CX + i * (SEC_W + PART_W) + SEC_W; + if (isOpen) { + const pg = ctx.createLinearGradient(0, PIPE_Y, 0, FLOOR); + pg.addColorStop(0, liq.color + '88'); + pg.addColorStop(1, liq.color + 'CC'); + ctx.fillStyle = pg; + } else { + ctx.fillStyle = 'rgba(8,4,18,0.95)'; + } + ctx.fillRect(px, PIPE_Y, PART_W, PIPE_H); + } + + /* ── 3. Wave on each liquid surface ─────────────────── */ + for (let i = 0; i < n; i++) { + const sx = CX + i * (SEC_W + PART_W); + this._drawWaveSurface(ctx, sx + 2, liqTopY, SEC_W - 4, liq.color, this._waveT + i * 1.3); + } + + /* ── 4. Outer container walls (U-shape, open at top) ── */ + ctx.save(); + ctx.strokeStyle = 'rgba(200,225,255,0.55)'; + ctx.lineWidth = 2.5; ctx.lineCap = 'round'; + ctx.shadowColor = 'rgba(180,210,255,0.22)'; ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.moveTo(CX, CY); // left wall top + ctx.lineTo(CX, FLOOR); // left wall + ctx.lineTo(CX + CW, FLOOR); // bottom floor + ctx.lineTo(CX + CW, CY); // right wall + ctx.stroke(); + /* glass reflection strip */ + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(CX + 5, CY); ctx.lineTo(CX + 5, FLOOR); ctx.stroke(); + ctx.restore(); + + /* ── 5. Internal partitions (stop at pipe zone top) ─── */ + ctx.save(); + ctx.strokeStyle = 'rgba(200,225,255,0.42)'; + ctx.lineWidth = PART_W; ctx.lineCap = 'butt'; + ctx.shadowColor = 'rgba(180,210,255,0.15)'; ctx.shadowBlur = 4; + for (let i = 0; i < n - 1; i++) { + const px = CX + i * (SEC_W + PART_W) + SEC_W + PART_W / 2; + ctx.beginPath(); + ctx.moveTo(px, CY); + ctx.lineTo(px, PIPE_Y); // stop above pipe zone + ctx.stroke(); + } + ctx.restore(); + + /* ── 6. Valve icon in each pipe gap ─────────────────── */ + for (let i = 0; i < n - 1; i++) { + const px = CX + i * (SEC_W + PART_W) + SEC_W; + const vm = px + PART_W / 2; + const vc = PIPE_Y + PIPE_H / 2; + if (isOpen) { + /* flow arrows ← → */ + this._drawArrow(ctx, vm - 16, vc, 12, 0, '#06D6A0', 1.8); + this._drawArrow(ctx, vm + 16, vc, -12, 0, '#06D6A0', 1.8); + } else { + ctx.save(); + ctx.translate(vm, vc); ctx.rotate(Math.PI / 4); + ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 2.5; + ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 10; + ctx.strokeRect(-6, -6, 12, 12); + ctx.restore(); + } + } + + /* ── 7. Height labels + dashed brackets ─────────────── */ + for (let i = 0; i < n; i++) { + const sx = CX + i * (SEC_W + PART_W); + if (liqFrac > 0.02) { + const h_m = liqFrac * this._tankH_m(); + ctx.fillStyle = liq.color; + ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; + ctx.fillText('h = ' + h_m.toFixed(2) + ' м', sx + SEC_W / 2, liqTopY - 10); + /* dashed bracket */ + ctx.save(); + ctx.strokeStyle = liq.color + '55'; ctx.lineWidth = 1; ctx.setLineDash([2, 4]); + ctx.beginPath(); + ctx.moveTo(sx + SEC_W - 6, PIPE_Y); + ctx.lineTo(sx + SEC_W - 6, liqTopY); + ctx.stroke(); ctx.setLineDash([]); + ctx.restore(); + } + } + + /* ── 8. Equal-level dashed line ─────────────────────── */ + if (isOpen && liqFrac > 0.02) { + ctx.save(); + ctx.setLineDash([12, 6]); + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.55; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 4; + ctx.beginPath(); + ctx.moveTo(CX - 14, liqTopY); + ctx.lineTo(CX + CW + 14, liqTopY); + ctx.stroke(); + ctx.setLineDash([]); ctx.globalAlpha = 1; ctx.shadowBlur = 0; + this._labelPill(ctx, CX + CW + 18, liqTopY, 'h₁ = h₂', '#06D6E0'); + ctx.restore(); + } + + /* ── 9. Status + formula ─────────────────────────────── */ + ctx.fillStyle = isOpen ? '#06D6A0' : '#F15BB5'; + ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'right'; + ctx.fillText('Кран: ' + (isOpen ? 'открыт' : 'закрыт'), W - 12, H - 34); + + const h_m = liqFrac * this._tankH_m(); + this._drawFormula(ctx, W / 2, H - 12, + 'h₁ = h₂ = ' + h_m.toFixed(2) + ' м P = ρgh = ' + Math.round(liq.rho * this.g * h_m) + ' Па', + '#9B5DE5'); + } + + /* ═══════════════════════════════════════════════════ + МОДУЛЬ 4 — АРХИМЕД + ═══════════════════════════════════════════════════ */ + _archTank() { + const W = this.W || 600, H = this.H || 400; + const tx = W * 0.03, ty = H * 0.06; + const tw = W * 0.60, th = H * 0.80; + const waterlineY = ty + th * (1 - this._waterLevel); + return { tx, ty, tw, th, waterlineY }; + } + + _initArch() { + if (this._archReady) return; + this._bodies = []; + this._archReady = true; + this._archAddBody(); + } + + _destroyArch() { + this._archReady = false; + this._bodies = []; + } + + /* pure-JS physics helpers ─────────────────────────── */ + _archAddBody() { + if (!this._archReady && this.mode === 'archimedes') { this._initArch(); return; } + if (!this._archReady) return; + const mat = HydroSim.MATERIALS[this.materialKey]; + const liq = HydroSim.LIQUIDS[this.liquidKey]; + const { tx, ty, tw, th, waterlineY } = this._archTank(); + const size = Math.max(46, Math.min(tw * 0.20, 76)); + const eqFrac = Math.min(1.0, mat.rho / liq.rho); + const sinks = eqFrac >= 1.0; + + /* spawn at equilibrium → zero entry velocity, no overshooting */ + let y = sinks + ? waterlineY + (ty + th - waterlineY) * 0.32 + : waterlineY + size * (eqFrac - 0.5); + y = Math.max(ty + size * 0.55, Math.min(ty + th - size * 0.55, y)); + + const spread = Math.min(tw * 0.26, size + 20); + const x = tx + tw * 0.25 + this._bodies.length * spread; + const s_m = size * 0.0018; + const volume = this._bodyShape === 'circle' + ? Math.PI * (s_m / 2) ** 2 * 0.08 + : s_m * s_m * 0.08; + + this._bodies.push({ + x, y, vy: 0, size, shape: this._bodyShape, + mat: this.materialKey, submergedFrac: sinks ? 1 : eqFrac, + wobble: 0, volume, + }); + } + + _archClear() { this._bodies = []; } + _archReset() { this._bodies = []; this._archAddBody(); } + + _archPhysStep() { + const liq = HydroSim.LIQUIDS[this.liquidKey]; + const { ty, th, waterlineY } = this._archTank(); + const botY = ty + th - 3, topY = ty + 3; + const G = 0.38; /* px/frame² — gravitational accel in canvas units */ + const DAMP = 0.22; /* viscous damping fraction/frame at full submersion */ + const VMAX = 8; /* speed cap px/frame */ + + for (const b of this._bodies) { + const mat = HydroSim.MATERIALS[b.mat]; + const half = b.size / 2; + const bot = b.y + half, top = b.y - half; + + /* submerged fraction */ + let frac = 0; + if (bot > waterlineY) { + frac = top >= waterlineY ? 1.0 : (bot - waterlineY) / b.size; + frac = Math.max(0, Math.min(1, frac)); + } + b.submergedFrac = frac; + + /* net downward acceleration: G·(1 − ρж/ρт·frac) + positive = downward (canvas Y), negative = floats up */ + const rhoRatio = Math.min(liq.rho / mat.rho, 10.0); + b.vy += G * (1.0 - rhoRatio * frac); + + /* viscous drag — heavier when deeper */ + if (frac > 0) b.vy *= (1.0 - DAMP * frac); + + b.vy = Math.max(-VMAX, Math.min(VMAX, b.vy)); + b.y += b.vy; + + /* soft collisions */ + if (b.y + half >= botY) { b.y = botY - half; b.vy *= -0.12; } + if (b.y - half <= topY) { b.y = topY + half; b.vy = Math.abs(b.vy) * 0.08; } + + /* visual wobble driven by vertical speed */ + b.wobble = (b.wobble || 0) * 0.86 + b.vy * 0.004; + } + } + + /* ── Draw ─────────────────────────────────────────── */ + _drawArchimedes(t) { + const ctx = this.ctx, W = this.W, H = this.H; + const liq = HydroSim.LIQUIDS[this.liquidKey]; + const { tx, ty, tw, th, waterlineY } = this._archTank(); + + /* ── air zone (above waterline) */ + const airH = Math.max(0, waterlineY - ty); + if (airH > 1) { + ctx.save(); + const ag = ctx.createLinearGradient(0, ty, 0, waterlineY); + ag.addColorStop(0, 'rgba(18,12,38,0.0)'); + ag.addColorStop(1, 'rgba(18,12,38,0.28)'); + ctx.fillStyle = ag; ctx.fillRect(tx + 4, ty, tw - 8, airH); + ctx.restore(); + } + + /* ── liquid zone (below waterline) */ + const liqH = ty + th - waterlineY; + if (liqH > 1) { + this._fillLiquidRect(ctx, tx + 4, waterlineY, tw - 8, liqH, liq.color); + } + + /* ── glass vessel + wave */ + this._drawGlassVessel(ctx, tx, ty, tw, th); + this._drawWaveSurface(ctx, tx + 4, waterlineY, tw - 8, liq.color, this._waveT); + + /* ── waterline dashed rule */ + ctx.save(); + ctx.setLineDash([10, 7]); ctx.strokeStyle = liq.color + 'BB'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(tx + 4, waterlineY); ctx.lineTo(tx + tw - 4, waterlineY); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = liq.color + 'CC'; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'right'; + ctx.fillText('← ' + liq.name, tx + tw - 6, waterlineY - 7); + ctx.restore(); + + /* ── bodies (clip to tank interior) */ + ctx.save(); + ctx.beginPath(); ctx.rect(tx + 4, ty + 1, tw - 8, th - 2); ctx.clip(); + for (const b of this._bodies) this._archDrawBody(ctx, b, waterlineY, liq); + ctx.restore(); + + /* ── force arrows — clipped to tank band so mg↓ never bleeds below the glass floor */ + ctx.save(); + ctx.beginPath(); ctx.rect(0, 0, W, ty + th); ctx.clip(); + for (const b of this._bodies) this._archDrawForces(ctx, b, liq); + ctx.restore(); + + /* ── info panel (right side) */ + const panX = tx + tw + 16, panW = W - panX - 8; + if (panW > 80) this._archDrawPanel(ctx, panX, ty, panW, th, liq); + + /* ── bottom formula */ + if (this._bodies.length > 0) { + const b = this._bodies[0]; + const FA = liq.rho * this.g * b.volume * b.submergedFrac; + const mg = HydroSim.MATERIALS[b.mat].rho * b.volume * this.g; + this._drawFormula(ctx, tx + tw * 0.5, H - 12, + `Fₐ = ${FA.toFixed(4)} Н mg = ${mg.toFixed(4)} Н ρж/ρт = ${(liq.rho / HydroSim.MATERIALS[b.mat].rho).toFixed(2)}`, + '#06D6E0'); + } else { + this._drawFormula(ctx, tx + tw * 0.5, H - 12, 'Fₐ = ρж · g · Vпогружённый — закон Архимеда', '#06D6E0'); + } + } + + _archDrawBody(ctx, b, waterlineY, liq) { + const mat = HydroSim.MATERIALS[b.mat]; + const half = b.size / 2; + + /* submerged liquid tint on body */ + if (b.submergedFrac > 0.01) { + ctx.save(); + const subTop = b.y - half; + ctx.beginPath(); ctx.rect(b.x - half - 1, waterlineY, b.size + 2, b.y + half - waterlineY + 1); ctx.clip(); + ctx.globalAlpha = 0.22; ctx.fillStyle = liq.color; + if (b.shape === 'circle') { ctx.beginPath(); ctx.arc(b.x, b.y, half, 0, Math.PI * 2); ctx.fill(); } + else { ctx.fillRect(b.x - half, b.y - half, b.size, b.size); } + ctx.restore(); + } + + /* main body */ + ctx.save(); + ctx.translate(b.x, b.y); ctx.rotate(b.wobble || 0); + ctx.shadowColor = mat.color + 'BB'; ctx.shadowBlur = 18; + ctx.fillStyle = mat.color; + if (b.shape === 'circle') { + ctx.beginPath(); ctx.arc(0, 0, half, 0, Math.PI * 2); ctx.fill(); + } else { + const r = Math.min(10, half * 0.25); + if (ctx.roundRect) ctx.roundRect(-half, -half, b.size, b.size, r); + else ctx.rect(-half, -half, b.size, b.size); + ctx.fill(); + } + ctx.shadowBlur = 0; + + /* material texture */ + this._archMatTex(ctx, b.mat, half, b.shape); + + /* outline */ + ctx.strokeStyle = 'rgba(255,255,255,0.38)'; ctx.lineWidth = 2; + if (b.shape === 'circle') { ctx.beginPath(); ctx.arc(0, 0, half, 0, Math.PI * 2); ctx.stroke(); } + else { + const r = Math.min(10, half * 0.25); + if (ctx.roundRect) ctx.roundRect(-half, -half, b.size, b.size, r); + else ctx.rect(-half, -half, b.size, b.size); + ctx.stroke(); + } + ctx.restore(); + + /* name tag below body */ + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; ctx.font = 'bold 11px Manrope,sans-serif'; ctx.textAlign = 'center'; + ctx.fillText(mat.name, b.x, b.y + half + 16); + ctx.restore(); + } + + _archMatTex(ctx, matKey, half, shape) { + ctx.save(); ctx.globalAlpha = 0.60; + if (matKey === 'wood' || matKey === 'cork') { + ctx.strokeStyle = 'rgba(0,0,0,0.18)'; ctx.lineWidth = 1.5; + if (shape === 'circle') { + for (let r = half * 0.35; r < half; r += half * 0.30) { + ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.stroke(); + } + } else { + for (let y2 = -half + 10; y2 < half; y2 += 10) { + ctx.beginPath(); ctx.moveTo(-half + 3, y2); ctx.lineTo(half - 3, y2); ctx.stroke(); + } + } + } else if (['iron','gold','aluminum','glass','plastic'].includes(matKey)) { + const g = ctx.createRadialGradient(-half * 0.3, -half * 0.3, 0, 0, 0, half * 1.1); + g.addColorStop(0, 'rgba(255,255,255,0.55)'); g.addColorStop(0.45, 'rgba(255,255,255,0.12)'); g.addColorStop(1, 'rgba(0,0,0,0.28)'); + ctx.fillStyle = g; + if (shape === 'circle') { ctx.beginPath(); ctx.arc(0, 0, half, 0, Math.PI * 2); ctx.fill(); } + else ctx.fillRect(-half, -half, half * 2, half * 2); + } else if (matKey === 'ice') { + ctx.strokeStyle = 'rgba(180,240,255,0.65)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(-half * 0.5, -half * 0.5); ctx.lineTo(half * 0.5, half * 0.5); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(half * 0.5, -half * 0.5); ctx.lineTo(-half * 0.5, half * 0.5); ctx.stroke(); + } else if (matKey === 'styrofoam') { + ctx.fillStyle = 'rgba(255,255,255,0.40)'; + for (let i = 0; i < 6; i++) { + const ax = Math.cos(i * 1.047) * half * 0.52, ay = Math.sin(i * 1.047) * half * 0.52; + ctx.beginPath(); ctx.arc(ax, ay, half * 0.09, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.restore(); + } + + _archDrawForces(ctx, b, liq) { + const mat = HydroSim.MATERIALS[b.mat]; + const half = b.size / 2; + + /* buoyancy arrows only when partly submerged */ + const FA_rel = (liq.rho / mat.rho) * b.submergedFrac; /* relative to mg */ + const showFA = b.submergedFrac > 0.01; + const BASE = 54; + const maxRel = Math.max(FA_rel, 1.0, 0.001); + const Fa_px = showFA ? Math.max(22, Math.min(90, (FA_rel / maxRel) * BASE)) : 0; + const mg_px = Math.max(22, Math.min(90, (1.0 / maxRel) * BASE)); + + const lx = b.x - half - 20, rx = b.x + half + 20; + if (showFA) this._drawArrow(ctx, lx, b.y, 0, -Fa_px, '#06D6E0', 3.5, 'Fₐ'); + this._drawArrow(ctx, rx, b.y, 0, mg_px, '#F15BB5', 3.5, 'mg'); + } + + _archDrawPanel(ctx, x, y, w, h, liq) { + /* background */ + ctx.save(); + ctx.fillStyle = 'rgba(6,4,20,0.78)'; ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + if (ctx.roundRect) ctx.roundRect(x, y, w, h, 10); else ctx.rect(x, y, w, h); + ctx.fill(); ctx.stroke(); ctx.restore(); + + if (!this._bodies.length) { + ctx.fillStyle = 'rgba(255,255,255,0.20)'; ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; + ctx.fillText('+ Добавьте тело', x + w / 2, y + h / 2); + return; + } + + const b = this._bodies[0]; + const mat = HydroSim.MATERIALS[b.mat]; + const FA = liq.rho * this.g * b.volume * b.submergedFrac; + const mg = mat.rho * b.volume * this.g; + const state = mat.rho < liq.rho * 0.99 ? 'ВСПЛЫВАЕТ' + : mat.rho > liq.rho * 1.01 ? 'ТОНЕТ' : 'ВЗВЕШЕНО'; + const stC = state === 'ВСПЛЫВАЕТ' ? '#06D6A0' : state === 'ТОНЕТ' ? '#F15BB5' : '#FFD166'; + const pulse = state !== 'ВЗВЕШЕНО' ? 0.7 + Math.sin(this._t * 0.004) * 0.3 : 1.0; + + const pad = 11, lh = 20; + let cy = y + 20; + + /* ── state badge ── */ + ctx.save(); + ctx.fillStyle = stC + '1E'; ctx.strokeStyle = stC + '66'; ctx.lineWidth = 1.5; + if (ctx.roundRect) ctx.roundRect(x + pad, cy - 15, w - pad * 2, 30, 7); + else ctx.rect(x + pad, cy - 15, w - pad * 2, 30); + ctx.fill(); ctx.stroke(); + ctx.globalAlpha = pulse; ctx.fillStyle = stC; + ctx.font = 'bold 14px Manrope,sans-serif'; ctx.textAlign = 'center'; + ctx.shadowColor = stC; ctx.shadowBlur = 10; + ctx.fillText(state, x + w / 2, cy + 4); + ctx.globalAlpha = 1; ctx.shadowBlur = 0; ctx.restore(); + cy += 38; + + /* ── separator ── */ + ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x + pad, cy); ctx.lineTo(x + w - pad, cy); ctx.stroke(); ctx.restore(); + cy += 13; + + /* ── material + liquid ── */ + ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText('МАТЕРИАЛ', x + pad, cy); cy += lh * 0.8; + ctx.fillStyle = mat.color; ctx.fillRect(x + pad, cy - 11, 12, 12); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 0.5; ctx.strokeRect(x + pad, cy - 11, 12, 12); + ctx.fillStyle = '#fff'; ctx.font = '12px Manrope,sans-serif'; ctx.fillText(mat.name, x + pad + 16, cy); + cy += lh * 0.7; + ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = '11px "JetBrains Mono",monospace'; + ctx.fillText('ρт = ' + mat.rho + ' кг/м³', x + pad, cy); cy += lh * 0.9; + ctx.fillText('ρж = ' + liq.rho + ' кг/м³', x + pad, cy); cy += lh * 1.2; ctx.restore(); + + /* ── force bars ── */ + ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText('СИЛЫ', x + pad, cy); cy += lh * 0.85; + const barW = w - pad * 2; + const maxF = Math.max(FA, mg, 1e-14); + const faW = Math.max(3, (FA / maxF) * barW); + const mgW = Math.max(3, (mg / maxF) * barW); + + /* Fа row */ + ctx.fillStyle = 'rgba(6,214,224,0.12)'; ctx.fillRect(x + pad, cy - 12, barW, 17); + ctx.fillStyle = '#06D6E0'; ctx.fillRect(x + pad, cy - 12, faW, 17); + ctx.fillStyle = '#fff'; ctx.font = '10px "JetBrains Mono",monospace'; + ctx.fillText('Fа ↑', x + pad + 3, cy); ctx.textAlign = 'right'; + ctx.fillText(FA.toFixed(5), x + pad + barW - 2, cy); ctx.textAlign = 'left'; + cy += lh * 1.05; + + /* mg row */ + ctx.fillStyle = 'rgba(241,91,181,0.12)'; ctx.fillRect(x + pad, cy - 12, barW, 17); + ctx.fillStyle = '#F15BB5'; ctx.fillRect(x + pad, cy - 12, mgW, 17); + ctx.fillStyle = '#fff'; + ctx.fillText('mg ↓', x + pad + 3, cy); ctx.textAlign = 'right'; + ctx.fillText(mg.toFixed(5), x + pad + barW - 2, cy); ctx.textAlign = 'left'; + cy += lh * 1.35; ctx.restore(); + + /* ── density ratio bar ── */ + ctx.save(); ctx.font = '10px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.32)'; + const ratio = (liq.rho / mat.rho); + ctx.fillText('ρж / ρт = ' + (ratio).toFixed(2), x + pad, cy); cy += lh * 0.85; + const rbW = w - pad * 2; + /* bar covers ratio 0..4; mark at ratio=1 */ + const normR = Math.min(ratio / 4, 1.0); + const mark1 = x + pad + rbW * 0.25; /* ratio = 1 position */ + ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fillRect(x + pad, cy - 10, rbW, 13); + const rg = ctx.createLinearGradient(x + pad, 0, x + pad + rbW, 0); + rg.addColorStop(0, '#F15BB5'); rg.addColorStop(0.25, '#FFD166'); rg.addColorStop(1, '#06D6A0'); + ctx.fillStyle = rg; ctx.fillRect(x + pad, cy - 10, normR * rbW, 13); + /* cursor on bar */ + const curX = x + pad + normR * rbW; + ctx.fillStyle = '#fff'; ctx.fillRect(curX - 2, cy - 13, 4, 19); + /* neutral marker */ + ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillRect(mark1 - 1, cy - 13, 2, 19); + ctx.fillStyle = 'rgba(255,255,255,0.30)'; ctx.font = '8px monospace'; ctx.textAlign = 'center'; + ctx.fillText('=1', mark1, cy + 14); + ctx.restore(); + } + + /* ═══════════════════════════════════════════════════ + HELPERS — RENDERING + ═══════════════════════════════════════════════════ */ + + _drawGlassVessel(ctx, x, y, w, h) { + ctx.save(); + /* thick wall gradient — left */ + const lG = ctx.createLinearGradient(x, 0, x + 14, 0); + lG.addColorStop(0, 'rgba(160,200,255,0.65)'); + lG.addColorStop(0.5, 'rgba(160,200,255,0.30)'); + lG.addColorStop(1, 'rgba(160,200,255,0.05)'); + ctx.fillStyle = lG; ctx.fillRect(x, y, 14, h + 14); + /* right */ + const rG = ctx.createLinearGradient(x + w - 14, 0, x + w, 0); + rG.addColorStop(0, 'rgba(160,200,255,0.05)'); + rG.addColorStop(0.5, 'rgba(160,200,255,0.25)'); + rG.addColorStop(1, 'rgba(160,200,255,0.60)'); + ctx.fillStyle = rG; ctx.fillRect(x + w - 14, y, 14, h + 14); + /* bottom */ + const bG = ctx.createLinearGradient(0, y + h, 0, y + h + 14); + bG.addColorStop(0, 'rgba(160,200,255,0.35)'); + bG.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = bG; ctx.fillRect(x, y + h, w, 14); + /* outline */ + ctx.strokeStyle = 'rgba(200,225,255,0.55)'; ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(x + 2, y); ctx.lineTo(x + 2, y + h + 12); ctx.lineTo(x + w - 2, y + h + 12); ctx.lineTo(x + w - 2, y); + ctx.stroke(); + /* inner reflection */ + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x + 6, y); ctx.lineTo(x + 6, y + h); ctx.stroke(); + ctx.restore(); + } + + _fillLiquidRect(ctx, x, y, w, h, color) { + ctx.save(); + const g = ctx.createLinearGradient(0, y, 0, y + h); + g.addColorStop(0, color + '33'); + g.addColorStop(0.25, color + '66'); + g.addColorStop(0.75, color + 'AA'); + g.addColorStop(1, color + 'CC'); + ctx.fillStyle = g; + ctx.fillRect(x, y, w, h); + /* deep shadow at bottom */ + const dG = ctx.createLinearGradient(0, y + h * 0.75, 0, y + h); + dG.addColorStop(0, 'rgba(0,0,30,0)'); + dG.addColorStop(1, 'rgba(0,0,30,0.22)'); + ctx.fillStyle = dG; ctx.fillRect(x, y + h * 0.75, w, h * 0.25); + ctx.restore(); + } + + _drawWaveSurface(ctx, x, y, w, color, wt) { + ctx.save(); + ctx.strokeStyle = color + 'CC'; ctx.lineWidth = 1.8; + ctx.beginPath(); + for (let px = x; px <= x + w; px += 2) { + const wy = y + Math.sin(px * 0.055 + wt) * 2.5 + Math.sin(px * 0.13 + wt * 1.3) * 1.0; + px === x ? ctx.moveTo(px, wy) : ctx.lineTo(px, wy); + } + ctx.stroke(); + /* surface sheen */ + ctx.strokeStyle = 'rgba(255,255,255,0.14)'; ctx.lineWidth = 1; + ctx.beginPath(); + for (let px = x; px <= x + w; px += 2) { + const wy = y + Math.sin(px * 0.055 + wt + 0.4) * 2.5; + px === x ? ctx.moveTo(px, wy - 2) : ctx.lineTo(px, wy - 2); + } + ctx.stroke(); + ctx.restore(); + } + + _drawLiquidShaped(ctx, x, y, w, h, frac, color, shape, wt) { + const liqH = h * Math.max(0, Math.min(frac, 1)); + const liqY = y + h - liqH; + if (liqH < 2) return; + ctx.save(); + /* shape clip */ + ctx.beginPath(); + switch (shape) { + case 'wide': ctx.rect(x - w*0.15, y, w*1.3, h); break; + case 'narrow': ctx.rect(x + w*0.2, y, w*0.6, h); break; + case 'trapezoid': { + const bl = x - w*0.12, br = x+w+w*0.12, tl = x, tr = x+w; + ctx.moveTo(tl, y); ctx.lineTo(bl, y+h); ctx.lineTo(br, y+h); ctx.lineTo(tr, y); ctx.closePath(); + break; + } + default: ctx.rect(x, y, w, h); + } + ctx.clip(); + const g = ctx.createLinearGradient(0, liqY, 0, y + h); + g.addColorStop(0, color + '44'); g.addColorStop(0.4, color + '88'); g.addColorStop(1, color + 'BB'); + ctx.fillStyle = g; ctx.fillRect(x - w*0.2, liqY, w*1.4, liqH); + ctx.restore(); + } + + _drawVesselShaped(ctx, x, y, w, h, shape) { + ctx.save(); + ctx.strokeStyle = 'rgba(200,225,255,0.55)'; ctx.lineWidth = 2.5; + ctx.shadowColor = 'rgba(180,210,255,0.20)'; ctx.shadowBlur = 6; + ctx.beginPath(); + switch (shape) { + case 'wide': + ctx.moveTo(x - w*0.15, y); ctx.lineTo(x - w*0.15, y+h); ctx.lineTo(x+w+w*0.15, y+h); ctx.lineTo(x+w+w*0.15, y); + break; + case 'narrow': + ctx.moveTo(x + w*0.2, y); ctx.lineTo(x+w*0.2, y+h); ctx.lineTo(x+w*0.8, y+h); ctx.lineTo(x+w*0.8, y); + break; + case 'trapezoid': + ctx.moveTo(x, y); ctx.lineTo(x-w*0.12, y+h); ctx.lineTo(x+w+w*0.12, y+h); ctx.lineTo(x+w, y); + break; + default: + ctx.moveTo(x, y); ctx.lineTo(x, y+h); ctx.lineTo(x+w, y+h); ctx.lineTo(x+w, y); + } + ctx.stroke(); + /* inner reflection strip */ + ctx.shadowBlur = 0; ctx.strokeStyle = 'rgba(255,255,255,0.14)'; ctx.lineWidth = 1; + const lx = shape === 'wide' ? x - w*0.15 + 5 : shape === 'narrow' ? x+w*0.2+5 : x+5; + ctx.beginPath(); ctx.moveTo(lx, y); ctx.lineTo(lx, y+h); ctx.stroke(); + ctx.restore(); + } + + _shapeRect(x, w, shape) { + if (shape === 'wide') return [x - w*0.15, w*1.3]; + if (shape === 'narrow') return [x + w*0.2, w*0.6]; + return [x, w]; + } + + _drawArrow(ctx, x, y, dx, dy, color, lw = 3, label) { + const len = Math.hypot(dx, dy); + if (len < 2) return; + ctx.save(); + ctx.strokeStyle = color; ctx.fillStyle = color; + ctx.lineWidth = lw; ctx.shadowColor = color; ctx.shadowBlur = 7; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x+dx, y+dy); ctx.stroke(); + const a = Math.atan2(dy, dx), ar = 11; + ctx.beginPath(); + ctx.moveTo(x+dx, y+dy); + ctx.lineTo(x+dx - ar*Math.cos(a-0.35), y+dy - ar*Math.sin(a-0.35)); + ctx.lineTo(x+dx - ar*Math.cos(a+0.35), y+dy - ar*Math.sin(a+0.35)); + ctx.closePath(); ctx.fill(); + if (label) { + ctx.shadowBlur = 0; + const lx = x + dx * 0.5 + (dy === 0 ? 0 : 16); + const ly = y + dy * 0.5 + (dx === 0 ? -10 : -6); + /* pill background */ + ctx.font = 'bold 12px monospace'; + const tw2 = ctx.measureText(label).width + 8; + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(lx - tw2/2, ly - 9, tw2, 16, 4); else ctx.rect(lx - tw2/2, ly - 9, tw2, 16); + ctx.fill(); + ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(label, lx, ly - 1); ctx.textBaseline = 'alphabetic'; + } + ctx.restore(); + } + + _drawRuler(ctx, x, y, h, labelTop, labelBot) { + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(x+8, y); ctx.lineTo(x+8, y+h); ctx.stroke(); + for (let i = 0; i <= 10; i++) { + const ty2 = y + (i/10)*h, tw2 = i%5===0 ? 10 : 5; + ctx.beginPath(); ctx.moveTo(x+8-tw2, ty2); ctx.lineTo(x+8, ty2); ctx.stroke(); + if (i % 5 === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '10px monospace'; ctx.textAlign = 'right'; + ctx.fillText(i === 0 ? labelTop : (i === 10 ? labelBot : ''), x+5, ty2+4); + } + } + ctx.restore(); + } + + _drawGauge(ctx, cx, cy, val, maxVal, unit, r = 44) { + const frac = Math.min(val / Math.max(maxVal, 1), 1); + const startA = Math.PI * 0.75, endA = Math.PI * 2.25; + const valA = startA + (endA - startA) * frac; + const color = this._lerpColor('#06D6E0', '#F15BB5', frac); + ctx.save(); + /* outer ring */ + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = r * 0.22 + 4; + ctx.beginPath(); ctx.arc(cx, cy, r, startA, endA); ctx.stroke(); + /* track */ + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = r * 0.22; + ctx.beginPath(); ctx.arc(cx, cy, r, startA, endA); ctx.stroke(); + /* value arc */ + ctx.strokeStyle = color; ctx.shadowColor = color; ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc(cx, cy, r, startA, valA); ctx.stroke(); + ctx.shadowBlur = 0; + /* tick marks */ + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const a = startA + (endA - startA) * (i / 4); + ctx.beginPath(); + ctx.moveTo(cx + (r - 8)*Math.cos(a), cy + (r - 8)*Math.sin(a)); + ctx.lineTo(cx + (r + 3)*Math.cos(a), cy + (r + 3)*Math.sin(a)); + ctx.stroke(); + } + /* needle */ + ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(cx, cy); + ctx.lineTo(cx + (r - 12)*Math.cos(valA), cy + (r - 12)*Math.sin(valA)); ctx.stroke(); + ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI*2); ctx.fill(); + /* value */ + ctx.fillStyle = color; ctx.font = 'bold 13px monospace'; ctx.textAlign = 'center'; + ctx.fillText(Math.round(val) + ' ' + unit, cx, cy + r + 20); + ctx.restore(); + } + + _drawInfoPanel(ctx, x, y, w, h, rows) { + if (w < 50) return; + ctx.save(); + ctx.fillStyle = 'rgba(8,6,20,0.55)'; ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x, y, w, Math.min(h, rows.length * 22 + 20), 8); + else ctx.rect(x, y, w, rows.length * 22 + 20); + ctx.fill(); ctx.stroke(); + ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'left'; + rows.forEach((r, i) => { + const ry = y + 16 + i * 22; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillText(r.label, x + 10, ry); + ctx.fillStyle = r.color || 'rgba(255,255,255,0.85)'; + ctx.textAlign = 'right'; ctx.fillText(r.value, x + w - 10, ry); + ctx.textAlign = 'left'; + }); + ctx.restore(); + } + + _labelPill(ctx, x, y, text, color) { + ctx.save(); + ctx.font = 'bold 12px Manrope,sans-serif'; + const tw2 = ctx.measureText(text).width + 16; + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x, y - 11, tw2, 20, 5); else ctx.rect(x, y - 11, tw2, 20); + ctx.fill(); + ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(text, x + 8, y - 1); ctx.textBaseline = 'alphabetic'; + ctx.restore(); + } + + _drawFormula(ctx, x, y, text, color) { + ctx.save(); + ctx.font = '12px "JetBrains Mono",monospace'; + const tw2 = ctx.measureText(text).width; + const pad = 12, bh = 28; + let bx = x - (tw2 + pad*2) / 2; + bx = Math.max(4, Math.min((this.W||600) - tw2 - pad*2 - 4, bx)); + const by = y - bh; + ctx.fillStyle = 'rgba(6,4,18,0.90)'; ctx.strokeStyle = color; ctx.lineWidth = 1.3; + ctx.shadowColor = color; ctx.shadowBlur = 6; + const rr = 7; + ctx.beginPath(); + ctx.moveTo(bx+rr, by); ctx.lineTo(bx+tw2+pad*2-rr, by); + ctx.quadraticCurveTo(bx+tw2+pad*2, by, bx+tw2+pad*2, by+rr); + ctx.lineTo(bx+tw2+pad*2, by+bh-rr); + ctx.quadraticCurveTo(bx+tw2+pad*2, by+bh, bx+tw2+pad*2-rr, by+bh); + ctx.lineTo(bx+rr, by+bh); ctx.quadraticCurveTo(bx, by+bh, bx, by+bh-rr); + ctx.lineTo(bx, by+rr); ctx.quadraticCurveTo(bx, by, bx+rr, by); + ctx.closePath(); ctx.fill(); ctx.stroke(); + ctx.shadowBlur = 0; ctx.fillStyle = color; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(text, bx+pad, by+bh/2); ctx.textBaseline = 'alphabetic'; + ctx.restore(); + } + + /* ═══ Physics helpers ═══ */ + _tankH_m() { return 1.0; } + _recalcVessels() { + this._targetLevel = this._valveOpen ? this._liquidFrac * 0.82 : null; + if (!this._valveOpen) this._animLevel = null; + } + + /* ═══ Events ═══ */ + _bindEvents() { + this._onPD = e => this._pointerDown(e); + this._onPM = e => this._pointerMove(e); + this._onPU = () => { this._probe.dragging = false; }; + this.canvas.addEventListener('pointerdown', this._onPD); + this.canvas.addEventListener('pointermove', this._onPM); + window.addEventListener('pointerup', this._onPU); + } + _cp(e) { + const r = this.canvas.getBoundingClientRect(); + return { x: (e.clientX-r.left)*(this.W/r.width), y: (e.clientY-r.top)*(this.H/r.height) }; + } + _pointerDown(e) { + if (this.mode !== 'pressure') return; + const { x, y } = this._cp(e); + const tw = Math.min(this.W*0.30, 200), tx = this.W*0.12, ty = this.H*0.10, th = this.H*0.72; + const pX = tx + tw*0.5, pY = ty + this._probe.vy * th; + if (Math.hypot(x-pX, y-pY) < 22) { this._probe.dragging = true; this.canvas.setPointerCapture(e.pointerId); } + } + _pointerMove(e) { + if (!this._probe.dragging) return; + const { y } = this._cp(e); + const ty = this.H*0.10, th = this.H*0.72; + this._probe.vy = Math.max(0.02, Math.min(0.98, (y-ty)/th)); + } + + /* ═══ Utils ═══ */ + _lerpColor(c1, c2, t) { + const h = s => parseInt(s.slice(1), 16); + const p = (a, b, t2) => Math.round(Math.max(0, Math.min(255, a + (b-a)*t2))); + const v1 = h(c1), v2 = h(c2); + return '#' + [[(v1>>16)&255,(v2>>16)&255],[(v1>>8)&255,(v2>>8)&255],[v1&255,v2&255]] + .map(([a,b]) => p(a,b,t).toString(16).padStart(2,'0')).join(''); + } + _notify() { if (this.onUpdate) try { this.onUpdate(this.getInfo()); } catch {} } +} diff --git a/frontend/js/labs/isoprocess.js b/frontend/js/labs/isoprocess.js index 611fe15..04c96aa 100644 --- a/frontend/js/labs/isoprocess.js +++ b/frontend/js/labs/isoprocess.js @@ -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)); diff --git a/frontend/js/labs/mirror.js b/frontend/js/labs/mirror.js index d16dafe..34b5062 100644 --- a/frontend/js/labs/mirror.js +++ b/frontend/js/labs/mirror.js @@ -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)); diff --git a/frontend/js/labs/normaldist.js b/frontend/js/labs/normaldist.js index e75bff6..3d6bd6e 100644 --- a/frontend/js/labs/normaldist.js +++ b/frontend/js/labs/normaldist.js @@ -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); diff --git a/frontend/js/labs/pendulum.js b/frontend/js/labs/pendulum.js index a2a2976..88f337a 100644 --- a/frontend/js/labs/pendulum.js +++ b/frontend/js/labs/pendulum.js @@ -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; diff --git a/frontend/js/labs/probability.js b/frontend/js/labs/probability.js index d6e3122..45455e3 100644 --- a/frontend/js/labs/probability.js +++ b/frontend/js/labs/probability.js @@ -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); diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js index d43ec9e..b9cfd9a 100644 --- a/frontend/js/labs/projectile.js +++ b/frontend/js/labs/projectile.js @@ -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; diff --git a/frontend/js/labs/quadratic.js b/frontend/js/labs/quadratic.js index 0d84b5c..99ecda2 100644 --- a/frontend/js/labs/quadratic.js +++ b/frontend/js/labs/quadratic.js @@ -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; diff --git a/frontend/js/labs/refraction.js b/frontend/js/labs/refraction.js index 5470eb0..77babb3 100644 --- a/frontend/js/labs/refraction.js +++ b/frontend/js/labs/refraction.js @@ -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)); diff --git a/frontend/js/labs/thinlens.js b/frontend/js/labs/thinlens.js index ed4a0b9..b384099 100644 --- a/frontend/js/labs/thinlens.js +++ b/frontend/js/labs/thinlens.js @@ -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)); diff --git a/frontend/js/labs/titration.js b/frontend/js/labs/titration.js index 640f1f5..d97be34 100644 --- a/frontend/js/labs/titration.js +++ b/frontend/js/labs/titration.js @@ -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)); diff --git a/frontend/js/labs/triangle.js b/frontend/js/labs/triangle.js index d9b7756..574aa9c 100644 --- a/frontend/js/labs/triangle.js +++ b/frontend/js/labs/triangle.js @@ -792,7 +792,7 @@ class TriangleSim { // Formula this._drawFormulaBox(ctx, this.W, this.H, - `${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² − 2·${adjSide1Name}·${adjSide2Name}·cos${angName} ${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 - ? ` ${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); diff --git a/frontend/js/labs/waves.js b/frontend/js/labs/waves.js index 087251a..c2be230 100644 --- a/frontend/js/labs/waves.js +++ b/frontend/js/labs/waves.js @@ -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)); diff --git a/frontend/js/whiteboard.js b/frontend/js/whiteboard.js index daf7c14..edbaa52 100644 --- a/frontend/js/whiteboard.js +++ b/frontend/js/whiteboard.js @@ -75,14 +75,22 @@ class Whiteboard { this._fill = false; this._lineStyle = 'solid'; this._opacity = 1.0; + // text tool settings + this._textFontSize = 22; + this._textFontFamily = 'Manrope'; + this._textBold = false; + this._textItalic = false; this._template = opts.template || 'blank'; this._pageNum = opts.pageNum || 1; + this._stylusMultiplier = opts.stylusMultiplier ?? 0; // 0=disabled + this._effectiveWidth = null; this._selectedIds = new Set(); // multi-select this._dragState = null; this._clipboard = null; this._lassoRect = null; // {x1,y1,x2,y2} rubber-band selection this._snapGuides = []; // [{axis:'x'|'y', pos:number}] + this._snapEnabled = true; // snap guides on/off this._staticDirty = true; // two-layer: re-render static when true this._overlays = []; // ruler/protractor overlays (not saved to DB) this._overlayDrag = null; // {idx, type:'move'|'rotate'|'resize', ...} @@ -114,12 +122,50 @@ class Whiteboard { this._textInputDocHandler = null; // document pointerdown handler for outside-click this._objectInput = null; this._onObjectCreated = opts.onObjectCreated || null; + this._onToolSwitch = opts.onToolSwitch || null; this._onFormulaInsert = opts.onFormulaInsert || null; this._onCoordEdit = opts.onCoordEdit || null; this._onNumberLineEdit = opts.onNumberLineEdit || null; this._onCompassEdit = opts.onCompassEdit || null; this._editingFormulaStroke = null; this._laserPos = null; + this._laserTrail = []; // fade trail points (last 25) + this._pointerVx = 0; // last pointer virtual X (for eraser cursor) + this._pointerVy = 0; + this._textAlign = 'left'; // text alignment state + this._stickyColor = null; // forced sticky bg color (null = random) + this._tableRows = 3; // table picker rows + this._tableCols = 4; // table picker cols + + // Board theme + this._boardTheme = opts.boardTheme || 'chalkboard'; + this._bgNoiseCache = new Map(); // canvas element per theme + + // Compass state machine + this._compassState = 'idle'; // 'idle'|'setting-radius'|'waiting-arc'|'drawing-arc' + this._compassCenter = null; + this._compassRadius = 0; + this._compassArcStart = 0; + this._compassArcSweep = 0; + this._compassCurrentAngle = 0; + this._compassLastAngle = 0; + + // Mind map editing state + this._editingMindmapStroke = null; // compound stroke being edited node-by-node + this._selectedMindmapNodeId = null; // id of currently selected node within mindmap + this._mmNodeDragStart = null; // {nodeId, origRelX, origRelY, startVx, startVy} + + // Smart connector anchor snapping + this._connSnapStart = null; + this._connSnapEnd = null; + this._connHoverShapeId = null; + + // rAF render gate — at most one render per browser frame + this._rafPending = false; + + // Pause rendering when tab is hidden (resume on visibilitychange) + this._visHandler = () => { if (!document.hidden) this._doRender(); }; + document.addEventListener('visibilitychange', this._visHandler); // dynamic overlay canvas (selection, snap guides, live strokes, laser) this._dynCanvas = document.createElement('canvas'); @@ -166,20 +212,29 @@ class Whiteboard { _bindEvents() { const c = this._canvas; - const onDown = e => { if (!this._readOnly) this._onPointerDown(e); }; + const onDown = e => { + if (e.button === 1) { e.preventDefault(); this._beginPan(e); return; } + if (!this._readOnly) this._onPointerDown(e); + }; const onMove = e => { + if (this._panStartCss) { this._onPointerMove(e); return; } // pan works for everyone if (!this._readOnly) { this._onPointerMove(e); return; } // readOnly: still broadcast cursor position if callback set if (this._onCursorMove) { const [vx, vy] = this._pointerPos(e); const now = Date.now(); - if (now - this._cursorThrottle > 100) { + if (now - this._cursorThrottle > 33) { this._cursorThrottle = now; this._onCursorMove(vx, vy); } } }; - const onUp = e => { if (!this._readOnly) this._onPointerUp(e); }; + const onUp = e => { + if (this._panStartCss) { this._onPointerUp(e); return; } // pan end for everyone + if (!this._readOnly) this._onPointerUp(e); + }; + // Prevent browser auto-scroll cursor on middle-click + c.addEventListener('mousedown', e => { if (e.button === 1) e.preventDefault(); }); c.addEventListener('pointerdown', onDown); c.addEventListener('pointermove', onMove); @@ -199,6 +254,33 @@ class Whiteboard { _handleKeyDown(e) { if (this._readOnly) return; + // Mindmap keyboard shortcuts + if (this._editingMindmapStroke) { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if ((e.key === 'Delete' || e.key === 'Backspace') && this._selectedMindmapNodeId) { + e.preventDefault(); + this._deleteMindmapNode(this._editingMindmapStroke, this._selectedMindmapNodeId); + return; + } + if (e.key === 'Tab' && this._selectedMindmapNodeId) { + e.preventDefault(); + this._addMindmapChild(this._editingMindmapStroke, this._selectedMindmapNodeId); + return; + } + if (e.key === 'Escape') { + this._editingMindmapStroke = null; + this._selectedMindmapNodeId = null; + this._mmNodeDragStart = null; + this.render(); + return; + } + if ((e.key === 'F2' || e.key === 'Enter') && this._selectedMindmapNodeId) { + e.preventDefault(); + this._editMindmapNodeText(this._editingMindmapStroke, this._selectedMindmapNodeId); + return; + } + } const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return; @@ -210,7 +292,11 @@ class Whiteboard { if (this._selectedIds.size > 0) { e.preventDefault(); this.deleteSelected(); } } if (e.key === 'Escape') { - this._selectedIds.clear(); this._dragState = null; this._lassoRect = null; this.render(); + if (this._tool === 'compass' && this._compassState !== 'idle') { + this._compassState = 'idle'; this._drawing = false; this.render(); + } else { + this._selectedIds.clear(); this._dragState = null; this._lassoRect = null; this.render(); + } } // Zoom shortcuts if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.zoomTo(this._zoom * 1.25); } @@ -229,22 +315,49 @@ class Whiteboard { _isObjectStroke(s) { return s.tool === 'image' || s.tool === 'sticky' || s.tool === 'formula' || s.tool === 'table' || s.tool === 'coordinate' || - s.tool === 'numberline' || s.tool === 'compass'; + s.tool === 'numberline' || s.tool === 'compass' || s.tool === 'mindmap'; } _isResizableStroke(s) { - return this._isObjectStroke(s) || s.tool === 'shape' || s.tool === 'connector'; + // Compass uses specialized handles, not corner resize; mindmap has its own node handles + return (this._isObjectStroke(s) && s.tool !== 'compass' && s.tool !== 'mindmap') || s.tool === 'shape' || s.tool === 'connector'; } /* Returns {x, y, w, h} bounding box in virtual coords for any stroke type */ _getStrokeBBox(stroke) { const d = stroke.data; + // Compass uses cx/cy/radius geometry + if (stroke.tool === 'compass') { + const pad = 40; + const r = (d.radius || 50) + pad; + return { x: d.cx - r, y: d.cy - r, w: r * 2, h: r * 2 }; + } + if (stroke.tool === 'mindmap') { + if (!d.nodes || !d.nodes.length) return { x: d.x - 100, y: d.y - 60, w: 200, h: 120 }; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const n of d.nodes) { + const hw = (this._mmNodeDepth(d, n.id) === 0 ? 140 : 120) / 2 + 30; + const hh = (this._mmNodeDepth(d, n.id) === 0 ? 54 : 44) / 2 + 20; + minX = Math.min(minX, d.x + n.relX - hw); + maxX = Math.max(maxX, d.x + n.relX + hw); + minY = Math.min(minY, d.y + n.relY - hh); + maxY = Math.max(maxY, d.y + n.relY + hh); + } + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; + } if (this._isObjectStroke(stroke)) return { x: d.x, y: d.y, w: d.w, h: d.h }; if (stroke.tool === 'text') { const lines = (d.text || '').split('\n').length; return { x: d.x, y: d.y, w: 400, h: (d.fontSize || 22) * 1.45 * (lines + 1) }; } - if (stroke.tool === 'shape' || stroke.tool === 'connector') { + if (stroke.tool === 'connector') { + const ep = this._getConnectorEndpoints(d); + return { + x: Math.min(ep.x1, ep.x2), y: Math.min(ep.y1, ep.y2), + w: Math.abs(ep.x2 - ep.x1) || 20, h: Math.abs(ep.y2 - ep.y1) || 20, + }; + } + if (stroke.tool === 'shape') { return { x: Math.min(d.x1, d.x2), y: Math.min(d.y1, d.y2), w: Math.abs(d.x2 - d.x1) || 20, h: Math.abs(d.y2 - d.y1) || 20, @@ -261,10 +374,16 @@ class Whiteboard { /* Move any stroke by virtual delta */ _moveStroke(stroke, dvx, dvy) { const d = stroke.data; + if (stroke.tool === 'mindmap') { stroke.data.x += dvx; stroke.data.y += dvy; return; } + if (stroke.tool === 'compass') { d.cx += dvx; d.cy += dvy; return; } if (this._isObjectStroke(stroke) || stroke.tool === 'text') { d.x += dvx; d.y += dvy; - } else if (stroke.tool === 'shape' || stroke.tool === 'connector') { + } else if (stroke.tool === 'shape') { d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy; + } else if (stroke.tool === 'connector') { + if (!d.fromId) { d.x1 += dvx; d.y1 += dvy; } + if (!d.toId) { d.x2 += dvx; d.y2 += dvy; } + if (d.fromId && d.toId) { d.x1 += dvx; d.y1 += dvy; d.x2 += dvx; d.y2 += dvy; } } else if (d.points) { d.points = d.points.map(([px, py]) => [px + dvx, py + dvy]); } @@ -290,8 +409,11 @@ class Whiteboard { else { data.y1 = newMaxY; data.y2 = newMinY; } } + setSnapEnabled(v) { this._snapEnabled = v; if (!v) { this._snapGuides = []; this.render(); } } + /* Snap guides: compute alignment guides for movingStroke vs. all other strokes */ _computeSnapGuides(movingStroke) { + if (!this._snapEnabled) { this._snapGuides = []; return; } const SNAP = 8; // virtual-pixel threshold const b = this._getStrokeBBox(movingStroke); const guides = []; @@ -315,10 +437,218 @@ class Whiteboard { this._snapGuides = guides; } + /* ── Mind map helpers ─────────────────────────────────────────── */ + + _mmNodeDepth(data, nodeId) { + let depth = 0, current = nodeId; + while (depth < 20) { + const node = data.nodes.find(n => n.id === current); + if (!node || node.parentId === null) break; + current = node.parentId; + depth++; + } + return depth; + } + + _mmNodeSize(depth) { + if (depth === 0) return { w: 148, h: 54 }; + if (depth === 1) return { w: 124, h: 42 }; + return { w: 106, h: 36 }; + } + + _mmNodeAbsPos(data, node) { + return { x: data.x + node.relX, y: data.y + node.relY }; + } + + _mmNodeRect(data, node) { + const pos = this._mmNodeAbsPos(data, node); + const sz = this._mmNodeSize(this._mmNodeDepth(data, node.id)); + return { x: pos.x - sz.w / 2, y: pos.y - sz.h / 2, w: sz.w, h: sz.h, cx: pos.x, cy: pos.y }; + } + + _hitTestMindmapNode(stroke, vx, vy) { + const d = stroke.data; + for (let i = d.nodes.length - 1; i >= 0; i--) { + const n = d.nodes[i]; + const r = this._mmNodeRect(d, n); + if (vx >= r.x && vx <= r.x + r.w && vy >= r.y && vy <= r.y + r.h) return n; + } + return null; + } + + _mmSubtree(data, nodeId) { + const ids = new Set([nodeId]); + let changed = true; + while (changed) { + changed = false; + for (const n of data.nodes) { + if (!ids.has(n.id) && ids.has(n.parentId)) { ids.add(n.id); changed = true; } + } + } + return ids; + } + + _addMindmapChild(stroke, parentNodeId) { + const data = stroke.data; + const parent = data.nodes.find(n => n.id === parentNodeId); + if (!parent) return; + const depth = this._mmNodeDepth(data, parentNodeId); + const siblings = data.nodes.filter(n => n.parentId === parentNodeId); + const colors = ['#06D6E0', '#F15BB5', '#A8E063', '#FF9F43', '#FF6B6B', '#4361EE', '#9B5DE5']; + const col = depth === 0 ? colors[siblings.length % colors.length] : (parent.color || '#06D6E0'); + const spread = 140; + const dist = depth === 0 ? 280 : 220; + let relX, relY; + if (depth === 0) { + const rightCount = data.nodes.filter(n => n.parentId === 'root' && n.relX > 0).length; + const leftCount = data.nodes.filter(n => n.parentId === 'root' && n.relX < 0).length; + const goRight = rightCount <= leftCount; + relX = goRight ? dist : -dist; + const sideNodes = data.nodes.filter(n => n.parentId === 'root' && (goRight ? n.relX > 0 : n.relX < 0)); + relY = sideNodes.length === 0 ? 0 : (sideNodes.length % 2 === 0 ? -1 : 1) * Math.ceil(sideNodes.length / 2) * spread; + } else { + relX = parent.relX + (parent.relX >= 0 ? dist : -dist); + if (siblings.length === 0) relY = parent.relY; + else if (siblings.length === 1) relY = parent.relY - spread; + else relY = parent.relY + (siblings.length - 1) * (spread * 0.7) * (siblings.length % 2 === 0 ? 1 : -1); + } + const newId = `mm_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`; + data.nodes.push({ id: newId, text: 'Новый узел', parentId: parentNodeId, relX, relY, color: col }); + this._selectedMindmapNodeId = newId; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); + setTimeout(() => { + if (this._editingMindmapStroke === stroke) this._editMindmapNodeText(stroke, newId); + }, 60); + } + + _deleteMindmapNode(stroke, nodeId) { + const data = stroke.data; + if (!nodeId) return; + const node = data.nodes.find(n => n.id === nodeId); + if (!node || node.parentId === null) return; + const toDelete = this._mmSubtree(data, nodeId); + data.nodes = data.nodes.filter(n => !toDelete.has(n.id)); + this._selectedMindmapNodeId = null; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); + } + + _editMindmapNodeText(stroke, nodeId) { + const data = stroke.data; + const node = data.nodes.find(n => n.id === nodeId); + if (!node) return; + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeObjectInput(); + const r = this._mmNodeRect(data, node); + const [cx, cy] = this._toCanvas(r.x, r.y); + const cw = (r.w / Whiteboard.VW) * this._cssW; + const ch = (r.h / Whiteboard.VH) * this._cssH; + const depth = this._mmNodeDepth(data, nodeId); + const vFontSize = depth === 0 ? 15 : 13; + const fs = Math.max(9, Math.round((vFontSize / Whiteboard.VH) * this._cssH)); + const ta = document.createElement('textarea'); + ta.value = node.text || ''; + ta.style.cssText = ` + position:absolute; left:${cx}px; top:${cy}px; + width:${Math.max(60, cw)}px; height:${Math.max(22, ch)}px; + font-size:${fs}px; font-family:'Manrope',sans-serif; font-weight:600; + color:#fff; background:transparent; border:none; outline:none; resize:none; + padding:2px 6px; text-align:center; line-height:1.35; + box-sizing:border-box; caret-color:#fff; z-index:20; + `; + wrap.style.position = 'relative'; + wrap.appendChild(ta); + ta.focus(); + ta.select(); + this._objectInput = { el: ta, strokeId: stroke.id }; + const commit = () => { + const text = ta.value.trim() || node.text; + this._removeObjectInput(); + const s = this._strokes.find(x => x.id === stroke.id); + if (!s) return; + const n2 = s.data.nodes.find(n => n.id === nodeId); + if (n2) n2.text = text || 'Узел'; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(s); + }; + ta.addEventListener('keydown', ev => { + ev.stopPropagation(); + if (ev.key === 'Escape') { ev.preventDefault(); this._removeObjectInput(); } + if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); } + if (ev.key === 'Tab') { ev.preventDefault(); commit(); setTimeout(() => { if (this._editingMindmapStroke) this._addMindmapChild(this._editingMindmapStroke, nodeId); }, 80); } + }); + ta.addEventListener('blur', () => setTimeout(commit, 120)); + } + + _enterMindmapEdit(stroke, vx, vy) { + this._editingMindmapStroke = stroke; + this._selectedId = stroke.id; + const nodeHit = this._hitTestMindmapNode(stroke, vx, vy); + this._selectedMindmapNodeId = nodeHit ? nodeHit.id : 'root'; + this.render(); + } + + _getAnchorPoint(stroke, anchor) { + const b = this._getStrokeBBox(stroke); + const cx = b.x + b.w / 2, cy = b.y + b.h / 2; + switch (anchor) { + case 'n': return { x: cx, y: b.y }; + case 's': return { x: cx, y: b.y + b.h }; + case 'e': return { x: b.x + b.w, y: cy }; + case 'w': return { x: b.x, y: cy }; + default: return { x: cx, y: cy }; + } + } + + _findNearestAnchor(vx, vy, excludeId = null) { + const THRESH = 40; + let best = null, bestDist = THRESH; + const anchors = ['n', 's', 'e', 'w', 'center']; + for (let i = this._strokes.length - 1; i >= 0; i--) { + const s = this._strokes[i]; + if (s.tool === 'connector' || s.tool === 'pencil' || s.tool === 'highlighter' || s.tool === 'eraser') continue; + if (s.data.isBackground || s.data.locked) continue; + if (s.id === excludeId) continue; + for (const anchor of anchors) { + const p = this._getAnchorPoint(s, anchor); + const dist = Math.hypot(vx - p.x, vy - p.y); + if (dist < bestDist) { bestDist = dist; best = { stroke: s, anchor, x: p.x, y: p.y }; } + } + } + return best; + } + + _getConnectorEndpoints(d) { + let x1 = d.x1, y1 = d.y1, x2 = d.x2, y2 = d.y2; + if (d.fromId) { + const s = this._strokes.find(x => x.id === d.fromId); + if (s) { const p = this._getAnchorPoint(s, d.fromAnchor || 'center'); x1 = p.x; y1 = p.y; } + } + if (d.toId) { + const s = this._strokes.find(x => x.id === d.toId); + if (s) { const p = this._getAnchorPoint(s, d.toAnchor || 'center'); x2 = p.x; y2 = p.y; } + } + return { x1, y1, x2, y2 }; + } + + _anchorDirection(anchor) { + switch (anchor) { + case 'n': return [0, -1]; case 's': return [0, 1]; + case 'e': return [1, 0]; case 'w': return [-1, 0]; + default: return [0, 0]; + } + } + /* Hit-test any stroke (all types) — returns topmost hit */ _hitTestAny(vx, vy) { for (let i = this._strokes.length - 1; i >= 0; i--) { const s = this._strokes[i]; + if (s.data.isBackground || s.data.locked) continue; const b = this._getStrokeBBox(s); if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) return s; } @@ -387,21 +717,30 @@ class Whiteboard { /* ── pointer handlers ───────────────────────────────────────────────── */ + _beginPan(e) { + const rect = this._canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + this._panStartCss = [cx, cy]; + this._panStartPan = [this._panVX, this._panVY]; + this._canvas.style.cursor = 'grabbing'; + this._canvas.setPointerCapture(e.pointerId); + } + _onPointerDown(e) { // ── pan mode (Space + drag) ─────────────────────────────────────────── - if (this._spaceDown) { - const rect = this._canvas.getBoundingClientRect(); - const cx = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left; - const cy = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top; - this._panStartCss = [cx, cy]; - this._panStartPan = [this._panVX, this._panVY]; - this._canvas.style.cursor = 'grabbing'; - this._canvas.setPointerCapture(e.pointerId); - return; - } + if (this._spaceDown) { this._beginPan(e); return; } const [vx, vy] = this._pointerPos(e); + // Pressure-sensitive width for stylus + if (this._stylusMultiplier > 0 && e.pointerType === 'pen') { + const p = Math.max(0.1, e.pressure || 0.5); + this._effectiveWidth = Math.max(1, Math.round(this._width * p * this._stylusMultiplier)); + } else { + this._effectiveWidth = null; + } + // ── overlay drag (ruler/protractor) — always checked first ──────────── if (this._overlays.length > 0) { const hit = this._hitTestOverlay(vx, vy); @@ -444,6 +783,50 @@ class Whiteboard { this._lastClickVx = vx; this._lastClickVy = vy; + // ── mindmap edit mode: intercept pointer events ────────────────────── + if (this._editingMindmapStroke && this._tool === 'select') { + const mm = this._editingMindmapStroke; + const nodeHit = this._hitTestMindmapNode(mm, vx, vy); + if (nodeHit) { + if (dbl) { this._editMindmapNodeText(mm, nodeHit.id); return; } + this._selectedMindmapNodeId = nodeHit.id; + this._mmNodeDragStart = { nodeId: nodeHit.id, lastVx: vx, lastVy: vy }; + this._canvas.setPointerCapture(e.pointerId); + this._staticDirty = true; + this.render(); + return; + } + // Check "+" button + if (this._selectedMindmapNodeId) { + const selNode = mm.data.nodes.find(n => n.id === this._selectedMindmapNodeId); + if (selNode) { + const r = this._mmNodeRect(mm.data, selNode); + const btnAbsX = r.x + r.w + 10 + 10; + const btnAbsY = r.y + r.h / 2; + if (Math.hypot(vx - btnAbsX, vy - btnAbsY) < 18) { + this._addMindmapChild(mm, this._selectedMindmapNodeId); + return; + } + } + } + // Click outside mindmap bbox: exit editing + const bbox = this._getStrokeBBox(mm); + const pad = 30; + if (vx < bbox.x - pad || vx > bbox.x + bbox.w + pad || vy < bbox.y - pad || vy > bbox.y + bbox.h + pad) { + this._editingMindmapStroke = null; + this._selectedMindmapNodeId = null; + this._mmNodeDragStart = null; + this.render(); + // Fall through to normal select behavior + } else { + // Click inside mindmap but not on a node: deselect node + this._selectedMindmapNodeId = null; + this._staticDirty = true; + this.render(); + return; + } + } + // Check handles on the primary selected stroke const primarySel = this._selectedIds.size === 1 ? this._strokes.find(s => s.id === this._selectedId) : null; @@ -536,9 +919,10 @@ class Whiteboard { // ── laser pointer (ephemeral — not saved) ──────────────────────────── if (this._tool === 'laser') { this._canvas.setPointerCapture(e.pointerId); - this._drawing = true; - this._laserPos = [vx, vy]; - this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`; + this._drawing = true; + this._laserPos = [vx, vy]; + this._laserTrail = [[vx, vy]]; + this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`; if (this._onStrokeProgress) this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); this.render(); @@ -586,16 +970,23 @@ class Whiteboard { return; } - // ── compass tool ───────────────────────────────────────────────────── - if (this._tool === 'compass') { - const W = 260, H = 260; - const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W)); - const y = Math.max(0, Math.min(vy - H / 2, Whiteboard.VH - H)); + // ── mindmap tool ─────────────────────────────────────────────────────── + if (this._tool === 'mindmap') { + const colors = ['#06D6E0', '#F15BB5', '#A8E063', '#FF9F43', '#FF6B6B', '#4361EE']; + const rightX = 280, leftX = -280; const stroke = { - id: this._localIdCounter--, tool: 'compass', - data: { x, y, w: W, h: H, angle: 0, spread: Math.PI / 4, - arcAngle: Math.PI * 2, arcStart: 0, - color: this._color, radius: null, showRadius: true }, + id: this._localIdCounter--, tool: 'mindmap', + data: { + x: vx, y: vy, + nodes: [ + { id: 'root', text: 'Главная идея', parentId: null, relX: 0, relY: 0, color: '#9B5DE5' }, + { id: 'mm1', text: 'Ветка 1', parentId: 'root', relX: rightX, relY: -140, color: colors[0] }, + { id: 'mm2', text: 'Ветка 2', parentId: 'root', relX: rightX, relY: 0, color: colors[1] }, + { id: 'mm3', text: 'Ветка 3', parentId: 'root', relX: rightX, relY: 140, color: colors[2] }, + { id: 'mm4', text: 'Ветка 4', parentId: 'root', relX: leftX, relY: -70, color: colors[3] }, + { id: 'mm5', text: 'Ветка 5', parentId: 'root', relX: leftX, relY: 70, color: colors[4] }, + ], + }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); @@ -605,6 +996,34 @@ class Whiteboard { this.render(); if (this._onStrokeDone) this._onStrokeDone(stroke); if (this._onObjectCreated) this._onObjectCreated(stroke); + this._editingMindmapStroke = stroke; + this._selectedMindmapNodeId = 'root'; + if (this._onToolSwitch) this._onToolSwitch('select'); + return; + } + + // ── compass tool: two-phase state machine ──────────────────────────── + if (this._tool === 'compass') { + e.preventDefault(); + this._canvas.setPointerCapture(e.pointerId); + if (this._compassState === 'idle') { + // Phase 1: set center, start dragging for radius + this._compassCenter = { x: vx, y: vy }; + this._compassRadius = 0; + this._compassCurrentAngle = -Math.PI / 2; + this._compassLastAngle = -Math.PI / 2; + this._compassState = 'setting-radius'; + this._drawing = true; + } else if (this._compassState === 'waiting-arc') { + // Phase 2: set arc start, start dragging for sweep + const dx = vx - this._compassCenter.x, dy = vy - this._compassCenter.y; + this._compassArcStart = Math.atan2(dy, dx); + this._compassArcSweep = 0; + this._compassCurrentAngle = this._compassArcStart; + this._compassLastAngle = this._compassArcStart; + this._compassState = 'drawing-arc'; + } + this.render(); return; } @@ -612,6 +1031,14 @@ class Whiteboard { this._canvas.setPointerCapture(e.pointerId); this._drawing = true; this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`; + if (this._tool === 'connector') { + const snap = this._findNearestAnchor(vx, vy); + if (snap) { + this._connSnapStart = { strokeId: snap.stroke.id, anchor: snap.anchor, x: snap.x, y: snap.y }; + } else { + this._connSnapStart = null; + } + } if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { this._shapeStart = [vx, vy]; this._shapeEnd = [vx, vy]; @@ -637,11 +1064,12 @@ class Whiteboard { } const [vx, vy] = this._pointerPos(e); + this._pointerVx = vx; this._pointerVy = vy; - // Broadcast cursor position to other participants (throttled to 50ms) + // Broadcast cursor position to other participants (throttled to ~30fps) if (this._onCursorMove) { const now = Date.now(); - if (now - this._cursorThrottle > 100) { + if (now - this._cursorThrottle > 33) { this._cursorThrottle = now; this._onCursorMove(vx, vy); } @@ -674,15 +1102,38 @@ class Whiteboard { return; } - // ── laser: update position ──────────────────────────────────────────── + // ── laser: update position + trail ─────────────────────────────────── if (this._tool === 'laser' && this._drawing) { this._laserPos = [vx, vy]; + this._laserTrail.push([vx, vy]); + if (this._laserTrail.length > 25) this._laserTrail.shift(); if (this._onStrokeProgress && this._liveId) this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); this.render(); return; } + // ── compass: update radius or arc sweep ────────────────────────────── + if (this._tool === 'compass' && this._compassState !== 'idle') { + const dx = vx - this._compassCenter.x, dy = vy - this._compassCenter.y; + if (this._compassState === 'setting-radius') { + this._compassRadius = Math.hypot(dx, dy); + this._compassCurrentAngle = Math.atan2(dy, dx); + } else if (this._compassState === 'drawing-arc') { + this._compassCurrentAngle = Math.atan2(dy, dx); + // Accumulate sweep with wrap-around handling + let delta = this._compassCurrentAngle - this._compassLastAngle; + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + this._compassArcSweep += delta; + // Clamp to ±2π (full circle max) + this._compassArcSweep = Math.max(-Math.PI * 2, Math.min(Math.PI * 2, this._compassArcSweep)); + this._compassLastAngle = this._compassCurrentAngle; + } + this.render(); + return; + } + // ── select: update cursor + drag ────────────────────────────────────── if (this._tool === 'select') { // Lasso update @@ -722,6 +1173,22 @@ class Whiteboard { this._canvas.style.cursor = cur; } + // Mindmap node drag + if (this._mmNodeDragStart) { + const mm = this._editingMindmapStroke; + if (mm) { + const dvx = vx - this._mmNodeDragStart.lastVx; + const dvy = vy - this._mmNodeDragStart.lastVy; + this._mmNodeDragStart.lastVx = vx; + this._mmNodeDragStart.lastVy = vy; + const node = mm.data.nodes.find(n => n.id === this._mmNodeDragStart.nodeId); + if (node) { node.relX += dvx; node.relY += dvy; } + this._staticDirty = true; + this.render(); + return; + } + } + if (!this._dragState) return; const ds = this._dragState; if (ds.type === 'move') { @@ -761,27 +1228,88 @@ class Whiteboard { return; } + // Connector: update hover shape + snap target (even when not drawing) + if (this._tool === 'connector') { + const hit = this._hitTestAny(vx, vy); + this._connHoverShapeId = hit && hit.tool !== 'connector' ? hit.id : null; + const snapEndRaw = this._findNearestAnchor(vx, vy, this._connSnapStart?.strokeId); + this._connSnapEnd = snapEndRaw ? { strokeId: snapEndRaw.stroke.id, anchor: snapEndRaw.anchor, x: snapEndRaw.x, y: snapEndRaw.y } : null; + if (this._connSnapEnd && this._drawing) { + this._shapeEnd = [this._connSnapEnd.x, this._connSnapEnd.y]; + this.render(); + return; + } + } + if (!this._drawing) return; if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { - this._shapeEnd = [vx, vy]; + let [ex, ey] = [vx, vy]; + // Shift: proportional constraint for shapes (square/circle) + if (e.shiftKey && this._isShapeTool() && this._shapeStart) { + const [sx, sy] = this._shapeStart; + const dxv = ex - sx, dyv = ey - sy; + const side = Math.max(Math.abs(dxv), Math.abs(dyv)); + ex = sx + (dxv >= 0 ? side : -side); + ey = sy + (dyv >= 0 ? side : -side); + } + this._shapeEnd = [ex, ey]; this.render(); } else { - this._curPts.push([vx, vy]); + // Shift: straight line (snapped to 0°/45°/90°) for pencil and highlighter + if (e.shiftKey && this._curPts.length >= 1 && + (this._tool === 'pencil' || this._tool === 'highlighter')) { + const [sx, sy] = this._curPts[0]; + const dx = vx - sx, dy = vy - sy; + const angle = Math.atan2(dy, dx); + const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4); + const dist = Math.hypot(dx, dy); + this._curPts = [this._curPts[0], [sx + Math.cos(snapped) * dist, sy + Math.sin(snapped) * dist]]; + } else { + this._curPts.push([vx, vy]); + } this.render(); } if (this._onStrokeProgress && !this._progressTimer) { this._progressTimer = setTimeout(() => { this._progressTimer = null; this._flushProgress(); - }, 50); + }, 20); } } _onWheel(e) { e.preventDefault(); - const rect = this._canvas.getBoundingClientRect(); - const cx = e.clientX - rect.left; - const cy = e.clientY - rect.top; + const rect = this._canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + + // Ctrl+wheel or pinch-zoom (ctrlKey set by browser on pinch) + if (e.ctrlKey) { + const [vx, vy] = this._toVirtual(cx, cy); + const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; + this.zoomTo(this._zoom * factor, vx, vy); + return; + } + + // Shift+wheel → horizontal pan + if (e.shiftKey) { + const sx = (this._cssW || 300) / Whiteboard.VW; + this._panVX += e.deltaY / (sx * this._zoom); + this._clampPan(); this._staticDirty = true; this.render(); + return; + } + + // Two-finger trackpad scroll (deltaMode=0, both axes) → pan + if (e.deltaMode === 0 && Math.abs(e.deltaX) > 2) { + const sx = (this._cssW || 300) / Whiteboard.VW; + const sy = (this._cssH || 150) / Whiteboard.VH; + this._panVX += e.deltaX / (sx * this._zoom); + this._panVY += e.deltaY / (sy * this._zoom); + this._clampPan(); this._staticDirty = true; this.render(); + return; + } + + // Regular wheel → zoom const [vx, vy] = this._toVirtual(cx, cy); const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; this.zoomTo(this._zoom * factor, vx, vy); @@ -813,6 +1341,45 @@ class Whiteboard { if (this._onZoomChange) this._onZoomChange(1); } + /* Zoom to fit all visible strokes, with optional padding in virtual px. + Falls back to resetView() when there are no strokes. */ + zoomFitStrokes(padding = 80) { + const VW = Whiteboard.VW, VH = Whiteboard.VH; + if (!this._strokes.length) { this.resetView(); return; } + + let minX = VW, minY = VH, maxX = 0, maxY = 0; + for (const s of this._strokes) { + const b = this._getStrokeBBox(s); + if (b.w === VW && b.h === VH) continue; // skip fallback bbox (unknown type) + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + if (maxX <= minX || maxY <= minY) { this.resetView(); return; } + + // Add padding, clamp to board bounds + minX = Math.max(0, minX - padding); + minY = Math.max(0, minY - padding); + maxX = Math.min(VW, maxX + padding); + maxY = Math.min(VH, maxY + padding); + + const contentW = maxX - minX; + const contentH = maxY - minY; + + // zoom so the content bbox fills the canvas (pick limiting axis) + const zoom = Math.min(VW / contentW, VH / contentH, 8); + this._zoom = Math.max(0.25, zoom); + + // center the content + this._panVX = (minX + maxX) / 2 - VW / (2 * this._zoom); + this._panVY = (minY + maxY) / 2 - VH / (2 * this._zoom); + this._clampPan(); + this._staticDirty = true; + this.render(); + if (this._onZoomChange) this._onZoomChange(this._zoom); + } + _clampPan() { const VW = Whiteboard.VW, VH = Whiteboard.VH; const visW = VW / this._zoom; @@ -861,16 +1428,16 @@ class Whiteboard { if (!this._shapeStart || !this._shapeEnd) return; data = { shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], - color: this._color, width: this._width, fill: this._fill }; + color: this._color, width: this._w, fill: this._fill }; } else if (this._tool === 'connector') { if (!this._shapeStart || !this._shapeEnd) return; data = { x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], - color: this._color, width: this._width, arrowEnd: true, arrowStart: false }; + color: this._color, width: this._w, arrowEnd: true, arrowStart: false }; } else { if (this._curPts.length === 0) return; data = { points: [...this._curPts], - color: this._tool === 'eraser' ? null : this._color, width: this._width }; + color: this._tool === 'eraser' ? null : this._color, width: this._w }; } this._onStrokeProgress({ liveId: this._liveId, @@ -896,11 +1463,50 @@ class Whiteboard { return; } + // ── compass: phase transitions ──────────────────────────────────────── + if (this._tool === 'compass') { + if (this._compassState === 'setting-radius') { + if (this._compassRadius < 15) { + // Too small: cancel + this._compassState = 'idle'; this._drawing = false; + } else { + this._compassState = 'waiting-arc'; + // Stay in "drawing" state so the preview stays visible + } + this.render(); + } else if (this._compassState === 'drawing-arc') { + if (Math.abs(this._compassArcSweep) > 0.05) { + const stroke = { + id: this._localIdCounter--, tool: 'compass', + data: { + cx: this._compassCenter.x, cy: this._compassCenter.y, + radius: Math.max(1, Math.round(this._compassRadius)), + arcStart: this._compassArcStart, + arcSweep: this._compassArcSweep, + color: this._color, + lineWidth: this._width, + showLegs: true, + }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._staticDirty = true; + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + } + this._compassState = 'idle'; this._drawing = false; + this.render(); + } + return; + } + // ── laser pointer: cancel preview ───────────────────────────────────── if (this._tool === 'laser') { - this._drawing = false; - this._laserPos = null; - const liveId = this._liveId; + this._drawing = false; + this._laserPos = null; + this._laserTrail = []; + const liveId = this._liveId; this._liveId = null; if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); this.render(); @@ -925,6 +1531,14 @@ class Whiteboard { this.render(); return; } + // Mindmap node drag end + if (this._mmNodeDragStart) { + const mm = this._editingMindmapStroke; + if (mm && this._onStrokeUpdated) this._onStrokeUpdated(mm); + this._mmNodeDragStart = null; + this.render(); + return; + } if (this._dragState) { this._snapGuides = []; // Notify server of updated positions for all moved strokes @@ -954,12 +1568,13 @@ class Whiteboard { if (vh < 40) vh = 240; vx = Math.min(vx, Whiteboard.VW - vw); vy = Math.min(vy, Whiteboard.VH - vh); + const tR = this._tableRows || 3, tC = this._tableCols || 4; const stroke = { id: this._localIdCounter--, tool: 'table', data: { x: Math.max(0, vx), y: Math.max(0, vy), w: vw, h: vh, - rows: 3, cols: 4, - cells: [['','','',''], ['','','',''], ['','','','']], + rows: tR, cols: tC, + cells: Array.from({ length: tR }, () => Array(tC).fill('')), borderColor: '#9B5DE5', bgColor: 'rgba(26,22,37,0.85)', textColor: '#e8e0f7', @@ -979,19 +1594,29 @@ class Whiteboard { // ── connector tool ──────────────────────────────────────────────────── if (this._tool === 'connector') { - const [x1, y1] = this._shapeStart; - const [x2, y2] = this._shapeEnd || this._shapeStart; + let [x1, y1] = this._shapeStart; + let [x2, y2] = this._shapeEnd || this._shapeStart; + if (this._connSnapStart) { x1 = this._connSnapStart.x; y1 = this._connSnapStart.y; } + if (this._connSnapEnd) { x2 = this._connSnapEnd.x; y2 = this._connSnapEnd.y; } if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) { if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); + this._connSnapStart = null; this._connSnapEnd = null; return; } + const isCurved = !!(this._connSnapStart || this._connSnapEnd); + const data = { x1, y1, x2, y2, color: this._color, width: this._width, + arrowEnd: true, arrowStart: false, + lineStyle: this._lineStyle, opacity: this._opacity, + connStyle: isCurved ? 'curved' : 'straight', + }; + if (this._connSnapStart) { data.fromId = this._connSnapStart.strokeId; data.fromAnchor = this._connSnapStart.anchor; } + if (this._connSnapEnd) { data.toId = this._connSnapEnd.strokeId; data.toAnchor = this._connSnapEnd.anchor; } const stroke = { id: this._localIdCounter--, tool: 'connector', - data: { x1, y1, x2, y2, color: this._color, width: this._width, - arrowEnd: true, arrowStart: false, - lineStyle: this._lineStyle, opacity: this._opacity }, + data, }; this._shapeStart = null; this._shapeEnd = null; + this._connSnapStart = null; this._connSnapEnd = null; this._connHoverShapeId = null; this._strokes.push(stroke); this._undoStack.push(stroke.id); this._redoStack = []; @@ -1012,7 +1637,7 @@ class Whiteboard { const stroke = { id: this._localIdCounter--, tool: 'shape', data: { shape: this._tool, x1, y1, x2, y2, - color: this._color, width: this._width, fill: this._fill, + color: this._color, width: this._w, fill: this._fill, lineStyle: this._lineStyle, opacity: this._opacity }, }; this._shapeStart = null; this._shapeEnd = null; @@ -1033,7 +1658,7 @@ class Whiteboard { const stroke = { id: this._localIdCounter--, tool: this._tool, data: { points: this._curPts, - color: this._tool === 'eraser' ? null : this._color, width: this._width, + color: this._tool === 'eraser' ? null : this._color, width: this._w, lineStyle: this._lineStyle, opacity: this._opacity }, }; this._curPts = []; @@ -1061,10 +1686,12 @@ class Whiteboard { const offX = canvasRect.left - wrapRect.left; const offY = canvasRect.top - wrapRect.top; - const fs = Math.max(14, Math.round((22 / Whiteboard.VH) * ch)); + const fs = Math.max(10, Math.round((this._textFontSize / Whiteboard.VH) * ch)); const W = Math.max(120, Math.min(300, cw - 16)); const left = Math.min(cx + offX, offX + cw - W - 4); const top = Math.max(offY, Math.min(cy + offY, offY + ch - 110)); + const fw = this._textBold ? 'bold' : 'normal'; + const fi = this._textItalic ? 'italic' : 'normal'; const ta = document.createElement('textarea'); ta.placeholder = 'Введите текст… (Enter — вставить, Esc — отмена)'; @@ -1072,8 +1699,9 @@ class Whiteboard { ta.style.cssText = ` position:absolute; left:${left}px; top:${top}px; width:${W}px; min-height:68px; box-sizing:border-box; - font-size:${fs}px; font-family:'Manrope',sans-serif; - color:${this._color}; + font-size:${fs}px; font-family:'${this._textFontFamily}',sans-serif; + font-weight:${fw}; font-style:${fi}; + color:${this._color}; text-align:${this._textAlign}; background:rgba(12,8,24,0.92); border:1.5px solid rgba(155,93,229,0.7); border-radius:8px; outline:none; resize:none; @@ -1090,7 +1718,13 @@ class Whiteboard { if (!text) return; const stroke = { id: this._localIdCounter--, tool: 'text', - data: { text, x: vx, y: vy, fontSize: 22, color: this._color }, + data: { text, x: vx, y: vy, + fontSize: this._textFontSize, + fontFamily: this._textFontFamily, + fontWeight: this._textBold ? 'bold' : 'normal', + fontStyle: this._textItalic ? 'italic' : 'normal', + textAlign: this._textAlign, + color: this._color }, }; this._strokes.push(stroke); this._undoStack.push(stroke.id); @@ -1141,7 +1775,7 @@ class Whiteboard { const x = Math.max(0, Math.min(vx - W / 2, Whiteboard.VW - W)); const y = Math.max(0, Math.min(vy - 20, Whiteboard.VH - H)); const bgColors = ['#FFE066', '#FF9F7F', '#B5EAD7', '#C7CEEA', '#FFDAC1', '#E2B7F5']; - const bgColor = bgColors[Math.floor(Math.random() * bgColors.length)]; + const bgColor = this._stickyColor || bgColors[Math.floor(Math.random() * bgColors.length)]; const stroke = { id: this._localIdCounter--, tool: 'sticky', data: { x, y, w: W, h: H, text: '', bgColor, textColor: '#1a1a2e', fontSize: 16 }, @@ -1355,9 +1989,11 @@ class Whiteboard { /* ── edit dispatch ──────────────────────────────────────────────────── */ _editObject(stroke, vx, vy) { + if (stroke.tool === 'mindmap') { this._enterMindmapEdit(stroke, vx, vy); return; } if (stroke.tool === 'coordinate') { if (this._onCoordEdit) this._onCoordEdit(stroke); return; } if (stroke.tool === 'numberline') { if (this._onNumberLineEdit) this._onNumberLineEdit(stroke); return; } if (stroke.tool === 'compass') { if (this._onCompassEdit) this._onCompassEdit(stroke); return; } + if (stroke.tool === 'shape') { this._editShapeText(stroke); return; } if (stroke.tool === 'sticky') { this._editSticky(stroke); return; } if (stroke.tool === 'formula') { this._editFormula(stroke); return; } if (stroke.tool === 'table') { @@ -1381,6 +2017,59 @@ class Whiteboard { } } + _editShapeText(stroke) { + const d = stroke.data; + if (d.shape === 'line' || d.shape === 'arrow') return; + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeObjectInput(); + + const canvasRect = this._canvas.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + const offX = canvasRect.left - wrapRect.left; + const offY = canvasRect.top - wrapRect.top; + + const [ccx1, ccy1] = this._toCanvas(Math.min(d.x1, d.x2), Math.min(d.y1, d.y2)); + const [ccx2, ccy2] = this._toCanvas(Math.max(d.x1, d.x2), Math.max(d.y1, d.y2)); + const cw = ccx2 - ccx1, ch = ccy2 - ccy1; + const pad = Math.max(8, cw * 0.06); + const tcol = d.textColor || '#ffffff'; + + const ta = document.createElement('textarea'); + ta.value = d.text || ''; + ta.placeholder = 'Текст…'; + ta.style.cssText = [ + 'position:absolute;', + `left:${offX + ccx1 + pad}px; top:${offY + ccy1 + pad}px;`, + `width:${Math.max(60, cw - pad * 2)}px;`, + `height:${Math.max(36, ch - pad * 2)}px;`, + 'background:rgba(0,0,0,0.18); border:1px dashed rgba(255,255,255,0.45);', + 'border-radius:4px; outline:none; resize:none; overflow:hidden;', + `padding:4px 6px; color:${tcol}; caret-color:${tcol};`, + 'font:bold 14px Manrope,sans-serif; text-align:center;', + 'z-index:20; line-height:1.4;', + ].join(''); + + wrap.appendChild(ta); + ta.focus(); ta.select(); + this._objectInput = ta; + + const commit = () => { + d.text = ta.value.trim() || null; + if (this._objectInput === ta) { ta.remove(); this._objectInput = null; } + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); + }; + + ta.addEventListener('blur', commit); + ta.addEventListener('keydown', e => { + if (e.key === 'Escape') { ta.value = d.text || ''; commit(); } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commit(); } + e.stopPropagation(); + }); + } + /* ── live stroke API (remote preview) ──────────────────────────────── */ setLiveStroke(liveId, tool, data, userName, color) { @@ -1402,60 +2091,111 @@ class Whiteboard { /* ── chalkboard background ──────────────────────────────────────────── */ - _getBgNoise() { - if (this._bgNoise) return this._bgNoise; + // Returns a noise HTMLCanvasElement for the given theme (cached) + _getBgNoiseCanvas(theme) { + if (this._bgNoiseCache.has(theme)) return this._bgNoiseCache.get(theme); try { const SIZE = 256; const oc = document.createElement('canvas'); oc.width = oc.height = SIZE; const oc2 = oc.getContext('2d'); - // Base chalkboard green - oc2.fillStyle = '#213d26'; + const cfg = { + chalkboard: { bg: '#213d26', delta: 22, gBias: 4, smear: 'horizontal', smearAlpha: 0.05, smearColor: '#ffffff', smearCount: 6 }, + blackboard: { bg: '#1a1a2e', delta: 18, gBias: 0, smear: 'diagonal', smearAlpha: 0.03, smearColor: '#a0a0b8', smearCount: 4 }, + corkboard: { bg: '#7a5c1e', delta: 25, gBias: 0, smear: 'diagonal', smearAlpha: 0.04, smearColor: '#c8a050', smearCount: 5 }, + whiteboard: { bg: '#f0f0f0', delta: 5, gBias: 0, smear: 'none', smearAlpha: 0.02, smearColor: '#cccccc', smearCount: 3 }, + }[theme] || { bg: '#213d26', delta: 22, gBias: 4, smear: 'horizontal', smearAlpha: 0.05, smearColor: '#ffffff', smearCount: 6 }; + oc2.fillStyle = cfg.bg; oc2.fillRect(0, 0, SIZE, SIZE); - // Per-pixel noise for chalk dust texture const img = oc2.getImageData(0, 0, SIZE, SIZE); - const d = img.data; - for (let i = 0; i < d.length; i += 4) { - const n = (Math.random() - 0.5) * 22; - d[i] = Math.max(0, Math.min(255, d[i] + n)); - d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n + 4)); // slightly more green variation - d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n)); + const dd = img.data; + for (let i = 0; i < dd.length; i += 4) { + const n = (Math.random() - 0.5) * cfg.delta; + dd[i] = Math.max(0, Math.min(255, dd[i] + n)); + dd[i + 1] = Math.max(0, Math.min(255, dd[i + 1] + n + cfg.gBias)); + dd[i + 2] = Math.max(0, Math.min(255, dd[i + 2] + n)); } oc2.putImageData(img, 0, 0); - // Faint horizontal chalk smear lines - oc2.globalAlpha = 0.05; - oc2.strokeStyle = '#ffffff'; - oc2.lineWidth = 1; - for (let i = 0; i < 6; i++) { - const y = Math.random() * SIZE; - oc2.beginPath(); - oc2.moveTo(0, y); - oc2.lineTo(SIZE, y + (Math.random() - 0.5) * 6); - oc2.stroke(); + if (cfg.smear !== 'none') { + oc2.globalAlpha = cfg.smearAlpha; + oc2.strokeStyle = cfg.smearColor; + oc2.lineWidth = 1; + for (let i = 0; i < cfg.smearCount; i++) { + const x0 = Math.random() * SIZE, y0 = Math.random() * SIZE; + oc2.beginPath(); + if (cfg.smear === 'horizontal') { + oc2.moveTo(0, y0); oc2.lineTo(SIZE, y0 + (Math.random() - 0.5) * 6); + } else { + const angle = Math.random() * Math.PI; + oc2.moveTo(x0, y0); + oc2.lineTo(x0 + Math.cos(angle) * SIZE * 0.4, y0 + Math.sin(angle) * SIZE * 0.4); + } + oc2.stroke(); + } } - this._bgNoise = this._ctx.createPattern(oc, 'repeat'); - } catch { this._bgNoise = false; } - return this._bgNoise; + // Cork knot spots for corkboard + if (theme === 'corkboard') { + for (let k = 0; k < 10; k++) { + const kx = Math.random() * SIZE, ky = Math.random() * SIZE; + const kr = 2 + Math.random() * 3; + oc2.globalAlpha = 0.06; + oc2.fillStyle = '#4a3010'; + oc2.beginPath(); oc2.arc(kx, ky, kr, 0, Math.PI * 2); oc2.fill(); + } + } + this._bgNoiseCache.set(theme, oc); + } catch { this._bgNoiseCache.set(theme, null); } + return this._bgNoiseCache.get(theme); } _renderBg(ctx) { const W = this._cssW || 300; const H = this._cssH || 150; ctx.clearRect(0, 0, W, H); - ctx.fillStyle = '#213d26'; - ctx.fillRect(0, 0, W, H); - const noise = this._getBgNoise(); - if (noise) { - ctx.save(); - ctx.globalCompositeOperation = 'overlay'; - ctx.globalAlpha = 0.12; - ctx.fillStyle = noise; - ctx.fillRect(0, 0, W, H); - ctx.restore(); + const theme = this._boardTheme || 'chalkboard'; + + // Base background + const bgColors = { + chalkboard: '#213d26', + blackboard: '#1a1a2e', + corkboard: '#7a5c1e', + whiteboard: null, // uses gradient + }; + if (theme === 'whiteboard') { + const wg = ctx.createLinearGradient(0, 0, 0, H); + wg.addColorStop(0, '#f6f6f6'); wg.addColorStop(1, '#e8e8e8'); + ctx.fillStyle = wg; + } else { + ctx.fillStyle = bgColors[theme] || '#213d26'; } + ctx.fillRect(0, 0, W, H); + + // Noise texture (overlay composite) + const noiseCanvas = this._getBgNoiseCanvas(theme); + if (noiseCanvas) { + try { + const pat = ctx.createPattern(noiseCanvas, 'repeat'); + if (pat) { + ctx.save(); + ctx.globalCompositeOperation = theme === 'whiteboard' ? 'multiply' : 'overlay'; + ctx.globalAlpha = theme === 'whiteboard' ? 0.08 : 0.12; + ctx.fillStyle = pat; + ctx.fillRect(0, 0, W, H); + ctx.restore(); + } + } catch { /* ignore pattern errors */ } + } + + // Vignette + const vigColors = { + chalkboard: [0, 0, 0, 0.28], + blackboard: [0, 0, 0, 0.35], + corkboard: [40, 20, 0, 0.22], + whiteboard: [0, 0, 0, 0.04], + }[theme] || [0, 0, 0, 0.28]; const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.25, W / 2, H / 2, Math.max(W, H) * 0.78); vg.addColorStop(0, 'rgba(0,0,0,0)'); - vg.addColorStop(1, 'rgba(0,0,0,0.28)'); + vg.addColorStop(1, `rgba(${vigColors.join(',')})`); ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H); } @@ -1463,18 +2203,21 @@ class Whiteboard { _renderTemplate(ctx) { const W = this._cssW || 300; const H = this._cssH || 150; + const isLight = this._boardTheme === 'whiteboard'; + const lineC = isLight ? 'rgba(0,0,0,0.10)' : 'rgba(255,255,255,0.08)'; + const dotC = isLight ? 'rgba(0,0,0,0.20)' : 'rgba(255,255,255,0.18)'; ctx.save(); if (this._template === 'grid') { const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; - ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 0.8; + ctx.strokeStyle = lineC; ctx.lineWidth = 0.8; ctx.beginPath(); for (let x = stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); } else if (this._template === 'lined') { const stepY = (36 / Whiteboard.VH) * H; - ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 0.8; + ctx.strokeStyle = lineC; ctx.lineWidth = 0.8; ctx.beginPath(); for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); @@ -1482,7 +2225,7 @@ class Whiteboard { const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; const r = Math.max(1, (1.5 / Whiteboard.VW) * W); - ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.fillStyle = dotC; for (let x = stepX; x < W; x += stepX) for (let y = stepY; y < H; y += stepY) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); @@ -1491,24 +2234,27 @@ class Whiteboard { const ox = W / 2, oy = H / 2; const stepX = (40 / Whiteboard.VW) * W; const stepY = (40 / Whiteboard.VH) * H; + const coordGridC = isLight ? 'rgba(0,0,0,0.07)' : 'rgba(255,255,255,0.06)'; + const coordAxisC = isLight ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.35)'; + const coordTickC = isLight ? 'rgba(0,0,0,0.28)' : 'rgba(255,255,255,0.25)'; // Light grid - ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.6; + ctx.strokeStyle = coordGridC; ctx.lineWidth = 0.6; ctx.beginPath(); for (let x = ox % stepX; x < W; x += stepX) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } for (let y = oy % stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } ctx.stroke(); // Axes - ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1.2; + ctx.strokeStyle = coordAxisC; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(W, oy); ctx.moveTo(ox, 0); ctx.lineTo(ox, H); ctx.stroke(); // Arrows - const ar = 7; ctx.fillStyle = 'rgba(255,255,255,0.35)'; + const ar = 7; ctx.fillStyle = coordAxisC; ctx.beginPath(); ctx.moveTo(W, oy); ctx.lineTo(W-ar, oy-ar/2); ctx.lineTo(W-ar, oy+ar/2); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox-ar/2, ar); ctx.lineTo(ox+ar/2, ar); ctx.closePath(); ctx.fill(); // Tick marks - ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 0.8; + ctx.strokeStyle = coordTickC; ctx.lineWidth = 0.8; const tk = 4; ctx.beginPath(); for (let x = ox + stepX; x < W - ar; x += stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); } for (let x = ox - stepX; x > 0; x -= stepX) { ctx.moveTo(x, oy-tk); ctx.lineTo(x, oy+tk); } @@ -1519,7 +2265,18 @@ class Whiteboard { ctx.restore(); } + /* Schedule a render on the next animation frame. + Multiple calls within the same frame are collapsed into one. */ render() { + if (this._rafPending) return; + this._rafPending = true; + requestAnimationFrame(() => { + this._rafPending = false; + if (!document.hidden) this._doRender(); + }); + } + + _doRender() { if (this._staticDirty) { this._renderStatic(); this._staticDirty = false; } this._renderDynamic(); this._renderMinimap(); @@ -1529,7 +2286,13 @@ class Whiteboard { const ctx = this._ctx; this._renderBg(ctx); if (this._template && this._template !== 'blank') this._renderTemplate(ctx); - for (const s of this._strokes) this._renderStroke(ctx, s); + // Background images render first (locked layer, always behind everything) + for (const s of this._strokes) { + if (s.tool === 'image' && s.data.isBackground) this._renderStroke(ctx, s); + } + for (const s of this._strokes) { + if (!(s.tool === 'image' && s.data.isBackground)) this._renderStroke(ctx, s); + } } _renderDynamic() { @@ -1543,14 +2306,181 @@ class Whiteboard { this._renderStroke(ctx, ls); } - // Laser pointer (local teacher view) - if (this._tool === 'laser' && this._drawing && this._laserPos) { - const [lvx, lvy] = this._laserPos; - const [lcx, lcy] = this._toCanvas(lvx, lvy); + // Laser pointer (local teacher view) — fade trail + if (this._tool === 'laser' && this._drawing && this._laserTrail.length > 0) { + const n = this._laserTrail.length; ctx.save(); - ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15; - ctx.fillStyle = '#ff4444'; - ctx.beginPath(); ctx.arc(lcx, lcy, 6, 0, Math.PI * 2); ctx.fill(); + for (let i = 0; i < n; i++) { + const [lvx, lvy] = this._laserTrail[i]; + const [lcx, lcy] = this._toCanvas(lvx, lvy); + const t = (i + 1) / n; + ctx.globalAlpha = t * 0.85; + ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15 * t; + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); ctx.arc(lcx, lcy, Math.max(2, 6 * t), 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + } + + // Eraser: visual cursor circle follows pointer + if (this._tool === 'eraser') { + const [ecx, ecy] = this._toCanvas(this._pointerVx, this._pointerVy); + const er = Math.max(4, ((this._w / Whiteboard.VW) * W) / 2); + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.5; + ctx.setLineDash([3, 2]); + ctx.beginPath(); ctx.arc(ecx, ecy, er, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); ctx.restore(); + } + + // Connector: anchor point hints on hover shape + if (this._tool === 'connector') { + const anchors = ['n', 's', 'e', 'w', 'center']; + const shapeIdsToShow = new Set(); + if (this._connHoverShapeId) shapeIdsToShow.add(this._connHoverShapeId); + if (this._drawing && this._connSnapStart) shapeIdsToShow.add(this._connSnapStart.strokeId); + + for (const shapeId of shapeIdsToShow) { + const shape = this._strokes.find(s => s.id === shapeId); + if (!shape || shape.tool === 'connector') continue; + ctx.save(); + for (const anchor of anchors) { + const p = this._getAnchorPoint(shape, anchor); + const [acx, acy] = this._toCanvas(p.x, p.y); + const isSnap = this._connSnapEnd && + this._connSnapEnd.strokeId === shapeId && this._connSnapEnd.anchor === anchor; + ctx.beginPath(); + ctx.arc(acx, acy, isSnap ? 7 : 5, 0, Math.PI * 2); + ctx.fillStyle = isSnap ? '#06D6E0' : 'rgba(6,214,224,0.35)'; + ctx.strokeStyle = isSnap ? '#06D6E0' : 'rgba(6,214,224,0.7)'; + ctx.lineWidth = 1.5; + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); + } + } + + // Compass tool live preview (3 state-machine phases) + if (this._tool === 'compass' && this._compassState !== 'idle' && this._compassCenter) { + const [ccx, ccy] = this._toCanvas(this._compassCenter.x, this._compassCenter.y); + const sx = W / Whiteboard.VW; + const cr = this._compassRadius * sx; + const lw = Math.max(1.5, (this._width || 2) * sx); + const arcColor = this._color || '#FFE066'; + ctx.save(); + + if (this._compassState === 'setting-radius') { + // Dashed ghost circle + radius line to pointer + if (cr > 2) { + ctx.strokeStyle = 'rgba(255,255,255,0.28)'; + ctx.lineWidth = 1; ctx.setLineDash([5, 4]); + ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + } + const [pmx, pmy] = this._toCanvas(this._pointerVx, this._pointerVy); + ctx.strokeStyle = 'rgba(6,214,224,0.4)'; + ctx.lineWidth = 1; ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(ccx, ccy); ctx.lineTo(pmx, pmy); ctx.stroke(); + ctx.setLineDash([]); + // Center dot + ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + // Radius label + if (cr > 14) { + const rLabel = `r = ${Math.round(this._compassRadius)}`; + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.textBaseline = 'bottom'; ctx.textAlign = 'left'; + const tw = ctx.measureText(rLabel).width; + const lx = ccx + cr * 0.28 + 2, ly = ccy - cr * 0.18 - 4; + ctx.fillStyle = 'rgba(20,14,36,0.78)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(lx - 4, ly - 15, tw + 8, 17, 3); + else ctx.rect(lx - 4, ly - 15, tw + 8, 17); + ctx.fill(); + ctx.fillStyle = 'rgba(230,225,255,0.9)'; ctx.fillText(rLabel, lx, ly); + ctx.textAlign = 'left'; + } + + } else if (this._compassState === 'waiting-arc') { + // Dashed full-circle preview + "click to draw arc" hint + ctx.strokeStyle = arcColor + '77'; + ctx.lineWidth = lw; ctx.setLineDash([7, 5]); + ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + // Hint text below circle + const hintText = 'Кликни, чтобы начать дугу'; + ctx.font = '11px Manrope, sans-serif'; + ctx.textBaseline = 'top'; ctx.textAlign = 'center'; + const tw2 = ctx.measureText(hintText).width; + const hx2 = ccx, hy2 = ccy + cr + 10; + ctx.fillStyle = 'rgba(20,14,36,0.82)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(hx2 - tw2 / 2 - 6, hy2 - 2, tw2 + 12, 19, 4); + else ctx.rect(hx2 - tw2 / 2 - 6, hy2 - 2, tw2 + 12, 19); + ctx.fill(); + ctx.fillStyle = 'rgba(200,190,230,0.92)'; ctx.fillText(hintText, hx2, hy2 + 1); + ctx.textAlign = 'left'; + + } else if (this._compassState === 'drawing-arc') { + // Live arc + instrument legs + const arcEnd = this._compassArcStart + this._compassArcSweep; + const ccwArc = this._compassArcSweep < 0; + ctx.strokeStyle = arcColor; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.setLineDash([]); + ctx.shadowColor = arcColor; ctx.shadowBlur = 4; + ctx.beginPath(); ctx.arc(ccx, ccy, cr, this._compassArcStart, arcEnd, ccwArc); ctx.stroke(); + ctx.shadowBlur = 0; + + const ptx = ccx + cr * Math.cos(arcEnd); + const pty = ccy + cr * Math.sin(arcEnd); + const legLen = cr * 1.55; + const dist = Math.hypot(ptx - ccx, pty - ccy); + if (dist >= 2 && dist < legLen * 2) { + const mx = (ccx + ptx) / 2, my = (ccy + pty) / 2; + const perpX = -(pty - ccy) / dist; + const perpY = (ptx - ccx) / dist; + const halfH = Math.sqrt(Math.max(0, legLen * legLen - (dist / 2) * (dist / 2))); + const sign = (my + perpY * halfH) <= (my - perpY * halfH) ? 1 : -1; + const hx3 = mx + sign * perpX * halfH; + const hy3 = my + sign * perpY * halfH; + ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = Math.max(1, lw * 0.7); + ctx.beginPath(); ctx.moveTo(hx3, hy3); ctx.lineTo(ccx, ccy); ctx.stroke(); + ctx.strokeStyle = 'rgba(155,93,229,0.6)'; + ctx.beginPath(); ctx.moveTo(hx3, hy3); ctx.lineTo(ptx, pty); ctx.stroke(); + ctx.fillStyle = 'rgba(215,210,232,0.85)'; + ctx.beginPath(); ctx.arc(hx3, hy3, Math.max(3, lw * 1.2), 0, Math.PI * 2); ctx.fill(); + } + // Center dot + ctx.fillStyle = '#06D6E0'; ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(ccx, ccy, 4, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + // Pencil tip dot + ctx.fillStyle = arcColor; ctx.shadowColor = arcColor; ctx.shadowBlur = 6; + ctx.beginPath(); ctx.arc(ptx, pty, Math.max(2.5, lw * 0.8), 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + // Sweep angle label + const sweepDeg = Math.abs(this._compassArcSweep * 180 / Math.PI); + if (sweepDeg > 3) { + const midAngle = this._compassArcStart + this._compassArcSweep / 2; + const llx = ccx + (cr + 18) * Math.cos(midAngle); + const lly = ccy + (cr + 18) * Math.sin(midAngle); + const lText = `${sweepDeg.toFixed(1)}°`; + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const ltw = ctx.measureText(lText).width; + ctx.fillStyle = 'rgba(20,14,36,0.8)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(llx - ltw / 2 - 4, lly - 9, ltw + 8, 18, 3); + else ctx.rect(llx - ltw / 2 - 4, lly - 9, ltw + 8, 18); + ctx.fill(); + ctx.fillStyle = arcColor; ctx.fillText(lText, llx, lly); + ctx.textAlign = 'left'; + } + } ctx.restore(); } @@ -1599,14 +2529,33 @@ class Whiteboard { this._renderStroke(ctx, { tool: 'connector', data: { x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], - color: this._color, width: this._width, arrowEnd: true, arrowStart: false, + color: this._color, width: this._w, arrowEnd: true, arrowStart: false, }}); } else { this._renderStroke(ctx, { tool: 'shape', data: { shape: this._tool, x1: this._shapeStart[0], y1: this._shapeStart[1], x2: this._shapeEnd[0], y2: this._shapeEnd[1], - color: this._color, width: this._width, fill: this._fill, + color: this._color, width: this._w, fill: this._fill, }}); + // Size label near bottom-right corner of the shape + const [sx, sy] = this._shapeStart, [ex, ey] = this._shapeEnd; + const vw2 = Math.abs(ex - sx), vh2 = Math.abs(ey - sy); + if (vw2 > 8 || vh2 > 8) { + const [labx, laby] = this._toCanvas(Math.max(sx, ex), Math.max(sy, ey)); + const label = `${Math.round(vw2)} × ${Math.round(vh2)}`; + ctx.save(); + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.textBaseline = 'top'; + const tw = ctx.measureText(label).width; + ctx.fillStyle = 'rgba(20,14,36,0.82)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(labx + 6, laby + 4, tw + 12, 20, 4); + else ctx.rect(labx + 6, laby + 4, tw + 12, 20); + ctx.fill(); + ctx.fillStyle = '#e8e0f7'; + ctx.fillText(label, labx + 12, laby + 8); + ctx.restore(); + } } } else if (this._tool === 'table' && this._shapeStart && this._shapeEnd) { const [x1, y1] = this._shapeStart, [x2, y2] = this._shapeEnd; @@ -1627,6 +2576,7 @@ class Whiteboard { } _renderStroke(ctx, stroke) { + if (stroke.tool === 'mindmap') { this._renderMindmap(ctx, stroke); return; } if (stroke.tool === 'shape') { this._renderShape(ctx, stroke); return; } if (stroke.tool === 'text') { this._renderText(ctx, stroke); return; } if (stroke.tool === 'image') { this._renderImage(ctx, stroke); return; } @@ -1818,13 +2768,42 @@ class Whiteboard { break; } } + + // Text inside shape (set via double-click) + if (d.text && d.shape !== 'line' && d.shape !== 'arrow') { + const vH2 = Math.abs(d.y2 - d.y1); + const autoFsV = Math.max(12, Math.min(38, vH2 * 0.18)); + const fs = Math.max(9, Math.round(((d.fontSize || autoFsV) / Whiteboard.VH) * (this._cssH || 150))); + const tpad = Math.max(8, W * 0.06); + const maxTW = W - tpad * 2; + if (maxTW > 10) { + ctx.setLineDash([]); + ctx.fillStyle = d.textColor || '#ffffff'; + ctx.shadowBlur = 0; + ctx.font = `bold ${fs}px Manrope, sans-serif`; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + const tmidX = (cx1 + cx2) / 2, tmidY = (cy1 + cy2) / 2; + const tLines = this._wrapText(ctx, d.text, maxTW); + const lineH = fs * 1.3; + const totalH = tLines.length * lineH; + let ty = tmidY - totalH / 2 + lineH / 2; + const botLim = Math.max(cy1, cy2) - tpad; + for (const line of tLines) { + if (ty > botLim) break; + ctx.fillText(line, tmidX, ty); + ty += lineH; + } + } + } ctx.restore(); } _renderConnector(ctx, stroke) { const d = stroke.data; - const [cx1, cy1] = this._toCanvas(d.x1, d.y1); - const [cx2, cy2] = this._toCanvas(d.x2, d.y2); + const ep = this._getConnectorEndpoints(d); + const [cx1, cy1] = this._toCanvas(ep.x1, ep.y1); + const [cx2, cy2] = this._toCanvas(ep.x2, ep.y2); const lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300)); const dx = cx2 - cx1, dy = cy2 - cy1; @@ -1844,14 +2823,26 @@ class Whiteboard { : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : []; ctx.setLineDash(dash); - // Line + // Line (straight or bezier) ctx.beginPath(); - ctx.moveTo(cx1, cy1); - ctx.lineTo(cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7); + if (d.connStyle === 'curved' && (d.fromAnchor || d.toAnchor)) { + const dist = Math.hypot(cx2 - cx1, cy2 - cy1); + const k = Math.max(40, dist * 0.4); + const [d1x, d1y] = this._anchorDirection(d.fromAnchor || 'center'); + const [d2x, d2y] = this._anchorDirection(d.toAnchor || 'center'); + const cpx1 = cx1 + d1x * k, cpy1 = cy1 + d1y * k; + const cpx2 = cx2 - d2x * k, cpy2 = cy2 - d2y * k; + ctx.moveTo(cx1, cy1); + ctx.bezierCurveTo(cpx1, cpy1, cpx2, cpy2, cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7); + } else { + ctx.moveTo(cx1, cy1); + ctx.lineTo(cx2 - ux * headL * 0.7, cy2 - uy * headL * 0.7); + } ctx.stroke(); // End arrowhead if (d.arrowEnd !== false) { + ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx2, cy2); ctx.lineTo(cx2 - ux * headL + uy * headW, cy2 - uy * headL - ux * headW); @@ -1862,6 +2853,7 @@ class Whiteboard { // Start arrowhead if (d.arrowStart) { + ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(cx1, cy1); ctx.lineTo(cx1 + ux * headL + uy * headW, cy1 + uy * headL - ux * headW); @@ -1875,37 +2867,42 @@ class Whiteboard { _renderHighlighter(ctx, stroke) { const d = stroke.data; if (!d.points || d.points.length < 2) return; - const lw = Math.max(8, (d.width / Whiteboard.VW) * (this._cssW || 300) * 3); - ctx.save(); - ctx.globalCompositeOperation = 'source-over'; - ctx.globalAlpha = 0.38; - ctx.strokeStyle = d.color || '#FFE066'; - ctx.lineWidth = lw; - ctx.lineCap = 'square'; - ctx.lineJoin = 'round'; - ctx.beginPath(); + const W = this._cssW || 300, H = this._cssH || 150; + const lw = Math.max(8, (d.width / Whiteboard.VW) * W * 3); + // Render on offscreen canvas first so self-overlapping segments + // don't accumulate alpha (uniform highlight, no darker crossing points) + const off = document.createElement('canvas'); + off.width = W; off.height = H; + const octx = off.getContext('2d'); + octx.strokeStyle = d.color || '#FFE066'; + octx.lineWidth = lw; + octx.lineCap = 'square'; + octx.lineJoin = 'round'; + octx.beginPath(); const [fx, fy] = this._toCanvas(d.points[0][0], d.points[0][1]); - ctx.moveTo(fx, fy); + octx.moveTo(fx, fy); for (let i = 1; i < d.points.length; i++) { const [px, py] = this._toCanvas(d.points[i][0], d.points[i][1]); - ctx.lineTo(px, py); + octx.lineTo(px, py); } - ctx.stroke(); + octx.stroke(); + ctx.save(); + ctx.globalAlpha = 0.38; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(off, 0, 0); ctx.restore(); } _renderLaser(ctx, stroke) { const d = stroke.data; if (!d.points || d.points.length === 0) return; + // Render only the last point (remote users don't get trail history) const [vx, vy] = d.points[d.points.length - 1]; const [cx, cy] = this._toCanvas(vx, vy); ctx.save(); - ctx.shadowColor = '#ff2222'; - ctx.shadowBlur = 15; + ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15; ctx.fillStyle = '#ff4444'; - ctx.beginPath(); - ctx.arc(cx, cy, 6, 0, Math.PI * 2); - ctx.fill(); + ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } @@ -1914,11 +2911,15 @@ class Whiteboard { if (!d.text) return; const [cx, cy] = this._toCanvas(d.x, d.y); const fontSize = Math.round(((d.fontSize || 22) / Whiteboard.VH) * (this._cssH || 150)); + const fw = d.fontWeight || 'normal'; + const fi = d.fontStyle || 'normal'; + const ff = d.fontFamily || 'Manrope'; ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = d.color || '#ffffff'; - ctx.font = `${fontSize}px 'Manrope', sans-serif`; + ctx.font = `${fi} ${fw} ${fontSize}px '${ff}', sans-serif`; ctx.textBaseline = 'top'; + ctx.textAlign = d.textAlign || 'left'; // chalk effect on text too ctx.shadowColor = d.color || '#ffffff'; ctx.shadowBlur = 1.2; @@ -1942,8 +2943,17 @@ class Whiteboard { if (stroke._img.complete && stroke._img.naturalWidth > 0) { ctx.save(); ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = d.opacity ?? 1.0; if (d.rotation) { const [ocx, ocy] = this._toCanvas(d.x + d.w / 2, d.y + d.h / 2); ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } - ctx.drawImage(stroke._img, cx, cy, cw, ch); + if (d.isBackground && d.fit === 'contain') { + // Letterbox: maintain aspect ratio, center in canvas + const iW = stroke._img.naturalWidth, iH = stroke._img.naturalHeight; + const scale = Math.min(cw / iW, ch / iH); + const dw = iW * scale, dh = iH * scale; + ctx.drawImage(stroke._img, cx + (cw - dw) / 2, cy + (ch - dh) / 2, dw, dh); + } else { + ctx.drawImage(stroke._img, cx, cy, cw, ch); + } ctx.restore(); } } @@ -2009,6 +3019,113 @@ class Whiteboard { ctx.restore(); } + _renderMindmap(ctx, stroke) { + const d = stroke.data; + if (!d.nodes) return; + const isEditing = this._editingMindmapStroke === stroke; + const selNodeId = isEditing ? this._selectedMindmapNodeId : null; + + // Draw edges first (behind nodes) + for (const node of d.nodes) { + if (node.parentId === null) continue; + const parent = d.nodes.find(n => n.id === node.parentId); + if (!parent) continue; + const pr = this._mmNodeRect(d, parent); + const cr = this._mmNodeRect(d, node); + const dx = cr.cx - pr.cx; + const px1 = dx >= 0 ? pr.cx + pr.w / 2 : pr.cx - pr.w / 2; + const px2 = dx >= 0 ? cr.cx - cr.w / 2 : cr.cx + cr.w / 2; + const [cpx1, cpy1] = this._toCanvas(px1, pr.cy); + const [cpx2, cpy2] = this._toCanvas(px2, cr.cy); + const k = Math.abs(cpx2 - cpx1) * 0.5; + ctx.save(); + ctx.strokeStyle = node.color || '#9B5DE5'; + ctx.lineWidth = 1.8; + ctx.globalAlpha = 0.65; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(cpx1, cpy1); + ctx.bezierCurveTo(cpx1 + (dx >= 0 ? k : -k), cpy1, cpx2 + (dx >= 0 ? -k : k), cpy2, cpx2, cpy2); + ctx.stroke(); + ctx.restore(); + } + + // Draw nodes + for (const node of d.nodes) { + const r = this._mmNodeRect(d, node); + const depth = this._mmNodeDepth(d, node.id); + const [cx, cy] = this._toCanvas(r.x, r.y); + const cw = (r.w / Whiteboard.VW) * this._cssW; + const ch = (r.h / Whiteboard.VH) * this._cssH; + const isSelected = isEditing && node.id === selNodeId; + const color = node.color || '#9B5DE5'; + + ctx.save(); + ctx.globalAlpha = 0.92; + ctx.fillStyle = depth === 0 ? color : `${color}33`; + const rad = depth === 0 ? 10 : 8; + if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, rad); + else { ctx.beginPath(); ctx.rect(cx, cy, cw, ch); } + ctx.fill(); + + ctx.strokeStyle = isSelected ? '#ffffff' : color; + ctx.lineWidth = isSelected ? 2 : 1.5; + ctx.globalAlpha = isSelected ? 1.0 : 0.8; + if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, rad); + else { ctx.beginPath(); ctx.rect(cx, cy, cw, ch); } + ctx.stroke(); + + const vFontSize = depth === 0 ? 15 : 13; + const fs = Math.max(8, Math.round((vFontSize / Whiteboard.VH) * this._cssH)); + ctx.fillStyle = depth === 0 ? '#fff' : '#e8e0f7'; + ctx.globalAlpha = 1.0; + ctx.font = `${depth === 0 ? '700' : '600'} ${fs}px Manrope, sans-serif`; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + ctx.shadowBlur = 0; + const lines = this._wrapText(ctx, node.text || '', cw - 10); + const lh = fs * 1.3; + const totalH = lines.length * lh; + let ty = cy + ch / 2 - totalH / 2 + lh / 2; + for (const line of lines) { + ctx.fillText(line, cx + cw / 2, ty); + ty += lh; + } + + if (isSelected) { + const [bx, by] = this._toCanvas(r.x + r.w + 10, r.y + r.h / 2 - 10); + const btnR = Math.max(7, (10 / Whiteboard.VW) * this._cssW); + ctx.beginPath(); + ctx.arc(bx + btnR, by + btnR, btnR, 0, Math.PI * 2); + ctx.fillStyle = '#06D6E0'; + ctx.globalAlpha = 0.9; + ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.globalAlpha = 1.0; + ctx.font = `bold ${Math.round(btnR * 1.4)}px Manrope`; + ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; + ctx.fillText('+', bx + btnR, by + btnR); + } + ctx.restore(); + } + + // Outer selection rect when whole mindmap selected but NOT in node-edit mode + if (!isEditing && this._selectedIds.has(stroke.id)) { + const bbox = this._getStrokeBBox(stroke); + const [bx, by] = this._toCanvas(bbox.x - 10, bbox.y - 10); + const bw = ((bbox.w + 20) / Whiteboard.VW) * this._cssW; + const bh = ((bbox.h + 20) / Whiteboard.VH) * this._cssH; + ctx.save(); + ctx.strokeStyle = 'rgba(155,93,229,0.6)'; + ctx.lineWidth = 1.5; ctx.setLineDash([5, 3]); + if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 8); + else { ctx.beginPath(); ctx.rect(bx, by, bw, bh); } + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } + } + _darkenHex(hex, amount) { try { const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount); @@ -2288,12 +3405,154 @@ class Whiteboard { } setWidth(px) { this._width = px; } setFill(v) { this._fill = v; } + setTextFontSize(n) { this._textFontSize = Math.max(8, Math.min(120, n)); } + setTextFontFamily(f){ this._textFontFamily = f; } + setTextBold(v) { this._textBold = v; } + setTextItalic(v) { this._textItalic = v; } + setTextAlign(v) { this._textAlign = v || 'left'; if (this._textInput) this._textInput.style.textAlign = v; } setReadOnly(v) { this._readOnly = v; } setLineStyle(style) { this._lineStyle = style; } setOpacity(v) { this._opacity = Math.max(0.05, Math.min(1, v)); } + setStylusMultiplier(v) { this._stylusMultiplier = v; } + setBoardTheme(name) { this._boardTheme = name || 'chalkboard'; this._bgNoiseCache.delete(name); this._staticDirty = true; this.render(); } + setStickyColor(c) { this._stickyColor = c || null; } + setTableSize(r, c) { this._tableRows = Math.max(1, r); this._tableCols = Math.max(1, c); } + + // Toggle arrowStart or arrowEnd on the selected connector + toggleConnectorArrow(which) { + if (this._selectedIds.size !== 1) return; + const sel = this._strokes.find(s => s.id === this._selectedId); + if (!sel || sel.tool !== 'connector') return; + sel.data[which] = !sel.data[which]; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(sel); + } + + // Align all selected strokes by edge/center + alignStrokes(direction) { + if (this._selectedIds.size < 2) return; + const items = []; + for (const id of this._selectedIds) { + const s = this._strokes.find(x => x.id === id); + if (s) items.push({ stroke: s, bbox: this._getStrokeBBox(s) }); + } + let ref; + if (direction === 'left') ref = Math.min(...items.map(b => b.bbox.x)); + else if (direction === 'right') ref = Math.max(...items.map(b => b.bbox.x + b.bbox.w)); + else if (direction === 'top') ref = Math.min(...items.map(b => b.bbox.y)); + else if (direction === 'bottom') ref = Math.max(...items.map(b => b.bbox.y + b.bbox.h)); + else if (direction === 'centerH') ref = (Math.min(...items.map(b => b.bbox.x)) + Math.max(...items.map(b => b.bbox.x + b.bbox.w))) / 2; + else if (direction === 'centerV') ref = (Math.min(...items.map(b => b.bbox.y)) + Math.max(...items.map(b => b.bbox.y + b.bbox.h))) / 2; + for (const { stroke, bbox } of items) { + let dvx = 0, dvy = 0; + if (direction === 'left') dvx = ref - bbox.x; + else if (direction === 'right') dvx = ref - (bbox.x + bbox.w); + else if (direction === 'top') dvy = ref - bbox.y; + else if (direction === 'bottom') dvy = ref - (bbox.y + bbox.h); + else if (direction === 'centerH') dvx = ref - (bbox.x + bbox.w / 2); + else if (direction === 'centerV') dvy = ref - (bbox.y + bbox.h / 2); + if (dvx !== 0 || dvy !== 0) { + this._moveStroke(stroke, dvx, dvy); + if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); + } + } + this._staticDirty = true; + this.render(); + } + get _w() { return this._effectiveWidth ?? this._width; } setTemplate(name) { this._template = name || 'blank'; this._staticDirty = true; this.render(); } setPageNum(n) { this._pageNum = n; } + /* ── Insert pre-built educational template ───────────────────────────── */ + insertTemplate(name) { + const VW = Whiteboard.VW, VH = Whiteboard.VH; + // Center of current viewport in virtual coords + const [vcx, vcy] = this._toVirtual((this._cssW || VW) / 2, (this._cssH || VH) / 2); + const cx = vcx, cy = vcy; + + const mk = (tool, data) => ({ tool, data: { ...data } }); + const sh = (shape, x1, y1, x2, y2, color = '#06D6E0', width = 3, fill = false, extra = {}) => + mk('shape', { shape, x1: cx + x1, y1: cy + y1, x2: cx + x2, y2: cy + y2, color, width, fill, ...extra }); + const tx = (x, y, text, color = '#e8e0f7', fontSize = 18) => + mk('text', { x: cx + x, y: cy + y, text, color, fontSize }); + + const tpls = { + venn2: () => [ + sh('ellipse', -310, -200, +90, +200, '#06D6E0'), + sh('ellipse', -90, -200, +310, +200, '#F15BB5'), + ], + venn3: () => [ + sh('ellipse', -250, -220, +50, +100, '#06D6E0'), + sh('ellipse', -50, -220, +250, +100, '#F15BB5'), + sh('ellipse', -140, -80, +140, +240, '#FFE066'), + ], + tchart: () => [ + sh('rect', -390, -230, +390, +230, '#9B5DE5', 2), + sh('line', 0, -230, 0, +230, '#9B5DE5', 2), + sh('line', -390, -150, +390, -150, '#9B5DE5', 2), + tx(-290, -210, 'Колонка 1', '#9B5DE5'), + tx( +20, -210, 'Колонка 2', '#9B5DE5'), + ], + timeline: () => [ + sh('line', -460, 0, +460, 0, '#06D6E0', 3), + sh('line', 462, -8, 462, 8, '#06D6E0', 0, false), // pseudo arrow tip + ...Array.from({ length: 5 }, (_, i) => { + const bx = -360 + i * 180; + return sh('line', bx, -35, bx, +35, '#06D6E0', 2); + }), + ...Array.from({ length: 5 }, (_, i) => + tx(-378 + i * 180, +48, `Событие ${i + 1}`, '#9ca3af', 15) + ), + ], + quadrant: () => [ + sh('line', -380, 0, +380, 0, '#9B5DE5', 2), + sh('line', 0, -260, 0, +260, '#9B5DE5', 2), + tx(+20, -250, 'I', 'rgba(155,93,229,0.65)', 22), + tx(-70, -250, 'II', 'rgba(155,93,229,0.65)', 22), + tx(-80, +210, 'III', 'rgba(155,93,229,0.65)', 22), + tx(+20, +210, 'IV', 'rgba(155,93,229,0.65)', 22), + ], + pyramid: () => { + const levels = 4; + return [ + sh('triangle', -300, -230, +300, +230, '#06D6E0', 2), + ...Array.from({ length: levels - 1 }, (_, i) => { + const t = (i + 1) / levels; + const y = -230 + t * 460; + const halfW = 300 * t; + return sh('line', -halfW, y, +halfW, y, '#06D6E0', 1); + }), + ]; + }, + swot: () => [ + sh('rect', -400, -250, +400, +250, '#9B5DE5', 2), + sh('line', 0, -250, 0, +250, '#9B5DE5', 2), + sh('line', -400, 0, +400, 0, '#9B5DE5', 2), + tx(-380, -240, 'Strengths', '#06D6E0', 18), + tx( 20, -240, 'Weaknesses', '#F15BB5', 18), + tx(-380, +15, 'Opportunities','#A8E063', 18), + tx( 20, +15, 'Threats', '#FF6B6B', 18), + ], + }; + + const strokes = (tpls[name] ? tpls[name]() : []); + if (!strokes.length) return; + const newIds = []; + for (const s of strokes) { + s.id = this._localIdCounter--; + this._strokes.push(s); + this._undoStack.push(s.id); + newIds.push(s.id); + if (this._onStrokeDone) this._onStrokeDone(s); + } + this._redoStack = []; + this._staticDirty = true; + this._selectedIds.clear(); + for (const id of newIds) this._selectedIds.add(id); + this.render(); + } + exportPNG() { const off = document.createElement('canvas'); off.width = Whiteboard.VW; off.height = Whiteboard.VH; @@ -2509,6 +3768,57 @@ class Whiteboard { reader.readAsDataURL(blob); } + /* ── page background image ──────────────────────────────────────────── */ + + pasteImageAsBackground(blob) { + const reader = new FileReader(); + reader.onload = ev => { + const img = new Image(); + img.onload = () => { + // Resize to max 1920 for reasonable data size + const maxPx = 1920; + let pw = img.naturalWidth, ph = img.naturalHeight; + if (pw > maxPx || ph > maxPx) { + if (pw >= ph) { ph = Math.round(ph * maxPx / pw); pw = maxPx; } + else { pw = Math.round(pw * maxPx / ph); ph = maxPx; } + } + const tmp = document.createElement('canvas'); + tmp.width = pw; tmp.height = ph; + tmp.getContext('2d').drawImage(img, 0, 0, pw, ph); + const src = tmp.toDataURL('image/jpeg', 0.85); + this.removePageBackground(); + const stroke = { + id: this._localIdCounter--, tool: 'image', + data: { src, x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH, + isBackground: true, fit: 'contain' }, + }; + this._strokes.unshift(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + }; + img.src = ev.target.result; + }; + reader.readAsDataURL(blob); + } + + removePageBackground() { + const idx = this._strokes.findIndex(s => s.tool === 'image' && s.data.isBackground); + if (idx === -1) return; + const stroke = this._strokes.splice(idx, 1)[0]; + const ui = this._undoStack.indexOf(stroke.id); + if (ui !== -1) this._undoStack.splice(ui, 1); + this._staticDirty = true; + this.render(); + if (this._onStrokeUndo) this._onStrokeUndo(stroke.id); + } + + hasPageBackground() { + return this._strokes.some(s => s.tool === 'image' && s.data.isBackground); + } + /* ── copy / paste / delete / z-order ─────────────────────────────────── */ copy() { @@ -2556,6 +3866,12 @@ class Whiteboard { const ui = this._undoStack.indexOf(id); if (ui !== -1) this._undoStack.splice(ui, 1); } + const deletedIds = new Set(ids); + for (const s of this._strokes) { + if (s.tool !== 'connector') continue; + if (deletedIds.has(s.data.fromId)) { delete s.data.fromId; delete s.data.fromAnchor; } + if (deletedIds.has(s.data.toId)) { delete s.data.toId; delete s.data.toAnchor; } + } this._staticDirty = true; this.render(); for (const id of ids) { if (this._onStrokeUndo) this._onStrokeUndo(id); } @@ -2685,9 +4001,10 @@ class Whiteboard { if (this._ro) this._ro.disconnect(); this._removeTextInput(); this._removeObjectInput(); - document.removeEventListener('keydown', this._onKeyDown); - document.removeEventListener('keyup', this._onKeyUp); - document.removeEventListener('paste', this._onPaste); + document.removeEventListener('keydown', this._onKeyDown); + document.removeEventListener('keyup', this._onKeyUp); + document.removeEventListener('paste', this._onPaste); + document.removeEventListener('visibilitychange', this._visHandler); if (this._dynCanvas?.parentElement) this._dynCanvas.parentElement.removeChild(this._dynCanvas); if (this._mmCanvas?.parentElement) this._mmCanvas.parentElement.removeChild(this._mmCanvas); } @@ -3116,69 +4433,83 @@ class Whiteboard { _renderCompass(ctx, stroke) { const d = stroke.data; - const [cx, cy] = this._toCanvas(d.x, d.y); - const cw = (d.w / Whiteboard.VW) * (this._cssW || 300); - const ch = (d.h / Whiteboard.VH) * (this._cssH || 150); - const r = Math.min(cw, ch) * 0.38; - const ocx = cx + cw / 2, ocy = cy + ch / 2; + // New format: {cx, cy, radius, arcStart, arcSweep, color, lineWidth, showLegs} + if (d.cx == null) return; // skip legacy strokes with old format + const [ccx, ccy] = this._toCanvas(d.cx, d.cy); + const sx = (this._cssW || 300) / Whiteboard.VW; + const cr = d.radius * sx; + const lw = Math.max(1.5, (d.lineWidth || 2) * sx); + const arcColor = d.color || '#FFE066'; ctx.save(); - if (d.rotation) { ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } + ctx.globalAlpha = d.opacity ?? 1.0; - // Background - ctx.fillStyle = 'rgba(18,13,30,0.82)'; - ctx.strokeStyle = 'rgba(6,214,224,0.25)'; - ctx.lineWidth = 1; - if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 8); else ctx.rect(cx, cy, cw, ch); - ctx.fill(); ctx.stroke(); - - // Draw arm of compass (needle) - const angle = (d.angle || 0); // current arc angle - const spread = d.spread || Math.PI / 4; // angle between legs (half = radius) - const legLen = r * 1.15; - - // Left leg (pivot) — straight down - ctx.strokeStyle = 'rgba(6,214,224,0.7)'; - ctx.lineWidth = 2; + // 1. Draw the arc + const arcEnd = d.arcStart + d.arcSweep; + const ccw = d.arcSweep < 0; + ctx.strokeStyle = arcColor; + ctx.lineWidth = lw; ctx.lineCap = 'round'; - const pivotAngle = -Math.PI / 2 - spread / 2; - const drawAngle = -Math.PI / 2 + spread / 2; - ctx.beginPath(); - ctx.moveTo(ocx, ocy); - ctx.lineTo(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen); - ctx.stroke(); - - // Right leg (pencil tip) - ctx.strokeStyle = 'rgba(155,93,229,0.8)'; - ctx.beginPath(); - ctx.moveTo(ocx, ocy); - ctx.lineTo(ocx + Math.cos(drawAngle + angle) * legLen, ocy + Math.sin(drawAngle + angle) * legLen); - ctx.stroke(); - - // Hinge circle at top - ctx.fillStyle = 'rgba(6,214,224,0.9)'; - ctx.beginPath(); ctx.arc(ocx, ocy, 4, 0, Math.PI * 2); ctx.fill(); - - // Drawn arc - const arcR = (Math.cos(spread / 2) * legLen); // approximate radius - const arcAngle = d.arcAngle || Math.PI * 2; - const arcStart = d.arcStart || 0; - ctx.strokeStyle = (d.color || '#FFE066') + 'cc'; - ctx.lineWidth = 1.8; ctx.setLineDash([]); + ctx.shadowColor = arcColor; + ctx.shadowBlur = Math.max(2, lw * 0.8); ctx.beginPath(); - ctx.arc(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen, - arcR, arcStart, arcStart + arcAngle); + ctx.arc(ccx, ccy, cr, d.arcStart, arcEnd, ccw); ctx.stroke(); + ctx.shadowBlur = 0; - // Radius label - if (d.showRadius && d.radius) { - const fs = Math.max(9, Math.round((11 / Whiteboard.VH) * (this._cssH || 150))); - ctx.fillStyle = 'rgba(255,255,255,0.6)'; - ctx.font = `${fs}px Manrope,sans-serif`; - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText(`r = ${d.radius}`, ocx, cy + 6); + // 2. Compass instrument legs + if (d.showLegs !== false) { + const ptx = ccx + cr * Math.cos(arcEnd); + const pty = ccy + cr * Math.sin(arcEnd); + const legLen = cr * 1.55; + const dist = Math.hypot(ptx - ccx, pty - ccy); + + // Hinge above center & pencil tip (isoceles triangle apex) + let hx, hy; + if (dist < 2) { + hx = ccx; hy = ccy - legLen; + } else { + const mx = (ccx + ptx) / 2, my = (ccy + pty) / 2; + const perpX = -(pty - ccy) / dist; + const perpY = (ptx - ccx) / dist; + const halfH = Math.sqrt(Math.max(0, legLen * legLen - (dist / 2) * (dist / 2))); + // Choose perpendicular direction that is "upward" (smaller canvas Y) + const sign = (my + perpY * halfH) <= (my - perpY * halfH) ? 1 : -1; + hx = mx + sign * perpX * halfH; + hy = my + sign * perpY * halfH; + } + + // Needle leg: hinge → center (fixed pivot, cyan) + ctx.strokeStyle = 'rgba(6,214,224,0.75)'; + ctx.lineWidth = Math.max(1, lw * 0.7); + ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ccx, ccy); ctx.stroke(); + + // Pencil leg: hinge → pencil tip (violet) + ctx.strokeStyle = 'rgba(155,93,229,0.75)'; + ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(ptx, pty); ctx.stroke(); + + // Hinge screw dot + const hr = Math.max(3, lw * 1.3); + ctx.fillStyle = 'rgba(215,210,232,0.92)'; + ctx.strokeStyle = 'rgba(100,80,140,0.6)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(hx, hy, hr, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + + // Center needle tip (cyan glow) + ctx.fillStyle = '#06D6E0'; + ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(ccx, ccy, Math.max(2.5, lw), 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + + // Pencil tip dot (arc color glow) + ctx.fillStyle = arcColor; + ctx.shadowColor = arcColor; ctx.shadowBlur = 6; + ctx.beginPath(); ctx.arc(ptx, pty, Math.max(2, lw * 0.85), 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; } + ctx.restore(); } diff --git a/frontend/knowledge-map.html b/frontend/knowledge-map.html index a31d6e6..8af09d4 100644 --- a/frontend/knowledge-map.html +++ b/frontend/knowledge-map.html @@ -380,6 +380,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/lab.html b/frontend/lab.html index baea92c..d48c851 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -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 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
@@ -933,6 +935,54 @@ + + + + + + + + + + + + + + +
Формулы
+
+ + + + + + + +
+ +
+ + + +
@@ -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(`${info.formula}`); + if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`); + if (info.matName) lines.push(`Материал: ${info.matName}`); + if (info.FA) lines.push(`F_A = ${info.FA} Н`); + if (info.mg) lines.push(`mg = ${info.mg} Н`); + if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`); + if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`); + el.innerHTML = lines.join('
'); + // 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 @@ + diff --git a/frontend/lesson-history.html b/frontend/lesson-history.html new file mode 100644 index 0000000..91d380c --- /dev/null +++ b/frontend/lesson-history.html @@ -0,0 +1,1894 @@ + + + + + + Архив уроков — LearnSpace + + + + + + + +
+ + +
+
+ + +
+
+

+ + Архив уроков +

+
+
+ + + + + + + + + + +
+
+ + +
+
+
+
Уроков проведено
+
+
+
+
Общее время
+
+ +
+
+
Учителей
+
+
+
+
Учеников охвачено
+
+
+
+
Сообщений в чатах
+
+
+ + +
+ + +
+
+
+ + +
+
+
+
+
+
+
+ +
+ + +
+
+ +

Выберите урок из списка слева

+
+ +
+
+
+
+
+ + + + + + + + + + diff --git a/frontend/lesson.html b/frontend/lesson.html index 164e51b..51fa462 100644 --- a/frontend/lesson.html +++ b/frontend/lesson.html @@ -787,6 +787,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/library.html b/frontend/library.html index 10d89d0..47b40f5 100644 --- a/frontend/library.html +++ b/frontend/library.html @@ -211,6 +211,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/live-quiz.html b/frontend/live-quiz.html index 331b170..0afcef9 100644 --- a/frontend/live-quiz.html +++ b/frontend/live-quiz.html @@ -8,6 +8,7 @@ + + + +
+ + +
+
+ + +
+
+

+ + Мои уроки +

+
+
+ +
+
+ + +
+
+
+
Уроков посещено
+
+
+
+
Общее время
+
+
+
+
Учителей
+
+
+
+
Сообщений в чатах
+
+
+ + +
+ + +
+
+
+ + +
+
+
+
+
+
+
+ +
+ + +
+
+ +

Выберите урок из списка слева

+
+ + +
+
+
+
+
+ + + + + + + diff --git a/frontend/pet.html b/frontend/pet.html index e595588..0a0ad9b 100644 --- a/frontend/pet.html +++ b/frontend/pet.html @@ -351,6 +351,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/profile.html b/frontend/profile.html index 67b5c1a..c1a2829 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -550,6 +550,7 @@ Карта знаний Красная книга Онлайн-урок + Архив уроков
diff --git a/frontend/question-bank.html b/frontend/question-bank.html index 9c4e69c..03e0961 100644 --- a/frontend/question-bank.html +++ b/frontend/question-bank.html @@ -8,6 +8,7 @@ +