commit be4d43105e637f77503842d6294c9905303b4ad0 Author: Maxim Dolgolyov Date: Sun Apr 12 10:10:37 2026 +0300 LearnSpace: full-stack educational whiteboard platform Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude/rules/ast-index.md b/.claude/rules/ast-index.md new file mode 100644 index 0000000..1e02547 --- /dev/null +++ b/.claude/rules/ast-index.md @@ -0,0 +1,45 @@ +# ast-index Rules + +## Mandatory Search Rules + +1. **ALWAYS use ast-index FIRST** for any code search task +2. **NEVER duplicate results** — if ast-index found usages/implementations, that IS the complete answer +3. **DO NOT run grep "for completeness"** after ast-index returns results +4. **Use grep/Search ONLY when:** + - ast-index returns empty results + - Searching for regex patterns (ast-index uses literal match) + - Searching for string literals inside code (`"some text"`) + - Searching in comments content + +## Why ast-index + +ast-index is 17-69x faster than grep (1-10ms vs 200ms-3s) and returns structured, accurate results. + +## Command Reference + +| Task | Command | Time | +|------|---------|------| +| Universal search | `ast-index search "query"` | ~10ms | +| Find class/component | `ast-index class "ComponentName"` | ~1ms | +| Find symbol | `ast-index symbol "SymbolName"` | ~1ms | +| Find usages | `ast-index usages "SymbolName"` | ~8ms | +| Find implementations | `ast-index implementations "Interface"` | ~5ms | +| Call hierarchy | `ast-index call-tree "function" --depth 3` | ~1s | +| Find callers | `ast-index callers "functionName"` | ~1s | +| Module deps | `ast-index deps "module-name"` | ~10ms | +| File outline | `ast-index outline "File.tsx"` | ~1ms | + +## JavaScript-Specific Commands + +| Task | Command | +|------|---------| +| Find classes | `ast-index class "ClassName"` | +| Find functions | `ast-index symbol "functionName"` | +| Find Express routes | `ast-index search "router.get"` | +| File structure | `ast-index outline "path/to/file.js"` | + +## Index Management + +- `ast-index rebuild` — Full reindex (run once after clone) +- `ast-index update` — After git pull/merge +- `ast-index stats` — Show index statistics diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ebe9fdd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,174 @@ +{ + "permissions": { + "allow": [ + "Bash(ast-index *)", + "Bash(node -e \"require\\(''./backend/src/controllers/sessionController.js''\\); console.log\\(''sessionController OK''\\)\")", + "Bash(node -e \"require\\(''./backend/src/controllers/courseController.js''\\); console.log\\(''courseController OK''\\)\")", + "Bash(node -e \"require\\(''''./backend/src/controllers/courseController.js''''\\); console.log\\(''''courseController OK''''\\)\")", + "Bash(node -e \"require\\(''''./backend/src/controllers/lessonController.js''''\\); console.log\\(''''lessonController OK''''\\)\")", + "Bash(node -e \"require\\(''''./backend/src/controllers/questionController.js''''\\); console.log\\(''''questionController OK''''\\)\")", + "Bash(node -e \"require\\(''''./backend/src/controllers/testController.js''''\\); console.log\\(''''testController OK''''\\)\")", + "Bash(node -e \":*)", + "Bash(grep -n \"case ''accordion''\" \"g:/Dev/Тесты/BQ-System/frontend/lesson-editor.html\")", + "Bash(grep -n \"case ''quote''\\\\|case ''checklist''\\\\|case ''button''\" \"g:/Dev/Тесты/BQ-System/frontend/lesson-editor.html\")", + "Bash(grep -n \"href=//theory\\\\|href=''''//theory''''\\\\|lesson-editor\\\\|editLesson\\\\|openEditor\" g:/Dev/Тесты/BQ-System/frontend/theory.html)", + "Bash(grep -v \"^.*://\\\\|//.*stat\")", + "Bash(ls \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)", + "Bash(wc -l g:/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(grep -rn \"= ''//\\\\|= \"\"//\\\\|href=\"\"//\\\\|href=''//\\\\|href = ''//\\\\|href = \"\"//\\\\|\\\\.href = ''//\" \"g:/Dev/Тесты/BQ-System/frontend/\")", + "Bash(grep -v \"http\\\\|https\\\\|cdn\\\\|fonts\\\\|googleapis\\\\|jsdelivr\\\\|://\\\\|comment\\\\|//\")", + "Bash(grep -rn //course g:/Dev/Тесты/BQ-System/)", + "WebFetch(domain:hoster.by)", + "WebFetch(domain:www.websiteplanet.com)", + "WebFetch(domain:hostings.info)", + "Bash(head -10 grep -n \"^const stmts\\\\|^const _stmts\\\\|= db.prepare\\\\|db\\\\.prepare\" \"g:/Dev/Тесты/BQ-System/backend/src/controllers/assignmentController.js\")", + "Bash(node -e \"require\\(''./src/controllers/gamificationController''\\)\")", + "Bash(node -e \"require\\(''./src/controllers/classController''\\)\")", + "Bash(grep -rn \"BQ\\\\.notif\\\\.init\\\\|notif\\\\.init\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)", + "Bash(grep -rn \"connectSSE\\\\|EventSource\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)", + "Bash(grep -rn \"notif-item\\\\|notif-dot\\\\|notif-msg\\\\|notif-time\\\\|notif-empty\\\\|notif-read-all\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)", + "Bash(grep -l 'href=\"\"/lab\"\"' frontend/*.html)", + "Bash(grep -rn \"notifications.js\" frontend/*.html)", + "Read(//g/Dev/Тесты/BQ-System/**)", + "Bash(python -c \":*)", + "WebFetch(domain:www.npmjs.com)", + "WebFetch(domain:medium.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:github.com)", + "WebFetch(domain:saturncloud.io)", + "WebFetch(domain:neilagrawal.com)", + "WebFetch(domain:mitchum.blog)", + "Bash(node src/db/seed-theory-2.js)", + "Bash(ls g:/Dev/Тесты/BQ-System/backend/src/db/seed*)", + "Bash(grep -r \"three\\\\|Three\\\\|babylon\\\\|Babylon\\\\|cesium\\\\|Cesium\\\\|webgl\\\\|WebGL\" g:/Dev/Тесты/BQ-System/frontend --include=*.html --include=*.js)", + "Bash(node src/db/seed-red-book.js)", + "Bash(grep -n \"requireAuth\\\\|optionalAuth\" \"g:/Dev/Тесты/BQ-System/backend/src/routes/\"*.js)", + "Bash(node -e \"require\\(''''./src/server.js''''\\)\")", + "Bash(node:*)", + "Bash(grep -v \"Use \\\\`node\")", + "Bash(node -e \"const db=require\\(''''./src/db/db''''\\); db.prepare\\(''''SELECT id,name_ru,category FROM rb_species WHERE id>=63 ORDER BY id''''\\).all\\(\\).forEach\\(r=>console.log\\(r.id,r.category,r.name_ru\\)\\); console.log\\(''''---''''\\); db.prepare\\(''''SELECT id,title,xp_reward FROM rb_quests ORDER BY id''''\\).all\\(\\).forEach\\(r=>console.log\\(r.id,r.xp_reward,r.title\\)\\); console.log\\(''''---''''\\); console.log\\(''''Видов:'''',db.prepare\\(''''SELECT COUNT\\(*\\) as n FROM rb_species''''\\).get\\(\\).n,''''Квестов:'''',db.prepare\\(''''SELECT COUNT\\(*\\) as n FROM rb_quests''''\\).get\\(\\).n,''''Связей:'''',db.prepare\\(''''SELECT COUNT\\(*\\) as n FROM rb_food_web''''\\).get\\(\\).n\\);\")", + "Bash(grep -n \"BQ.api.*features\\\\|/api/features\" frontend/*.html)", + "Bash(grep -l \"sb-link\\\\|sb-nav\" \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)", + "Bash(python -c \"print\\(''hello''\\)\")", + "Bash(awk 'NR>=1180 && NR<=1200' backend/src/db/migrate.js)", + "Bash(awk 'NR>=1410 && NR<=1425' backend/src/db/migrate.js)", + "Bash(awk 'NR>=1465 && NR<=1490' backend/src/db/migrate.js)", + "Bash(node -e \"require\\(''''./backend/src/db/migrate''''\\)\")", + "Bash(ls g:/Dev/Тесты/BQ-System/frontend/biochem*.html)", + "Bash(grep -rn [😀-🙏🔬-🧪⚗️⚛️✓✗←→↑↓★☆✔✖👤🏫] g:/Dev/Тесты/BQ-System/frontend/ g:/Dev/Тесты/BQ-System/js/)", + "Bash(python /tmp/scan_emoji.py)", + "Bash(python scan_emoji.py)", + "Bash(python replace_emoji2.py)", + "Bash(find /g/Dev/Тесты/BQ-System/frontend -name \"*.html\" -exec head -5 {})", + "Bash(-print)", + "Bash(do echo:*)", + "Bash(head -15 grep -n \"bio-sidebar\\\\|bio-body\\\\|bio-panel\" \"g:/Dev/Тесты/BQ-System/frontend/biochem.html\")", + "Bash(head -3 grep -n \"\" \"g:/Dev/Тесты/BQ-System/frontend/homework.html\")", + "Bash(head -3 grep -n \"\" \"g:/Dev/Тесты/BQ-System/frontend/admin.html\")", + "Bash(head -3 grep -n \"\" \"g:/Dev/Тесты/BQ-System/frontend/test-run.html\")", + "Bash(head -3 grep -n \"\" \"g:/Dev/Тесты/BQ-System/frontend/flashcards.html\")", + "Bash(grep -l \"sb-foot\\\\|nav-user-chip\" \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)", + "Bash(grep -l \"sb-foot\" \"g:/Dev/Тесты/BQ-System/frontend/\"*.html)", + "Bash(ls -la frontend/biochem*.html)", + "Bash(find g:/Dev/Тесты/BQ-System/js -name *.js)", + "Read(//.pp-hint/|/**)", + "Bash(grep -n \"id === ''waves''\" g:/Dev/Тесты/BQ-System/frontend/lab.html)", + "Bash(ls g:/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(grep -v \"//.*toast\")", + "Bash(do ast-index:*)", + "Bash(grep -v \"^$\")", + "Bash(echo \"FILE: $f\")", + "Bash(do python3 -c \":*)", + "Bash(ast-index search:*)", + "Bash(chmod +x \"g:/Dev/Тесты/BQ-System/backend/scripts/backup.sh\")", + "Read(//BQ/.initPage/**)", + "Read(//showBoardIfAllowed/**)", + "Bash(node -e \"require\\(''express-rate-limit''\\)\")", + "Bash(find g:DevТестыBQ-System -name *.sql -type f)", + "Read(//g/Dev/**)", + "Bash(ls g:/Dev/Тесты/BQ-System/.env*)", + "Bash(docker --version)", + "Bash(curl -s https://api.ipify.org)", + "Bash(curl -s --connect-timeout 5 http://45.132.194.53:3000/api/health)", + "Bash(curl -s --connect-timeout 8 http://45.132.194.53:3000/api/health)", + "Bash(grep -n \"title>\" /g/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(mv bioquantum.db learnspace.db)", + "Bash(mv bioquantum.db-shm learnspace.db-shm)", + "Bash(mv bioquantum.db-wal learnspace.db-wal)", + "Bash(taskkill //F //IM node.exe)", + "Bash(taskkill /F /PID 72676)", + "Bash(cmd /c \"taskkill /F /PID 72676\")", + "Bash(cmd /c \"taskkill /F /PID 72676 2>&1\")", + "Bash(powershell -Command \"Stop-Process -Id 72676 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id \\(Get-NetTCPConnection -LocalPort 3000 -State Listen\\).OwningProcess -Force\")", + "Bash(findstr /n /i \"draw\" frontend/classroom.html)", + "Bash(findstr /i \"allow\\\\|perm\\\\|grant\\\\|toggle\\\\|_can\")", + "Bash(findstr /n \"canDraw\" frontend/classroom.html)", + "Bash(taskkill /F /PID 56144)", + "Bash(cmd /c \"taskkill /F /PID 56144\")", + "Bash(cmd /c \"taskkill /F /PID 56144 2>&1\")", + "Bash(powershell -Command \"Stop-Process -Id 56144 -Force\")", + "Bash(findstr \"LISTENING\")", + "Bash(curl -s -X POST http://localhost:3000/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"123@123.by\",\"password\":\"123456\"}')", + "Bash(powershell -Command \"Get-Process -Id 13632 -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, MainWindowTitle\")", + "Bash(powershell -Command \"\\(Get-Process -Id 13632\\).Path\")", + "Bash(powershell -Command ':*)", + "Bash(curl -s http://localhost:3000/api/auth/login -X POST -H 'Content-Type: application/json' -d '{\"email\":\"admin@bq.local\",\"password\":\"123456\"}')", + "Bash(powershell -Command \"Stop-Process -Id 13632 -Force -ErrorAction SilentlyContinue\" sleep 1 cd \"g:/Dev/Тесты/BQ-System\")", + "Bash(powershell -Command \"Stop-Process -Id 13632 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id 11320 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id 44452 -Force\")", + "Bash(grep -rn \"function esc\\(s\\)\" frontend/*.html)", + "Bash(grep -rln \"Аналитика\\\\|Банк вопросов\\\\|Live-квиз\" frontend/*.html)", + "Bash(powershell -Command \"Stop-Process -Id 62712 -Force\")", + "Bash(findstr /i classroom)", + "Bash(powershell -Command \"Stop-Process -Id 71864 -Force\")", + "Bash(grep -n \"addClient\\\\|notifications/stream\" backend/src/controllers/*.js)", + "Bash(tasklist /FI \"IMAGENAME eq node.exe\")", + "Bash(taskkill /F /PID 24032)", + "Bash(powershell -Command \"Stop-Process -Id 24032 -Force -ErrorAction SilentlyContinue; Write-Output 'done'\")", + "Bash(ls frontend/gradebook*)", + "Bash(awk -F: '{print $1}')", + "Bash(npm --prefix backend run dev)", + "Bash(taskkill /PID 78248 /F)", + "Bash(cmd /c \"taskkill /PID 78248 /F\")", + "Bash(cmd /c \"taskkill /F /PID 78248\")", + "Bash(cmd /c \"taskkill /F /PID 78248 2>&1\")", + "Bash(powershell -Command \"Stop-Process -Id 78248 -Force -ErrorAction SilentlyContinue; Start-Sleep -Seconds 1; Get-Process -Id 78248 -ErrorAction SilentlyContinue\")", + "Bash(powershell -Command \"Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object -ExpandProperty OwningProcess\")", + "Bash(powershell -Command \"Stop-Process -Id 45184 -Force\")", + "Bash(powershell -Command \"Start-Sleep 2; \\(Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess\")", + "Read(//c/Users/Home/AppData/Local/Temp/**)", + "Bash(curl -s -I \"http://localhost:3000/js/api.js\")", + "Bash(curl -s \"http://localhost:3000/js/api.js\")", + "Bash(powershell -Command \"Stop-Process -Id 83740 -Force\")", + "Bash(powershell -Command \"\\(Get-NetTCPConnection -LocalPort 3000 -State Listen\\).OwningProcess\")", + "Bash(powershell -Command \"Get-Process -Id 57008 | Select-Object -ExpandProperty MainWindowTitle; \\(Get-WmiObject Win32_Process -Filter 'ProcessId=57008'\\).CommandLine\")", + "Bash(powershell -Command \"Stop-Process -Id 57008 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id 37516 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id 2764 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id 81936 -Force\")", + "Bash(powershell -Command \"Stop-Process -Id 40888 -Force\")", + "Bash(powershell -Command \"\\(Get-WmiObject Win32_Process -Filter 'ProcessId=69696'\\).CommandLine\")", + "Bash(curl -s \"http://localhost:3000/api/classroom/1/cursor\" -X POST -H \"Content-Type: application/json\" -d '{\"x\":0,\"y\":0}')", + "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\")" + ], + "additionalDirectories": [ + "\\tmp" + ] + }, + "enabledPlugins": { + "ast-index@ast-index": true + }, + "extraKnownMarketplaces": { + "ast-index": { + "source": { + "source": "github", + "repo": "defendend/Claude-ast-index-search" + } + } + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..24f23df --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,231 @@ +{ + "permissions": { + "allow": [ + "Bash(cat \"g:/Dev/Тесты/BQ-System/backend/src/db/\"*.js)", + "Bash(node -e \"const db = require\\(''./src/db/db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users''\\).all\\(\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")", + "Bash(wc -l \"g:/Dev/Тесты/BQ-System/frontend/\"*.html \"g:/Dev/Тесты/BQ-System/js/\"*.js)", + "Bash(cat \"g:/Dev/Тесты/BQ-System/backend/src/db/migrations/\"*.sql)", + "Bash(node src/db/migrate.js)", + "Bash(node src/db/seed-math.js)", + "Bash(npm install multer --save)", + "Bash(node -e \":*)", + "Bash(node export-math.js)", + "Bash(rm export-math.js)", + "Bash(node -e \"require\\(''./src/db/migrate''\\)\")", + "Bash(node -e \"const db=require\\(''./src/db/db''\\); console.log\\(db.prepare\\(''PRAGMA table_info\\(tests\\)''\\).all\\(\\).map\\(c=>c.name\\).join\\('', ''\\)\\); console.log\\(db.prepare\\(''PRAGMA table_info\\(topics\\)''\\).all\\(\\).map\\(c=>c.name\\).join\\('', ''\\)\\);\")", + "Bash(node -e \"require\\(''./src/controllers/adminController''\\)\")", + "Bash(node -e \"require\\(''./backend/src/controllers/classController''\\)\")", + "Bash(true\" \"g:/Dev/Тесты/BQ-System \")", + "Bash(node -e \"try { require\\(''./backend/src/controllers/classController''\\); console.log\\(''OK''\\); } catch\\(e\\) { console.error\\(e.message\\); }\")", + "Bash(node -e \"const db=require\\(''./backend/src/db/db''\\);try{db.prepare\\(''SELECT id FROM assignments LIMIT 1''\\).all\\(\\);console.log\\(''OK''\\)}catch\\(e\\){console.error\\(e.message\\)}\")", + "Bash(curl -s http://localhost:3000/api/assignments/my -H \"Authorization: Bearer invalid_token\")", + "Bash(curl -s http://localhost:3000/api/health)", + "Bash(netstat -ano)", + "Bash(findstr \":3000\")", + "Bash(findstr LISTENING)", + "Bash(taskkill /PID 28096 /F)", + "Bash(node \"g:\\\\Dev\\\\Тесты\\\\BQ-System\\\\backend\\\\src\\\\server.js\")", + "Bash(cmd //c \"taskkill /PID 28096 /F\")", + "Bash(node -e \"require\\(''dotenv''\\).config\\({path:''./backend/.env''}\\); console.log\\(''JWT_SECRET:'', process.env.JWT_SECRET ? ''SET'' : ''MISSING''\\)\")", + "Bash(node -e \"require\\(''dotenv''\\).config\\(\\); console.log\\(''JWT_SECRET:'', process.env.JWT_SECRET ? ''SET \\(''+process.env.JWT_SECRET.slice\\(0,10\\)+''...\\)'' : ''MISSING''\\)\")", + "Bash(cmd //c \"taskkill /F /IM node.exe\")", + "Bash(wc -l \"g:/Dev/Тесты/BQ-System/frontend\"/*.html)", + "Bash(grep -n \"LIMIT 500\\\\|LIMIT 200\\\\|LIMIT 100\\\\|LIMIT 50\" \"g:/Dev/Тесты/BQ-System/backend/src/controllers/\"*.js)", + "Bash(node -e \"const db = require\\(''./src/db/db''\\); console.log\\(JSON.stringify\\(db.prepare\\(''SELECT id,email,name,role FROM users''\\).all\\(\\)\\)\\)\")", + "Bash(node seed.js)", + "Bash(taskkill /F /IM node.exe)", + "Bash(powershell -Command \"Get-Process node -ErrorAction SilentlyContinue | Stop-Process -Force; Write-Host ''done''\")", + "Bash(timeout /t 8 /nobreak)", + "Bash(timeout 5 node src/server.js)", + "Bash(node -e \"const fs=require\\(''fs''\\);const emoji=/[\\\\u{1F300}-\\\\u{1FFFF}\\\\u{2600}-\\\\u{27BF}]/u;const files=[''frontend/classes.html'',''frontend/admin.html'',''frontend/board.html'',''frontend/library.html'',''frontend/dashboard.html''];let found=false;files.forEach\\(f=>{const lines=fs.readFileSync\\(f,''utf8''\\).split\\(''\\\\n''\\);lines.forEach\\(\\(l,i\\)=>{if\\(emoji.test\\(l\\)\\){console.log\\(f+'':''+\\(i+1\\)+'': ''+l.trim\\(\\).slice\\(0,120\\)\\);found=true;}}\\);}\\);if\\(found===false\\)console.log\\(''No emojis found''\\);\")", + "Bash(grep -v \"//.*innerHTML\")", + "Bash(grep \"innerHTML\\\\|template\\\\|backtick\\\\|\\\\`\")", + "Bash(node -e \"console.log\\(require\\(''crypto''\\).randomBytes\\(32\\).toString\\(''hex''\\)\\)\")", + "Bash(node -e \"const db = require\\(''better-sqlite3''\\)\\(''g:/Dev/Тесты/BQ-System/backend/bioquantum.db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users WHERE role = ?''\\).all\\(''admin''\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")", + "Bash(node -e \"const db = require\\(''better-sqlite3''\\)\\(''bioquantum.db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users WHERE role = ?''\\).all\\(''admin''\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")", + "Bash(node -e \"const db = require\\(''./node_modules/better-sqlite3''\\)\\(''bioquantum.db''\\); const users = db.prepare\\(''SELECT id, email, name, role FROM users WHERE role = ?''\\).all\\(''admin''\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")", + "Bash(ls g:/Dev/Тесты/BQ-System/backend/node_modules/.bin/better*)", + "Bash(node -e \"const db = require\\(''''./src/db/db''''\\); const users = db.prepare\\(''''SELECT id, email, name, role FROM users''''\\).all\\(\\); console.log\\(JSON.stringify\\(users, null, 2\\)\\);\")", + "Bash(where ast-index:*)", + "Bash(ast-index --version)", + "Bash(ast-index rebuild:*)", + "Bash(ast-index stats:*)", + "Bash(ast-index outline:*)", + "Bash(ast-index search:*)", + "Bash(find g:/Dev/Тесты/BQ-System/backend -name *.db -o -name *.sqlite)", + "Bash(node src/db/seed-ct2021-v1.js)", + "Bash(pip list:*)", + "Bash(where pdftoppm:*)", + "Bash(where pdfimages:*)", + "Bash(where magick:*)", + "Bash(where gs:*)", + "Bash(pdftoppm --help)", + "Bash(python3 -c 'import fitz')", + "Bash(pip install:*)", + "Bash(python3 -c \"import fitz; print\\(''ok''\\)\")", + "Bash(python -c \"import fitz; print\\(''ok''\\)\")", + "Bash(where pip:*)", + "Bash(where pip3:*)", + "Bash(where python:*)", + "Bash(where python3:*)", + "Bash(where py:*)", + "Bash(python -c \"import sys; print\\(sys.version\\)\")", + "Bash(cmd /c \"python --version\")", + "Bash(cmd /c \"where python\")", + "Bash(where convert:*)", + "Bash(where pnmcut:*)", + "Bash(where ffmpeg:*)", + "Bash(scoop list:*)", + "Bash(cp /tmp/ct2021-06.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page6.png\")", + "Bash(cp /tmp/ct2021-07.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page7.png\")", + "Bash(cp /tmp/ct2021-08.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page8.png\")", + "Bash(cp /tmp/ct2021-09.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/page9.png\")", + "Bash(node -e \"const s = require\\(''sharp''\\); console.log\\(''ok''\\)\")", + "Bash(node src/db/crop_images.js)", + "Bash(pdftoppm -png -r 50 -f 6 -l 6 \"g:/Dev/Тесты/BQ-System/ЦТ-ЦЭ/ЦТ 2021.pdf\" \"/tmp/small\")", + "Bash(cp /tmp/small-06.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small6.png\")", + "Bash(pdftoppm -png -r 50 -f 7 -l 9 \"g:/Dev/Тесты/BQ-System/ЦТ-ЦЭ/ЦТ 2021.pdf\" \"/tmp/small\")", + "Bash(cp /tmp/small-07.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small7.png\")", + "Bash(cp /tmp/small-08.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small8.png\")", + "Bash(cp /tmp/small-09.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/small9.png\")", + "Bash(cp /tmp/pt-06.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt6.png\")", + "Bash(node -e \"const s=require\\(''sharp''\\); s\\(''../frontend/img/questions/pt6.png''\\).metadata\\(\\).then\\(m=>console.log\\(m.width, m.height\\)\\)\")", + "Bash(cp /tmp/pt-07.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt7.png\")", + "Bash(cp /tmp/pt-08.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt8.png\")", + "Bash(cp /tmp/pt-09.png \"g:/Dev/Тесты/BQ-System/frontend/img/questions/pt9.png\")", + "Bash(head -15 grep -n \"^class\\\\|fit\\(\\)\\\\|start\\(\\)\\\\|stop\\(\\)\\\\|reset\\(\\)\\\\|setT\" \"g:/Dev/Тесты/BQ-System/frontend/js/labs/states.js\")", + "Bash(npx ast-index:*)", + "Bash(npm info:*)", + "Bash(head -10 node -e \"require\\(''''acorn''''\\); console.log\\(''''acorn ok''''\\)\")", + "Bash(winget list:*)", + "Bash(ast-index install-claude-plugin:*)", + "Bash(npm list:*)", + "Read(//c/Users/Home/AppData/Roaming/npm/**)", + "Bash(grep -n \"sim-gas\\\\|sim-diff\\\\|soon.*Кинетика\\\\|reactions\\\\|chem-react\\\\|Химические\\\\|id: ''''gas''''\\\\|id: ''''diff''''\" frontend/lab.html)", + "Bash(grep -n \"id: ''reactions''\" frontend/lab.html)", + "Bash(awk 'NR>=1813 && NR<=1820' frontend/lab.html)", + "Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/flask.js\")", + "Bash(xargs -I {} basename {})", + "Bash(find \"g:/Dev/Тесты/BQ-System/backend/src/db/migrations\" -name \"*.sql\" -exec wc -l {} +)", + "Bash(grep -n \"case ''callout''\\\\|case ''video''\\\\|case ''table''\\\\|case ''flashcard''\\\\|case ''sim''\\\\|toggleNotes\\\\|saveNote\\\\|notes-panel\" \"g:/Dev/Тесты/BQ-System/frontend/lesson.html\")", + "Bash(ast-index symbol:*)", + "WebSearch", + "Bash(ast-index version:*)", + "Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/js/labs/stereo.js)", + "Bash(node -e \"global.THREE = {}; try { require\\(''g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js''\\); console.log\\(''OK, StereoSim:'', typeof StereoSim\\); } catch\\(e\\) { console.log\\(''Error:'', e.message\\); }\")", + "Bash(xxd \"g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js\")", + "Bash(curl -sL \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js\")", + "Bash(curl -sL \"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js\")", + "Bash(curl -sL \"https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js\")", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/frontend/js/labs/stereo.js\")", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/frontend/js/labs/triangle.js\")", + "Bash(node -c frontend/js/labs/forcesandbox.js)", + "Bash(grep -E \"\\\\.js$\")", + "Bash(ls -la /g/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(xargs grep:*)", + "Bash(grep -r \"xp\\\\|level\\\\|badge\\\\|achievement\\\\|streak\\\\|reward\\\\|points\\\\|gamif\" /g/Dev/Тесты/BQ-System/backend/src/ --include=*.js)", + "Bash(node -c backend/src/controllers/gamificationController.js)", + "Bash(node -c backend/src/controllers/classController.js)", + "Bash(node -c js/api.js)", + "Bash(node -c backend/src/controllers/sessionController.js)", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/controllers/sessionController.js\")", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/controllers/gamificationController.js\")", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/routes/gamification.js\")", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/backend/src/db/migrate.js\")", + "Bash(node -c \"g:/Dev/Тесты/BQ-System/js/api.js\")", + "Bash(python3:*)", + "Bash(for f:*)", + "Bash(do echo:*)", + "Read(//g/Dev/Тесты/BQ-System/**)", + "Bash(done)", + "Bash(ls -lh /g/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(wc -l /g/Dev/Тесты/BQ-System/backend/src/controllers/*.js)", + "Bash(node -c backend/src/controllers/shopController.js)", + "Bash(node -c backend/src/controllers/templateController.js)", + "Bash(node -c backend/src/routes/shop.js)", + "Bash(node -c backend/src/routes/templates.js)", + "Bash(node -c backend/src/server.js)", + "Bash(node -c backend/src/controllers/lessonController.js)", + "Bash(node -c backend/src/db/migrate.js)", + "Bash(xargs basename:*)", + "Bash(node -c backend/src/controllers/permissionsController.js)", + "Bash(node -c backend/src/middleware/auth.js)", + "Bash(node -c backend/src/routes/gamification.js)", + "Bash(node -c backend/src/routes/courses.js)", + "Bash(wc -l frontend/*.html)", + "Bash(find backend/src/controllers -name \"*.js\" -exec wc -l {} +)", + "Bash(find . -name test* -o -name *test.js -o -name spec*)", + "Bash(grep \"Error\\\\|error\\\\|TODO\\\\|FIXME\" backend/src/controllers/*.js)", + "Bash(grep -i \"not implemented\\\\|unimplemented\\\\|coming soon\\\\|not yet\" backend/src/controllers/*.js frontend/*.html)", + "Bash(grep \"res.status\\(501\\\\|status: 501\\\\|res.json\\({.*disabled\\\\|res.json\\({.*not_implemented\" backend/src/controllers/*.js)", + "Bash(xargs wc:*)", + "Bash(grep -r \"editor\\\\|sandbox\\\\|molecule\" g:/Dev/Тесты/BQ-System/frontend/js/labs/*.js)", + "Bash(node -c frontend/js/labs/chemsandbox.js)", + "Bash(ls frontend/*.html)", + "Bash(node -e \"try { require\\(''./backend/src/middleware/validate''\\); console.log\\(''validate.js OK''\\); } catch\\(e\\) { console.error\\(e.message\\); }\")", + "Bash(node --test tests/auth.test.js)", + "Bash(node --test tests/shop.test.js)", + "Bash(node --test tests/sessions.test.js)", + "Bash(npm test:*)", + "Bash(find g:/Dev/Тесты/BQ-System/frontend/js/labs -type f -name *.js)", + "Bash(grep \"\\\\.js$\")", + "Bash(grep -r \"TODO\\\\|FIXME\\\\|скоро\\\\|coming soon\\\\|ТОТО\\\\|XXX\" g:/Dev/Тесты/BQ-System --include=*.js --include=*.html --include=*.css)", + "Bash(wc -l \"g:/Dev/Тесты/BQ-System/backend/tests\"/*.js)", + "Bash(grep -r \"скоро\\\\|coming soon\\\\|TODO\\\\|FIXME\" g:/Dev/Тесты/BQ-System --include=*.js --include=*.html --exclude-dir=node_modules)", + "Bash(find g:/Dev/Тесты/BQ-System -name *.json -path */data/*)", + "Bash(grep -E \"\\\\.\\(js|html\\)$\")", + "Bash(find /g/Dev/Тесты/BQ-System -name *.json -path */data/*)", + "Bash(node src/db/seed-theory.js)", + "Bash(timeout 5 node -e \"require\\(''./src/controllers/lessonController''\\);require\\(''./src/controllers/courseController''\\);console.log\\(''✓ Controllers OK''\\)\")", + "Bash(node -e \"require\\(''./src/controllers/bookmarkController''\\); require\\(''./src/routes/bookmarks''\\); console.log\\(''✓ Bookmarks OK''\\)\")", + "Bash(node -e \"require\\(''./src/controllers/searchController''\\); console.log\\(''✓ Search OK''\\)\")", + "Bash(node -e \"require\\(''./src/db/migrate''\\); require\\(''./src/controllers/sessionController''\\); console.log\\(''✓ OK''\\)\")", + "Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/projectile.js\")", + "Bash(node --check \"g:/Dev/Тесты/BQ-System/frontend/js/labs/collision.js\")", + "Bash(node --check frontend/js/labs/gas.js)", + "Bash(node --check frontend/js/labs/brownian.js)", + "Bash(node --check frontend/js/labs/states.js)", + "Bash(node --check frontend/js/labs/diffusion.js)", + "Bash(node --check \"G:\\\\Dev\\\\Тесты\\\\BQ-System\\\\frontend\\\\js\\\\labs\\\\states.js\")", + "Bash(xargs ls:*)", + "Bash(find \"g:/Dev/Тесты/BQ-System\" -type f \\\\\\( -name \"*.js\" -o -name \"*.html\" -o -name \"*.css\" \\\\\\) -not -path \"*/node_modules/*\" -exec wc -l {} +)", + "Bash(head -10 grep -n \"info\\(\\)\\\\|_emitUpdate\\\\|onUpdate\" \"g:/Dev/Тесты/BQ-System/frontend/js/labs/photosynthesis.js\")", + "Bash(grep -n \"^ $\\\\|^ \" \"g:/Dev/Тесты/BQ-System/frontend/admin.html\")", + "Bash(grep -v \"c.name+'':''+c.type\\).join\\(''\\\\n''\\)\\)\")", + "Bash(node -e \"const db=require\\(''./backend/src/db/db''\\); console.log\\(''phys questions:'', db.prepare\\(\"\"SELECT COUNT\\(*\\) as n FROM questions q JOIN subjects s ON s.id=q.subject_id WHERE s.slug=''phys''\"\"\\).get\\(\\).n\\); console.log\\(''total questions:'', db.prepare\\(''SELECT COUNT\\(*\\) as n FROM questions''\\).get\\(\\).n\\);\")", + "Bash(node -e \"const db=require\\(''./backend/src/db/db''\\); const q=db.prepare\\(''SELECT s.slug, COUNT\\(*\\) as n FROM questions q JOIN subjects s ON s.id=q.subject_id GROUP BY s.slug''\\).all\\(\\); console.log\\(q\\);\")", + "Bash(node src/db/seed-phys.js)", + "Bash(node src/db/seed-chem.js)", + "Bash(node src/db/dedup-bio.js)", + "Bash(node -e \"const db=require\\(''./src/db/db''\\); console.log\\(db.prepare\\(\"\"SELECT sql FROM sqlite_master WHERE name=''bookmarks''\"\"\\).get\\(\\).sql\\)\")", + "Bash(find g:/Dev/Тесты/BQ-System -name *.bak -o -name *.old -o -name *.tmp -o -name *.orig)", + "Bash(head -20 echo --- find g:/Dev/Тесты/BQ-System -name test*.js -not -path */node_modules/*)", + "Bash(grep -rn \"href.*homework\\\\|href.*theory\\\\|href.*gradebook\" g:/Dev/Тесты/BQ-System/frontend/ --include=*.html)", + "Bash(wc -l /g/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(node -e \"const db=require\\(''./src/db/db''\\); const tables=db.prepare\\(\"\"SELECT name FROM sqlite_master WHERE type=''table'' ORDER BY name\"\"\\).all\\(\\); tables.forEach\\(t=>console.log\\(t.name\\)\\)\")", + "Bash(grep -o 'https://[^\"\"\"\"'''' ]*' /g/Dev/Тесты/BQ-System/frontend/*.html)", + "Bash(wc -l seed*.js pool.js crop_images.js dedup-bio.js)", + "Bash(grep -E \"$\\\\{.*name.*\\\\}\")", + "Bash(grep -n \"function esc\" frontend/*.html js/*.js)", + "Bash(node -e \"const src=require\\(''fs''\\).readFileSync\\(''frontend/lab.html'',''utf8''\\); [''gas'',''brownian'',''states'',''diffusion'',''reactions''].forEach\\(n=>{const idx=src.indexOf\\(''id=\"\"''+n+''-canvas\"\"''\\); const ctx=src.substring\\(Math.max\\(0,idx-200\\),idx\\); console.log\\(n+'': ''+\\(/proj-canvas-outer/.test\\(ctx\\)?''proj-canvas-outer'':''OTHER''\\)\\);}\\)\")", + "Bash(node -e \"const src=require\\(''fs''\\).readFileSync\\(''frontend/dashboard.html'',''utf8''\\); const lines=src.split\\(''\\\\n''\\); const g=[''esc'',''parseDate'',''fmtRelTime'',''safeHref'',''connectSSE'']; for\\(let i=0;i` с вариантами «Студент» / «Учитель». Роль «Администратор» создаётся только через admin-панель. + +#### [MEDIUM] Accessibility: нет ссылки «Забыли пароль?» +Стандартный элемент auth-страниц полностью отсутствует. +**Фикс:** добавить ссылку под полем пароля на форме входа. + +#### [LOW] Consistency: `showBtnSuccess` вставляет строку `'✓ Сохранено'` через конкатенацию текста +Использование символа `✓` в коде (эмодзи-подобный символ) противоречит правилу проекта «только SVG-иконки». +**Фикс:** заменить `'✓ '` на inline SVG с классом `.ic`. + +--- + +### dashboard.html + +#### [MEDIUM] Loading: у виджетов есть скелетоны, но нет fallback при сетевой ошибке +Если API возвращает 500 — скелетон не убирается и данные не появляются. Пользователь не понимает, что произошло. +**Фикс:** в каждом `catch` блоке заменять скелетон на rich-empty с текстом ошибки и кнопкой «Повторить». + +#### [MEDIUM] Navigation: кнопки быстрых действий (`adm-actions`) видны для teacher/admin, но не имеют состояния загрузки +После нажатия на «Создать тест» или «Добавить класс» кнопка не блокируется — возможно двойное нажатие. +**Фикс:** `btn.disabled = true` сразу при клике. + +#### [LOW] Feedback: таймер дедлайна предупреждает только за 2 минуты (`secondsLeft <= 120`) +Для длинных тестов (1 час+) единственное предупреждение придёт слишком поздно, если пользователь отвлёкся. +**Фикс:** добавить промежуточное предупреждение при 25% оставшегося времени и за 5 минут. + +--- + +### board.html + +#### [HIGH] Flow: кнопка «Перейти» на карточке задания ведёт на `/dashboard`, а не на тест +```html +Перейти +``` +Студент не может начать задание прямо с борда — он попадает на главную страницу. +**Фикс:** ссылка должна вести на `/test-run?session=...&assignment=...` (с нужными параметрами задания). + +#### [MEDIUM] Consistency: ссылка на Red Book в сайдбаре содержит `.html`-расширение +```html + +``` +Все остальные ссылки: `/dashboard`, `/board`, `/classes` — без расширения. +**Фикс:** добавить Express-роут `/red-book` → отдавать `red-book.html`, изменить ссылку на `/red-book`. Это же исправление нужно во **всех** файлах с сайдбаром. + +#### [MEDIUM] Navigation: студент без класса видит пустой бord без способа вступить по инвайт-коду +Пустое состояние есть, но нет поля ввода инвайт-кода. +**Фикс:** добавить в empty-state форму «Введи код класса» с полем и кнопкой. + +#### [MEDIUM] Feedback: бейдж «LIVE» рендерится всегда, независимо от статуса +```html +LIVE +``` +JS должен скрывать/показывать его по реальному статусу сессии. +**Фикс:** `liveBadge.style.display = session.is_live ? '' : 'none'`. + +#### [LOW] Data integrity: реакции сохраняются только в `localStorage` +Данные `ls_reactions` пропадут на другом браузере/устройстве и не видны другим пользователям. +**Фикс:** сохранять реакцию через POST `/api/posts/:id/react`, localStorage использовать только как оптимистичный кэш. + +--- + +### classes.html + +#### [MEDIUM] Forms: кнопка «Создать класс» видна до выполнения JS role-check +В HTML `btn-new-cl` отрисовывается сразу. Студент, быстро открыв страницу, видит её до скрытия. +**Фикс:** добавить `style="display:none"` в HTML, показывать через JS только для teacher/admin. + +#### [MEDIUM] Navigation: ссылка на Gradebook скрыта в маленькой иконке в заголовке сайдбара +Новый пользователь никогда не найдёт журнал оценок. +**Фикс:** вынести «Журнал» как отдельный пункт сайдбара (или добавить явную кнопку в шапку детали класса). + +#### [MEDIUM] Confirmation: кнопка `btn-delete-class` — неясно, есть ли подтверждение +В HTML диалог подтверждения не виден. Если он реализован только в JS-коде в виде `confirm()` браузера — это слабое решение. +**Фикс:** использовать кастомный modal с явным текстом «Удалить класс X? Это действие необратимо», кнопками «Отмена» / «Удалить». + +#### [LOW] Accessibility: все элементы управления классами доступны только после выбора класса из списка +Состояние «ничего не выбрано» показывает заглушку «Выберите класс» без инструкции о том, что нужно сделать слева. +**Фикс:** добавить стрелку-подсказку или анимацию, указывающую на список классов. + +--- + +### admin.html + +#### [HIGH] Confirmation: смена роли пользователя через inline `... +``` +Неосторожный клик меняет роль немедленно. Это деструктивное действие: учитель превращается в студента без возможности отмены. +**Фикс:** при `change` события показывать confirmation modal «Изменить роль пользователя X с Y на Z?», только после подтверждения отправлять PATCH-запрос. + +#### [HIGH] Confirmation: кнопка `btn-del-q` (удалить вопрос) без видимого диалога подтверждения +Удаление вопроса из банка — необратимое действие. +**Фикс:** confirmation modal с предупреждением об необратимости. + +#### [MEDIUM] Error handling: нет отображения ошибок при провале admin-операций +Если PATCH /api/users/:id/role вернул 500, пользователь не узнает об этом. +**Фикс:** показывать toast/alert с текстом ошибки из ответа API. + +#### [LOW] UX: `max-height: 0` → `max-height: 4000px` в session drawer +Анимация открытия непредсказуема по скорости — CSS считает переход от 0 до 4000px, хотя реальная высота 200px. +**Фикс:** использовать JavaScript для установки точной `max-height: element.scrollHeight + 'px'`. + +--- + +### test-run.html + +#### [MEDIUM] Feedback: кнопка «Завершить тест» доступна с первого вопроса без минимального порога +Студент может нечаянно нажать и завершить тест, ответив на 0 вопросов. +**Фикс:** показывать предупреждение в confirmation modal: «Ты ответил на X из Y вопросов. Уверен, что хочешь завершить?» + +#### [MEDIUM] Accessibility: `` — пустой атрибут alt у изображений в вопросах +Студенты с нарушениями зрения не получат описание изображения. +**Фикс:** заполнять `alt` из поля описания вопроса в БД (например, `alt="Изображение к вопросу: ..."`). + +#### [MEDIUM] Loading: нет серверного сохранения прогресса теста в процессе +SessionStorage работает только в рамках одной вкладки. При сбое браузера прогресс теряется. +**Фикс:** периодически (каждые 30 сек) отправлять автосохранение через PATCH `/api/sessions/:id/autosave` с текущими ответами. + +#### [LOW] Feedback: предупреждение таймера только за 120 секунд — мало для коротких тестов +**Фикс:** добавить предупреждение при достижении 25% оставшегося времени (независимо от абсолютного значения). + +--- + +### test-result.html + +#### [HIGH] Flow: обе кнопки «К тестам» и «В кабинет» ведут на `/dashboard` +```html +К тестам +В кабинет +``` +Дублирование кнопок с одинаковым назначением сбивает с толку. +**Фикс:** «К тестам» → `/board` (список заданий), «В кабинет» → `/dashboard`, добавить третью кнопку «Пройти ещё раз» → `/test-run?...` с теми же параметрами. + +#### [MEDIUM] Navigation: нет возврата к заданию на борде, если тест был из assignment +Если студент пришёл с `/board?class=5`, после теста нет ссылки «Вернуться к классу». +**Фикс:** передавать `returnUrl` параметром и использовать его в кнопке «Назад». + +--- + +### homework.html + +#### [HIGH] Error handling: пустой `catch {}` при загрузке данных класса в `initStudent()` +```js +catch {} +``` +Студент видит пустую форму без объяснения причины. +**Фикс:** заменить на `catch(e) { showError('Не удалось загрузить задания. ' + e.message); }`. + +#### [HIGH] Forms: при нескольких классах у студента задание всегда подаётся в `classes[0].id` +Студент в 2+ классах может случайно отправить работу не в тот класс. +**Фикс:** добавить ` + + +
+ + + На главную + + +
+ + + + diff --git a/frontend/500.html b/frontend/500.html new file mode 100644 index 0000000..4183e1f --- /dev/null +++ b/frontend/500.html @@ -0,0 +1,77 @@ + + + + + + 500 — Ошибка сервера + + + + + + +
+
+ + + + +
+
500
+
Что-то пошло не так
+
+ + Ошибка на стороне сервера — мы уже в курсе +
+
Произошла внутренняя ошибка. Попробуй перезагрузить страницу или вернуться позже.
+
+ + + + На главную + +
+
+ + diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..ebf548a --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,4837 @@ + + + + + + Панель управления — LearnSpace + + + + + + + + + +
+ +
+ +
+
Панель управления
+
Загрузка…
+ +
+ +
+ + +
+
Общая статистика
+
+
По предметам
+
+
+ + +
+
+ + + + + + + + + Шаблон +
+
Выберите предмет или загрузите все вопросы
+
+ + +
+
+ + + + +
+
Загрузка…
+
+ + +
+
+ + + +
+
+
+ + + + +
+ + Перейти в классы +
+
+
+ + +
+
Настройка доступных тестов
+
Настройте что увидят ученики на дашборде: режим, количество вопросов, источник.
+
+
+ + +
+
Пользователи
+
+ + + +
ПользовательРольТестовСредний %РегистрацияПосл. вход
+
+
+
+
+
+ + + + + + +
+
+
История тестов
+
+
+
+ + +
+
+ + + + +
+
+
+ + +
+
+
Права доступа по ролям
+

Настройте, что могут делать учителя и ученики. Администраторы имеют все права всегда.

+
+ +
+
+ Учитель +
+
+
+ +
+
+ Ученик +
+
+
+
+ + +
+
Магазин
+
+ +
Товары
+
+ +
+
+ + + + + +
IDНазваниеТипЦенаПроданоАктивенДействия
+
+ + + +
Начислить монеты
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ + +
+
Геймификация
+
+ +
Топ-10 по XP
+
+ + + + + +
#ИмяXPУровеньМонеты
+
+ +
Покупки в магазине
+
+ + + + + +
ВремяПользовательПредметТипЦена
+
+ +
Последние начисления XP
+
+ + + + + +
ВремяИмяXPПричина
+
+ +
Начислить XP / Монеты
+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
Сбросить прогресс пользователя
+
+
+ + +
+
+
+ + +
+
Шаблоны курсов
+
+ + + + + +
IDНазваниеПредметКатегорияАвторПубличныйДействия
+
+ +
Шаблоны уроков
+
+ + + + + +
IDНазваниеПредметКатегорияАвторПубличныйДействия
+
+
+ +
+
Управление симуляциями
+ + +
+
+
+
Модуль симуляций
+
Отключить полностью — страница «Лаборатория» станет недоступна для всех пользователей
+
+ +
+
+ + +
Отключённые симуляции не отображаются в лаборатории. Симуляции в статусе «скоро» не показываются независимо от этой настройки.
+
+
Загрузка…
+
+
+ + +
+
Управление играми
+
Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.
+
+
Загрузка…
+
+ +
Модули для «Своб. ученика»
+
Отключённые модули скрываются только для пользователей с ролью Своб. ученик. Глобальные настройки выше применяются поверх этих.
+
+
Загрузка…
+
+
+ + +
+
Журнал удалённых работ
+
Все удалённые работы учеников записываются сюда. Данные сохраняются даже после удаления файлов.
+
+ + + +
+
+
+ + +
+
Управление темами
+
+ + + +
+ +
+
+ + +
+
Рассылка уведомлений
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+ + +
+
+ Журнал действий администраторов + +
+
+
+ + +
+
+ Журнал ошибок сервера + +
+
+
+ + +
+
Здоровье системы
+
+
+ +
+
+
+ + +
+
+
Права пользователя
+

Индивидуальные настройки переопределяют права роли для этого учителя.

+
+
+ + +
+
+
+ + +
+
+
Редактировать пользователя
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
Добавить вопрос
+ +
+ +
+ + + + + +
+
+ +
+
+ + +
+
+ + + +
Введите новую — создастся автоматически
+
+
+ + +
+
+ +
+ Формулы: + + + + + + + + + + + + + + + + + + +
+ +
+ + +
0 / 500
+
+ +
+
Предпросмотр
+
Введите текст вопроса…
+
+ +
+ Варианты ответов — отметьте правильный +
+
+ + + + + + +
+ +
+ + + +
+ +
+ +
+ + +
+ +
+ +
+
+ + +
+
+
Создать задание
+ +
+ +
+ + +
+
+ +
+
+ + +
+
+ + + +
+ + +
+ +
+ +
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + + +
+ + +
+ +
+ +
+
+ + +
+
+
Редактировать задание
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + + +
+ + +
+ +
+ +
+
+ + +
+
+
Создать тест
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
Оставьте пустым — время рассчитывается автоматически
+
+ +
+ +
+ + +
+
При «Скрыть» ученики видят только итоговый балл
+
+ +
+ +
+
+ + + +
+
+ + + + diff --git a/frontend/analytics.html b/frontend/analytics.html new file mode 100644 index 0000000..ea7521a --- /dev/null +++ b/frontend/analytics.html @@ -0,0 +1,711 @@ + + + + + + Аналитика — LearnSpace + + + + + + + + +
+ +
+
+ + +
+
+
+ Классы +
+
Аналитика
+ +
+
+
+ + + + + +
+
+
+
Выберите класс
+ Выберите класс из списка выше, чтобы увидеть аналитику +
+
+ +
+
+ + + + + + + + diff --git a/frontend/biochem-library.html b/frontend/biochem-library.html new file mode 100644 index 0000000..c83a284 --- /dev/null +++ b/frontend/biochem-library.html @@ -0,0 +1,673 @@ + + + + + + Библиотека молекул — LearnSpace + + + + + + +
+ +
+ +
+ + + + +
+ + + + + + + +
+ + +
+
+
+
+ + + +

Загрузка молекул…

+
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
Категория
+
+
+ + + + +
+
+
+
+ + + + + + + + diff --git a/frontend/biochem-pathways.html b/frontend/biochem-pathways.html new file mode 100644 index 0000000..846c015 --- /dev/null +++ b/frontend/biochem-pathways.html @@ -0,0 +1,1288 @@ + + + + + + Метаболические пути — Биохимия — LearnSpace + + + + + + + +
+ +
+ +
+ + + + +
+ Путь: + + + + +
+ + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+
+ + +
+
+
Молекула
+
Обучение
+
Путь
+
+
+
+
+
Кликни на молекулу
на схеме
+
+ +
+ + + +
+
Обозначения
+
+
+
+
+
+
+ + + + + + + diff --git a/frontend/biochem-properties.html b/frontend/biochem-properties.html new file mode 100644 index 0000000..29619f2 --- /dev/null +++ b/frontend/biochem-properties.html @@ -0,0 +1,605 @@ + + + + + + Свойства молекул — LearnSpace + + + + + + +
+ +
+ +
+
+ +
+
+
Молекулы
+ +
+ + + + + +
+
+
Загрузка…
+
+ + +
+
+
+
Сравнение молекул
+
Добавь до 4 молекул из списка слева
+
+
+ +
+
+
+
Выбери молекулы из списка, чтобы сравнить их свойства
+
+
+
+
+
+
+ + + + + + + + diff --git a/frontend/biochem-reactions.html b/frontend/biochem-reactions.html new file mode 100644 index 0000000..098dccb --- /dev/null +++ b/frontend/biochem-reactions.html @@ -0,0 +1,843 @@ + + + + + + Реакции — Биохимия — LearnSpace + + + + + + +
+ +
+ +
+ + + + +
+ + + + + + + + +
+ + + + +
+
+
+ + + +

Загрузка реакций…

+
+
+
+
+
+ + + + + + + + diff --git a/frontend/biochem.html b/frontend/biochem.html new file mode 100644 index 0000000..2822617 --- /dev/null +++ b/frontend/biochem.html @@ -0,0 +1,2079 @@ + + + + + + Молекулярный конструктор — LearnSpace + + + + + + +
+ +
+ +
+ +
+ Биохимия +
+ +
+
+ + + + +
+ + + + +
+ + +
+ +
+
⬡ Бензол C₆H₆
+
⬡ Циклогексан C₆
+
⬠ Циклопентан C₅
+
⬡⬡ Нафталин C₁₀H₈
+
+
+
+ + +
+ +
+ +
+ + +
+
Кликни на холст, чтобы добавить атом · Перетащи от атома, чтобы создать связь · Клик по связи меняет порядок
+
+ + +
+
+ + + +
+ + +
+
+
Формула
+
+
+ + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ Прогресс: 0/0 + 0 XP +
+
+
+
+ + + + + + + + +
+
+
Загрузка…
+
+
+ + +
+
Загрузка…
+
+
+
+
+
+ + + + + + + + + + + + diff --git a/frontend/board.html b/frontend/board.html new file mode 100644 index 0000000..74d1171 --- /dev/null +++ b/frontend/board.html @@ -0,0 +1,960 @@ + + + + + + Доска класса — LearnSpace + + + + + + + + + +
+ +
+
+ +
+ + +
+ Новые записи — нажмите чтобы обновить +
+ + +
+
Доска класса
+
LIVE
+ + Обновление через 30 с +
+ + + + + +
+ + +
+ + +
+ + + + +
+ + +
+
+ + + + +
+
+ + + + diff --git a/frontend/classes.html b/frontend/classes.html new file mode 100644 index 0000000..93522eb --- /dev/null +++ b/frontend/classes.html @@ -0,0 +1,2620 @@ + + + + + + Классы — LearnSpace + + + + + + + +
+ +
+
+ + +
+
+ Классы + + + + +
+
+
+ +
+
+
Личные задания
+
+
+ +
+
+
+
+ + +
+ + +
+
+
Выберите класс
+
Нажмите на класс слева или создайте новый
+ +
+ + + + + + + +
+
+ + + + + + + + +
+
+ + +
+
Результаты
+ + +
+ + + + + +
+ + + + + + +
Нажмите на ученика, чтобы посмотреть его ответы
+
+
+ + + + + +
+
+ +
+
+
+
+
+ +
+
+ + + + + + + +
+ + + + +
+ + + + diff --git a/frontend/classroom.html b/frontend/classroom.html new file mode 100644 index 0000000..953b5b9 --- /dev/null +++ b/frontend/classroom.html @@ -0,0 +1,4254 @@ + + + + + + Онлайн-урок — LearnSpace + + + + + + + +
+ +
+ +
+ +
+
+
+
+ + Онлайн-урок +
+ + + +
+
+ + +
+ +
+ + + + + + + + + + + +
+ + +
+
+ + + +
+ + +
+ + +
+
+ +

Участники появятся когда урок начнётся

+
+
+
+ + + + + + +
+
+
+
+ + +
+
+
Начать онлайн-урок
+
Выберите аудиторию и введите тему
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ + + + +
+ + +
+
+
+ + + + + + + + + + + + + +
+
+
+ Шаблоны уроков + +
+
+
Нет сохранённых шаблонов
+
+
+ + +
+
+
+ + + + + + + + + + + diff --git a/frontend/collection-rb.html b/frontend/collection-rb.html new file mode 100644 index 0000000..e994489 --- /dev/null +++ b/frontend/collection-rb.html @@ -0,0 +1,488 @@ + + + + + + Моя коллекция — Красная книга РБ + + + + + + + +
+ + + + +
+
+ + +
+
+

Моя коллекция

+

Открытые виды Красной книги Беларуси

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
Все
+
CR
+
EN
+
VU
+
NT
+
+ + +
+
+ + +
+

Открытые виды

+ +
+ + +
+ +
+ +
+
+
+ +
+ + + + + + + diff --git a/frontend/collection.html b/frontend/collection.html new file mode 100644 index 0000000..b7e8160 --- /dev/null +++ b/frontend/collection.html @@ -0,0 +1,551 @@ + + + + + + Коллекция — LearnSpace + + + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
Коллекция карточек
+
Открывай карточки, прокачивая знания по темам
+
+
+ + +
+
+
+ +
+
+
+
Всего тем
+
+
+
+
+ +
+
+
+
Открыто
+
+
+
+
+ +
+
+
+
Платина
+
+
+
+
+ +
+
+
+
Золото
+
+
+
+
+ +
+
+
+
Серебро
+
+
+
+
+ +
+
+
+
Бронза
+
+
+
+ + +
+
Прогресс коллекции
+
+
0%
+
+ + +
+ + + + + + +
+
+ +
+
+ + +
+
+
Все +
+
+
Платина +
+
+
Золото +
+
+
Серебро +
+
+
Бронза +
+
+
Закрыто +
+
+ + +
+
+
+ Загружаем коллекцию… +
+
+ +
+
+
+ + + + + + + + + diff --git a/frontend/course.html b/frontend/course.html new file mode 100644 index 0000000..e496b24 --- /dev/null +++ b/frontend/course.html @@ -0,0 +1,1167 @@ + + + + + + Курс — LearnSpace + + + + + + + +
+ +
+
+ + +
+
+ +
+ +
+ + + + +
+
Уроки
+
+ + +
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/crossword.html b/frontend/crossword.html new file mode 100644 index 0000000..caa3420 --- /dev/null +++ b/frontend/crossword.html @@ -0,0 +1,829 @@ + + + + + + Кроссворд — LearnSpace + + + + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
Кроссворд
+
Угадай биологические термины по вопросам
+
+
+ +
+
+ + +
+ + + + + +
+ + + + + +
+
+
Генерируем кроссворд…
+
+ + + +
+
+
+ + +
+
+
+
+
+ +
+ + +
+
+
+ + +
+ + + + + + + + + + + diff --git a/frontend/css/ls.css b/frontend/css/ls.css new file mode 100644 index 0000000..9cfbf7e --- /dev/null +++ b/frontend/css/ls.css @@ -0,0 +1,957 @@ +/* ═══════════════════════════════════════════════════════ + LearnSpace — Shared Design System /css/ls.css + ═══════════════════════════════════════════════════════ */ + +/* ── Reset ── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ── Design tokens ── */ +:root { + --bg: #EEF2FF; + --surface: rgba(255,255,255,0.82); + --border: rgba(15,23,42,0.10); + --border-h: rgba(15,23,42,0.20); + --text: #0F172A; + --text-2: #3D4F6B; + --text-3: #8898AA; + + --violet: #9B5DE5; + --cyan: #06D6E0; + --green: #06D664; + --pink: #F15BB5; + --amber: #FFB347; + + --grad-1: linear-gradient(135deg, #06D6E0, #9B5DE5); + + --r-lg: 20px; + --r-pill: 999px; + + --blur: blur(20px); + /* two-layer shadow: crisp + ambient */ + --shadow: 0 2px 8px rgba(15,23,42,0.08), 0 8px 40px rgba(15,23,42,0.10); + --shadow-h: 0 4px 16px rgba(15,23,42,0.12), 0 16px 56px rgba(15,23,42,0.13); + + --tr: 0.22s ease; +} + +/* ── Body + dot-grid ── */ +body { + font-family: 'Manrope', sans-serif; + background: var(--bg); + background-image: radial-gradient(circle, rgba(15,23,42,0.055) 1px, transparent 1px); + background-size: 22px 22px; + min-height: 100vh; + color: var(--text); +} + +/* ── Custom scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.35); border-radius: 99px; } +::-webkit-scrollbar-thumb:hover { background: rgba(155,93,229,0.60); } + +/* ── Focus ring ── */ +:focus-visible { outline: 2px solid var(--violet); outline-offset: 3px; } + +/* ── Navbar ── */ +.nav { + position: sticky; top: 0; z-index: 100; + padding: 10px 24px; + display: flex; align-items: center; justify-content: space-between; + background: rgba(238,242,255,0.90); + backdrop-filter: var(--blur); + border-bottom: 1px solid var(--border); +} +.nav-logo { + font-family: 'Unbounded', sans-serif; + font-size: 1rem; font-weight: 800; + color: var(--text); text-decoration: none; + letter-spacing: -0.01em; +} +.nav-logo span { + background: var(--grad-1); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; +} +.nav-right { display: flex; align-items: center; gap: 10px; } + +/* nav user avatar chip */ +.nav-user-chip { + display: flex; align-items: center; gap: 8px; + padding: 4px 12px 4px 4px; + background: rgba(155,93,229,0.07); + border: 1.5px solid rgba(155,93,229,0.18); + border-radius: var(--r-pill); +} +.nav-avatar { + width: 28px; height: 28px; + border-radius: 50%; + background: var(--grad-1); + display: flex; align-items: center; justify-content: center; + font-family: 'Unbounded', sans-serif; + font-size: 0.62rem; font-weight: 800; color: #fff; + flex-shrink: 0; +} +.nav-user-name { + font-size: 0.78rem; font-weight: 600; color: var(--text-2); + max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* legacy .nav-user (plain text) */ +.nav-user { font-size: 0.85rem; color: var(--text-2); } + +.btn-nav { + padding: 6px 16px; + border: 1.5px solid var(--border-h); + border-radius: var(--r-pill); + background: transparent; + font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600; + color: var(--text-3); + cursor: pointer; text-decoration: none; display: inline-block; + transition: all var(--tr); +} +.btn-nav:hover { border-color: var(--violet); color: var(--violet); } +.nav-active { + background: rgba(155,93,229,0.08) !important; + border-color: var(--violet) !important; + color: var(--violet) !important; + cursor: default; pointer-events: none; +} + +/* ── Shimmer btn-primary ── */ +.btn-primary { + position: relative; overflow: hidden; + padding: 10px 26px; + border: none; border-radius: var(--r-pill); + background: var(--grad-1); + color: #fff; + font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; + cursor: pointer; + transition: transform var(--tr), box-shadow var(--tr); + box-shadow: 0 2px 14px rgba(155,93,229,0.30); +} +.btn-primary::after { + content: ''; + position: absolute; top: 0; left: -120%; + width: 80%; height: 100%; + background: linear-gradient(100deg, transparent, rgba(255,255,255,0.28), transparent); + transform: skewX(-15deg); + transition: left 0.55s ease; +} +.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 22px rgba(155,93,229,0.40); } +.btn-primary:hover::after { left: 160%; } +.btn-primary:active { transform: translateY(0); } + +/* ── Ghost & danger buttons ── */ +.btn-ghost { + padding: 8px 18px; + border: 1.5px solid var(--border-h); border-radius: var(--r-pill); + background: transparent; + font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; + color: var(--text-2); cursor: pointer; + transition: all var(--tr); +} +.btn-ghost:hover { border-color: var(--violet); color: var(--violet); } + +.btn-danger { + padding: 6px 14px; + border: 1.5px solid rgba(241,91,181,0.35); border-radius: var(--r-pill); + background: rgba(241,91,181,0.06); + font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 600; + color: #c0306a; cursor: pointer; + transition: all var(--tr); +} +.btn-danger:hover { border-color: var(--pink); background: rgba(241,91,181,0.12); color: #a0204a; } + +/* ── Form inputs ── */ +.form-input { + width: 100%; + padding: 10px 14px; + border: 1.5px solid var(--border-h); border-radius: 12px; + background: rgba(255,255,255,0.70); + font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text); + transition: border-color var(--tr), box-shadow var(--tr); +} +.form-input:focus { + outline: none; + border-color: var(--violet); + box-shadow: 0 0 0 3px rgba(155,93,229,0.15); +} + +/* ── Badges ── */ +.badge { + display: inline-flex; align-items: center; + padding: 2px 9px; + border-radius: var(--r-pill); + font-size: 0.72rem; font-weight: 700; +} +.badge-violet { background: rgba(155,93,229,0.12); color: var(--violet); } +.badge-cyan { background: rgba(6,214,224,0.12); color: #05aab3; } +.badge-green { background: rgba(6,214,100,0.12); color: #059950; } +.badge-pink { background: rgba(241,91,181,0.12); color: #c0306a; } +.badge-amber { background: rgba(255,179,71,0.15); color: #b06a00; } + +/* ── Modal ── */ +.modal-overlay { + position: fixed; inset: 0; z-index: 1000; + background: rgba(15,23,42,0.48); + backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; + padding: 20px; + animation: fadeIn 0.18s ease; +} +.modal { + background: #fff; + border-radius: 24px; + padding: 32px 28px; + max-width: 520px; width: 100%; + box-shadow: var(--shadow-h); + animation: fadeUp 0.22s ease; +} +.modal-title { + font-family: 'Unbounded', sans-serif; + font-size: 1.05rem; font-weight: 800; + margin-bottom: 20px; +} +.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; } + +/* ── Spinner ── */ +.spinner { + width: 32px; height: 32px; + border: 3px solid var(--border); + border-top-color: var(--violet); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 30px auto; display: block; +} + +/* ── Empty state ── */ +.empty { + text-align: center; padding: 40px 20px; + color: var(--text-3); font-size: 0.88rem; +} +.empty-icon { font-size: 2.4rem; margin-bottom: 12px; } + +/* ── Error text ── */ +.error { color: var(--pink); font-size: 0.85rem; padding: 12px 0; } + +/* ── Keyframes ── */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes fadeUp { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes shimmer { + 0% { left: -120%; } + 100% { left: 160%; } +} + +/* ══════════════════════════════════════════ + SIDEBAR LAYOUT +══════════════════════════════════════════ */ +.app-layout { + display: flex; + min-height: 100vh; +} + +.app-layout > .sidebar { + width: 230px; + flex-shrink: 0; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + background: rgba(238,242,255,0.94); + backdrop-filter: blur(28px); + border-right: 1.5px solid var(--border); + padding: 0 10px 16px; + z-index: 50; + scrollbar-width: none; +} +.app-layout > .sidebar::-webkit-scrollbar { display: none; } + +.sb-brand { + display: flex; + align-items: center; + padding: 20px 12px 14px; + margin-bottom: 4px; +} + +.sb-logo { + font-family: 'Unbounded', sans-serif; + font-size: 1.05rem; + font-weight: 800; + color: var(--text); + text-decoration: none; +} +.sb-logo span { + background: var(--grad-1); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.sb-nav { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.sb-link, a.sb-link, button.sb-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 12px; + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-3); + transition: all var(--tr); + cursor: pointer; + border: none; + background: transparent; + width: 100%; + text-align: left; + font-family: 'Manrope', sans-serif; + position: relative; + white-space: nowrap; +} +.sb-link:hover { + background: rgba(155,93,229,0.07); + color: var(--text); +} +.sb-link.active { + background: rgba(155,93,229,0.10); + color: var(--violet); + font-weight: 700; +} +.sb-link.active::before { + content: ''; + position: absolute; + left: 0; top: 5px; bottom: 5px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--grad-1); +} + +.sb-icon { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: inherit; +} +.sb-icon svg { + width: 18px; + height: 18px; + stroke-width: 1.85; +} +/* tab-icon: inline svg в табах и кнопках */ +.tab-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} +.tab-icon svg { + width: 16px; + height: 16px; + stroke-width: 2; +} +/* stat-icon: иконки в карточках KPI */ +.stat-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} +.stat-icon svg { + width: 22px; + height: 22px; + stroke-width: 1.75; +} + +.sb-divider { + height: 1px; + background: var(--border); + margin: 8px 2px; +} + +.sb-badge { + min-width: 18px; + height: 18px; + border-radius: 99px; + background: var(--pink); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; + margin-left: auto; +} + +.sb-foot { + padding-top: 10px; + border-top: 1px solid var(--border); + margin-top: 6px; +} + +.sb-user-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 12px; + transition: background var(--tr); +} +.sb-user-row:hover { background: rgba(155,93,229,0.05); } + +.sb-avatar { + width: 32px; height: 32px; + border-radius: 10px; + background: var(--grad-1); + display: flex; align-items: center; justify-content: center; + font-family: 'Unbounded', sans-serif; + font-size: 0.6rem; font-weight: 800; color: #fff; + flex-shrink: 0; +} + +.sb-user-info { flex: 1; min-width: 0; } +.sb-user-name { + font-size: 0.78rem; font-weight: 700; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-bottom: 1px; +} +.sb-logout { + font-size: 0.68rem; color: var(--text-3); + background: none; border: none; cursor: pointer; padding: 0; + font-family: 'Manrope', sans-serif; font-weight: 600; + transition: color var(--tr); +} +.sb-logout:hover { color: var(--pink); } + +.sb-content { + flex: 1; + min-width: 0; + overflow-x: hidden; +} + +/* notif drop anchored relative to sidebar */ +.notif-drop { + position: fixed; + left: 246px; + width: 320px; + max-height: 420px; + overflow-y: auto; + background: #fff; + border: 1.5px solid var(--border-h); + border-radius: 16px; + box-shadow: 0 12px 48px rgba(15,23,42,0.14); + z-index: 1000; + display: none; + font-size: 0.82rem; + color: var(--text-2); +} +.notif-drop.open { display: block; } +.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 13px 16px 9px; border-bottom: 1px solid var(--border); } +.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.77rem; font-weight: 800; } +.notif-read-all { background: none; border: none; font-size: 0.72rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; padding: 0; } +.notif-read-all:hover { text-decoration: underline; } +.notif-item { display: flex; gap: 9px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; text-decoration: none; color: inherit; transition: background var(--tr); } +.notif-item:last-child { border-bottom: none; } +.notif-item:hover { background: rgba(155,93,229,0.04); } +.notif-item.unread { background: rgba(155,93,229,0.06); } +.notif-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; } +.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); } +.notif-msg { font-size: 0.79rem; line-height: 1.4; flex: 1; } +.notif-time { font-size: 0.68rem; color: var(--text-3); margin-top: 2px; } +.notif-empty { padding: 26px 16px; text-align: center; color: var(--text-3); font-size: 0.83rem; } + +/* ══════════════════════════════════════════ + PAGE TRANSITIONS (View Transitions API) +══════════════════════════════════════════ */ +@view-transition { navigation: auto; } + +/* Shared element: sidebar stays fixed, only main content transitions */ +.sb-content { view-transition-name: main-content; } +.sidebar { view-transition-name: sidebar; } + +/* Sidebar: no animation — stays in place */ +::view-transition-old(sidebar), +::view-transition-new(sidebar) { animation: none; } + +/* Main content: slide + fade */ +::view-transition-old(main-content) { + animation: 180ms cubic-bezier(.4,0,1,1) both vt-slide-out; +} +::view-transition-new(main-content) { + animation: 260ms cubic-bezier(0,.5,.3,1) both vt-slide-in; +} + +/* Fallback for pages without sidebar (login, test-run, test-result) */ +::view-transition-old(root) { + animation: 160ms cubic-bezier(.4,0,1,1) both vt-fade-out; +} +::view-transition-new(root) { + animation: 240ms cubic-bezier(0,.5,.3,1) both vt-fade-in; +} + +@keyframes vt-slide-out { + from { opacity: 1; transform: translateX(0) scale(1); } + to { opacity: 0; transform: translateX(-16px) scale(.98); } +} +@keyframes vt-slide-in { + from { opacity: 0; transform: translateX(20px) scale(.98); } + to { opacity: 1; transform: translateX(0) scale(1); } +} +@keyframes vt-fade-out { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes vt-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ══════════════════════════════════════════ + LOGIN PAGE SPLIT LAYOUT +══════════════════════════════════════════ */ +.login-page { + display: block !important; + padding: 0 !important; +} +.login-layout { + display: flex; + min-height: 100vh; +} + +.login-left { + flex: 1; + background: linear-gradient(145deg, #0f0c29 0%, #1a1547 40%, #24243e 100%); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + padding: 60px 56px; +} +.login-left::before { + content: ''; + position: absolute; inset: 0; + background-image: radial-gradient(circle, rgba(255,255,255,0.055) 1px, transparent 1px); + background-size: 22px 22px; + pointer-events: none; +} +.ll-blob1, .ll-blob2 { position: absolute; border-radius: 50%; pointer-events: none; } +.ll-blob1 { width: 520px; height: 520px; background: radial-gradient(circle, rgba(155,93,229,0.32), transparent 70%); top: -160px; right: -100px; } +.ll-blob2 { width: 380px; height: 380px; background: radial-gradient(circle, rgba(6,214,224,0.22), transparent 70%); bottom: -100px; left: 15%; } + +.ll-inner { position: relative; z-index: 1; max-width: 440px; } +.ll-logo { + font-family: 'Unbounded', sans-serif; + font-size: 1.35rem; font-weight: 800; + color: #fff; + margin-bottom: 52px; +} +.ll-logo span { + background: linear-gradient(135deg, #06D6E0, #9B5DE5); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; +} +.ll-headline { + font-family: 'Unbounded', sans-serif; + font-size: 1.75rem; font-weight: 800; line-height: 1.3; + color: #fff; + margin-bottom: 14px; + text-shadow: 0 2px 20px rgba(0,0,0,0.25); +} +.ll-tagline { + font-size: 0.88rem; color: rgba(255,255,255,0.58); font-weight: 500; + margin-bottom: 40px; line-height: 1.65; +} +.ll-features { display: flex; flex-direction: column; gap: 10px; } +.ll-feat { + display: flex; align-items: center; gap: 12px; + font-size: 0.85rem; color: rgba(255,255,255,0.82); font-weight: 600; + padding: 10px 16px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.09); + border-radius: 12px; +} +.ll-feat-icon { font-size: 1rem; flex-shrink: 0; } + +.login-right { + width: 450px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 44px 40px; + background: #f4f6ff; +} +.login-right .auth-wrap { + width: 100%; + max-width: 100%; +} + +@media (max-width: 840px) { + .login-layout { flex-direction: column; } + .login-left { padding: 40px 28px; min-height: 300px; } + .login-right { width: 100%; padding: 36px 24px; } + .ll-headline { font-size: 1.35rem; } +} + +/* ══════════════════════════════════════════ + A1: STAGGERED ENTRANCE ANIMATION +══════════════════════════════════════════ */ +@keyframes cardIn { + from { opacity: 0; transform: translateY(18px) scale(0.97); } + to { opacity: 1; transform: none; } +} +.stagger-item { + animation: cardIn 0.42s cubic-bezier(0.22, 0.61, 0.36, 1) both; + animation-delay: calc(var(--i, 0) * 50ms); +} + +/* ══════════════════════════════════════════ + D1: SKELETON 2.0 — VIOLET SHIMMER +══════════════════════════════════════════ */ +@keyframes ls-shimmer { + from { background-position: -600px 0; } + to { background-position: 600px 0; } +} +.ls-sk { + background: linear-gradient( + 90deg, + rgba(155,93,229,0.06) 20%, + rgba(155,93,229,0.16) 40%, + rgba(255,255,255,0.7) 50%, + rgba(155,93,229,0.16) 60%, + rgba(155,93,229,0.06) 80% + ) !important; + background-size: 1200px 100% !important; + animation: ls-shimmer 1.8s infinite ease-in-out !important; +} + +/* ══════════════════════════════════════════ + C2: RICH EMPTY STATES +══════════════════════════════════════════ */ +.rich-empty { + display: flex; flex-direction: column; align-items: center; + gap: 10px; padding: 52px 24px; text-align: center; + background: rgba(255,255,255,0.7); + border: 1.5px dashed rgba(155,93,229,0.22); + border-radius: 20px; + backdrop-filter: blur(12px); + animation: cardIn 0.4s ease both; +} +.rich-empty-svg { opacity: 0.7; } +.rich-empty-title { + font-family: 'Unbounded', sans-serif; + font-size: 0.95rem; font-weight: 800; color: var(--text); + margin-top: 4px; +} +.rich-empty-sub { + font-size: 0.83rem; color: var(--text-3); + max-width: 290px; line-height: 1.62; margin-bottom: 2px; +} +.rich-empty-btn { + padding: 10px 26px; + background: var(--grad-1); color: #fff; + border: none; border-radius: 999px; + font-family: 'Manrope', sans-serif; + font-size: 0.84rem; font-weight: 700; + cursor: pointer; transition: all var(--tr); +} +.rich-empty-btn:hover { opacity: 0.88; transform: translateY(-2px); box-shadow: 0 8px 24px rgba(155,93,229,0.3); } + +/* ══════════════════════════════════════════ + C1: COLLAPSIBLE SIDEBAR +══════════════════════════════════════════ */ +/* Smooth transition for sidebar width */ +.app-layout > .sidebar { transition: width 0.28s cubic-bezier(0.4,0,0.2,1); } + +/* Label text inside nav links — fades/collapses */ +.sb-lbl { + overflow: hidden; + max-width: 160px; + transition: max-width 0.25s ease, opacity 0.18s ease, margin 0.18s ease; + white-space: nowrap; + flex-shrink: 1; + min-width: 0; +} + +/* Toggle button (chevron) */ +.sb-toggle { + width: 26px; height: 26px; border-radius: 50%; + background: rgba(255,255,255,0.85); + border: 1.5px solid var(--border-h); + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; flex-shrink: 0; + box-shadow: 0 1px 6px rgba(15,23,42,0.10); + transition: background var(--tr), border-color var(--tr), transform 0.28s ease; + padding: 0; margin-left: auto; +} +.sb-toggle:hover { background: var(--violet); color: #fff; border-color: var(--violet); } +.sb-toggle svg { width: 13px; height: 13px; stroke-width: 2.5; } + +/* ── Collapsed state ── */ +.app-layout.sb-collapsed > .sidebar { width: 62px; padding: 0 6px 16px; } +.app-layout.sb-collapsed .sb-brand { padding: 20px 4px 14px; justify-content: center; } +.app-layout.sb-collapsed .sb-logo { display: none; } +.app-layout.sb-collapsed .sb-lbl { max-width: 0; opacity: 0; } +.app-layout.sb-collapsed .sb-link { justify-content: center; padding: 10px 0; gap: 0; } +.app-layout.sb-collapsed .sb-badge { display: none !important; } +.app-layout.sb-collapsed .sb-user-info { display: none; } +.app-layout.sb-collapsed .sb-user-row { justify-content: center; padding: 8px 0; } +.app-layout.sb-collapsed .sb-toggle { margin: 0; transform: rotate(180deg); } + +/* ══════════════════════════════════════════ + GLOBAL SEARCH MODAL +══════════════════════════════════════════ */ +.gs-overlay { + position: fixed; inset: 0; z-index: 9500; + background: rgba(15,23,42,0.45); backdrop-filter: blur(12px); + display: flex; align-items: flex-start; justify-content: center; + padding: 10vh 20px 20px; opacity: 0; pointer-events: none; + transition: opacity .18s ease; +} +.gs-overlay.open { opacity: 1; pointer-events: auto; } +.gs-box { + width: 100%; max-width: 560px; background: #fff; + border-radius: 20px; box-shadow: 0 40px 100px rgba(15,23,42,0.28); + overflow: hidden; transform: scale(.96) translateY(-8px); + transition: transform .18s ease; +} +.gs-overlay.open .gs-box { transform: scale(1) translateY(0); } +.gs-input-wrap { + display: flex; align-items: center; gap: 10px; + padding: 16px 20px; border-bottom: 1px solid rgba(15,23,42,0.08); +} +.gs-input-wrap svg { flex-shrink: 0; color: #8898AA; } +.gs-input { + flex: 1; border: none; outline: none; font-family: 'Manrope', sans-serif; + font-size: 0.95rem; font-weight: 500; color: #0F172A; background: transparent; +} +.gs-input::placeholder { color: #B0BEC5; } +.gs-kbd { + font-size: 0.65rem; font-weight: 700; color: #8898AA; background: rgba(15,23,42,0.06); + padding: 3px 7px; border-radius: 5px; line-height: 1; +} +.gs-results { + max-height: 380px; overflow-y: auto; padding: 8px; +} +.gs-empty { + text-align: center; padding: 40px 20px; color: #8898AA; font-size: 0.85rem; +} +.gs-group-label { + font-size: 0.65rem; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.06em; color: #8898AA; padding: 10px 12px 4px; +} +.gs-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; border-radius: 12px; cursor: pointer; + transition: background .12s; +} +.gs-item:hover, .gs-item.active { background: rgba(155,93,229,0.06); } +.gs-item-icon { + width: 34px; height: 34px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; + font-size: 0.85rem; flex-shrink: 0; +} +.gs-icon-lesson { background: rgba(155,93,229,0.1); color: #9B5DE5; } +.gs-icon-course { background: rgba(6,214,160,0.1); color: #06D6A0; } +.gs-icon-file { background: rgba(6,214,224,0.1); color: #06D6E0; } +.gs-icon-question { background: rgba(245,158,11,0.1); color: #F59E0B; } +.gs-item-body { flex: 1; min-width: 0; } +.gs-item-title { + font-size: 0.85rem; font-weight: 600; color: #0F172A; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.gs-item-sub { + font-size: 0.7rem; color: #8898AA; margin-top: 1px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.gs-item-arrow { color: #ccc; flex-shrink: 0; } + +/* ══════════════════════════════════════════ + MOBILE TOPBAR +══════════════════════════════════════════ */ +.mob-bar { display: none; } + +/* ══════════════════════════════════════════ + RESPONSIVE — TABLET & MOBILE (≤ 768px) +══════════════════════════════════════════ */ +@media (max-width: 768px) { + /* ── Mobile top bar ── */ + .mob-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: fixed; + top: 0; left: 0; right: 0; + height: 56px; + padding: 0 14px; + background: rgba(238,242,255,0.95); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + z-index: 150; + gap: 10px; + } + .mob-bar-logo { + font-family: 'Unbounded', sans-serif; + font-size: 0.9rem; font-weight: 800; + color: var(--text); text-decoration: none; + flex: 1; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .mob-bar-logo span { + background: var(--grad-1); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; + } + .mob-bar-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } + .mob-icon-btn { + width: 36px; height: 36px; + border-radius: 10px; + background: transparent; + border: 1.5px solid var(--border-h); + display: flex; align-items: center; justify-content: center; + cursor: pointer; + transition: all var(--tr); + position: relative; + flex-shrink: 0; + } + .mob-icon-btn:hover { background: rgba(155,93,229,0.08); border-color: var(--violet); } + .mob-icon-btn svg { width: 17px; height: 17px; stroke: var(--text-2); stroke-width: 2; pointer-events: none; } + + /* ── Sidebar becomes fixed drawer ── */ + .app-layout { flex-direction: column; } + .app-layout > .sidebar { + position: fixed !important; + top: 0; left: 0; + height: 100vh !important; + width: 272px !important; + transform: translateX(-100%); + z-index: 200; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.28s ease !important; + box-shadow: none !important; + padding-top: 0 !important; + } + .app-layout.sb-open > .sidebar { + transform: translateX(0) !important; + box-shadow: 8px 0 40px rgba(15,23,42,0.22) !important; + } + + /* Backdrop overlay when drawer is open */ + .sb-backdrop { + display: none; + position: fixed; inset: 0; + background: rgba(15,23,42,0.40); + z-index: 190; + backdrop-filter: blur(2px); + animation: fadeIn 0.2s ease; + } + .sb-backdrop.open { display: block; } + + /* Hide desktop collapse toggle on mobile */ + .sb-toggle { display: none !important; } + + /* Content: full width, offset for topbar */ + .sb-content { width: 100%; padding-top: 56px; } + + /* Restore labels inside drawer on mobile (ignore collapsed state) */ + .app-layout > .sidebar .sb-lbl { max-width: 160px !important; opacity: 1 !important; } + .app-layout > .sidebar .sb-logo { display: block !important; } + .app-layout > .sidebar .sb-link { justify-content: flex-start !important; padding: 9px 12px !important; gap: 10px !important; } + .app-layout > .sidebar .sb-user-info { display: block !important; } + .app-layout > .sidebar .sb-user-row { justify-content: flex-start !important; padding: 8px 10px !important; } + .app-layout > .sidebar .sb-badge { display: inline-flex !important; } + .app-layout > .sidebar .sb-brand { padding: 20px 12px 14px !important; justify-content: flex-start !important; } + + /* ── Notification dropdown ── */ + .notif-drop { + position: fixed !important; + left: auto !important; + right: 14px !important; + top: 60px !important; + width: min(360px, calc(100vw - 28px)) !important; + } + + /* ── Modal: bottom sheet on mobile ── */ + .modal-overlay { + align-items: flex-end; + padding: 0; + } + .modal { + max-width: 100% !important; + width: 100%; + border-radius: 22px 22px 0 0; + padding: 24px 20px calc(32px + env(safe-area-inset-bottom, 0px)); + max-height: 90vh; + overflow-y: auto; + } + + /* ── Safe area for fixed bottom elements ── */ + .mob-bar { + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + /* ── Login page: handled by login.html inline styles ── */ +} + +/* ══════════════════════════════════════════ + INLINE SVG ICONS (.ic) +══════════════════════════════════════════ */ +/* Replaces emoji / Unicode symbols throughout the UI */ +.ic { + display: inline-block; + width: 1em; height: 1em; + vertical-align: -0.15em; + fill: none; + stroke: currentColor; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; + overflow: visible; +} +/* Larger display icons (page headers, empty-state hints) */ +.page-header-icon .ic, +.hint-icon .ic, +.complete-icon .ic { + width: 1.1em; height: 1.1em; + stroke-width: 1.5; +} + +/* ══════════════════════════════════════════ + FEATURE FLAGS +══════════════════════════════════════════ */ +/* Student without a class: hide leaderboard */ +body.no-class #lb-section { display: none !important; } + +body.no-gamification .gam-bar, +body.no-gamification .lb-widget, +body.no-gamification .achievements-section, +body.no-gamification #tab-btn-achievements, +body.no-gamification #tab-btn-shop, +body.no-gamification #tab-achievements, +body.no-gamification #tab-shop { display: none !important; } + +/* ══════════════════════════════════════════ + RESPONSIVE — SMALL PHONES (≤ 480px) +══════════════════════════════════════════ */ +@media (max-width: 480px) { + .mob-bar { padding: 0 12px; } + .modal { padding: 20px 16px 28px; } + .btn-primary { padding: 10px 20px; } + .filter-tabs { gap: 4px; } + .filter-tab { padding: 6px 12px; font-size: 0.78rem; } +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..af446da --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,3684 @@ + + + + + + Личный кабинет — LearnSpace + + + + + + + +
+ +
+
+ + +
+
LS
+
+
Привет,
+
Выбери тест и начни
+
+ +
+ +
+ + + + + + + + + + + + + + +
+ +
+
+
Задания
+ +
+
+ +
+
+ +
+
+ + + +
+
Тесты
+
+
+ + +
+ + + + + + + + +
+
+ + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ N Новый тест + T Библиотека + B Доска + ? Подсказки +
+ + + + + + + + + + + +
+
+ + + + diff --git a/frontend/favicon.svg b/frontend/favicon.svg new file mode 100644 index 0000000..26f14d3 --- /dev/null +++ b/frontend/favicon.svg @@ -0,0 +1,4 @@ + + + BQ + diff --git a/frontend/flashcards.html b/frontend/flashcards.html new file mode 100644 index 0000000..6920445 --- /dev/null +++ b/frontend/flashcards.html @@ -0,0 +1,871 @@ + + + + + + Флэш-карточки — LearnSpace + + + + + + +
+ + +
+
+ + +
+
+

Флэш-карточки

+ +
+
+
+
Загрузка…
+
+
+ + +
+
+ +

Название колоды

+ + +
+
+ +
+ + + +
+
+ + +
+
+ + +
+
+ +

Изучение

+
+
+
+
1 / 10
+ +
+
+
+ Вопрос +
+
+
+ Ответ +
+
+ ЗНАЮ + ЕЩЁ РАЗ +
+
+
Нажмите, чтобы увидеть ответ
+ +
+ + + + +
+ + + +
+
+ +
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/gradebook.html b/frontend/gradebook.html new file mode 100644 index 0000000..222c3de --- /dev/null +++ b/frontend/gradebook.html @@ -0,0 +1,554 @@ + + + + + + Журнал оценок — LearnSpace + + + + + + + +
+ +
+
+ + +
+
+
+ Классы +
+
Журнал оценок
+ +
+ +
+
+
+
+ + + + + +
+
+
+ Выберите класс, чтобы увидеть журнал оценок +
+
+ +
+
+ + + + + + + + diff --git a/frontend/hangman.html b/frontend/hangman.html new file mode 100644 index 0000000..280f918 --- /dev/null +++ b/frontend/hangman.html @@ -0,0 +1,878 @@ + + + + + + Виселица — LearnSpace + + + + + + + +
+ + +
+
+ +
+
+
+
Виселица
+
Угадай термин из учебной программы по буквам
+
+
+ +
+
+ +
+
Серия: 0
+
Победы: 0
+
Поражений: 0
+
Рекорд: 0
+
XP: 0
+
+ +
+ + + + + +
+ +
+
Загрузка…
+ + + +
+
+ + +
+
Победа!
+
+
+ + +
+ + +
+ +
+
+
+ + +
+ + + + + + + + diff --git a/frontend/homework.html b/frontend/homework.html new file mode 100644 index 0000000..a027e95 --- /dev/null +++ b/frontend/homework.html @@ -0,0 +1,593 @@ + + + + + + Домашние задания — LearnSpace + + + + + + + +
+ +
+
+
+
Домашние задания
+
Загрузка…
+ + + + + + + + + + +
+
+
+
+ + + + + + + + diff --git a/frontend/img/questions/ct2021v1_a1.png b/frontend/img/questions/ct2021v1_a1.png new file mode 100644 index 0000000..9803aef Binary files /dev/null and b/frontend/img/questions/ct2021v1_a1.png differ diff --git a/frontend/img/questions/ct2021v1_a15.png b/frontend/img/questions/ct2021v1_a15.png new file mode 100644 index 0000000..96d3817 Binary files /dev/null and b/frontend/img/questions/ct2021v1_a15.png differ diff --git a/frontend/img/questions/ct2021v1_a17.png b/frontend/img/questions/ct2021v1_a17.png new file mode 100644 index 0000000..415c175 Binary files /dev/null and b/frontend/img/questions/ct2021v1_a17.png differ diff --git a/frontend/img/questions/ct2021v1_a18.png b/frontend/img/questions/ct2021v1_a18.png new file mode 100644 index 0000000..4d86ac7 Binary files /dev/null and b/frontend/img/questions/ct2021v1_a18.png differ diff --git a/frontend/img/questions/ct2021v1_a7.png b/frontend/img/questions/ct2021v1_a7.png new file mode 100644 index 0000000..7dc602d Binary files /dev/null and b/frontend/img/questions/ct2021v1_a7.png differ diff --git a/frontend/img/questions/ct2021v1_b1.png b/frontend/img/questions/ct2021v1_b1.png new file mode 100644 index 0000000..a4fecbd Binary files /dev/null and b/frontend/img/questions/ct2021v1_b1.png differ diff --git a/frontend/img/questions/ct2021v1_b3.png b/frontend/img/questions/ct2021v1_b3.png new file mode 100644 index 0000000..40823e2 Binary files /dev/null and b/frontend/img/questions/ct2021v1_b3.png differ diff --git a/frontend/img/questions/ct2021v1_b4.png b/frontend/img/questions/ct2021v1_b4.png new file mode 100644 index 0000000..382f108 Binary files /dev/null and b/frontend/img/questions/ct2021v1_b4.png differ diff --git a/frontend/js/classroom-rtc.js b/frontend/js/classroom-rtc.js new file mode 100644 index 0000000..98f80e0 --- /dev/null +++ b/frontend/js/classroom-rtc.js @@ -0,0 +1,358 @@ +/** + * ClassroomRTC — WebRTC mesh manager for audio + screen sharing. + * + * Topology: full mesh (each peer ↔ each peer). + * Audio: Opus ~30 Kbit/s per connection. + * Screen: teacher → renegotiation adds video track to existing connections. + * + * Usage: + * const rtc = new ClassroomRTC({ + * sessionId, userId, + * onSignal(targetId, payload) — POST /signal + * onScreenStream(stream|null) — remote screen arrived / stopped + * onMicActive(userId, active) — visual mic indicator + * }); + * await rtc.startAudio(); + * await rtc.connectTo([uid1, uid2]); // new joiner sends offers + * rtc.handleSignal(fromUid, payload); // called from SSE + * rtc.addPeerForScreenShare(uid); // if teacher, offer screen to new joiner + * rtc.removePeer(uid); + * rtc.destroy(); + */ +class ClassroomRTC { + constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive }) { + 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._peers = new Map(); // uid → PeerState + this._localStream = null; // mic audio + this._screenStream = null; // outbound screen (teacher) + this._muted = false; + + this._vadTimers = new Map(); // uid → {ctx, timer} for VAD + } + + /* ── Audio ───────────────────────────────────────────��───────────────── */ + + async startAudio() { + try { + this._localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; }); + this._startVAD(this._uid, this._localStream); + return true; + } catch { + return false; + } + } + + /* ── Voice Activity Detection ────────────────────────────────────────── */ + + _startVAD(uid, stream) { + if (!this._onMicActive || !window.AudioContext) return; + this._stopVAD(uid); + try { + const ctx = new AudioContext(); + const src = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.4; + src.connect(analyser); + const buf = new Uint8Array(analyser.frequencyBinCount); + let speaking = false; + const THRESHOLD = 12; + const timer = setInterval(() => { + analyser.getByteFrequencyData(buf); + const avg = buf.reduce((a, b) => a + b, 0) / buf.length; + const now = avg > THRESHOLD; + if (now !== speaking) { + speaking = now; + try { this._onMicActive(uid, speaking); } catch {} + } + }, 120); + this._vadTimers.set(uid, { ctx, timer }); + } catch {} + } + + _stopVAD(uid) { + const vad = this._vadTimers.get(uid); + if (!vad) return; + clearInterval(vad.timer); + try { vad.ctx.close(); } catch {} + this._vadTimers.delete(uid); + } + + _stopAllVAD() { + for (const uid of [...this._vadTimers.keys()]) this._stopVAD(uid); + } + + stopAudio() { + this._stopVAD(this._uid); + if (!this._localStream) return; + this._localStream.getTracks().forEach(t => t.stop()); + this._localStream = null; + } + + toggleMute() { + this._muted = !this._muted; + if (this._localStream) { + this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; }); + } + return this._muted; + } + + isMuted() { return this._muted; } + + forceMute() { + this._muted = true; + if (this._localStream) { + this._localStream.getAudioTracks().forEach(t => { t.enabled = false; }); + } + } + + /* ── Connections ─────────────────────────────────────────────────────── */ + + /** New joiner calls this to send offers to all existing participants. */ + async connectTo(userIds) { + for (const uid of userIds) { + if (uid !== this._uid) await this._sendOffer(uid); + } + } + + /** Called when a new peer joins (existing participants call this to handle + * teacher screen-share renegotiation if needed). */ + async addPeerForScreenShare(uid) { + if (!this._screenStream) return; + // New person joined while we are sharing screen — renegotiate + const peer = this._getPeer(uid); + if (peer) { + const vt = this._screenStream.getVideoTracks()[0]; + if (vt && !peer.screenSender) { + peer.screenSender = peer.pc.addTrack(vt, this._screenStream); + // onnegotiationneeded will fire and renegotiate automatically + } + } + } + + removePeer(uid) { + const peer = this._peers.get(uid); + if (!peer) return; + this._stopVAD(uid); + if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); } + try { peer.pc.close(); } catch {} + this._peers.delete(uid); + } + + /** + * Called after SSE reconnect: re-connect peers whose ICE connection failed. + * Peers that are still connected are left alone. + * @param {number[]} currentUserIds — list of user ids currently in session (excluding self) + */ + async recoverPeers(currentUserIds) { + // Close and remove peers that left during the SSE gap + for (const uid of [...this._peers.keys()]) { + if (!currentUserIds.includes(uid)) this.removePeer(uid); + } + + // Re-offer peers with failed/closed ICE + for (const uid of currentUserIds) { + if (uid === this._uid) continue; + const peer = this._peers.get(uid); + if (!peer) { + // Completely new peer (joined while SSE was down) — we act as initiator + await this._sendOffer(uid); + } else { + const state = peer.pc.iceConnectionState; + if (state === 'failed' || state === 'closed' || state === 'disconnected') { + this.removePeer(uid); + await this._sendOffer(uid); + } + // else: 'connected' or 'completed' → leave intact + } + } + } + + /* ── Signaling ───────────────────────────────────────────────────────── */ + + async handleSignal(fromUid, payload) { + const { kind } = payload; + try { + if (kind === 'offer') await this._handleOffer(fromUid, payload); + else if (kind === 'answer') await this._handleAnswer(fromUid, payload); + else if (kind === 'candidate') await this._handleCandidate(fromUid, payload); + } catch (e) { + console.warn('[RTC] handleSignal error', kind, e); + } + } + + async _handleOffer(fromUid, payload) { + const peer = this._getOrCreate(fromUid); + await peer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp }); + peer.remoteSet = true; + await this._flushCandidates(peer); + + // Add own audio track if not already present + if (this._localStream) { + const existing = peer.pc.getSenders().map(s => s.track); + this._localStream.getAudioTracks().forEach(t => { + if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream); + }); + } + + const answer = await peer.pc.createAnswer(); + await peer.pc.setLocalDescription(answer); + this._onSignal(fromUid, { kind: 'answer', sdp: answer.sdp }); + } + + async _handleAnswer(fromUid, payload) { + const peer = this._peers.get(fromUid); + if (!peer) return; + await peer.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp }); + peer.remoteSet = true; + await this._flushCandidates(peer); + } + + async _handleCandidate(fromUid, payload) { + const peer = this._peers.get(fromUid); + if (!peer) return; + if (peer.remoteSet) { + try { await peer.pc.addIceCandidate(payload.candidate); } catch {} + } else { + peer.pendingCandidates.push(payload.candidate); + } + } + + async _flushCandidates(peer) { + for (const c of peer.pendingCandidates) { + try { await peer.pc.addIceCandidate(c); } catch {} + } + peer.pendingCandidates = []; + } + + async _sendOffer(targetUid) { + const peer = this._getOrCreate(targetUid); + if (this._localStream) { + const existing = peer.pc.getSenders().map(s => s.track); + this._localStream.getAudioTracks().forEach(t => { + if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream); + }); + } + if (this._screenStream) { + const vt = this._screenStream.getVideoTracks()[0]; + if (vt && !peer.screenSender) { + peer.screenSender = peer.pc.addTrack(vt, this._screenStream); + } + } + const offer = await peer.pc.createOffer(); + await peer.pc.setLocalDescription(offer); + this._onSignal(targetUid, { kind: 'offer', sdp: offer.sdp }); + } + + _getOrCreate(uid) { + if (this._peers.has(uid)) return this._peers.get(uid); + return this._createPeer(uid); + } + + _getPeer(uid) { return this._peers.get(uid) || null; } + + _createPeer(uid) { + const ICE = { iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + ]}; + + const pc = new RTCPeerConnection(ICE); + const peer = { pc, audioEl: null, screenSender: null, remoteSet: false, pendingCandidates: [], negotiating: false }; + this._peers.set(uid, peer); + + pc.onicecandidate = e => { + if (e.candidate) this._onSignal(uid, { kind: 'candidate', candidate: e.candidate.toJSON() }); + }; + + /* Renegotiation: fires when tracks are added/removed (e.g. screen share) */ + pc.onnegotiationneeded = async () => { + if (peer.negotiating || !peer.remoteSet) return; + peer.negotiating = true; + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + this._onSignal(uid, { kind: 'offer', sdp: offer.sdp }); + } catch {} + finally { peer.negotiating = false; } + }; + + pc.ontrack = e => { + const track = e.track; + const stream = e.streams[0] || new MediaStream([track]); + + if (track.kind === 'audio') { + if (!peer.audioEl) { + peer.audioEl = document.createElement('audio'); + peer.audioEl.autoplay = true; + peer.audioEl.style.display = 'none'; + document.body.appendChild(peer.audioEl); + } + peer.audioEl.srcObject = stream; + this._startVAD(uid, stream); + } else if (track.kind === 'video') { + if (this._onScreen) this._onScreen(stream); + track.onended = () => { if (this._onScreen) this._onScreen(null); }; + } + }; + + return peer; + } + + /* ── Screen sharing (teacher) ───────────────────────���─────────────────── */ + + async startScreenShare() { + try { + this._screenStream = await navigator.mediaDevices.getDisplayMedia({ + video: { cursor: 'always' }, audio: false, + }); + } catch { return null; } + + const vt = this._screenStream.getVideoTracks()[0]; + vt.onended = () => this.stopScreenShare(); + + // Add to all existing peer connections (onnegotiationneeded will renegotiate) + for (const [, peer] of this._peers) { + if (!peer.screenSender) { + peer.screenSender = peer.pc.addTrack(vt, this._screenStream); + } + } + + return this._screenStream; + } + + async stopScreenShare() { + if (!this._screenStream) return; + this._screenStream.getTracks().forEach(t => t.stop()); + this._screenStream = null; + + for (const [, peer] of this._peers) { + if (peer.screenSender) { + try { peer.pc.removeTrack(peer.screenSender); } catch {} + peer.screenSender = null; + } + } + } + + isSharing() { return !!this._screenStream; } + + /* ── Cleanup ─────────────────────────────────────────────────────────── */ + + destroy() { + this._stopAllVAD(); + for (const uid of [...this._peers.keys()]) this.removePeer(uid); + this.stopAudio(); + if (this._screenStream) { + this._screenStream.getTracks().forEach(t => t.stop()); + this._screenStream = null; + } + } +} + +if (typeof module !== 'undefined') module.exports = ClassroomRTC; diff --git a/frontend/js/labs/_util.js b/frontend/js/labs/_util.js new file mode 100644 index 0000000..7b48b4a --- /dev/null +++ b/frontend/js/labs/_util.js @@ -0,0 +1,191 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + SimUtil — shared utility functions for lab simulations. + Loaded once before all sim scripts. + ══════════════════════════════════════════════════════════════ */ + +const SimUtil = (() => { + + /** + * DPR-aware canvas resize. Sets canvas pixel size and applies + * `setTransform` so subsequent drawing is in CSS-pixel coords. + * Returns { W, H, dpr }. + */ + function fitCanvas(canvas, ctx) { + const dpr = window.devicePixelRatio || 1; + const w = canvas.offsetWidth || canvas.parentElement?.offsetWidth || 600; + const h = canvas.offsetHeight || canvas.parentElement?.offsetHeight || 400; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { W: w, H: h, dpr }; + } + + /** + * Compute a "nice" grid step for the given pixel range and target ~n divisions. + */ + function niceStep(rangePx, scale, n) { + if (!n) n = 8; + const raw = rangePx / scale / n; + const p = Math.pow(10, Math.floor(Math.log10(raw))); + for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; + return p; + } + + /** + * Format number for grid/axis labels. + */ + function fmt(n, step) { + if (n === 0) return '0'; + if (step >= 1 && Number.isInteger(n)) return String(n); + if (step < 0.001) return n.toExponential(1); + const dec = Math.max(0, -Math.floor(Math.log10(step))); + return n.toFixed(dec); + } + + /** + * Draw an arrow from (x1,y1) to (x2,y2). + */ + function arrow(ctx, x1, y1, x2, y2, color, lw) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.sqrt(dx * dx + dy * dy); + if (len < 1) return; + const ux = dx / len, uy = dy / len; + const hs = Math.min(10, len * 0.3); // head size + + ctx.save(); + ctx.strokeStyle = color || '#fff'; + ctx.fillStyle = color || '#fff'; + ctx.lineWidth = lw || 2; + ctx.lineCap = 'round'; + + // shaft + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2 - ux * hs * 0.5, y2 - uy * hs * 0.5); + ctx.stroke(); + + // head + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ux * hs - uy * hs * 0.4, y2 - uy * hs + ux * hs * 0.4); + ctx.lineTo(x2 - ux * hs + uy * hs * 0.4, y2 - uy * hs - ux * hs * 0.4); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + /** + * Draw a coordinate grid with labels. + * @param {object} opts — { ox, oy, scl, font, gridColor, labelColor } + */ + function drawGrid(ctx, W, H, opts) { + const { ox = 0, oy = 0, scl = 50, font, gridColor, labelColor } = opts || {}; + + const step = niceStep(W, scl); + + // math pixel helpers + const toPx = (mx, my) => [W / 2 + (mx - ox) * scl, H / 2 - (my - oy) * scl]; + const toMx = (px) => (px - W / 2) / scl + ox; + const toMy = (py) => -(py - H / 2) / scl + oy; + + const x0 = toMx(0), x1 = toMx(W); + const y0 = toMy(H), y1 = toMy(0); + const gx = Math.floor(x0 / step) * step; + const gy = Math.floor(y0 / step) * step; + + // grid lines + ctx.strokeStyle = gridColor || 'rgba(255,255,255,0.065)'; + ctx.lineWidth = 1; + for (let x = gx; x <= x1 + step; x += step) { + const [px] = toPx(x, 0); + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + } + for (let y = gy; y <= y1 + step; y += step) { + const [, py] = toPx(0, y); + ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); + } + + // labels + ctx.font = font || '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = labelColor || 'rgba(255,255,255,0.3)'; + const [axX, axY] = toPx(0, 0); + const lblY = Math.max(4, Math.min(H - 18, axY + 5)); + const lblX = Math.max(28, Math.min(W - 6, axX - 5)); + + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let x = gx; x <= x1; x += step) { + if (Math.abs(x) < step * 0.01) continue; + const [px] = toPx(x, 0); + if (px < 18 || px > W - 18) continue; + ctx.fillText(fmt(x, step), px, lblY); + } + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let y = gy; y <= y1; y += step) { + if (Math.abs(y) < step * 0.01) continue; + const [, py] = toPx(0, y); + if (py < 12 || py > H - 12) continue; + ctx.fillText(fmt(y, step), lblX, py); + } + + // axes + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(0, axY); ctx.lineTo(W - 10, axY); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(axX, H); ctx.lineTo(axX, 8); ctx.stroke(); + + // arrowheads + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + _arrowHead(ctx, W - 8, axY, 0); + _arrowHead(ctx, axX, 6, -Math.PI / 2); + + // axis labels + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = 'bold 12px Manrope, sans-serif'; + ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; + ctx.fillText('x', W - 10, axY - 13); + ctx.textBaseline = 'top'; ctx.textAlign = 'left'; + ctx.fillText('y', axX + 7, 4); + } + + function _arrowHead(ctx, x, y, angle) { + const s = 5; + ctx.save(); ctx.translate(x, y); ctx.rotate(angle); + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + + /** + * Draw a tooltip panel at (x, y). + * @param {string[]} rows — lines of text + * @param {object} opts — { bg, fg, font, padding, radius } + */ + function tooltip(ctx, x, y, rows, opts) { + const { bg = 'rgba(22,22,38,0.92)', fg = '#ddd', font = '12px Manrope, sans-serif', + padding = 8, radius = 8 } = opts || {}; + ctx.save(); + ctx.font = font; + let maxW = 0; + for (const r of rows) maxW = Math.max(maxW, ctx.measureText(r).width); + const w = maxW + padding * 2; + const lineH = 17; + const h = rows.length * lineH + padding * 2; + const tx = x + 14, ty = y - h / 2; + + ctx.fillStyle = bg; + ctx.beginPath(); + ctx.roundRect(tx, ty, w, h, radius); + ctx.fill(); + ctx.fillStyle = fg; + ctx.textBaseline = 'top'; + ctx.textAlign = 'left'; + for (let i = 0; i < rows.length; i++) { + ctx.fillText(rows[i], tx + padding, ty + padding + i * lineH); + } + ctx.restore(); + } + + return { fitCanvas, niceStep, fmt, arrow, drawGrid, tooltip }; +})(); diff --git a/frontend/js/labs/angrybirds.js b/frontend/js/labs/angrybirds.js new file mode 100644 index 0000000..fc70da6 --- /dev/null +++ b/frontend/js/labs/angrybirds.js @@ -0,0 +1,855 @@ +'use strict'; + +/* ═══════════════════════════════════════════════════════════════════ + AngryBirdsSim — Angry Birds Physics + Real projectile physics: RK4, drag, wind, gravity by planet. + Blocks: AABB with impulse collisions and destruction. + Pigs: circle targets with HP system. + 6 levels with increasing difficulty. + ═══════════════════════════════════════════════════════════════════ */ + +const AB_PLANETS = { + earth: { g: 9.81, label: 'Земля ', sky1: '#0f1923', sky2: '#1a3a2a', ground: '#3d6b47', g_label: '9.81' }, + moon: { g: 1.62, label: 'Луна ', sky1: '#000008', sky2: '#0a0a18', ground: '#7a7a66', g_label: '1.62' }, + mars: { g: 3.71, label: 'Марс ', sky1: '#2a0800', sky2: '#5c1e00', ground: '#7a3010', g_label: '3.71' }, + jupiter: { g: 24.79, label: 'Юпитер ', sky1: '#1a0a00', sky2: '#3d1e00', ground: '#5c3220', g_label: '24.79' }, +}; + +const AB_MATS = { + wood: { color: '#b5651d', border: '#7a3f0a', hpMax: 120, mass: 2.0, debris: '#8B4513' }, + stone: { color: '#7a7a7a', border: '#444', hpMax: 320, mass: 6.0, debris: '#555' }, + glass: { color: '#a8d8ea', border: '#5badd4', hpMax: 45, mass: 0.8, debris: '#d4eef8' }, +}; + +const AB_BIRDS = { + normal: { color: '#e63946', r: 18, mass: 1.0, Cd: 0.28, label: 'Красная' }, + heavy: { color: '#888', r: 23, mass: 4.2, Cd: 0.45, label: 'Тяжёлая' }, + fast: { color: '#ffd166', r: 13, mass: 0.55, Cd: 0.08, label: 'Жёлтая' }, +}; + +const _PX_M = 42; // pixels per metre (base scale, adjusted in _layout) + +/* ── Level definitions ── */ +/* rx = metres right of slingshot, gy = metres above ground (bottom of block) */ +function _buildLevels() { + return [ + /* 1 — Tutorial: one wooden column, one pig */ + { planet: 'earth', wind: 0, birdType: 'normal', birds: 3, + blocks: [ + { mat: 'wood', rx: 7.2, gy: 0, w: 0.55, h: 1.9 }, + { mat: 'wood', rx: 6.8, gy: 1.9, w: 1.2, h: 0.38 }, + ], + pigs: [{ rx: 7.2, gy: 2.35 }] }, + + /* 2 — Glass tower, 2 pigs */ + { planet: 'earth', wind: 0, birdType: 'normal', birds: 4, + blocks: [ + { mat: 'glass', rx: 6.6, gy: 0, w: 0.45, h: 1.6 }, + { mat: 'glass', rx: 8.2, gy: 0, w: 0.45, h: 1.6 }, + { mat: 'glass', rx: 6.3, gy: 1.6, w: 2.5, h: 0.4 }, + { mat: 'glass', rx: 7.3, gy: 2.0, w: 0.55, h: 1.3 }, + ], + pigs: [{ rx: 6.8, gy: 2.05 }, { rx: 7.3, gy: 3.4 }] }, + + /* 3 — Wind, wooden house */ + { planet: 'earth', wind: 5, birdType: 'normal', birds: 4, + blocks: [ + { mat: 'wood', rx: 6.5, gy: 0, w: 0.5, h: 2.1 }, + { mat: 'wood', rx: 9.1, gy: 0, w: 0.5, h: 2.1 }, + { mat: 'wood', rx: 6.2, gy: 2.1, w: 3.4, h: 0.45 }, + { mat: 'wood', rx: 7.1, gy: 2.55, w: 0.5, h: 1.6 }, + { mat: 'wood', rx: 8.5, gy: 2.55, w: 0.5, h: 1.6 }, + { mat: 'wood', rx: 6.8, gy: 4.15, w: 2.5, h: 0.4 }, + ], + pigs: [{ rx: 7.8, gy: 2.6 }, { rx: 7.8, gy: 4.6 }] }, + + /* 4 — Moon, stone fortress, heavy bird */ + { planet: 'moon', wind: 0, birdType: 'heavy', birds: 3, + blocks: [ + { mat: 'stone', rx: 7.0, gy: 0, w: 0.6, h: 2.6 }, + { mat: 'stone', rx: 9.6, gy: 0, w: 0.6, h: 2.6 }, + { mat: 'stone', rx: 6.7, gy: 2.6, w: 3.5, h: 0.6 }, + { mat: 'stone', rx: 8.2, gy: 3.2, w: 0.6, h: 1.6 }, + ], + pigs: [{ rx: 7.5, gy: 3.2 }, { rx: 8.5, gy: 4.8 }] }, + + /* 5 — Mars, headwind, mixed materials, fast bird */ + { planet: 'mars', wind: -4, birdType: 'fast', birds: 4, + blocks: [ + { mat: 'stone', rx: 6.5, gy: 0, w: 0.5, h: 1.3 }, + { mat: 'wood', rx: 6.5, gy: 1.3, w: 0.5, h: 1.6 }, + { mat: 'stone', rx: 8.2, gy: 0, w: 0.5, h: 1.3 }, + { mat: 'wood', rx: 8.2, gy: 1.3, w: 0.5, h: 1.6 }, + { mat: 'stone', rx: 6.2, gy: 2.9, w: 2.9, h: 0.5 }, + { mat: 'wood', rx: 7.4, gy: 3.4, w: 0.7, h: 1.1 }, + ], + pigs: [{ rx: 6.8, gy: 3.4 }, { rx: 8.8, gy: 3.4 }] }, + + /* 6 — Jupiter, strong wind, multi-level, 3 pigs */ + { planet: 'jupiter', wind: 7, birdType: 'normal', birds: 5, + blocks: [ + { mat: 'stone', rx: 6.2, gy: 0, w: 0.5, h: 1.6 }, + { mat: 'wood', rx: 7.7, gy: 0, w: 0.5, h: 1.6 }, + { mat: 'stone', rx: 9.2, gy: 0, w: 0.5, h: 1.6 }, + { mat: 'stone', rx: 5.9, gy: 1.6, w: 1.2, h: 0.5 }, + { mat: 'wood', rx: 7.4, gy: 1.6, w: 2.3, h: 0.5 }, + { mat: 'stone', rx: 8.9, gy: 1.6, w: 1.2, h: 0.5 }, + { mat: 'stone', rx: 6.7, gy: 2.1, w: 0.5, h: 1.6 }, + { mat: 'stone', rx: 8.7, gy: 2.1, w: 0.5, h: 1.6 }, + { mat: 'wood', rx: 6.4, gy: 3.7, w: 3.1, h: 0.45 }, + ], + pigs: [{ rx: 6.4, gy: 2.15 }, { rx: 7.7, gy: 2.15 }, { rx: 8.9, gy: 2.15 }] }, + ]; +} + +/* ══════════════════════════════════════════════════════════════════ */ +class AngryBirdsSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.levelIdx = 0; + this.state = 'aim'; // 'aim' | 'flying' | 'settling' | 'win' | 'lose' + this.bird = null; + this.birdsLeft = []; + this.blocks = []; + this.pigs = []; + this.score = 0; + + this._particles = []; + this._raf = null; + this._lastTs = null; + this._settleTimer = 0; + this._previewPath = []; + this._drag = { pulling: false, mx: 0, my: 0 }; // mx/my updated in _layout() + + /* layout (computed in _layout) */ + this._gY = 0; // ground Y (canvas px) + this._sX = 0; // sling X + this._sY = 0; // sling Y (bird rest position) + this._sc = _PX_M; // px per metre + + this._levels = _buildLevels(); + this._stars = Array.from({ length: 70 }, () => ({ + x: Math.random(), y: Math.random() * 0.7, r: 0.5 + Math.random() * 1.5, a: 0.3 + Math.random() * 0.7, + })); + + this.onUpdate = null; + this._ready = false; // true after first _initLevel + + new ResizeObserver(() => { this.fit(); if (!this._raf) this.draw(); }) + .observe(canvas.parentElement || canvas); + } + + /* ── PUBLIC API ─────────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const el = this.canvas.parentElement || this.canvas; + const W = el.clientWidth || 800; + const H = el.clientHeight || 480; + this.canvas.width = W * dpr; + this.canvas.height = H * dpr; + this.canvas.style.width = W + 'px'; + this.canvas.style.height = H + 'px'; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._layout(); + /* Only init on first open — resize must NOT reset the game */ + if (!this._ready) { this._ready = true; this._initLevel(); } + } + + loadLevel(n) { + this.levelIdx = Math.max(0, Math.min(n, this._levels.length - 1)); + this._ready = true; // ensure _initLevel runs even before first fit + this._initLevel(); + } + + restart() { this._ready = true; this._initLevel(); } + + start() { + if (this._raf) return; + this._lastTs = null; + const tick = (ts) => { + const dt = this._lastTs ? Math.min((ts - this._lastTs) / 1000, 0.05) : 0.016; + this._lastTs = ts; + this._update(dt); + this.draw(); + this._raf = requestAnimationFrame(tick); + }; + this._raf = requestAnimationFrame(tick); + } + + stop() { + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + draw() { + if (!this.W) return; + this._drawBackground(); + this._drawSlingshot(); + this._drawBlocks(); + this._drawPigs(); + if (this.bird) this._drawBird(this.bird); + this._drawParticles(); + if (this.state === 'aim' && this._drag.pulling) this._drawPreview(); + this._drawSlingshotRubber(); + this._drawQueue(); + this._drawHUD(); + if (this.state === 'win' || this.state === 'lose') this._drawOverlay(); + } + + info() { + const lvl = this._levels[this.levelIdx]; + const planet = lvl ? AB_PLANETS[lvl.planet] : AB_PLANETS.earth; + return { + level: this.levelIdx + 1, + birds: this.birdsLeft.length + (this.bird ? 1 : 0), + pigs: this.pigs.filter(p => !p.destroyed).length, + score: this.score, + planet: planet ? planet.label : '—', + g: planet ? planet.g_label : '—', + }; + } + + /* ── MOUSE HANDLERS ─────────────────────────────────────────── */ + + handleMouseDown(e) { + if (this.state !== 'aim' || !this.bird) return; + const { x, y } = this._evt(e); + if (Math.hypot(x - this._sX, y - this._sY) < 54) { + this._drag.pulling = true; + this._clampDrag(x, y); + this._updatePreview(); + } + } + + handleMouseMove(e) { + if (!this._drag.pulling) return; + const { x, y } = this._evt(e); + this._clampDrag(x, y); + this._updatePreview(); + this.draw(); + } + + handleMouseUp(e) { + if (!this._drag.pulling) return; + this._drag.pulling = false; + const dx = this._drag.mx - this._sX; + const dy = this._drag.my - this._sY; + if (Math.hypot(dx, dy) < 12) { this.draw(); return; } + const K = 5.8; // px stretch px/s velocity + this._launch(-dx * K, -dy * K); + } + + /* ── PRIVATE ────────────────────────────────────────────────── */ + + _layout() { + this._gY = Math.round(this.H * 0.80); + this._sX = Math.round(this.W * 0.17); + this._sY = this._gY - 68; + this._sc = Math.max(34, Math.min(50, this.H / 11)); + /* keep drag position at sling so rubber band doesn't snap to (0,0) */ + if (!this._drag.pulling) { this._drag.mx = this._sX; this._drag.my = this._sY; } + } + + _initLevel() { + if (!this.W) return; + const lvl = this._levels[this.levelIdx]; + if (!lvl) return; + + this.state = 'aim'; + this.score = 0; + this._particles = []; + this._previewPath = []; + this._settleTimer = 0; + this._drag.pulling = false; + + this.birdsLeft = Array(lvl.birds - 1).fill(lvl.birdType); + this.bird = this._spawnBird(lvl.birdType); + this._placeBirdAtSling(this.bird); + + const sc = this._sc; + this.blocks = lvl.blocks.map((b, i) => { + const mat = AB_MATS[b.mat] || AB_MATS.wood; + return { + id: i, mat: b.mat, + x: this._sX + b.rx * sc, + y: this._gY - b.gy * sc - b.h * sc, + w: b.w * sc, h: b.h * sc, + vx: 0, vy: 0, angle: 0, angVel: 0, + hp: mat.hpMax, maxHp: mat.hpMax, + mass: mat.mass * b.w * b.h, + destroyed: false, onGround: true, // start stationary; set false on impulse + }; + }); + + this.pigs = lvl.pigs.map((p, i) => ({ + id: i, + x: this._sX + p.rx * sc, + y: this._gY - p.gy * sc - 20, + vx: 0, vy: 0, + r: 20, hp: 100, maxHp: 100, + destroyed: false, flash: 0, onGround: true, // start stationary + })); + + if (this.onUpdate) this.onUpdate(this.info()); + } + + _spawnBird(type) { + const def = AB_BIRDS[type] || AB_BIRDS.normal; + return { + type, color: def.color, + x: 0, y: 0, vx: 0, vy: 0, + r: def.r, mass: def.mass, Cd: def.Cd, + trail: [], launched: false, destroyed: false, + }; + } + + _placeBirdAtSling(bird) { + bird.x = this._sX; bird.y = this._sY; + bird.vx = 0; bird.vy = 0; + bird.launched = false; bird.trail = []; + } + + _launch(vx, vy) { + if (!this.bird) return; + this.bird.vx = vx; this.bird.vy = vy; + this.bird.launched = true; + this.state = 'flying'; + this._emit('launch', this.bird.x, this.bird.y, this.bird.color, 8); + if (this.onUpdate) this.onUpdate(this.info()); + } + + _clampDrag(mx, my) { + let dx = mx - this._sX; + let dy = my - this._sY; + if (dx > 5) dx = 5; // mostly left only + const dist = Math.hypot(dx, dy); + const maxPull = 80; + if (dist > maxPull) { dx = dx / dist * maxPull; dy = dy / dist * maxPull; } + this._drag.mx = this._sX + dx; + this._drag.my = this._sY + dy; + } + + /* ── UPDATE LOOP ─────────────────────────────────────────────── */ + + _update(dt) { + if (this.state === 'flying') { + this._stepBird(dt); + this._collisions(); + if (this.bird && (this.bird.destroyed || this._offScreen(this.bird))) { + this.bird = null; + this.state = 'settling'; + this._settleTimer = 1.5; + } + } + + if (this.state === 'flying' || this.state === 'settling') { + this._stepBlocks(dt); + this._stepPigs(dt); + } + + if (this.state === 'settling') { + this._settleTimer -= dt; + if (this._settleTimer <= 0) { + if (this.pigs.every(p => p.destroyed)) { + this._win(); + } else if (!this.birdsLeft.length) { + this.state = 'lose'; + if (this.onUpdate) this.onUpdate(this.info()); + } else { + this.bird = this._spawnBird(this.birdsLeft.shift()); + this._placeBirdAtSling(this.bird); + this.state = 'aim'; + if (this.onUpdate) this.onUpdate(this.info()); + } + } + } + + this._stepParticles(dt); + } + + _planet() { + const lvl = this._levels[this.levelIdx]; + return AB_PLANETS[lvl?.planet] || AB_PLANETS.earth; + } + + _stepBird(dt) { + const bird = this.bird; + if (!bird?.launched) return; + const g = this._planet().g * this._sc; // px/s² + const lvl = this._levels[this.levelIdx]; + const wind = (lvl.wind || 0) * this._sc * 0.22; // px/s² wind accel + + /* Quadratic drag in px-space (empirical, looks right) */ + const spd = Math.hypot(bird.vx, bird.vy); + const kD = 2.8e-5 * bird.Cd; + const ax = (wind - kD * spd * bird.vx) / bird.mass; + const ay = (g - kD * spd * bird.vy) / bird.mass; + + /* Simple Euler (fast enough for game) */ + bird.vx += ax * dt; + bird.vy += ay * dt; + bird.x += bird.vx * dt; + bird.y += bird.vy * dt; + + /* Trail */ + bird.trail.push({ x: bird.x, y: bird.y }); + if (bird.trail.length > 22) bird.trail.shift(); + + /* Ground bounce / stop */ + if (bird.y + bird.r >= this._gY) { + bird.y = this._gY - bird.r; + bird.vy *= -0.32; + bird.vx *= 0.72; + this._emit('impact', bird.x, this._gY, '#a0d080', 5); + if (Math.abs(bird.vy) < 22) bird.destroyed = true; + } + } + + _stepBlocks(dt) { + const g = this._planet().g * this._sc; + for (const b of this.blocks) { + if (b.destroyed) continue; + if (!b.onGround) { + b.vy += g * dt; + b.x += b.vx * dt; b.y += b.vy * dt; + b.angle += b.angVel * dt; + b.vx *= 0.992; + if (b.y + b.h >= this._gY) { + b.y = this._gY - b.h; + b.vy *= -0.22; b.vx *= 0.65; b.angVel *= 0.45; + if (Math.abs(b.vy) < 18) { b.vy = 0; b.onGround = true; } + } + } else { + // sleeping on ground: slide + check if kicked into air + if (Math.abs(b.vx) > 0.5 || Math.abs(b.vy) > 0.5) b.onGround = false; + b.vx *= 0.82; b.x += b.vx * dt; + } + } + } + + _stepPigs(dt) { + const g = this._planet().g * this._sc; + for (const p of this.pigs) { + if (p.destroyed) continue; + if (p.flash > 0) p.flash -= dt; + if (p.onGround) { + // wake up if kicked + if (Math.abs(p.vx) > 0.5 || Math.abs(p.vy) > 0.5) p.onGround = false; + p.vx *= 0.82; p.x += p.vx * dt; + continue; + } + p.vy += g * dt; + p.x += p.vx * dt; p.y += p.vy * dt; + if (p.y + p.r >= this._gY) { + p.y = this._gY - p.r; p.vy *= -0.18; p.vx *= 0.65; + if (Math.abs(p.vy) < 10) { p.vy = 0; p.onGround = true; } + } + } + } + + _collisions() { + const bird = this.bird; + if (!bird?.launched) return; + + /* Bird vs Blocks */ + for (const b of this.blocks) { + if (b.destroyed) continue; + const cx = Math.max(b.x, Math.min(bird.x, b.x + b.w)); + const cy = Math.max(b.y, Math.min(bird.y, b.y + b.h)); + const dx = bird.x - cx, dy = bird.y - cy; + const dist = Math.hypot(dx, dy); + if (dist >= bird.r) continue; + + const nx = dist > 0.5 ? dx / dist : 0; + const ny = dist > 0.5 ? dy / dist : -1; + const vRel = (bird.vx - b.vx) * nx + (bird.vy - b.vy) * ny; + if (vRel >= 0) continue; + + const e = 0.28; + const j = -(1 + e) * vRel / (1 / bird.mass + 1 / b.mass); + bird.vx += j / bird.mass * nx; + bird.vy += j / bird.mass * ny; + b.vx -= j / b.mass * nx; + b.vy -= j / b.mass * ny; + b.angVel += (dx * (-j / b.mass * ny) - dy * (-j / b.mass * nx)) * 0.015; + b.onGround = false; // wake up block — now subject to gravity + + const dmg = Math.abs(j) * 0.18; + b.hp -= dmg; + this.score += Math.max(0, Math.floor(dmg * 3)); + this._emit('hit', cx, cy, AB_MATS[b.mat]?.debris || '#888', 5); + if (b.hp <= 0) { + b.destroyed = true; + this.score += 500; + this._emit('destroy', b.x + b.w / 2, b.y + b.h / 2, AB_MATS[b.mat]?.debris || '#888', 15); + } + bird.x += nx * (bird.r - dist + 1); + bird.y += ny * (bird.r - dist + 1); + } + + /* Bird vs Pigs */ + for (const p of this.pigs) { + if (p.destroyed) continue; + const dx = bird.x - p.x, dy = bird.y - p.y; + const dist = Math.hypot(dx, dy); + const minD = bird.r + p.r; + if (dist >= minD) continue; + + const nx = dist > 0.5 ? dx / dist : 0; + const ny = dist > 0.5 ? dy / dist : -1; + const pigMass = 2.2; + const vRel = (bird.vx - p.vx) * nx + (bird.vy - p.vy) * ny; + if (vRel >= 0) continue; + + const e = 0.15; + const j = -(1 + e) * vRel / (1 / bird.mass + 1 / pigMass); + bird.vx += j / bird.mass * nx; + bird.vy += j / bird.mass * ny; + p.vx -= j / pigMass * nx; + p.vy -= j / pigMass * ny; + p.onGround = false; // wake up pig + + const dmg = Math.abs(j) * 0.28; + p.hp -= dmg; p.flash = 0.35; + if (p.hp <= 0) { + p.destroyed = true; + this.score += 5000; + this._emit('destroy', p.x, p.y, '#4ade80', 20); + } else { + this._emit('hit', p.x, p.y, '#86efac', 5); + } + bird.x += nx * (minD - dist + 1); + bird.y += ny * (minD - dist + 1); + } + } + + _win() { + this.state = 'win'; + this.score += this.birdsLeft.length * 3000; + this._emit('confetti', this.W / 2, this.H * 0.35, '#ffd166', 50); + if (this.onUpdate) this.onUpdate(this.info()); + } + + _offScreen(b) { + return b.x > this.W + 60 || b.x < -60 || b.y > this.H + 60; + } + + /* ── PARTICLES ───────────────────────────────────────────────── */ + + _emit(type, x, y, color, n) { + const confetti = ['#ffd166', '#ef476f', '#06d6e0', '#7bf5a4', '#9b5de5']; + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const spd = type === 'confetti' ? 80 + Math.random() * 200 + : type === 'destroy' ? 90 + Math.random() * 220 + : 45 + Math.random() * 100; + this._particles.push({ + x, y, + vx: Math.cos(a) * spd, + vy: Math.sin(a) * spd - (type === 'destroy' || type === 'confetti' ? 100 : 20), + r: type === 'confetti' ? 4 + Math.random() * 5 : 2 + Math.random() * 4, + color: type === 'confetti' ? confetti[i % confetti.length] : color, + gravity: type === 'confetti' ? 180 : 300, + life: 1, maxLife: 0.5 + Math.random() * 0.9, + }); + } + } + + _stepParticles(dt) { + for (const p of this._particles) { + p.x += p.vx * dt; p.y += p.vy * dt; + p.vy += p.gravity * dt; p.vx *= 0.97; + p.life -= dt / p.maxLife; + } + this._particles = this._particles.filter(p => p.life > 0); + } + + /* ── PREVIEW PATH ────────────────────────────────────────────── */ + + _updatePreview() { + const dx = this._drag.mx - this._sX; + const dy = this._drag.my - this._sY; + const K = 5.8; + const vx0 = -dx * K, vy0 = -dy * K; + const g = this._planet().g * this._sc; + const lvl = this._levels[this.levelIdx]; + const wind = (lvl?.wind || 0) * this._sc * 0.22; + + this._previewPath = []; + let x = this._sX, y = this._sY, vx = vx0, vy = vy0; + const dt = 0.028; + for (let i = 0; i < 38; i++) { + vx += wind * dt; vy += g * dt; + x += vx * dt; y += vy * dt; + if (y > this._gY || x > this.W) break; + this._previewPath.push({ x, y, a: 1 - i / 38 }); + } + } + + /* ── DRAWING ─────────────────────────────────────────────────── */ + + _drawBackground() { + const ctx = this.ctx; + const pl = this._planet(); + ctx.clearRect(0, 0, this.W, this.H); + + /* Sky */ + const sky = ctx.createLinearGradient(0, 0, 0, this._gY); + sky.addColorStop(0, pl.sky1); sky.addColorStop(1, pl.sky2); + ctx.fillStyle = sky; ctx.fillRect(0, 0, this.W, this._gY); + + /* Stars */ + for (const s of this._stars) { + ctx.beginPath(); ctx.arc(s.x * this.W, s.y * this._gY, s.r, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${s.a})`; ctx.fill(); + } + + /* Ground */ + const gnd = ctx.createLinearGradient(0, this._gY, 0, this.H); + gnd.addColorStop(0, pl.ground); + gnd.addColorStop(1, this._shade(pl.ground, 0.45)); + ctx.fillStyle = gnd; ctx.fillRect(0, this._gY, this.W, this.H - this._gY); + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, this._gY); ctx.lineTo(this.W, this._gY); ctx.stroke(); + + /* Wind arrow (visual, centred top) */ + const lvl = this._levels[this.levelIdx]; + if (lvl?.wind) { + const dir = lvl.wind > 0 ? 1 : -1; + const len = Math.min(Math.abs(lvl.wind) * 7, 70); + const wx = this.W * 0.5, wy = 22; + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2.5; + ctx.setLineDash([5, 3]); + ctx.beginPath(); ctx.moveTo(wx - dir * len / 2, wy); ctx.lineTo(wx + dir * len / 2, wy); ctx.stroke(); + ctx.setLineDash([]); + const ax = wx + dir * len / 2; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.beginPath(); ctx.moveTo(ax, wy); ctx.lineTo(ax - dir*9, wy-5); ctx.lineTo(ax - dir*9, wy+5); ctx.closePath(); ctx.fill(); + } + } + + _drawSlingshot() { + const ctx = this.ctx; + const sx = this._sX, sy = this._sY, gY = this._gY; + ctx.strokeStyle = '#6b3a1f'; ctx.lineWidth = 9; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(sx, gY); ctx.lineTo(sx - 2, sy + 14); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(sx - 12, gY + 4); ctx.lineTo(sx - 18, sy - 12); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(sx + 12, gY + 4); ctx.lineTo(sx + 18, sy - 12); ctx.stroke(); + ctx.fillStyle = '#4a2510'; + ctx.beginPath(); ctx.arc(sx - 18, sy - 12, 5.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(sx + 18, sy - 12, 5.5, 0, Math.PI * 2); ctx.fill(); + } + + _drawSlingshotRubber() { + const ctx = this.ctx; + const sx = this._sX, sy = this._sY; + const bx = (this._drag.pulling && this.bird) ? this._drag.mx : (this.bird ? this.bird.x : sx); + const by = (this._drag.pulling && this.bird) ? this._drag.my : (this.bird ? this.bird.y : sy); + ctx.strokeStyle = '#7a4020'; ctx.lineWidth = 3; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(sx - 18, sy - 12); ctx.lineTo(bx, by); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(sx + 18, sy - 12); ctx.lineTo(bx, by); ctx.stroke(); + } + + _drawQueue() { + const birds = this.birdsLeft; + if (!birds.length) return; + const qy = this._gY + 26; + const gap = 28; + const startX = this._sX + 30; // right of sling handle + for (let i = 0; i < Math.min(birds.length, 8); i++) { + const def = AB_BIRDS[birds[i]] || AB_BIRDS.normal; + this._drawBirdShape(startX + i * gap, qy, def.r * 0.6, def.color, 0.65); + } + } + + _drawBird(bird) { + const ctx = this.ctx; + /* trail */ + for (let i = 0; i < bird.trail.length; i++) { + const t = bird.trail[i]; + ctx.beginPath(); ctx.arc(t.x, t.y, bird.r * 0.38, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${(i / bird.trail.length) * 0.35})`; ctx.fill(); + } + this._drawBirdShape(bird.x, bird.y, bird.r, bird.color, 1); + } + + _drawBirdShape(x, y, r, color, alpha) { + const ctx = this.ctx; + ctx.save(); ctx.globalAlpha = alpha; + const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.1, x, y, r); + g.addColorStop(0, this._lighten(color, 60)); g.addColorStop(1, color); + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = g; ctx.fill(); + ctx.strokeStyle = this._shade(color, 0.6); ctx.lineWidth = 1.5; ctx.stroke(); + /* eyes */ + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc(x + r * 0.26, y - r * 0.18, r * 0.24, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#1a1a1a'; + ctx.beginPath(); ctx.arc(x + r * 0.33, y - r * 0.15, r * 0.11, 0, Math.PI * 2); ctx.fill(); + /* eyebrow (angry) */ + ctx.strokeStyle = '#333'; ctx.lineWidth = Math.max(1.5, r * 0.13); ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(x + r * 0.08, y - r * 0.4); ctx.lineTo(x + r * 0.52, y - r * 0.28); ctx.stroke(); + ctx.restore(); + } + + _drawBlocks() { + const ctx = this.ctx; + for (const b of this.blocks) { + if (b.destroyed) continue; + const mat = AB_MATS[b.mat] || AB_MATS.wood; + const hp = b.hp / b.maxHp; + ctx.save(); + ctx.translate(b.x + b.w / 2, b.y + b.h / 2); ctx.rotate(b.angle); + ctx.fillStyle = mat.color; ctx.globalAlpha = 0.45 + hp * 0.55; + ctx.fillRect(-b.w / 2, -b.h / 2, b.w, b.h); + ctx.globalAlpha = 1; + ctx.strokeStyle = mat.border; ctx.lineWidth = 1.5; + ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h); + if (hp < 0.55) { + ctx.strokeStyle = `rgba(0,0,0,${(1 - hp) * 0.55})`; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(-b.w * 0.12, -b.h * 0.35); ctx.lineTo(b.w * 0.18, b.h * 0.12); + ctx.moveTo(b.w * 0.08, -b.h * 0.22); ctx.lineTo(-b.w * 0.2, b.h * 0.3); + ctx.stroke(); + } + ctx.restore(); + } + } + + _drawPigs() { + const ctx = this.ctx; + for (const p of this.pigs) { + if (p.destroyed) continue; + const flash = p.flash > 0; + ctx.save(); + const grd = ctx.createRadialGradient(p.x - p.r * 0.3, p.y - p.r * 0.3, p.r * 0.1, p.x, p.y, p.r); + grd.addColorStop(0, flash ? '#ffcc44' : '#7bf5a4'); + grd.addColorStop(1, flash ? '#ff6600' : '#22c55e'); + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = grd; ctx.fill(); + ctx.strokeStyle = '#166534'; ctx.lineWidth = 2; ctx.stroke(); + /* snout */ + ctx.beginPath(); ctx.ellipse(p.x, p.y + p.r * 0.28, p.r * 0.38, p.r * 0.26, 0, 0, Math.PI * 2); + ctx.fillStyle = flash ? '#ffdd88' : '#4ade80'; ctx.fill(); + ctx.fillStyle = '#166534'; + ctx.beginPath(); ctx.arc(p.x - p.r * 0.13, p.y + p.r * 0.25, p.r * 0.07, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(p.x + p.r * 0.13, p.y + p.r * 0.25, p.r * 0.07, 0, Math.PI * 2); ctx.fill(); + /* eyes */ + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc(p.x - p.r * 0.26, p.y - p.r * 0.1, p.r * 0.23, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(p.x + p.r * 0.26, p.y - p.r * 0.1, p.r * 0.23, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#111'; + ctx.beginPath(); ctx.arc(p.x - p.r * 0.22, p.y - p.r * 0.08, p.r * 0.1, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(p.x + p.r * 0.3, p.y - p.r * 0.08, p.r * 0.1, 0, Math.PI * 2); ctx.fill(); + /* HP bar */ + if (p.hp < p.maxHp) { + const bw = p.r * 2.2, bh = 5, bx = p.x - bw / 2, by = p.y - p.r - 11; + ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx, by, bw, bh); + ctx.fillStyle = `hsl(${120 * p.hp / p.maxHp},88%,42%)`; + ctx.fillRect(bx, by, bw * p.hp / p.maxHp, bh); + } + ctx.restore(); + } + } + + _drawParticles() { + const ctx = this.ctx; + for (const p of this._particles) { + ctx.save(); ctx.globalAlpha = Math.max(0, p.life) * 0.88; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = p.color; ctx.fill(); ctx.restore(); + } + } + + _drawPreview() { + const ctx = this.ctx; + ctx.save(); + for (const pt of this._previewPath) { + ctx.beginPath(); ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${pt.a * 0.55})`; ctx.fill(); + } + ctx.restore(); + } + + _drawHUD() { + const ctx = this.ctx; + const pl = this._planet(); + const lvl = this._levels[this.levelIdx]; + + /* Score — top right */ + ctx.font = 'bold 20px Manrope,sans-serif'; ctx.textAlign = 'right'; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.fillText(this.score.toLocaleString('ru') + ' очков', this.W - 14, 30); + + /* Level — top left */ + ctx.textAlign = 'left'; ctx.font = 'bold 15px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.fillText(`Уровень ${this.levelIdx + 1} / ${this._levels.length}`, 14, 30); + + /* 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); + + /* Wind reminder */ + if (lvl?.wind) { + ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.65)'; + ctx.fillText(`Ветер ${lvl.wind > 0 ? '→' : '←'} ${Math.abs(lvl.wind)} м/с`, 14, 66); + } + + /* Active bird type label near sling */ + if (this.bird && this.state === 'aim') { + const def = AB_BIRDS[this.bird.type] || AB_BIRDS.normal; + ctx.font = '12px Manrope,sans-serif'; ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText(def.label, this._sX, this._gY + 50); + } + } + + _drawOverlay() { + const ctx = this.ctx; + const win = this.state === 'win'; + ctx.fillStyle = win ? 'rgba(0,30,8,0.78)' : 'rgba(30,0,0,0.78)'; + ctx.fillRect(0, 0, this.W, this.H); + const cx = this.W / 2, cy = this.H / 2; + ctx.textAlign = 'center'; + ctx.font = 'bold 34px Manrope,sans-serif'; + ctx.fillStyle = win ? '#7bf5a4' : '#ef476f'; + ctx.fillText(win ? '✦ Уровень пройден!' : '✦ Попробуй ещё раз', cx, cy - 18); + ctx.font = '17px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText(win ? `Очки: ${this.score.toLocaleString('ru')}` : 'Свиньи выжили!', cx, cy + 18); + ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.38)'; + ctx.fillText('Выбери уровень или нажми «Сначала»', cx, cy + 48); + } + + /* ── UTILS ───────────────────────────────────────────────────── */ + + _evt(e) { + const r = this.canvas.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + } + + _expandHex(hex) { + // Expand 3-digit (#abc #aabbcc) + if (/^#[0-9a-fA-F]{3}$/.test(hex)) + hex = '#' + hex[1]+hex[1] + hex[2]+hex[2] + hex[3]+hex[3]; + return hex; + } + + _shade(hex, f) { + hex = this._expandHex(hex); + const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); + return `rgb(${Math.floor(r*f)},${Math.floor(g*f)},${Math.floor(b*f)})`; + } + + _lighten(hex, add) { + hex = this._expandHex(hex); + const r = Math.min(255, parseInt(hex.slice(1,3),16)+add); + const g = Math.min(255, parseInt(hex.slice(3,5),16)+add); + const b = Math.min(255, parseInt(hex.slice(5,7),16)+add); + return `rgb(${r},${g},${b})`; + } +} diff --git a/frontend/js/labs/bohratom.js b/frontend/js/labs/bohratom.js new file mode 100644 index 0000000..cd61bf0 --- /dev/null +++ b/frontend/js/labs/bohratom.js @@ -0,0 +1,639 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + BohrAtomSim — Bohr atomic model simulation (hydrogen) + E_n = −13.6 / n² eV λ = 1240 / ΔE nm + Orbital animation · energy diagram · spectrum bar + ══════════════════════════════════════════════════════════════ */ + +class BohrAtomSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* physics */ + this.level = 2; // current energy level n (1–6) + this._angle = 0; // electron orbital angle + this._lastTransition = null; // { from, to, deltaE, wavelength, series } + this._emittedPhotons = []; // wavelengths emitted so far + + /* transition animation */ + this._trans = null; // { from, to, t, dur, photon } + this._photons = []; // flying photon particles [{x,y,vx,vy,color,t,maxT}] + + /* spectrum marks */ + this._specMarks = []; // wavelengths (nm) + + /* animation */ + this.playing = false; + this._raf = null; + this._lastTs = null; + + /* interaction */ + this._hoverLevel = null; + + this.onUpdate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ─────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setParams({ level } = {}) { + if (level !== undefined) { + const n = Math.max(1, Math.min(6, Math.round(+level))); + if (n !== this.level) this.transition(this.level, n); + } + this.draw(); + this._emit(); + } + + transition(from, to) { + from = Math.max(1, Math.min(6, Math.round(+from))); + to = Math.max(1, Math.min(6, Math.round(+to))); + if (from === to) return; + + const eFrom = -13.6 / (from * from); + const eTo = -13.6 / (to * to); + const deltaE = Math.abs(eTo - eFrom); + const wl = 1240 / deltaE; + const color = this._wavelengthToColor(wl); + const series = this._seriesName(from, to); + + this._lastTransition = { from, to, deltaE, wavelength: wl, series }; + + const isEmission = from > to; + if (isEmission) this._emittedPhotons.push(wl); + + /* push spectrum mark */ + if (!this._specMarks.includes(Math.round(wl))) { + this._specMarks.push(Math.round(wl)); + } + + /* start animation */ + this._trans = { + from, to, t: 0, dur: 0.5, + color, wavelength: wl, + isEmission, + }; + + if (!this.playing) { this.playing = true; this._lastTs = null; this._tick(); } + this._emit(); + } + + preset(name) { + const presets = { + lyman_alpha: { from: 2, to: 1 }, + balmer_alpha: { from: 3, to: 2 }, + balmer_beta: { from: 4, to: 2 }, + paschen: { from: 4, to: 3 }, + }; + const p = presets[name]; + if (!p) return; + this.level = p.from; + this.transition(p.from, p.to); + } + + reset() { + this.pause(); + this.level = 2; + this._angle = 0; + this._lastTransition = null; + this._emittedPhotons = []; + this._specMarks = []; + this._trans = null; + this._photons = []; + this.draw(); + this._emit(); + } + + play() { + if (this.playing) return; + this.playing = true; + this._lastTs = null; + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + start() { this.play(); } + stop() { this.pause(); } + + info() { + const n = this.level; + const en = -13.6 / (n * n); + return { + level: n, + energy: +en.toFixed(4), + lastTransition: this._lastTransition ? { ...this._lastTransition } : null, + emittedPhotons: this._emittedPhotons.slice(), + }; + } + + /* ── internals ─────────────────────────────── */ + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + _energyOf(n) { return -13.6 / (n * n); } + + _seriesName(from, to) { + const lo = Math.min(from, to); + if (lo === 1) return 'Lyman'; + if (lo === 2) return 'Balmer'; + if (lo === 3) return 'Paschen'; + if (lo === 4) return 'Brackett'; + if (lo === 5) return 'Pfund'; + return ''; + } + + _wavelengthToColor(nm) { + if (nm < 380) return '#9B5DE5'; + if (nm > 780) return '#EF476F'; + /* approximate visible spectrum */ + let r = 0, g = 0, b = 0; + if (nm < 450) { + const t = (nm - 380) / 70; + r = (1 - t) * 0.6; g = 0; b = 1; + } else if (nm < 495) { + const t = (nm - 450) / 45; + r = 0; g = t; b = 1; + } else if (nm < 570) { + const t = (nm - 495) / 75; + r = t; g = 1; b = 1 - t; + } else if (nm < 590) { + const t = (nm - 570) / 20; + r = 1; g = 1 - t * 0.5; b = 0; + } else if (nm < 620) { + const t = (nm - 590) / 30; + r = 1; g = 0.5 - t * 0.5; b = 0; + } else { + const t = Math.min((nm - 620) / 160, 1); + r = 1; g = 0; b = 0; + } + const clamp = v => Math.max(0, Math.min(255, Math.round(v * 255))); + return `rgb(${clamp(r)},${clamp(g)},${clamp(b)})`; + } + + /* ── tick / animate ────────────────────────── */ + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (this._lastTs === null) this._lastTs = ts; + const dt = Math.min((ts - this._lastTs) / 1000, 0.05); + this._lastTs = ts; + + /* orbital motion — angular speed inversely proportional to n */ + const omega = (2.5 / this.level); + this._angle += omega * dt * 2 * Math.PI; + if (this._angle > Math.PI * 2) this._angle -= Math.PI * 2; + + /* transition animation */ + if (this._trans) { + this._trans.t += dt; + if (this._trans.t >= this._trans.dur) { + this.level = this._trans.to; + /* spawn photon */ + if (this._trans.isEmission) { + const cx = this.W * 0.325; + const cy = (this.H - 44) * 0.5; + const a = this._angle; + const r = this._orbitRadius(this._trans.to); + const ex = cx + r * Math.cos(a); + const ey = cy + r * Math.sin(a); + const pa = Math.random() * Math.PI * 2; + this._photons.push({ + x: ex, y: ey, + vx: Math.cos(pa) * 120, vy: Math.sin(pa) * 120, + color: this._trans.color, t: 0, maxT: 1.2, + }); + } + this._trans = null; + this._emit(); + } + } + + /* update photons */ + for (const p of this._photons) { + p.x += p.vx * dt; + p.y += p.vy * dt; + p.t += dt; + } + this._photons = this._photons.filter(p => p.t < p.maxT); + + this.draw(); + this._tick(); + }); + } + + /* ── geometry helpers ──────────────────────── */ + + _orbitRadius(n) { + const maxR = Math.min(this.W * 0.325, (this.H - 44) * 0.5) * 0.85; + return 18 + (n - 1) * (maxR - 18) / 5; + } + + _diagramLevelY(n) { + /* energy diagram in right panel; map energy y */ + const panelTop = 30; + const panelBot = this.H - 74; + const eMin = -13.6; // n=1 + const eMax = -0.378; // n=6 + const en = this._energyOf(n); + const t = (en - eMin) / (eMax - eMin); + return panelBot - t * (panelBot - panelTop); + } + + /* ── draw ──────────────────────────────────── */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + const atomW = W * 0.65; + const panelX = atomW; + const specH = 44; // spectrum bar height at bottom + + /* divider */ + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fillRect(atomW - 1, 0, 2, H - specH); + + this._drawAtom(ctx, atomW, H - specH); + this._drawEnergyDiagram(ctx, panelX, W, H - specH); + this._drawSpectrumBar(ctx, W, H, specH); + this._drawPhotons(ctx); + } + + /* ── atom (left 65%) ───────────────────────── */ + + _drawAtom(ctx, aW, aH) { + const cx = aW * 0.5; + const cy = aH * 0.5; + + /* nucleus glow */ + const ng = ctx.createRadialGradient(cx, cy, 0, cx, cy, 20); + ng.addColorStop(0, 'rgba(255,220,80,0.9)'); + ng.addColorStop(0.3, 'rgba(255,200,60,0.3)'); + ng.addColorStop(1, 'rgba(255,200,60,0)'); + ctx.fillStyle = ng; + ctx.beginPath(); ctx.arc(cx, cy, 20, 0, Math.PI * 2); ctx.fill(); + + /* nucleus dot */ + ctx.fillStyle = '#FFD166'; + ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fill(); + + /* orbitals */ + for (let n = 1; n <= 6; n++) { + const r = this._orbitRadius(n); + const isCurrent = n === this._currentDisplayLevel(); + const alpha = isCurrent ? 0.6 : 0.15; + + ctx.strokeStyle = isCurrent ? '#06D6E0' : `rgba(255,255,255,${alpha})`; + ctx.lineWidth = isCurrent ? 2 : 1; + ctx.setLineDash(isCurrent ? [] : [4, 4]); + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + + /* label */ + const en = this._energyOf(n); + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.35)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(`n=${n} ${en.toFixed(2)} eV`, cx + r + 6, cy - 4); + } + + /* electron */ + const eLevel = this._currentDisplayLevel(); + let eAngle = this._angle; + let eR = this._orbitRadius(eLevel); + + /* during transition: interpolate radius */ + if (this._trans) { + const prog = Math.min(this._trans.t / this._trans.dur, 1); + const ease = prog * prog * (3 - 2 * prog); // smoothstep + const rFrom = this._orbitRadius(this._trans.from); + const rTo = this._orbitRadius(this._trans.to); + eR = rFrom + (rTo - rFrom) * ease; + } + + const ex = cx + eR * Math.cos(eAngle); + const ey = cy + eR * Math.sin(eAngle); + + /* electron glow */ + const eg = ctx.createRadialGradient(ex, ey, 0, ex, ey, 14); + eg.addColorStop(0, 'rgba(6,214,224,0.8)'); + eg.addColorStop(0.4, 'rgba(6,214,224,0.2)'); + eg.addColorStop(1, 'rgba(6,214,224,0)'); + ctx.fillStyle = eg; + ctx.beginPath(); ctx.arc(ex, ey, 14, 0, Math.PI * 2); ctx.fill(); + + /* electron dot */ + ctx.fillStyle = '#06D6E0'; + ctx.beginPath(); ctx.arc(ex, ey, 5, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.beginPath(); ctx.arc(ex - 1.5, ey - 1.5, 1.5, 0, Math.PI * 2); ctx.fill(); + + /* title */ + ctx.font = "bold 13px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('Модель атома Бора (водород)', aW * 0.5, 10); + } + + _currentDisplayLevel() { + if (this._trans) return this._trans.from; + return this.level; + } + + /* ── energy diagram (right 35%) ────────────── */ + + _drawEnergyDiagram(ctx, x0, W, pH) { + const pW = W - x0; + const pad = { l: 52, r: 16, t: 30, b: 20 }; + const lineX0 = x0 + pad.l; + const lineX1 = W - pad.r; + + /* panel bg */ + ctx.fillStyle = 'rgba(5,5,20,0.85)'; + ctx.fillRect(x0, 0, pW, pH); + + /* title */ + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('Энергетические уровни', x0 + 10, 10); + + /* draw each level */ + for (let n = 1; n <= 6; n++) { + const y = this._diagramLevelY(n); + const en = this._energyOf(n); + const isCurrent = n === this.level && !this._trans; + + /* line */ + ctx.strokeStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.3)'; + ctx.lineWidth = isCurrent ? 2.5 : 1.5; + ctx.beginPath(); ctx.moveTo(lineX0, y); ctx.lineTo(lineX1, y); ctx.stroke(); + + /* hover highlight */ + if (this._hoverLevel === n && n !== this.level) { + ctx.strokeStyle = 'rgba(155,93,229,0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(lineX0, y); ctx.lineTo(lineX1, y); ctx.stroke(); + } + + /* n label (right) */ + ctx.font = "11px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = isCurrent ? '#06D6E0' : 'rgba(255,255,255,0.6)'; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillText(`n=${n}`, lineX0 - 4, y); + + /* energy label (left of n) */ + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.textAlign = 'right'; + ctx.fillText(`${en.toFixed(2)}`, lineX0 - 30, y); + + /* dot on current level */ + if (isCurrent) { + ctx.fillStyle = '#06D6E0'; + ctx.beginPath(); ctx.arc(lineX0 + 8, y, 4, 0, Math.PI * 2); ctx.fill(); + } + } + + /* transition arrow */ + if (this._lastTransition) { + const lt = this._lastTransition; + const y1 = this._diagramLevelY(lt.from); + const y2 = this._diagramLevelY(lt.to); + const ax = (lineX0 + lineX1) * 0.5 + 10; + const col = this._wavelengthToColor(lt.wavelength); + + ctx.strokeStyle = col; + ctx.lineWidth = 2; + ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(ax, y1); ctx.lineTo(ax, y2); ctx.stroke(); + + /* arrowhead */ + const dir = y2 > y1 ? 1 : -1; + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(ax, y2); + ctx.lineTo(ax - 5, y2 - dir * 8); + ctx.lineTo(ax + 5, y2 - dir * 8); + ctx.closePath(); ctx.fill(); + + /* ΔE and λ labels */ + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = col; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + const midY = (y1 + y2) / 2; + ctx.fillText(`ΔE=${lt.deltaE.toFixed(2)} eV`, ax + 8, midY - 8); + ctx.fillText(`λ=${lt.wavelength.toFixed(1)} nm`, ax + 8, midY + 8); + + /* series name */ + if (lt.series) { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.fillText(lt.series, ax + 8, midY + 22); + } + } + } + + /* ── spectrum bar (bottom) ─────────────────── */ + + _drawSpectrumBar(ctx, W, H, barH) { + const y0 = H - barH; + + /* background strip */ + ctx.fillStyle = 'rgba(5,5,20,0.9)'; + ctx.fillRect(0, y0, W, barH); + + /* label */ + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('Спектр', 6, y0 + 2); + + /* visible spectrum gradient */ + const gradX0 = 50, gradX1 = W - 16; + const gradW = gradX1 - gradX0; + const gradY = y0 + 14, gradH = 16; + const nmMin = 380, nmMax = 780; + + for (let px = 0; px < gradW; px++) { + const nm = nmMin + (px / gradW) * (nmMax - nmMin); + ctx.fillStyle = this._wavelengthToColor(nm); + ctx.globalAlpha = 0.6; + ctx.fillRect(gradX0 + px, gradY, 1, gradH); + } + ctx.globalAlpha = 1; + + /* border */ + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.strokeRect(gradX0, gradY, gradW, gradH); + + /* nm tick labels */ + ctx.font = "8px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let nm = 400; nm <= 750; nm += 50) { + const px = gradX0 + ((nm - nmMin) / (nmMax - nmMin)) * gradW; + ctx.fillText(nm, px, gradY + gradH + 2); + } + + /* UV / IR labels */ + ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'right'; + ctx.fillText('UV', gradX0 - 4, gradY + 4); + ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; + ctx.fillText('IR', gradX1 + 4, gradY + 4); + + /* emission marks */ + for (const wl of this._specMarks) { + let px; + if (wl < nmMin) { + px = gradX0 - 6; + } else if (wl > nmMax) { + px = gradX1 + 6; + } else { + px = gradX0 + ((wl - nmMin) / (nmMax - nmMin)) * gradW; + } + + const col = this._wavelengthToColor(wl); + ctx.strokeStyle = col; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(px, gradY - 3); ctx.lineTo(px, gradY + gradH + 3); ctx.stroke(); + + /* tiny wavelength label above */ + ctx.font = "7px 'Manrope', system-ui, sans-serif"; + ctx.fillStyle = col; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(wl, px, gradY - 4); + } + } + + /* ── flying photons ────────────────────────── */ + + _drawPhotons(ctx) { + for (const p of this._photons) { + const alpha = 1 - p.t / p.maxT; + const r = 4 + p.t * 6; + + /* glow */ + const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 2); + g.addColorStop(0, p.color); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.globalAlpha = alpha * 0.5; + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(p.x, p.y, r * 2, 0, Math.PI * 2); ctx.fill(); + + /* core */ + ctx.globalAlpha = alpha; + ctx.fillStyle = p.color; + ctx.beginPath(); ctx.arc(p.x, p.y, r * 0.5, 0, Math.PI * 2); ctx.fill(); + + /* wavy trail */ + ctx.strokeStyle = p.color; + ctx.lineWidth = 1; + ctx.globalAlpha = alpha * 0.4; + ctx.beginPath(); + const len = 30; + const vMag = Math.hypot(p.vx, p.vy) || 1; + const dx = -p.vx / vMag, dy = -p.vy / vMag; + const nx = -dy, ny = dx; + for (let i = 0; i <= len; i++) { + const t = i / len; + const wx = p.x + dx * i * 1.5 + nx * Math.sin(t * 8 + p.t * 12) * 3; + const wy = p.y + dy * i * 1.5 + ny * Math.sin(t * 8 + p.t * 12) * 3; + i === 0 ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy); + } + ctx.stroke(); + + ctx.globalAlpha = 1; + } + } + + /* ── events ─────────────────────────────────── */ + + _bindEvents() { + const cv = this.canvas; + + const getPos = (e) => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { + mx: (t.clientX - r.left) * (this.W / r.width), + my: (t.clientY - r.top) * (this.H / r.height), + }; + }; + + const hitLevel = (mx, my) => { + /* check energy diagram area */ + const panelX = this.W * 0.65; + if (mx < panelX) return null; + + const pH = this.H - 44; + const pad = { l: 52, r: 16 }; + const lineX0 = panelX + pad.l; + const lineX1 = this.W - pad.r; + + for (let n = 1; n <= 6; n++) { + const y = this._diagramLevelY(n); + if (mx >= lineX0 - 10 && mx <= lineX1 + 10 && Math.abs(my - y) < 10) { + return n; + } + } + return null; + }; + + /* click on level transition */ + cv.addEventListener('click', e => { + const { mx, my } = getPos(e); + const n = hitLevel(mx, my); + if (n !== null && n !== this.level && !this._trans) { + this.transition(this.level, n); + } + }); + + /* hover cursor */ + cv.addEventListener('mousemove', e => { + const { mx, my } = getPos(e); + const n = hitLevel(mx, my); + this._hoverLevel = n; + cv.style.cursor = (n !== null && n !== this.level) ? 'pointer' : 'default'; + }); + + cv.addEventListener('mouseleave', () => { + this._hoverLevel = null; + }); + + /* touch tap */ + cv.addEventListener('touchend', e => { + if (e.changedTouches.length !== 1) return; + const r = cv.getBoundingClientRect(); + const mx = (e.changedTouches[0].clientX - r.left) * (this.W / r.width); + const my = (e.changedTouches[0].clientY - r.top) * (this.H / r.height); + const n = hitLevel(mx, my); + if (n !== null && n !== this.level && !this._trans) { + this.transition(this.level, n); + } + }); + } +} diff --git a/frontend/js/labs/brownian.js b/frontend/js/labs/brownian.js new file mode 100644 index 0000000..4745bb2 --- /dev/null +++ b/frontend/js/labs/brownian.js @@ -0,0 +1,406 @@ +'use strict'; + +/** + * BrownianSim v2 — Brownian Motion simulation. + * v2: age-gradient trail, MSD history chart, hover tooltip on big particle, + * resetOrigin() method. + */ +class BrownianSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + this.big = { x: 0, y: 0, vx: 0, vy: 0, r: 22 }; + this.small = []; + this.N = 120; + this.T = 1.0; + this.trail = []; + this._origin = { x: 0, y: 0 }; + this._steps = 0; + this._raf = null; + this.onUpdate = null; + this._dpr = 1; + + // v2 + this._msdHistory = []; // [{step, msd}] + this._hover = false; + + canvas.addEventListener('mousemove', e => this._onMouseMove(e)); + canvas.addEventListener('mouseleave', () => { this._hover = false; }); + } + + _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), + }; + } + + _onMouseMove(e) { + const { x, y } = this._cp(e); + this._hover = Math.hypot(x - this.big.x, y - this.big.y) < this.big.r + 22; + } + + // ── public API ────────────────────────────────────────────────────────────── + fit() { + const dpr = window.devicePixelRatio || 1; + this._dpr = dpr; + const w = this.canvas.offsetWidth, h = this.canvas.offsetHeight; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.W = w; this.H = h; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.reset(); + } + + reset() { + const { W, H } = this; + this.big = { x: W / 2, y: H / 2, vx: 0, vy: 0, r: 22 }; + this._origin = { x: W / 2, y: H / 2 }; + this.trail = [{ x: W / 2, y: H / 2 }]; + this._steps = 0; + this._msdHistory = []; + + const small = []; + let att = 0; + while (small.length < this.N && att < this.N * 20) { + att++; + const r = 4; + const x = r + Math.random() * (W - 2 * r); + const y = r + Math.random() * (H - 2 * r); + if (Math.hypot(x - W / 2, y - H / 2) < this.big.r + r + 8) continue; + const a = Math.random() * Math.PI * 2, s = this.T * 4.5; + small.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r }); + } + this.small = small; + } + + resetOrigin() { + this._origin = { x: this.big.x, y: this.big.y }; + this._msdHistory = []; + } + + setN(n) { this.N = Math.max(10, Math.min(300, n)); this.reset(); } + + setT(t) { + const f = Math.sqrt(t / this.T); + for (const s of this.small) { s.vx *= f; s.vy *= f; } + this.T = t; + } + + start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop.bind(this)); } + stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } + + // ── simulation ────────────────────────────────────────────────────────────── + _loop() { + this._step(); this._step(); this._step(); + this.draw(); + this._raf = requestAnimationFrame(this._loop.bind(this)); + } + + _step() { + const { W, H, big, small } = this; + + big.x += big.vx; big.y += big.vy; + if (big.x < big.r) { big.x = big.r; big.vx = Math.abs(big.vx); } + if (big.x > W - big.r) { big.x = W - big.r; big.vx = -Math.abs(big.vx); } + if (big.y < big.r) { big.y = big.r; big.vy = Math.abs(big.vy); } + if (big.y > H - big.r) { big.y = H - big.r; big.vy = -Math.abs(big.vy); } + + for (const s of small) { + s.x += s.vx; s.y += s.vy; + if (s.x < s.r) { s.x = s.r; s.vx = Math.abs(s.vx); } + if (s.x > W - s.r) { s.x = W - s.r; s.vx = -Math.abs(s.vx); } + if (s.y < s.r) { s.y = s.r; s.vy = Math.abs(s.vy); } + if (s.y > H - s.r) { s.y = H - s.r; s.vy = -Math.abs(s.vy); } + } + + // big vs small + const m1 = big.r * big.r; + for (const s of small) { + const dx = s.x - big.x, dy = s.y - big.y; + const dist = Math.hypot(dx, dy), md = big.r + s.r; + if (dist < md && dist > 0.001) { + const nx = dx / dist, ny = dy / dist; + const dvn = (big.vx - s.vx) * nx + (big.vy - s.vy) * ny; + if (dvn > 0) continue; + const m2 = s.r * s.r, imp = (2 * dvn) / (m1 + m2); + big.vx -= imp * m2 * nx; big.vy -= imp * m2 * ny; + s.vx += imp * m1 * nx; s.vy += imp * m1 * ny; + const ov = md - dist, f1 = m2 / (m1 + m2), f2 = m1 / (m1 + m2); + big.x -= nx * ov * f1; big.y -= ny * ov * f1; + s.x += nx * ov * f2; s.y += ny * ov * f2; + } + } + + // small vs small — spatial grid + const cs = 10, cols = Math.ceil(W / cs) + 1; + const grid = new Map(); + for (let i = 0; i < small.length; i++) { + const s = small[i]; + const k = Math.floor(s.x / cs) + Math.floor(s.y / cs) * cols; + if (!grid.has(k)) grid.set(k, []); + grid.get(k).push(i); + } + const checked = new Set(); + for (let i = 0; i < small.length; i++) { + const s1 = small[i]; + const cx = Math.floor(s1.x / cs), cy = Math.floor(s1.y / cs); + for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) { + const cell = grid.get((cx + dcx) + (cy + dcy) * cols); + if (!cell) continue; + for (const j of cell) { + if (j <= i) continue; + const pk = i * 10000 + j; + if (checked.has(pk)) continue; + checked.add(pk); + const s2 = small[j]; + const dx = s2.x - s1.x, dy = s2.y - s1.y; + const d = Math.hypot(dx, dy), md = s1.r + s2.r; + if (d < md && d > 0.001) { + const nx = dx / d, ny = dy / d; + const dvn = (s1.vx - s2.vx) * nx + (s1.vy - s2.vy) * ny; + if (dvn < 0) continue; + s1.vx -= dvn * nx; s1.vy -= dvn * ny; + s2.vx += dvn * nx; s2.vy += dvn * ny; + const ov = (md - d) / 2; + s1.x -= nx * ov; s1.y -= ny * ov; + s2.x += nx * ov; s2.y += ny * ov; + } + } + } + } + + // Trail + if (this._steps % 2 === 0) { + this.trail.push({ x: big.x, y: big.y }); + if (this.trail.length > 600) this.trail.shift(); + } + + // MSD history + if (this._steps % 6 === 0) { + const dx = big.x - this._origin.x, dy = big.y - this._origin.y; + this._msdHistory.push({ step: this._steps, msd: dx * dx + dy * dy }); + if (this._msdHistory.length > 250) this._msdHistory.shift(); + } + + this._steps++; + if (this._steps % 40 === 0 && this.onUpdate) this.onUpdate(this.info()); + } + + info() { + const dx = this.big.x - this._origin.x, dy = this.big.y - this._origin.y; + return { + steps: this._steps, + displacement: Math.hypot(dx, dy).toFixed(1), + msd: (dx * dx + dy * dy).toFixed(0), + speed: Math.hypot(this.big.vx, this.big.vy).toFixed(2), + N: this.N, T: this.T, + }; + } + + // ── drawing ───────────────────────────────────────────────────────────────── + draw() { + const { ctx, W, H } = this; + const TAU = Math.PI * 2; + + const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7); + bg.addColorStop(0, '#080818'); bg.addColorStop(1, '#03030C'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + // Dot grid + ctx.fillStyle = 'rgba(255,255,255,0.025)'; + for (let x = 0; x < W; x += 30) for (let y = 0; y < H; y += 30) { + ctx.beginPath(); ctx.arc(x, y, 1, 0, TAU); ctx.fill(); + } + + // MSD history chart (bottom-left) + this._drawMsdChart(ctx, W, H); + + // Age-gradient trail + const trail = this.trail; + for (let i = 1; i < trail.length; i++) { + const frac = i / trail.length; + // young gold (#FFD166), old dark indigo + const hue = 220 + (1 - frac) * 20; // 220..240 — indigo blue + const sat = 60 + frac * 40; + const lit = 20 + frac * 50; + ctx.beginPath(); + ctx.arc(trail[i].x, trail[i].y, 1.5, 0, TAU); + ctx.fillStyle = `hsla(${hue},${sat}%,${lit}%,${frac * 0.75})`; + ctx.fill(); + } + // Newest segment in gold + if (trail.length > 2) { + const t0 = trail[trail.length - 2], t1 = trail[trail.length - 1]; + ctx.strokeStyle = 'rgba(255,209,102,0.9)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(t0.x, t0.y); ctx.lineTo(t1.x, t1.y); ctx.stroke(); + } + + // Displacement vector + const ox = this._origin.x, oy = this._origin.y; + const bx = this.big.x, by = this.big.y; + const vlen = Math.hypot(bx - ox, by - oy); + if (vlen > 2) { + ctx.save(); + ctx.strokeStyle = 'rgba(255,100,100,0.6)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(bx, by); ctx.stroke(); + const ang = Math.atan2(by - oy, bx - ox), hl = 8; + ctx.fillStyle = 'rgba(255,100,100,0.6)'; + ctx.beginPath(); ctx.moveTo(bx, by); + ctx.lineTo(bx - hl * Math.cos(ang - 0.4), by - hl * Math.sin(ang - 0.4)); + ctx.lineTo(bx - hl * Math.cos(ang + 0.4), by - hl * Math.sin(ang + 0.4)); + ctx.closePath(); ctx.fill(); + const mx = (ox + bx) / 2, my = (oy + by) / 2; + ctx.fillStyle = 'rgba(255,140,140,0.85)'; + ctx.font = "10px 'Manrope', sans-serif"; + ctx.fillText(`|Δr| = ${vlen.toFixed(1)}`, mx + 6, my - 4); + ctx.restore(); + } + + // Origin marker + ctx.save(); + ctx.strokeStyle = 'rgba(255,100,100,0.35)'; ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.arc(ox, oy, 6, 0, TAU); ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + // Small particles + ctx.save(); + ctx.shadowBlur = 4; ctx.shadowColor = '#4CC9F0'; ctx.fillStyle = '#4CC9F0'; + for (const s of this.small) { + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, TAU); ctx.fill(); + } + ctx.restore(); + + // Big particle + const big = this.big; + ctx.save(); + ctx.shadowBlur = 32; ctx.shadowColor = 'rgba(255,214,0,0.65)'; + const grad = ctx.createRadialGradient( + big.x - big.r * 0.3, big.y - big.r * 0.3, 2, + big.x, big.y, big.r + ); + grad.addColorStop(0, '#FFD166'); grad.addColorStop(1, '#9B5DE5'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(big.x, big.y, big.r, 0, TAU); ctx.fill(); + + // Hover ring + if (this._hover) { + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(big.x, big.y, big.r + 4, 0, TAU); ctx.stroke(); + } + + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(big.x, big.y, big.r, 0, TAU); ctx.stroke(); + + ctx.fillStyle = 'white'; ctx.font = "bold 12px 'Manrope', sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('B', big.x, big.y); + ctx.restore(); + + // Hover tooltip + if (this._hover) this._drawBigTooltip(ctx, W, H); + } + + _drawMsdChart(ctx, W, H) { + const hist = this._msdHistory; + const chartW = 190, chartH = 90; + const cx = 14, cy = H - chartH - 14; + + ctx.save(); + ctx.fillStyle = 'rgba(0,0,10,0.72)'; + ctx.beginPath(); ctx.roundRect(cx, cy, chartW, chartH, 8); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = "9px 'Manrope', sans-serif"; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('MSD vs Шагов', cx + 8, cy + 7); + + if (hist.length > 2) { + const padL = 8, padR = 10, padT = 20, padB = 8; + const pw = chartW - padL - padR; + const ph = chartH - padT - padB; + const maxMsd = Math.max(...hist.map(h => h.msd), 1); + + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < hist.length; i++) { + const hx = cx + padL + (i / (hist.length - 1)) * pw; + const hy = cy + padT + ph - (hist[i].msd / maxMsd) * ph; + if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); + } + ctx.stroke(); + + // Theoretical linear MSD ~ D*t line (straight reference) + const lastMsd = hist[hist.length - 1].msd; + const firstMsd = hist[0].msd; + ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(cx + padL, cy + padT + ph - (firstMsd / maxMsd) * ph); + ctx.lineTo(cx + padL + pw, cy + padT + ph - (lastMsd / maxMsd) * ph); + ctx.stroke(); + ctx.setLineDash([]); + + // Current value + const last = hist[hist.length - 1]; + ctx.fillStyle = '#9B5DE5'; ctx.font = "bold 10px 'Manrope', sans-serif"; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillText(last.msd.toFixed(0), cx + chartW - padR - 2, + cy + padT + ph - (last.msd / maxMsd) * ph); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = "10px 'Manrope', sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('Накапливается…', cx + chartW / 2, cy + chartH / 2); + } + + ctx.restore(); + } + + _drawBigTooltip(ctx, W, H) { + const big = this.big; + const spd = Math.hypot(big.vx, big.vy); + const ke = 0.5 * big.r * big.r * spd * spd; // prop to mass (r²) + const dx = big.x - this._origin.x, dy = big.y - this._origin.y; + const disp = Math.hypot(dx, dy); + const msd = dx * dx + dy * dy; + + const rows = [ + ['|v|', spd.toFixed(2) + ' у.е.'], + ['KE', ke.toFixed(0) + ' у.е.'], + ['|Δr|', disp.toFixed(1) + ' px'], + ['MSD', msd.toFixed(0) + ' px²'], + ]; + + const tw = 142, th = 18 + rows.length * 17 + 8; + let tx = big.x + big.r + 12, ty = big.y - th / 2; + if (tx + tw > W - 10) tx = big.x - big.r - tw - 12; + ty = Math.max(8, Math.min(H - th - 8, ty)); + + ctx.save(); + ctx.fillStyle = 'rgba(6,8,28,0.93)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill(); + ctx.fillStyle = '#FFD166'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8, 8, 0, 0]); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke(); + + ctx.font = "11px 'Manrope', monospace"; ctx.textBaseline = 'middle'; + for (let i = 0; i < rows.length; i++) { + const ry = ty + 18 + i * 17; + ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.textAlign = 'left'; + ctx.fillText(rows[i][0], tx + 10, ry); + ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.textAlign = 'right'; + ctx.fillText(rows[i][1], tx + tw - 10, ry); + } + ctx.restore(); + } +} + +if (typeof module !== 'undefined') module.exports = BrownianSim; diff --git a/frontend/js/labs/celldivision.js b/frontend/js/labs/celldivision.js new file mode 100644 index 0000000..6a76b7a --- /dev/null +++ b/frontend/js/labs/celldivision.js @@ -0,0 +1,815 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + CellDivisionSim v2 — интерактивное деление клетки + Митоз и мейоз · анимация · частицы · скрабинг · клик + ════════════════════════════════════════════════════════════════ */ + +class CellDivisionSim { + + static MITOSIS_PHASES = [ + { id: 'interphase', label: 'Интерфаза', chromN: '2n = 46', dna: '2C → 4C', dur: 6000, + desc: 'G1+S+G2: клетка растёт, ДНК удваивается в S-периоде' }, + { id: 'prophase', label: 'Профаза', chromN: '2n = 46', dna: '4C', dur: 4500, + desc: 'Хромосомы конденсируются · ядерная оболочка разрушается · формируется веретено' }, + { id: 'metaphase', label: 'Метафаза', chromN: '2n = 46', dna: '4C', dur: 3500, + desc: 'Хромосомы на метафазной пластинке · нити веретена у кинетохор' }, + { id: 'anaphase', label: 'Анафаза', chromN: '4n = 92', dna: '4C', dur: 3000, + desc: 'Хроматиды расходятся к полюсам · клетка вытягивается' }, + { id: 'telophase', label: 'Телофаза', chromN: '2n = 46', dna: '4C', dur: 3000, + desc: 'Два ядра восстанавливаются · хромосомы деконденсируются' }, + { id: 'cytokinesis', label: 'Цитокинез', chromN: '2n = 46', dna: '2C', dur: 3500, + desc: 'Цитоплазма делится · 2 дочерних диплоидных клетки (2n = 46)' }, + ]; + + static MEIOSIS_PHASES = [ + { id: 'interphase', label: 'Интерфаза', chromN: '2n = 46', dna: '2C → 4C', dur: 4000, + desc: 'Репликация ДНК перед делением' }, + { id: 'prophase1', label: 'Профаза I', chromN: '2n = 46', dna: '4C', dur: 5000, + desc: 'Конъюгация гомологов · кроссинговер — рекомбинация генов' }, + { id: 'metaphase1', label: 'Метафаза I', chromN: '2n = 46', dna: '4C', dur: 3000, + desc: 'Биваленты (пары гомологов) выстраиваются по экватору' }, + { id: 'anaphase1', label: 'Анафаза I', chromN: '2n = 46', dna: '4C', dur: 3000, + desc: 'Гомологичные хромосомы расходятся к полюсам' }, + { id: 'telophase1', label: 'Телофаза I', chromN: 'n = 23', dna: '2C', dur: 2500, + desc: 'Два гаплоидных ядра · хромосомы ещё с сестринскими хроматидами' }, + { id: 'prophase2', label: 'Профаза II', chromN: 'n = 23', dna: '2C', dur: 2000, + desc: 'Без репликации ДНК · начало второго деления' }, + { id: 'metaphase2', label: 'Метафаза II', chromN: 'n = 23', dna: '2C', dur: 2500, + desc: 'Хромосомы на экваторе · нити веретена к хроматидам' }, + { id: 'anaphase2', label: 'Анафаза II', chromN: 'n = 23', dna: '2C', dur: 2500, + desc: 'Хроматиды расходятся к полюсам' }, + { id: 'telophase2', label: 'Телофаза II', chromN: 'n = 23', dna: 'C', dur: 2500, + desc: 'Четыре гаплоидных ядра формируются' }, + { id: 'cytokinesis', label: 'Цитокинез', chromN: 'n = 23', dna: 'C', dur: 3000, + desc: '4 гаплоидные клетки — гаметы (n = 23)' }, + ]; + + static C = { + bg: '#070711', + cell: 'rgba(34,211,153,0.055)', + cellStr: '#22d399', + nucFill: 'rgba(122,77,210,0.09)', + nucStr: '#9B5DE5', + chromatin:'#06D6E0', + ch: ['#EF476F','#FF9F1C','#9B5DE5','#06D6E0','#F15BB5','#7BF5A4'], + spindle: 'rgba(255,214,0,0.55)', + pole: '#FFD166', + furrow: '#22d399', + crossing: '#FFD166', + progress: '#22d399', + }; + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.mode = 'mitosis'; + this._phaseIdx = 0; + this._phaseT = 0; + this._autoPlay = true; + this._speed = 1.0; + this._raf = null; + this._last = 0; + this._time = 0; + this.W = 0; this.H = 0; + this.onUpdate = null; + this._chromatinDots = []; + this._particles = []; + this._draggingBar = false; + this._bindEvents(); + this.fit(); + } + + /* ── Lifecycle ──────────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 700; + const H = this.canvas.offsetHeight || 440; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._genChromatinDots(); + if (!this._raf) this._draw(); + } + + start() { + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + reset() { + this._phaseIdx = 0; + this._phaseT = 0; + this._particles = []; + this._emitUpdate(); + if (!this._raf) this._draw(); + } + + setMode(mode) { this.mode = mode; this.reset(); } + setSpeed(s) { this._speed = s; } + + nextPhase() { + const phases = this._phases(); + this._phaseIdx = (this._phaseIdx + 1) % phases.length; + this._phaseT = 0; + this._particles = []; + this._emitUpdate(); + if (!this._raf) this._draw(); + } + + prevPhase() { + const phases = this._phases(); + this._phaseIdx = (this._phaseIdx - 1 + phases.length) % phases.length; + this._phaseT = 0; + this._particles = []; + this._emitUpdate(); + if (!this._raf) this._draw(); + } + + jumpToPhase(idx) { + const phases = this._phases(); + this._phaseIdx = Math.max(0, Math.min(phases.length - 1, idx)); + this._phaseT = 0; + this._particles = []; + this._emitUpdate(); + if (!this._raf) this._draw(); + } + + toggleAutoPlay() { + this._autoPlay = !this._autoPlay; + if (this._autoPlay && !this._raf) this.start(); + return this._autoPlay; + } + + info() { + const phases = this._phases(); + const p = phases[this._phaseIdx]; + return { phase: p.label, chromN: p.chromN, dna: p.dna, + index: this._phaseIdx, total: phases.length, + progress: this._phaseT, mode: this.mode }; + } + + /* ── Events ─────────────────────────────────────────────────── */ + + _bindEvents() { + const c = this.canvas; + const getBarT = e => { + const rect = c.getBoundingClientRect(); + return Math.max(0, Math.min(1, (e.clientX - rect.left - 14) / (this.W - 28))); + }; + + c.addEventListener('click', e => { + const rect = c.getBoundingClientRect(); + if (e.clientY - rect.top < this.H - 28) this.nextPhase(); + }); + + c.addEventListener('mousedown', e => { + const rect = c.getBoundingClientRect(); + if (e.clientY - rect.top >= this.H - 28) { + this._draggingBar = true; + this._phaseT = getBarT(e); + if (!this._raf) this._draw(); + } + }); + c.addEventListener('mousemove', e => { + const rect = c.getBoundingClientRect(); + c.style.cursor = (e.clientY - rect.top >= this.H - 28) ? 'col-resize' : 'pointer'; + if (this._draggingBar) { this._phaseT = getBarT(e); if (!this._raf) this._draw(); } + }); + c.addEventListener('mouseup', () => { this._draggingBar = false; }); + c.addEventListener('mouseleave', () => { this._draggingBar = false; }); + + c.setAttribute('tabindex', '0'); + c.addEventListener('keydown', e => { + if (e.code === 'Space') { e.preventDefault(); this.toggleAutoPlay(); } + if (e.key === 'ArrowRight') { e.preventDefault(); this.nextPhase(); } + if (e.key === 'ArrowLeft') { e.preventDefault(); this.prevPhase(); } + }); + } + + /* ── Internals ──────────────────────────────────────────────── */ + + _phases() { + return this.mode === 'meiosis' + ? CellDivisionSim.MEIOSIS_PHASES + : CellDivisionSim.MITOSIS_PHASES; + } + + _emitUpdate() { + if (this.onUpdate) this.onUpdate(this.info()); + } + + _ease(t) { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + + _genChromatinDots() { + const { W, H } = this; + const cx = W / 2, cy = H / 2; + const nr = Math.min(W, H) * 0.17; + this._chromatinDots = Array.from({ length: 52 }, (_, i) => { + // stable seeded positions + const seed = i * 1337 + 42; + const a = ((seed * 9301 + 49297) % 233280) / 233280 * Math.PI * 2; + const r = ((seed * 4321 + 12345) % 233280) / 233280 * nr * 0.88; + const sz = 1.4 + ((seed * 2341 + 7777) % 233280) / 233280 * 2.5; + const ph = ((seed * 6543 + 3210) % 233280) / 233280 * Math.PI * 2; + return { x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * 0.85, size: sz, phase: ph }; + }); + } + + _spawnEnvelopeParticles(cx, cy, nucR) { + for (let i = 0; i < 32; i++) { + const a = Math.random() * Math.PI * 2; + const r = nucR * (0.88 + Math.random() * 0.18); + this._particles.push({ + x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r * 0.9, + vx: Math.cos(a) * (0.8 + Math.random() * 1.8), + vy: Math.sin(a) * (0.8 + Math.random() * 1.8), + life: 1, decay: 0.016 + Math.random() * 0.018, + size: 2 + Math.random() * 3, color: '#9B5DE5', + }); + } + } + + /* ── Tick ───────────────────────────────────────────────────── */ + + _tick(t) { + const dt = Math.min(t - this._last, 80); + this._last = t; + this._time += dt; + + if (this._autoPlay && !this._draggingBar) { + const phases = this._phases(); + const phase = phases[this._phaseIdx]; + this._phaseT += (dt / phase.dur) * this._speed; + + // nuclear envelope breakdown particles + if ((phase.id === 'prophase' || phase.id === 'prophase1') && + this._phaseT > 0.34 && this._phaseT < 0.38 && this._particles.length < 5) { + const cellR = Math.min(this.W, this.H) * 0.37; + this._spawnEnvelopeParticles(this.W / 2, this.H / 2, cellR * 0.46); + } + + if (this._phaseT >= 1) { + this._phaseT = 0; + this._phaseIdx = (this._phaseIdx + 1) % phases.length; + this._particles = []; + this._emitUpdate(); + } + } + + this._particles = this._particles.filter(p => { + p.x += p.vx; p.y += p.vy; p.vx *= 0.94; p.vy *= 0.94; + p.life -= p.decay; return p.life > 0; + }); + + this._emitUpdate(); + this._draw(); + } + + /* ── Drawing ────────────────────────────────────────────────── */ + + _draw() { + const { ctx, W, H } = this; + const C = CellDivisionSim.C; + const t = this._phaseT; + const phases = this._phases(); + const phase = phases[this._phaseIdx]; + const cx = W / 2, cy = H / 2; + const cellR = Math.min(W, H) * 0.37; + const nucR = cellR * 0.46; + + ctx.fillStyle = C.bg; + ctx.fillRect(0, 0, W, H); + + // subtle radial bg + const bg2 = ctx.createRadialGradient(cx, cy, 0, cx, cy, cellR * 1.5); + bg2.addColorStop(0, 'rgba(34,211,153,0.022)'); + bg2.addColorStop(1, 'transparent'); + ctx.fillStyle = bg2; + ctx.fillRect(0, 0, W, H); + + switch (phase.id) { + case 'interphase': this._drawInterphase(cx, cy, cellR, nucR, t); break; + case 'prophase': + case 'prophase1': this._drawProphase(cx, cy, cellR, nucR, t, phase.id === 'prophase1'); break; + case 'metaphase': + case 'metaphase1': this._drawMetaphase(cx, cy, cellR, t, phase.id === 'metaphase1', false); break; + case 'prophase2': this._drawProphase2(cx, cy, cellR, nucR, t); break; + case 'metaphase2': this._drawMetaphase(cx, cy, cellR, t, false, true); break; + case 'anaphase': this._drawAnaphase(cx, cy, cellR, t, false, false); break; + case 'anaphase1': this._drawAnaphase(cx, cy, cellR, t, true, false); break; + case 'anaphase2': this._drawAnaphase(cx, cy, cellR, t, false, true); break; + case 'telophase': this._drawTelophase(cx, cy, cellR, nucR, t, false, false); break; + case 'telophase1': this._drawTelophase(cx, cy, cellR, nucR, t, true, false); break; + case 'telophase2': this._drawTelophase(cx, cy, cellR, nucR, t, false, true); break; + case 'cytokinesis': this._drawCytokinesis(cx, cy, cellR, nucR, t); break; + } + + this._drawParticles(); + this._drawOverlay(phase); + this._drawProgressBar(); + this._drawHint(); + } + + /* ── Cell / nucleus ─────────────────────────────────────────── */ + + _cellPath(cx, cy, rx, ry, wobble) { + const ctx = this.ctx, N = 48; + ctx.beginPath(); + for (let i = 0; i <= N; i++) { + const a = (i / N) * Math.PI * 2; + const w = (wobble || 0) * (Math.sin(a * 3 + this._time * 0.00075) * 0.6 + + Math.sin(a * 5 + this._time * 0.00055) * 0.4); + ctx.lineTo(cx + Math.cos(a) * (rx + rx * w), + cy + Math.sin(a) * ((ry || rx * 0.88) + (ry || rx * 0.88) * w)); + } + ctx.closePath(); + } + + _drawCell(cx, cy, rx, ry, wobble, alpha) { + const ctx = this.ctx, C = CellDivisionSim.C; + ctx.save(); + ctx.globalAlpha = alpha !== undefined ? alpha : 1; + this._cellPath(cx, cy, rx, ry, wobble !== undefined ? wobble : 0.013); + ctx.shadowColor = C.cellStr; ctx.shadowBlur = 20; + ctx.fillStyle = C.cell; ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = C.cellStr; ctx.lineWidth = 2.2; + ctx.globalAlpha *= 0.65; ctx.stroke(); + ctx.restore(); + } + + _drawNucleus(cx, cy, rx, ry, alpha) { + const ctx = this.ctx, C = CellDivisionSim.C; + ctx.save(); ctx.globalAlpha = alpha; + ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry || rx * 0.9, 0, 0, Math.PI * 2); + ctx.shadowColor = C.nucStr; ctx.shadowBlur = 16; + ctx.fillStyle = C.nucFill; ctx.fill(); + ctx.shadowBlur = 0; ctx.setLineDash([3, 3]); + ctx.strokeStyle = C.nucStr; ctx.lineWidth = 1.6; ctx.stroke(); + ctx.setLineDash([]); ctx.restore(); + } + + /* ── Chromosome ─────────────────────────────────────────────── */ + + _drawChromosome(x, y, size, angle, color, sister, alpha) { + const ctx = this.ctx; + ctx.save(); + if (alpha !== undefined) ctx.globalAlpha = alpha; + ctx.translate(x, y); ctx.rotate(angle); + const aw = size * 0.20, ah = size * 0.50, gap = size * 0.11; + const offsets = sister ? [-gap, gap] : [0]; + ctx.shadowColor = color; ctx.shadowBlur = 10; + for (const ox of offsets) { + ctx.save(); ctx.translate(ox, 0); + ctx.beginPath(); + ctx.moveTo(0, -gap * 0.5); + ctx.bezierCurveTo(-aw * 1.1, -ah * 0.35, -aw * 1.3, -ah * 0.75, 0, -ah); + ctx.bezierCurveTo( aw * 1.3, -ah * 0.75, aw * 1.1, -ah * 0.35, 0, -gap * 0.5); + ctx.moveTo(0, gap * 0.5); + ctx.bezierCurveTo(-aw * 1.1, ah * 0.35, -aw * 1.3, ah * 0.75, 0, ah); + ctx.bezierCurveTo( aw * 1.3, ah * 0.75, aw * 1.1, ah * 0.35, 0, gap * 0.5); + ctx.fillStyle = color; ctx.fill(); + ctx.restore(); + } + ctx.shadowColor = '#fff'; ctx.shadowBlur = 6; + ctx.beginPath(); ctx.arc(0, 0, size * 0.12, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; ctx.fill(); + ctx.restore(); + } + + _chrPairs(cx, cy, r) { + const ch = CellDivisionSim.C.ch; + return [ + { x: cx - r * 0.50, y: cy - r * 0.28, angle: -0.20, color: ch[0] }, + { x: cx - r * 0.15, y: cy - r * 0.35, angle: 0.15, color: ch[1] }, + { x: cx + r * 0.28, y: cy - r * 0.20, angle: 0.28, color: ch[2] }, + { x: cx - r * 0.38, y: cy + r * 0.22, angle: 0.18, color: ch[3] }, + { x: cx + r * 0.12, y: cy + r * 0.32, angle: -0.22, color: ch[4] }, + { x: cx + r * 0.48, y: cy + r * 0.18, angle: -0.10, color: ch[5] }, + ]; + } + + _chrPairsHaploid(cx, cy, r) { + const ch = CellDivisionSim.C.ch; + return [ + { x: cx - r * 0.36, y: cy - r * 0.24, angle: -0.18, color: ch[0] }, + { x: cx + r * 0.08, y: cy - r * 0.08, angle: 0.12, color: ch[2] }, + { x: cx + r * 0.32, y: cy + r * 0.26, angle: 0.28, color: ch[4] }, + ]; + } + + /* ── Spindle ────────────────────────────────────────────────── */ + + _drawSpindle(cx, cy, cellR, alpha, chrs) { + const ctx = this.ctx, C = CellDivisionSim.C; + ctx.save(); ctx.globalAlpha = alpha; + const poleY = cellR * 0.72; + const poles = [{ x: cx, y: cy - poleY }, { x: cx, y: cy + poleY }]; + + // aster rays + for (const pole of poles) { + for (let i = 0; i < 10; i++) { + const a = (i / 10) * Math.PI * 2; + ctx.beginPath(); ctx.moveTo(pole.x, pole.y); + ctx.lineTo(pole.x + Math.cos(a) * 16, pole.y + Math.sin(a) * 16); + ctx.strokeStyle = 'rgba(255,214,0,0.28)'; ctx.lineWidth = 0.8; ctx.stroke(); + } + } + // fibers + if (chrs) { + for (const ch of chrs) { + for (const pole of poles) { + ctx.beginPath(); ctx.moveTo(pole.x, pole.y); + ctx.quadraticCurveTo(cx + (ch.x - cx) * 0.18, (pole.y + ch.y) / 2, ch.x, ch.y); + ctx.strokeStyle = C.spindle; ctx.lineWidth = 0.9; ctx.stroke(); + } + } + } + // pole dots + for (const p of poles) { + ctx.shadowColor = C.pole; ctx.shadowBlur = 14; + ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); + ctx.fillStyle = C.pole; ctx.fill(); + } + ctx.restore(); + } + + /* ── Phase renderers ────────────────────────────────────────── */ + + _drawInterphase(cx, cy, cellR, nucR, t) { + const ctx = this.ctx, C = CellDivisionSim.C; + const te = this._ease(t); + this._drawCell(cx, cy, cellR); + this._drawNucleus(cx, cy, nucR, nucR * 0.9, 1); + + const dots = this._chromatinDots; + ctx.save(); + for (let i = 0; i < dots.length; i++) { + const d = dots[i]; + const pulse = 0.35 + 0.25 * Math.sin(d.phase + this._time * 0.0015); + ctx.globalAlpha = pulse; + ctx.beginPath(); ctx.arc(d.x, d.y, d.size * 0.72, 0, Math.PI * 2); + ctx.shadowColor = C.chromatin; ctx.shadowBlur = 5; + ctx.fillStyle = C.chromatin; ctx.fill(); + // replication copies + if (te > 0.45 && i < Math.floor(((te - 0.45) / 0.55) * dots.length)) { + ctx.globalAlpha = ((te - 0.45) / 0.55) * 0.5; + ctx.beginPath(); ctx.arc(d.x + 3.5, d.y + 2, d.size * 0.62, 0, Math.PI * 2); + ctx.shadowColor = '#FFD166'; ctx.fillStyle = '#FFD166'; ctx.fill(); + } + } + ctx.restore(); + if (t > 0.42) { + const a = Math.min(1, (t - 0.42) * 7); + ctx.save(); ctx.globalAlpha = a; + ctx.font = 'bold 11px Manrope,sans-serif'; + ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8; + ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; + ctx.fillText('S-период: репликация ДНК', cx, cy + nucR + 26); + ctx.restore(); + } + } + + _drawProphase(cx, cy, cellR, nucR, t, isMeiosis1) { + const ctx = this.ctx, C = CellDivisionSim.C; + const te = this._ease(t); + this._drawCell(cx, cy, cellR); + this._drawNucleus(cx, cy, nucR, nucR * 0.9, 1 - te * 0.95); + if (te > 0.28) { + this._drawSpindle(cx, cy, cellR, (te - 0.28) / 0.72 * 0.6); + } + const chrs = this._chrPairs(cx, cy, cellR * 0.27); + const size = 11 + te * 15; + if (isMeiosis1) { + for (let i = 0; i < chrs.length; i++) { + const ch = chrs[i]; + const off = (1 - te) * 9 * (i % 2 === 0 ? 1 : -1); + this._drawChromosome(ch.x + off, ch.y, size, ch.angle, ch.color, true); + if (te > 0.52) { + const ca = (te - 0.52) / 0.48; + ctx.save(); ctx.globalAlpha = ca * 0.88; + ctx.shadowColor = C.crossing; ctx.shadowBlur = 10; + ctx.beginPath(); ctx.arc(ch.x, ch.y, size * 0.55, 0, Math.PI * 2); + ctx.strokeStyle = C.crossing; ctx.lineWidth = 2; ctx.stroke(); + ctx.restore(); + } + } + if (te > 0.52) { + const ca = (te - 0.52) / 0.48; + ctx.save(); ctx.globalAlpha = ca; + ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8; + ctx.font = 'bold 11px Manrope,sans-serif'; + ctx.fillStyle = '#FFD166'; ctx.textAlign = 'center'; + ctx.fillText('Кроссинговер — рекомбинация', cx, cy + cellR * 0.72); + ctx.restore(); + } + } else { + for (const ch of chrs) this._drawChromosome(ch.x, ch.y, size, ch.angle, ch.color, true); + } + } + + _drawProphase2(cx, cy, cellR, nucR, t) { + const te = this._ease(t); + this._drawCell(cx, cy, cellR * 0.9); + this._drawNucleus(cx, cy, nucR * 0.75, nucR * 0.67, 1 - te * 0.85); + const chrs = this._chrPairsHaploid(cx, cy, cellR * 0.22); + const size = 14 + te * 9; + if (te > 0.3) this._drawSpindle(cx, cy, cellR * 0.9, (te - 0.3) / 0.7 * 0.55); + for (const ch of chrs) this._drawChromosome(ch.x, ch.y, size, ch.angle, ch.color, true); + } + + _drawMetaphase(cx, cy, cellR, t, isMeiosis1, isHaploid) { + const ctx = this.ctx, C = CellDivisionSim.C; + const te = this._ease(t); + this._drawCell(cx, cy, cellR); + + ctx.save(); + ctx.setLineDash([5, 5]); + ctx.strokeStyle = `rgba(255,255,255,${0.14 + te * 0.12})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(cx - cellR * 0.82, cy); ctx.lineTo(cx + cellR * 0.82, cy); + ctx.stroke(); ctx.setLineDash([]); ctx.restore(); + + const ch = C.ch; + const sp = cellR * 0.55; + + if (isHaploid) { + const pos = [ + { x: cx - sp * 0.42, y: cy, angle: -0.05 }, + { x: cx, y: cy, angle: 0.0 }, + { x: cx + sp * 0.42, y: cy, angle: 0.05 }, + ]; + this._drawSpindle(cx, cy, cellR, 0.75 + te * 0.25, pos); + for (let i = 0; i < pos.length; i++) + this._drawChromosome(pos[i].x, pos[i].y, 21, pos[i].angle, ch[i * 2], true); + } else if (isMeiosis1) { + const pos = [ + { x: cx - sp * 0.54, y: cy, angle: -0.08 }, { x: cx - sp * 0.17, y: cy, angle: 0.04 }, + { x: cx + sp * 0.17, y: cy, angle: -0.04 }, { x: cx + sp * 0.54, y: cy, angle: 0.08 }, + { x: cx - sp * 0.35, y: cy, angle: 0.12 }, { x: cx + sp * 0.35, y: cy, angle: -0.12 }, + ]; + this._drawSpindle(cx, cy, cellR, 0.8 + te * 0.2, pos); + for (let i = 0; i < pos.length; i++) { + this._drawChromosome(pos[i].x - 5, pos[i].y, 19, pos[i].angle, ch[i % 6], true); + this._drawChromosome(pos[i].x + 5, pos[i].y, 19, pos[i].angle + 0.25, ch[(i + 3) % 6], true); + } + } else { + const pos = [ + { x: cx - sp * 0.54, y: cy, angle: -0.08 }, { x: cx - sp * 0.28, y: cy, angle: 0.04 }, + { x: cx - sp * 0.04, y: cy, angle: -0.02 }, { x: cx + sp * 0.04, y: cy, angle: 0.02 }, + { x: cx + sp * 0.28, y: cy, angle: -0.04 }, { x: cx + sp * 0.54, y: cy, angle: 0.08 }, + ]; + this._drawSpindle(cx, cy, cellR, 0.8 + te * 0.2, pos); + for (let i = 0; i < 6; i++) + this._drawChromosome(pos[i].x, pos[i].y, 22, pos[i].angle, ch[i], true); + } + } + + _drawAnaphase(cx, cy, cellR, t, isMeiosis1, isHaploid) { + const ctx = this.ctx, C = CellDivisionSim.C; + const te = this._ease(t); + const stretchY = 1 + te * 0.38; + ctx.save(); + ctx.translate(cx, cy); ctx.scale(1, stretchY); ctx.translate(-cx, -cy); + this._drawCell(cx, cy, cellR * (1 - te * 0.04)); + ctx.restore(); + + const poleOffset = cellR * 0.7 * te; + const topY = cy - poleOffset, botY = cy + poleOffset; + this._drawSpindle(cx, cy, cellR, 1 - te * 0.3); + + const ch = C.ch, sp = cellR * (isHaploid ? 0.32 : 0.44); + const n = isHaploid ? 3 : 6; + for (let i = 0; i < n; i++) { + const ox = (i - (n - 1) / 2) * sp * (isHaploid ? 0.5 : 0.34); + const ang = (i - (n - 1) / 2) * 0.07; + if (isMeiosis1) { + this._drawChromosome(cx + ox, topY, 20, ang, ch[i], true); + this._drawChromosome(cx + ox, botY, 20, ang, ch[(i + 3) % 6], true); + } else { + const ci = isHaploid ? i * 2 : i; + this._drawChromosome(cx + ox, topY, isHaploid ? 17 : 18, ang, ch[ci % 6], false); + this._drawChromosome(cx + ox, botY, isHaploid ? 17 : 18, ang, ch[ci % 6], false); + } + } + } + + _drawTelophase(cx, cy, cellR, nucR, t, isMeiosis1, isHaploid) { + const ctx = this.ctx, C = CellDivisionSim.C; + const te = this._ease(t); + const sep = cellR * 0.44; + + ctx.save(); + ctx.translate(cx, cy); ctx.scale(1, 1.30 - te * 0.12); ctx.translate(-cx, -cy); + this._drawCell(cx, cy, cellR); + ctx.restore(); + + for (const s of [-1, 1]) + this._drawNucleus(cx, cy + s * sep, nucR * 0.72, nucR * 0.65, te); + + const ch = C.ch, size = 20 * (1 - te * 0.78); + const alpha = 1 - te * 0.88, sp = cellR * (isHaploid ? 0.22 : 0.34); + const n = isHaploid ? 3 : 6; + for (let i = 0; i < n; i++) { + const ox = (i - (n - 1) / 2) * sp * (isHaploid ? 0.5 : 0.26); + const ci = isHaploid ? i * 2 : i; + ctx.save(); ctx.globalAlpha = alpha; + if (isMeiosis1) { + this._drawChromosome(cx + ox, cy - sep, size, 0, ch[ci % 6], true); + this._drawChromosome(cx + ox, cy + sep, size, 0, ch[(ci + 3) % 6], true); + } else { + this._drawChromosome(cx + ox, cy - sep, size, 0, ch[ci % 6], false); + this._drawChromosome(cx + ox, cy + sep, size, 0, ch[ci % 6], false); + } + ctx.restore(); + } + + if (te > 0.45) { + const fa = (te - 0.45) / 0.55; + ctx.save(); ctx.globalAlpha = fa * 0.6; + ctx.setLineDash([7, 5]); + ctx.shadowColor = C.furrow; ctx.shadowBlur = 10; + ctx.strokeStyle = C.furrow; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx - cellR * 0.7, cy); ctx.lineTo(cx + cellR * 0.7, cy); + ctx.stroke(); ctx.setLineDash([]); ctx.restore(); + } + } + + _drawCytokinesis(cx, cy, cellR, nucR, t) { + const ctx = this.ctx, C = CellDivisionSim.C; + const te = this._ease(t); + + if (te < 0.80) { + const pinch = te; + ctx.save(); + ctx.beginPath(); + for (let i = 0; i <= 48; i++) { + const a = (i / 48) * Math.PI * 2; + const s2 = Math.abs(Math.cos(a)); + const waist = 1 - pinch * Math.max(0, 1 - s2 * 2.2) * 0.90; + ctx.lineTo(cx + Math.cos(a) * cellR * waist, + cy + Math.sin(a) * cellR * 0.88 * (1 + pinch * 0.09)); + } + ctx.closePath(); + ctx.shadowColor = C.cellStr; ctx.shadowBlur = 16; + ctx.fillStyle = C.cell; ctx.fill(); ctx.shadowBlur = 0; + ctx.strokeStyle = C.cellStr; ctx.lineWidth = 2.2; + ctx.globalAlpha = 0.68; ctx.stroke(); ctx.restore(); + + ctx.save(); ctx.globalAlpha = pinch * 0.85; + ctx.shadowColor = C.furrow; ctx.shadowBlur = 14; + ctx.strokeStyle = C.furrow; ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(cx - cellR * (1 - pinch * 0.92), cy); + ctx.lineTo(cx + cellR * (1 - pinch * 0.92), cy); + ctx.stroke(); ctx.restore(); + } else { + const appear = (te - 0.80) / 0.20; + const sepY = cellR * 0.52; + for (const s of [-1, 1]) + this._drawCell(cx, cy + s * sepY * 0.54, cellR * 0.65, cellR * 0.57, 0.01, 0.5 + appear * 0.5); + } + + const sep = cellR * 0.50; + for (const s of [-1, 1]) + this._drawNucleus(cx, cy + s * sep * 0.52, nucR * 0.68, nucR * 0.61, Math.min(1, te * 1.5)); + + if (te > 0.72) { + const a = (te - 0.72) / 0.28; + ctx.save(); ctx.globalAlpha = a; + ctx.shadowColor = '#22d399'; ctx.shadowBlur = 12; + ctx.font = 'bold 12px Manrope,sans-serif'; + ctx.fillStyle = '#22d399'; ctx.textAlign = 'center'; + ctx.fillText( + this.mode === 'meiosis' ? '4 гаплоидные клетки (n = 23)' : '2 диплоидные клетки (2n = 46)', + cx, cy + cellR * 0.88); + ctx.restore(); + } + } + + /* ── Particles ──────────────────────────────────────────────── */ + + _drawParticles() { + const ctx = this.ctx; + for (const p of this._particles) { + ctx.save(); + ctx.globalAlpha = p.life * 0.82; + ctx.shadowColor = p.color; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fillStyle = p.color; ctx.fill(); + ctx.restore(); + } + } + + /* ── UI overlays ────────────────────────────────────────────── */ + + _drawOverlay(phase) { + const ctx = this.ctx; + const { W, H } = this; + + // phase name pill — top right + ctx.save(); + ctx.font = 'bold 14px Manrope,sans-serif'; + const tw = ctx.measureText(phase.label).width; + _cdRRect(ctx, W - tw - 30, 12, tw + 22, 28, 8); + ctx.shadowColor = '#22d399'; ctx.shadowBlur = 14; + ctx.fillStyle = 'rgba(34,211,153,0.12)'; ctx.fill(); ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(34,211,153,0.38)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = '#22d399'; ctx.textAlign = 'left'; + ctx.fillText(phase.label, W - tw - 19, 30); + ctx.restore(); + + // chromN + DNA — bottom left + ctx.save(); + ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(6,214,224,0.78)'; + ctx.fillText('n: ' + phase.chromN, 14, H - 46); + ctx.fillStyle = 'rgba(255,214,0,0.78)'; + ctx.fillText('ДНК: ' + phase.dna, 14, H - 32); + ctx.restore(); + + // description — bottom right + ctx.save(); + ctx.font = '10.5px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.36)'; + ctx.textAlign = 'right'; + const maxW = W * 0.5; + const words = phase.desc.split(' '); + let line = '', lines = []; + for (const w of words) { + const test = line + (line ? ' ' : '') + w; + if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = w; } + else line = test; + } + if (line) lines.push(line); + lines.forEach((l, i) => ctx.fillText(l, W - 14, H - 46 + i * 14)); + ctx.restore(); + } + + _drawProgressBar() { + const ctx = this.ctx; + const { W, H } = this; + const C = CellDivisionSim.C; + const phases = this._phases(); + const bx = 14, bw = W - 28, by = H - 14, bh = 4; + const total = (this._phaseIdx + this._phaseT) / phases.length; + + _cdRRect(ctx, bx, by - bh / 2, bw, bh, 2); + ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill(); + + if (total > 0) { + _cdRRect(ctx, bx, by - bh / 2, bw * total, bh, 2); + ctx.shadowColor = C.progress; ctx.shadowBlur = 8; + ctx.fillStyle = C.progress; ctx.fill(); ctx.shadowBlur = 0; + } + + // phase tick marks + for (let i = 1; i < phases.length; i++) { + const tx = bx + bw * (i / phases.length); + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.fillRect(tx - 0.5, by - bh, 1, bh * 2); + } + + // status + ctx.save(); + ctx.font = '10px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'left'; + ctx.fillText(this._autoPlay ? '> авто' : '|| пауза', bx, H - 22); + ctx.restore(); + } + + _drawHint() { + if (this._time >= 4500) return; + const a = Math.min(1, this._time / 600) * Math.max(0, 1 - (this._time - 3200) / 1300); + const ctx = this.ctx; + ctx.save(); + ctx.globalAlpha = a * 0.42; + ctx.font = '10.5px Manrope,sans-serif'; + ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; + ctx.fillText('Клик — следующая фаза · тяни полосу внизу · Space — пауза', this.W / 2, this.H - 26); + ctx.restore(); + } +} + +function _cdRRect(ctx, x, y, w, h, r) { + if (w <= 0 || h <= 0) return; + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} diff --git a/frontend/js/labs/chemsandbox.js b/frontend/js/labs/chemsandbox.js new file mode 100644 index 0000000..6c2a06b --- /dev/null +++ b/frontend/js/labs/chemsandbox.js @@ -0,0 +1,1670 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + ChemSandboxSim v2 — «Химическая песочница» + • Колба Эрленмейера с реалистичным стеклом + • Анимация вливания реагента сверху + • Удаление отдельного реагента из зоны + • Drag реагентов с полки + • Визуальные эффекты: осадки, газы, смена цвета, нагрев + ════════════════════════════════════════════════════════════════ */ + +class ChemSandboxSim { + + /* ── База веществ ─────────────────────────────────────────────── */ + + static SUBSTANCES = { + 'HCl': { name: 'Соляная к-та', state: 'aq', color: '#78D278', cat: 'acid' }, + 'H2SO4': { name: 'Серная к-та', state: 'aq', color: '#D2C378', cat: 'acid' }, + 'HNO3': { name: 'Азотная к-та', state: 'aq', color: '#E8D060', cat: 'acid' }, + 'CH3COOH': { name: 'Уксусная к-та', state: 'aq', color: '#C8E0A0', cat: 'acid' }, + 'NaOH': { name: 'Гидроксид натрия', state: 'aq', color: '#7BF5A4', cat: 'base' }, + 'KOH': { name: 'Гидроксид калия', state: 'aq', color: '#7BF5A4', cat: 'base' }, + 'Ca(OH)2': { name: 'Гидроксид кальция', state: 'aq', color: '#E0E0E0', cat: 'base' }, + 'NH3·H2O': { name: 'Аммиачная вода', state: 'aq', color: '#A0D8F0', cat: 'base' }, + 'NaCl': { name: 'Хлорид натрия', state: 's', color: '#FFFFFF', cat: 'salt' }, + 'CuSO4': { name: 'Сульфат меди', state: 'aq', color: '#4CC9F0', cat: 'salt' }, + 'BaCl2': { name: 'Хлорид бария', state: 'aq', color: '#E0E0E0', cat: 'salt' }, + 'AgNO3': { name: 'Нитрат серебра', state: 'aq', color: '#E0E0E0', cat: 'salt' }, + 'Na2CO3': { name: 'Карбонат натрия', state: 'aq', color: '#E0E0E0', cat: 'salt' }, + 'FeCl3': { name: 'Хлорид железа(III)', state: 'aq', color: '#D4A040', cat: 'salt' }, + 'Pb(NO3)2':{ name: 'Нитрат свинца', state: 'aq', color: '#E0E0E0', cat: 'salt' }, + 'K2CrO4': { name: 'Хромат калия', state: 'aq', color: '#FFD700', cat: 'salt' }, + 'Zn': { name: 'Цинк', state: 's', color: '#9BB8CC', cat: 'metal' }, + 'Fe': { name: 'Железо', state: 's', color: '#A08060', cat: 'metal' }, + 'Cu': { name: 'Медь', state: 's', color: '#C87840', cat: 'metal' }, + 'Mg': { name: 'Магний', state: 's', color: '#D6D6D6', cat: 'metal' }, + 'Na': { name: 'Натрий', state: 's', color: '#F5F0C8', cat: 'metal' }, + 'H2O': { name: 'Вода', state: 'l', color: '#6EB4D7', cat: 'other' }, + 'Phenolphthalein': { name: 'Фенолфталеин', state: 'ind', color: '#E0E0E0', cat: 'indicator' }, + 'Litmus': { name: 'Лакмус', state: 'ind', color: '#9B59B6', cat: 'indicator' }, + 'MethylOrange': { name: 'Метилоранж', state: 'ind', color: '#FF8C00', cat: 'indicator' }, + }; + + /* ── База реакций ─────────────────────────────────────────────── */ + + static REACTIONS = [ + // ── Нейтрализация ── + { r: ['HCl','NaOH'], eq: 'HCl + NaOH NaCl + H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: 'H⁺ + Cl⁻ + Na⁺ + OH⁻ Na⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['H2SO4','NaOH'], eq: 'H₂SO₄ + 2NaOH Na₂SO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: '2H⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ 2Na⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['HNO3','NaOH'], eq: 'HNO₃ + NaOH NaNO₃ + H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: 'H⁺ + NO₃⁻ + Na⁺ + OH⁻ Na⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['HCl','KOH'], eq: 'HCl + KOH KCl + H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: 'H⁺ + Cl⁻ + K⁺ + OH⁻ K⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['HCl','Ca(OH)2'], eq: '2HCl + Ca(OH)₂ CaCl₂ + 2H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: '2H⁺ + 2Cl⁻ + Ca²⁺ + 2OH⁻ Ca²⁺ + 2Cl⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['CH3COOH','NaOH'], eq: 'CH₃COOH + NaOH CH₃COONa + H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: 'CH₃COOH + Na⁺ + OH⁻ CH₃COO⁻ + Na⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ CH₃COO⁻ + H₂O', + why: 'Слабая кислота реагирует с OH⁻ целиком (не диссоциирует полностью)' }, + { r: ['H2SO4','KOH'], eq: 'H₂SO₄ + 2KOH K₂SO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: '2H⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ 2K⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['HNO3','KOH'], eq: 'HNO₃ + KOH KNO₃ + H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: 'H⁺ + NO₃⁻ + K⁺ + OH⁻ K⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ H₂O', + why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' }, + { r: ['CH3COOH','KOH'], eq: 'CH₃COOH + KOH CH₃COOK + H₂O', type: 'Нейтрализация', fx: { heat: true }, + ionFull: 'CH₃COOH + K⁺ + OH⁻ CH₃COO⁻ + K⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ CH₃COO⁻ + H₂O', + why: 'Слабая кислота реагирует с OH⁻ целиком' }, + { r: ['H2SO4','Ca(OH)2'], eq: 'H₂SO₄ + Ca(OH)₂ CaSO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true, precip: { c: '#F0F0F0', n: 'CaSO₄' } }, + ionFull: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ CaSO₄ + 2H₂O', ionNet: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ CaSO₄ + 2H₂O', + why: 'Нейтрализация + образование малорастворимого CaSO₄' }, + // ── Замещение (металл + кислота) ── + { r: ['Zn','HCl'], eq: 'Zn + 2HCl ZnCl₂ + H₂', type: 'Замещение', fx: { gas: 'H₂', heat: true }, + ionFull: 'Zn⁰ + 2H⁺ + 2Cl⁻ Zn²⁺ + 2Cl⁻ + H₂', ionNet: 'Zn⁰ + 2H⁺ Zn²⁺ + H₂', + why: 'Zn активнее H в ряду активности вытесняет водород' }, + { r: ['Zn','H2SO4'], eq: 'Zn + H₂SO₄ ZnSO₄ + H₂', type: 'Замещение', fx: { gas: 'H₂', heat: true }, + ionFull: 'Zn⁰ + 2H⁺ + SO₄²⁻ Zn²⁺ + SO₄²⁻ + H₂', ionNet: 'Zn⁰ + 2H⁺ Zn²⁺ + H₂', + why: 'Zn активнее H в ряду активности вытесняет водород' }, + { r: ['Fe','HCl'], eq: 'Fe + 2HCl FeCl₂ + H₂', type: 'Замещение', fx: { gas: 'H₂', heat: true }, + ionFull: 'Fe⁰ + 2H⁺ + 2Cl⁻ Fe²⁺ + 2Cl⁻ + H₂', ionNet: 'Fe⁰ + 2H⁺ Fe²⁺ + H₂', + why: 'Fe активнее H в ряду активности вытесняет водород' }, + { r: ['Fe','H2SO4'], eq: 'Fe + H₂SO₄ FeSO₄ + H₂', type: 'Замещение', fx: { gas: 'H₂', heat: true }, + ionFull: 'Fe⁰ + 2H⁺ + SO₄²⁻ Fe²⁺ + SO₄²⁻ + H₂', ionNet: 'Fe⁰ + 2H⁺ Fe²⁺ + H₂', + why: 'Fe активнее H в ряду активности вытесняет водород' }, + { r: ['Mg','HCl'], eq: 'Mg + 2HCl MgCl₂ + H₂', type: 'Замещение', fx: { gas: 'H₂', heat: true, violent: true }, + ionFull: 'Mg⁰ + 2H⁺ + 2Cl⁻ Mg²⁺ + 2Cl⁻ + H₂', ionNet: 'Mg⁰ + 2H⁺ Mg²⁺ + H₂', + why: 'Mg очень активен бурно вытесняет водород' }, + { r: ['Mg','H2SO4'], eq: 'Mg + H₂SO₄ MgSO₄ + H₂', type: 'Замещение', fx: { gas: 'H₂', heat: true, violent: true }, + ionFull: 'Mg⁰ + 2H⁺ + SO₄²⁻ Mg²⁺ + SO₄²⁻ + H₂', ionNet: 'Mg⁰ + 2H⁺ Mg²⁺ + H₂', + why: 'Mg очень активен бурно вытесняет водород' }, + // ── Замещение (металл + соль) ── + { r: ['CuSO4','Fe'], eq: 'CuSO₄ + Fe FeSO₄ + Cu', type: 'Замещение', fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#90C090' }, + ionFull: 'Cu²⁺ + SO₄²⁻ + Fe⁰ Fe²⁺ + SO₄²⁻ + Cu⁰', ionNet: 'Cu²⁺ + Fe⁰ Fe²⁺ + Cu⁰', + why: 'Fe активнее Cu вытесняет медь из раствора' }, + { r: ['CuSO4','Zn'], eq: 'CuSO₄ + Zn ZnSO₄ + Cu', type: 'Замещение', fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#E0E0E0' }, + ionFull: 'Cu²⁺ + SO₄²⁻ + Zn⁰ Zn²⁺ + SO₄²⁻ + Cu⁰', ionNet: 'Cu²⁺ + Zn⁰ Zn²⁺ + Cu⁰', + why: 'Zn активнее Cu вытесняет медь из раствора' }, + { r: ['AgNO3','Cu'], eq: '2AgNO₃ + Cu Cu(NO₃)₂ + 2Ag', type: 'Замещение', fx: { precip: { c: '#C0C0C0', n: 'Ag' }, colorTo: '#4CC9F0' }, + ionFull: '2Ag⁺ + 2NO₃⁻ + Cu⁰ Cu²⁺ + 2NO₃⁻ + 2Ag⁰', ionNet: '2Ag⁺ + Cu⁰ Cu²⁺ + 2Ag⁰', + why: 'Cu активнее Ag вытесняет серебро из раствора' }, + // ── Обмен с осадком ── + { r: ['AgNO3','NaCl'], eq: 'AgNO₃ + NaCl AgCl + NaNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'AgCl' } }, + ionFull: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ AgCl + Na⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ AgCl', + why: 'AgCl нерастворим ионы связываются в осадок' }, + { r: ['AgNO3','HCl'], eq: 'AgNO₃ + HCl AgCl + HNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'AgCl' } }, + ionFull: 'Ag⁺ + NO₃⁻ + H⁺ + Cl⁻ AgCl + H⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ AgCl', + why: 'AgCl нерастворим ионы связываются в осадок' }, + { r: ['BaCl2','H2SO4'], eq: 'BaCl₂ + H₂SO₄ BaSO₄ + 2HCl', type: 'Обмен', fx: { precip: { c: '#FFFFFF', n: 'BaSO₄' } }, + ionFull: 'Ba²⁺ + 2Cl⁻ + 2H⁺ + SO₄²⁻ BaSO₄ + 2H⁺ + 2Cl⁻', ionNet: 'Ba²⁺ + SO₄²⁻ BaSO₄', + why: 'BaSO₄ нерастворим ионы связываются в осадок' }, + { r: ['CuSO4','NaOH'], eq: 'CuSO₄ + 2NaOH Cu(OH)₂ + Na₂SO₄', type: 'Обмен', fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } }, + ionFull: 'Cu²⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ Cu(OH)₂ + 2Na⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ Cu(OH)₂', + why: 'Cu(OH)₂ нерастворим осаждается голубой гидроксид' }, + { r: ['FeCl3','NaOH'], eq: 'FeCl₃ + 3NaOH Fe(OH)₃ + 3NaCl', type: 'Обмен', fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } }, + ionFull: 'Fe³⁺ + 3Cl⁻ + 3Na⁺ + 3OH⁻ Fe(OH)₃ + 3Na⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ Fe(OH)₃', + why: 'Fe(OH)₃ нерастворим бурый осадок' }, + { r: ['Pb(NO3)2','K2CrO4'],eq: 'Pb(NO₃)₂ + K₂CrO₄ PbCrO₄ + 2KNO₃',type:'Обмен', fx: { precip: { c: '#FFD700', n: 'PbCrO₄' } }, + ionFull: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + CrO₄²⁻ PbCrO₄ + 2K⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + CrO₄²⁻ PbCrO₄', + why: 'PbCrO₄ нерастворим яркий жёлтый осадок' }, + { r: ['FeCl3','K2CrO4'], eq: '2FeCl₃ + 3K₂CrO₄ Fe₂(CrO₄)₃ + 6KCl',type:'Обмен', fx: { precip: { c: '#8B6914', n: 'Fe₂(CrO₄)₃' } }, + ionFull: '2Fe³⁺ + 6Cl⁻ + 6K⁺ + 3CrO₄²⁻ Fe₂(CrO₄)₃ + 6K⁺ + 6Cl⁻', ionNet: '2Fe³⁺ + 3CrO₄²⁻ Fe₂(CrO₄)₃', + why: 'Fe₂(CrO₄)₃ нерастворим' }, + { r: ['Pb(NO3)2','NaCl'], eq: 'Pb(NO₃)₂ + 2NaCl PbCl₂ + 2NaNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'PbCl₂' } }, + ionFull: 'Pb²⁺ + 2NO₃⁻ + 2Na⁺ + 2Cl⁻ PbCl₂ + 2Na⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + 2Cl⁻ PbCl₂', + why: 'PbCl₂ малорастворим белый осадок' }, + { r: ['CuSO4','KOH'], eq: 'CuSO₄ + 2KOH Cu(OH)₂ + K₂SO₄', type: 'Обмен', fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } }, + ionFull: 'Cu²⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ Cu(OH)₂ + 2K⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ Cu(OH)₂', + why: 'Cu(OH)₂ нерастворим голубой осадок' }, + { r: ['FeCl3','KOH'], eq: 'FeCl₃ + 3KOH Fe(OH)₃ + 3KCl', type: 'Обмен', fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } }, + ionFull: 'Fe³⁺ + 3Cl⁻ + 3K⁺ + 3OH⁻ Fe(OH)₃ + 3K⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ Fe(OH)₃', + why: 'Fe(OH)₃ нерастворим бурый осадок' }, + // ── Обмен с газом ── + { r: ['Na2CO3','HCl'], eq: 'Na₂CO₃ + 2HCl 2NaCl + H₂O + CO₂', type: 'Обмен', fx: { gas: 'CO₂' }, + ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ 2Na⁺ + 2Cl⁻ + H₂O + CO₂', ionNet: 'CO₃²⁻ + 2H⁺ H₂O + CO₂', + why: 'H₂CO₃ неустойчива разлагается на воду и газ CO₂' }, + { r: ['Na2CO3','H2SO4'], eq: 'Na₂CO₃ + H₂SO₄ Na₂SO₄ + H₂O + CO₂',type:'Обмен', fx: { gas: 'CO₂' }, + ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + SO₄²⁻ 2Na⁺ + SO₄²⁻ + H₂O + CO₂', ionNet: 'CO₃²⁻ + 2H⁺ H₂O + CO₂', + why: 'H₂CO₃ неустойчива разлагается на воду и газ CO₂' }, + { r: ['Na2CO3','HNO3'], eq: 'Na₂CO₃ + 2HNO₃ 2NaNO₃ + H₂O + CO₂',type:'Обмен', fx: { gas: 'CO₂' }, + ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2NO₃⁻ 2Na⁺ + 2NO₃⁻ + H₂O + CO₂', ionNet: 'CO₃²⁻ + 2H⁺ H₂O + CO₂', + why: 'H₂CO₃ неустойчива разлагается на воду и газ CO₂' }, + { r: ['Na2CO3','CH3COOH'], eq: 'Na₂CO₃ + 2CH₃COOH 2CH₃COONa + H₂O + CO₂',type:'Обмен', fx: { gas: 'CO₂' }, + ionFull: '2Na⁺ + CO₃²⁻ + 2CH₃COOH 2CH₃COO⁻ + 2Na⁺ + H₂O + CO₂', ionNet: 'CO₃²⁻ + 2CH₃COOH 2CH₃COO⁻ + H₂O + CO₂', + why: 'Уксусная кислота слабая, но карбонат-ион связывает H⁺' }, + // ── Активный металл + вода ── + { r: ['Na','H2O'], eq: '2Na + 2H₂O 2NaOH + H₂', type: 'Акт. металл', fx: { gas: 'H₂', heat: true, violent: true }, + ionNet: '2Na⁰ + 2H₂O 2Na⁺ + 2OH⁻ + H₂', + why: 'Na — щелочной металл, бурно реагирует с водой' }, + { r: ['Mg','H2O'], eq: 'Mg + 2H₂O Mg(OH)₂ + H₂', type: 'Акт. металл', fx: { gas: 'H₂', heat: true, precip: { c: '#E0E0E0', n: 'Mg(OH)₂' } }, + ionNet: 'Mg⁰ + 2H₂O Mg(OH)₂ + H₂', + why: 'Mg реагирует с горячей водой, Mg(OH)₂ малорастворим' }, + // ── Индикаторы ── + { r: ['Phenolphthalein','NaOH'], eq: 'Фенолфталеин + щёлочь малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' }, + why: 'pH > 8 фенолфталеин приобретает малиновую окраску' }, + { r: ['Phenolphthalein','KOH'], eq: 'Фенолфталеин + щёлочь малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' }, + why: 'pH > 8 фенолфталеин приобретает малиновую окраску' }, + { r: ['Phenolphthalein','Ca(OH)2'],eq:'Фенолфталеин + щёлочь малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' }, + why: 'pH > 8 фенолфталеин приобретает малиновую окраску' }, + { r: ['Phenolphthalein','NH3·H2O'],eq:'Фенолфталеин + аммиак бл.-розовый', type: 'Индикатор', fx: { colorTo: '#FFB0C0' }, + why: 'NH₃·H₂O — слабое основание, pH ~11, бледная окраска' }, + { r: ['Litmus','HCl'], eq: 'Лакмус + кислота красный', type: 'Индикатор', fx: { colorTo: '#EF476F' }, + why: 'pH < 5 лакмус краснеет' }, + { r: ['Litmus','H2SO4'], eq: 'Лакмус + кислота красный', type: 'Индикатор', fx: { colorTo: '#EF476F' }, + why: 'pH < 5 лакмус краснеет' }, + { r: ['Litmus','HNO3'], eq: 'Лакмус + кислота красный', type: 'Индикатор', fx: { colorTo: '#EF476F' }, + why: 'pH < 5 лакмус краснеет' }, + { r: ['Litmus','NaOH'], eq: 'Лакмус + щёлочь синий', type: 'Индикатор', fx: { colorTo: '#4466FF' }, + why: 'pH > 8 лакмус синеет' }, + { r: ['Litmus','KOH'], eq: 'Лакмус + щёлочь синий', type: 'Индикатор', fx: { colorTo: '#4466FF' }, + why: 'pH > 8 лакмус синеет' }, + { r: ['MethylOrange','HCl'], eq: 'Метилоранж + кислота розовый', type: 'Индикатор', fx: { colorTo: '#FF6666' }, + why: 'pH < 3.1 метилоранж розовеет' }, + { r: ['MethylOrange','H2SO4'], eq: 'Метилоранж + кислота розовый', type: 'Индикатор', fx: { colorTo: '#FF6666' }, + why: 'pH < 3.1 метилоранж розовеет' }, + { r: ['MethylOrange','NaOH'], eq: 'Метилоранж + щёлочь жёлтый', type: 'Индикатор', fx: { colorTo: '#FFD700' }, + why: 'pH > 4.4 метилоранж жёлтый' }, + // ── Нет реакции ── + { r: ['Cu','HCl'], eq: 'Cu + HCl — реакция не идёт', type: 'Нет реакции', fx: { none: true }, + why: 'Cu стоит после H в ряду активности не вытесняет водород' }, + { r: ['Cu','H2SO4'], eq: 'Cu + H₂SO₄(разб.) — нет реакции',type: 'Нет реакции', fx: { none: true }, + why: 'Cu стоит после H в ряду активности не вытесняет водород' }, + ]; + + /* ── Ряд активности металлов ─────────────────────────────────── */ + static ACTIVITY_SERIES = [ + { sym: 'K', name: 'Калий' }, + { sym: 'Na', name: 'Натрий' }, + { sym: 'Ca', name: 'Кальций' }, + { sym: 'Mg', name: 'Магний' }, + { sym: 'Al', name: 'Алюминий' }, + { sym: 'Zn', name: 'Цинк' }, + { sym: 'Fe', name: 'Железо' }, + { sym: 'Ni', name: 'Никель' }, + { sym: 'Sn', name: 'Олово' }, + { sym: 'Pb', name: 'Свинец' }, + { sym: 'H₂', name: 'Водород' }, + { sym: 'Cu', name: 'Медь' }, + { sym: 'Ag', name: 'Серебро' }, + { sym: 'Au', name: 'Золото' }, + ]; + + static PRESETS = { + neutralization: ['HCl', 'NaOH'], + gas_evolution: ['Na2CO3', 'HCl'], + precipitate: ['AgNO3', 'NaCl'], + displacement: ['CuSO4', 'Fe'], + indicator: ['Phenolphthalein', 'NaOH'], + violent: ['Na', 'H2O'], + yellow_precip: ['Pb(NO3)2', 'K2CrO4'], + blue_precip: ['CuSO4', 'NaOH'], + }; + + /* ── Конструктор ─────────────────────────────────────────────── */ + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.mixContents = []; + this.lastReaction = null; + this.filterCat = 'all'; + + // liquid state + this._liqColor = null; + this._liqTargetColor = null; + this._liqLevel = 0; + this._precipLevel = 0; + this._precipColor = null; + + // particles + this._bubbles = []; + this._precipParts = []; + this._steamParts = []; + this._sparkParts = []; + this._pourDrops = []; // pour animation + + // animation + this._raf = null; + this._last = 0; + this._time = 0; + this._animPhase = 0; + this._reacTimer = 0; + + // flask geometry cache + this._g = {}; + + // glow / waves + this._glowPulse = 0; + this._heatGlow = 0; + this._wave = 0; + this._wave2 = 0; + this._wave3 = 0; + + // gas label + this._gasLabel = null; + + // pour animation state + this._pouring = false; + this._pourColor = null; + this._pourTimer = 0; + + // drag state + this._drag = null; // { formula, x, y } + + // added items chips (for removal) + this._chipLayout = []; // [{formula, x, y, w, h}] + + // shelf layout cache + this._shelfKeys = []; + this._shelfStartX = 0; + this._shelfBottleW = 0; + this._shelfGap = 0; + this._shelfBottleY = 0; + this._shelfBottleH = 0; + this._shelfLayout = []; // [{formula, x, y, w, h}] + this._shelfScroll = 0; // horizontal scroll offset + this._shelfHover = -1; // hovered card index + + // pending preset timeouts + this._presetTimers = []; + + // quiz mode + this._quizMode = false; + this._quizTask = null; // { rx, question, answer: [f1,f2] } + this._quizScore = 0; + this._quizTotal = 0; + this._quizResult = null; // 'correct' | 'wrong' | null + this._quizResultT = 0; + + this.onUpdate = null; + this.onQuizUpdate = null; // callback(quizInfo) + this.fit(); + } + + /* ── Геометрия колбы Эрленмейера ─────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 600; + const H = this.canvas.offsetHeight || 400; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._calcGeom(); + } + + _calcGeom() { + const { W, H } = this; + // flask parameters (Erlenmeyer) + const r = Math.min(W * 0.17, H * 0.18); // body radius (smaller to fit info) + const cx = W * 0.50; + const cy = H * 0.34; // body center (higher) + const nw = r * 0.22; // neck half-width + const nh = r * 0.90; // neck height + const nt = cy - r - nh; // neck top Y + const nb = cy - r * 0.75; // neck-shoulder transition Y + const liqTop = cy - r * 0.35; // default liquid surface + // shelf — 2-row grid with large cards + const shelfH = 140; + const shelfY = H - shelfH - 4; + this._g = { r, cx, cy, nw, nh, nt, nb, liqTop, shelfY, shelfH }; + } + + _flaskPath(ctx) { + const { r, cx, cy, nw, nt, nb } = this._g; + ctx.beginPath(); + ctx.moveTo(cx - nw, nt); + ctx.lineTo(cx - nw, nb); + ctx.bezierCurveTo(cx - nw, cy - r * 0.38, cx - r * 0.85, cy - r * 0.08, cx - r, cy); + ctx.arc(cx, cy, r, Math.PI, 0, true); + ctx.bezierCurveTo(cx + r * 0.85, cy - r * 0.08, cx + nw, cy - r * 0.38, cx + nw, nb); + ctx.lineTo(cx + nw, nt); + ctx.closePath(); + } + + /* ── Запуск / остановка ─────────────────────────────────────── */ + + start() { + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + /* ── Публичный API ──────────────────────────────────────────── */ + + reset() { + // cancel any pending preset timers + for (const t of this._presetTimers) clearTimeout(t); + this._presetTimers = []; + + this.mixContents = []; + this.lastReaction = null; + this._liqColor = null; + this._liqTargetColor = null; + this._liqLevel = 0; + this._precipLevel = 0; + this._precipColor = null; + this._bubbles = []; + this._precipParts = []; + this._steamParts = []; + this._sparkParts = []; + this._pourDrops = []; + this._animPhase = 0; + this._reacTimer = 0; + this._heatGlow = 0; + this._gasLabel = null; + this._pouring = false; + this._chipLayout = []; + this._fireInfo(); + } + + resetReaction() { + // keep reagents in the zone, but clear reaction effects + this.lastReaction = null; + this._liqTargetColor = null; + this._animPhase = 0; + this._reacTimer = 0; + this._gasLabel = null; + this._bubbles = []; + this._precipParts = []; + this._steamParts = []; + this._sparkParts = []; + this._precipLevel = 0; + this._precipColor = null; + this._heatGlow = 0; + // recalc liquid to original colors without reaction effects + this._recalcLiquid(); + this._fireInfo(); + } + + addToMix(formula) { + if (this.mixContents.length >= 4) return; + if (this.mixContents.includes(formula)) return; + + this.mixContents.push(formula); + const sub = ChemSandboxSim.SUBSTANCES[formula]; + + // pour animation + this._pouring = true; + this._pourColor = sub.color; + this._pourTimer = 0; + this._spawnPourDrops(sub.color, sub.state === 's'); + + // liquid level (cap at 0.85 to keep within flask body) + if (sub.state !== 's') { + this._liqLevel = Math.min(0.85, this._liqLevel + 0.30); + this._liqColor = this._liqColor + ? this._blendColors(this._liqColor, sub.color, 0.45) + : sub.color; + } else { + this._liqLevel = Math.min(0.85, this._liqLevel + 0.10); + } + + // check reaction + this._checkReaction(); + this._fireInfo(); + } + + removeFromMix(formula) { + const idx = this.mixContents.indexOf(formula); + if (idx === -1) return; + this.mixContents.splice(idx, 1); + + // recalculate liquid + this._recalcLiquid(); + + // re-check reaction + this.lastReaction = null; + this._animPhase = 0; + this._gasLabel = null; + this._bubbles = []; + this._precipParts = []; + this._steamParts = []; + this._sparkParts = []; + this._precipLevel = 0; + this._precipColor = null; + this._heatGlow = 0; + this._liqTargetColor = null; + + if (this.mixContents.length >= 2) this._checkReaction(); + this._fireInfo(); + } + + _recalcLiquid() { + this._liqLevel = 0; + this._liqColor = null; + for (const f of this.mixContents) { + const s = ChemSandboxSim.SUBSTANCES[f]; + if (s.state !== 's') { + this._liqLevel = Math.min(0.85, this._liqLevel + 0.30); + this._liqColor = this._liqColor + ? this._blendColors(this._liqColor, s.color, 0.45) + : s.color; + } else { + this._liqLevel = Math.min(0.85, this._liqLevel + 0.10); + } + } + } + + setCategory(cat) { this.filterCat = cat; } + + preset(name) { + const p = ChemSandboxSim.PRESETS[name]; + if (!p) return; + this.reset(); + // add with staggered delay (store timer IDs so reset can cancel them) + this._presetTimers = p.map((f, i) => + setTimeout(() => this.addToMix(f), i * 600) + ); + } + + info() { + const r = this.lastReaction; + return { + mixed: this.mixContents.length, + contents: [...this.mixContents], + reaction: r && !r.fx.none ? true : false, + type: r ? r.type : null, + equation: r ? r.eq : null, + products: r && !r.fx.none ? this._productsStr(r) : null, + ionNet: r ? r.ionNet || null : null, + why: r ? r.why || null : null, + }; + } + + /* ── Поиск реакции ─────────────────────────────────────────── */ + + _checkReaction() { + const c = this.mixContents; + if (c.length < 2) return; + for (let i = 0; i < c.length; i++) { + for (let j = i + 1; j < c.length; j++) { + const rx = this._findReaction(c[i], c[j]); + if (rx) { + this.lastReaction = rx; + this._triggerReaction(rx); + if (this._quizMode) this._checkQuizAnswer(); + return; + } + } + } + } + + _findReaction(a, b) { + return ChemSandboxSim.REACTIONS.find(rx => + (rx.r[0] === a && rx.r[1] === b) || (rx.r[0] === b && rx.r[1] === a) + ) || null; + } + + _productsStr(rx) { + const parts = rx.eq.split(''); + return parts.length > 1 ? parts[1].trim() : '—'; + } + + /* ── Эффекты реакции ────────────────────────────────────────── */ + + _triggerReaction(rx) { + const fx = rx.fx; + if (fx.none) { this._fireInfo(); return; } + + this._animPhase = 1; + this._reacTimer = 0; + + if (fx.colorTo) this._liqTargetColor = fx.colorTo; + if (fx.gas) { this._gasLabel = fx.gas; this._spawnBubbles(fx.violent ? 45 : 22); } + if (fx.precip) { this._precipColor = fx.precip.c; this._spawnPrecipitate(28); } + if (fx.heat) { + this._heatGlow = 1.0; + this._spawnSteam(fx.violent ? 25 : 12); + if (fx.violent) this._spawnSparks(35); + } + this._fireInfo(); + } + + /* ── Частицы ────────────────────────────────────────────────── */ + + _spawnPourDrops(color, isSolid) { + const { cx, nt, nw } = this._g; + const n = isSolid ? 8 : 15; + for (let i = 0; i < n; i++) { + this._pourDrops.push({ + x: cx + (Math.random() - 0.5) * nw * 1.5, + y: nt - 20 - Math.random() * 30, + r: isSolid ? 2 + Math.random() * 3 : 1.5 + Math.random() * 2, + vy: 1.5 + Math.random() * 3, + vx: (Math.random() - 0.5) * 0.8, + color, + life: 1.0, + solid: isSolid, + }); + } + } + + _spawnBubbles(n) { + const { cx, cy, r } = this._g; + for (let i = 0; i < n; i++) { + const angle = (Math.random() - 0.5) * 1.2; + const dist = Math.random() * r * 0.6; + this._bubbles.push({ + x: cx + Math.sin(angle) * dist, + y: cy + r * 0.3 - Math.random() * 15, + r: 1.5 + Math.random() * 4.5, + vy: -(1.5 + Math.random() * 3), + vx: (Math.random() - 0.5) * 0.8, + life: 1.0, + delay: Math.random() * 2.5, + }); + } + } + + _spawnPrecipitate(n) { + const { cx, cy, r } = this._g; + for (let i = 0; i < n; i++) { + this._precipParts.push({ + x: cx + (Math.random() - 0.5) * r * 1.2, + y: cy - r * 0.2 + Math.random() * r * 0.4, + r: 1.5 + Math.random() * 3, + vy: 0.3 + Math.random() * 0.9, + vx: (Math.random() - 0.5) * 0.3, + life: 1.0, + delay: Math.random() * 1.8, + }); + } + } + + _spawnSteam(n) { + const { cx, nt, nw } = this._g; + for (let i = 0; i < n; i++) { + this._steamParts.push({ + x: cx + (Math.random() - 0.5) * nw * 2, + y: nt - 5 + Math.random() * 10, + r: 3 + Math.random() * 7, + vy: -(0.5 + Math.random() * 1.8), + vx: (Math.random() - 0.5) * 0.6, + life: 1.0, + delay: Math.random() * 2.5, + }); + } + } + + _spawnSparks(n) { + const { cx, nt, nw } = this._g; + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const sp = 2 + Math.random() * 5; + this._sparkParts.push({ + x: cx + (Math.random() - 0.5) * nw, + y: nt + 5, + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp - 3, + life: 1.0, + delay: Math.random() * 1.0, + }); + } + } + + /* ── Тик ────────────────────────────────────────────────────── */ + + _tick(now) { + const dt = Math.min((now - this._last) / 1000, 0.05); + this._last = now; + this._time += dt; + this._wave += dt * 1.7; + this._wave2 += dt * 2.3; + this._wave3 += dt * 0.88; + this._glowPulse += dt * 3.2; + + if (this._animPhase === 1) { + this._reacTimer += dt; + if (this._reacTimer > 5.0) this._animPhase = 2; + } + + // color lerp + if (this._liqTargetColor && this._liqColor) { + this._liqColor = this._lerpColor(this._liqColor, this._liqTargetColor, dt * 1.0); + } + + if (this._heatGlow > 0) this._heatGlow = Math.max(0, this._heatGlow - dt * 0.12); + + // pour animation + if (this._pouring) { + this._pourTimer += dt; + if (this._pourTimer > 1.2) this._pouring = false; + } + + // quiz result timer + if (this._quizResultT > 0) { + this._quizResultT -= dt; + if (this._quizResultT <= 0) { + this._quizResultT = 0; + if (this._quizResult === 'correct') this._nextQuizTask(); + } + } + + this._updatePour(dt); + this._updateBubbles(dt); + this._updatePrecip(dt); + this._updateSteam(dt); + this._updateSparks(dt); + + this.draw(); + } + + _updatePour(dt) { + const { cy, r } = this._g; + const surfY = this._getSurfaceY(); + for (let i = this._pourDrops.length - 1; i >= 0; i--) { + const d = this._pourDrops[i]; + d.vy += 8 * dt; // gravity + d.y += d.vy; + d.x += d.vx; + if (d.y > surfY) { + d.life -= dt * 3; + } + if (d.life <= 0 || d.y > cy + r) this._pourDrops.splice(i, 1); + } + } + + _updateBubbles(dt) { + const surfY = this._getSurfaceY(); + for (let i = this._bubbles.length - 1; i >= 0; i--) { + const b = this._bubbles[i]; + if (b.delay > 0) { b.delay -= dt; continue; } + b.x += b.vx; + b.y += b.vy; + b.vx += (Math.random() - 0.5) * 0.4; + b.life -= dt * 0.22; + if (b.y < surfY - 8 || b.life <= 0) this._bubbles.splice(i, 1); + } + if (this._animPhase === 1 && this._gasLabel && Math.random() < 0.35) { + this._spawnBubbles(2); + } + } + + _updatePrecip(dt) { + const { cy, r } = this._g; + const bottom = cy + r - 14; + for (let i = this._precipParts.length - 1; i >= 0; i--) { + const p = this._precipParts[i]; + if (p.delay > 0) { p.delay -= dt; continue; } + p.x += p.vx; + p.y += p.vy; + p.vx *= 0.99; + if (p.y >= bottom) { + p.y = bottom; p.vy = 0; p.vx = 0; + this._precipLevel = Math.min(1, this._precipLevel + 0.004); + p.life -= dt * 0.25; + } + if (p.life <= 0) this._precipParts.splice(i, 1); + } + } + + _updateSteam(dt) { + for (let i = this._steamParts.length - 1; i >= 0; i--) { + const s = this._steamParts[i]; + if (s.delay > 0) { s.delay -= dt; continue; } + s.x += s.vx; s.y += s.vy; + s.r += dt * 1.8; s.life -= dt * 0.35; + if (s.life <= 0) this._steamParts.splice(i, 1); + } + } + + _updateSparks(dt) { + for (let i = this._sparkParts.length - 1; i >= 0; i--) { + const s = this._sparkParts[i]; + if (s.delay > 0) { s.delay -= dt; continue; } + s.x += s.vx; s.y += s.vy; + s.vy += 4 * dt; + s.life -= dt * 0.7; + if (s.life <= 0) this._sparkParts.splice(i, 1); + } + } + + _getSurfaceY() { + const { cy, r, nt, nb } = this._g; + if (this._liqLevel <= 0) return cy + r; + // total fillable height: from flask bottom (cy+r) up to just below neck-shoulder (nb+4) + const maxH = (cy + r) - (nb + 4); + const liqH = maxH * Math.min(1, this._liqLevel) * 0.75; + return cy + r - liqH; + } + + /* ── Рендеринг ─────────────────────────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + ctx.clearRect(0, 0, W, H); + + this._drawBackground(); + this._drawFlaskShadow(); + this._drawLiquid(); + this._drawPrecipitate(); + this._drawBubbles(); + this._drawPourDrops(); + this._drawFlaskGlass(); + this._drawSteam(); + this._drawSparks(); + this._drawChips(); + this._drawEquation(); + this._drawShelf(); + this._drawDragGhost(); + if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint(); + if (this._quizMode) this._drawQuizOverlay(); + } + + _drawBackground() { + const { ctx, W, H } = this; + const bg = ctx.createRadialGradient(W / 2, H * 0.38, 0, W / 2, H * 0.38, W * 0.75); + bg.addColorStop(0, '#0c0c1a'); + bg.addColorStop(1, '#050508'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + // grid + ctx.strokeStyle = 'rgba(255,255,255,0.02)'; + ctx.lineWidth = 0.5; + for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } + for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } + } + + _drawFlaskShadow() { + const { ctx } = this; + const { cx, cy, r } = this._g; + // shadow beneath flask + const sg = ctx.createRadialGradient(cx, cy + r + 8, 0, cx, cy + r + 8, r * 1.1); + sg.addColorStop(0, 'rgba(0,0,0,0.25)'); + sg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = sg; + ctx.fillRect(cx - r * 1.2, cy + r - 2, r * 2.4, 25); + } + + _drawLiquid() { + if (this._liqLevel <= 0) return; + const { ctx } = this; + const g = this._g; + const surfY = this._getSurfaceY(); + + ctx.save(); + this._flaskPath(ctx); + ctx.clip(); + + const col = this._liqColor || '#6EB4D7'; + const liqGrad = ctx.createLinearGradient(0, surfY, 0, g.cy + g.r); + liqGrad.addColorStop(0, this._alphaColor(col, 0.45)); + liqGrad.addColorStop(0.5, this._alphaColor(col, 0.60)); + liqGrad.addColorStop(1, this._alphaColor(col, 0.75)); + ctx.fillStyle = liqGrad; + + // wavy surface + ctx.beginPath(); + ctx.moveTo(g.cx - g.r - 5, g.cy + g.r + 5); + const amp = 2.0; + const left = g.cx - g.r - 5, right = g.cx + g.r + 5; + for (let px = left; px <= right; px += 2) { + const t = (px - left) / (right - left); + const wy = surfY + Math.sin(t * 7 + this._wave) * amp + + Math.sin(t * 4.5 + this._wave2) * amp * 0.6; + ctx.lineTo(px, wy); + } + ctx.lineTo(right, g.cy + g.r + 5); + ctx.closePath(); + ctx.fill(); + + // meniscus highlight + ctx.beginPath(); + for (let px = left; px <= right; px += 2) { + const t = (px - left) / (right - left); + const wy = surfY + Math.sin(t * 7 + this._wave) * amp + + Math.sin(t * 4.5 + this._wave2) * amp * 0.6; + if (px === left) ctx.moveTo(px, wy); else ctx.lineTo(px, wy); + } + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.stroke(); + + // SSS (sub-surface scattering glow) + const ssg = ctx.createRadialGradient(g.cx, surfY + 20, 5, g.cx, surfY + 20, g.r * 0.8); + ssg.addColorStop(0, this._alphaColor(col, 0.12)); + ssg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = ssg; + ctx.fillRect(left, surfY, right - left, g.r * 2); + + ctx.restore(); + } + + _drawPrecipitate() { + const { ctx } = this; + const { cx, cy, r } = this._g; + if (this._precipLevel <= 0 && this._precipParts.length === 0) return; + + // everything clipped to flask shape + ctx.save(); + this._flaskPath(ctx); + ctx.clip(); + + // settled layer at flask bottom + if (this._precipLevel > 0 && this._precipColor) { + const layerH = r * 0.30 * this._precipLevel; + const layerY = cy + r - layerH; + const pg = ctx.createLinearGradient(0, layerY, 0, cy + r); + pg.addColorStop(0, this._alphaColor(this._precipColor, 0.35)); + pg.addColorStop(1, this._alphaColor(this._precipColor, 0.65)); + ctx.fillStyle = pg; + ctx.fillRect(cx - r - 2, layerY, r * 2 + 4, layerH); + } + + // falling particles (also clipped) + for (const p of this._precipParts) { + if (p.delay > 0) continue; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = this._alphaColor(this._precipColor || '#FFF', p.life * 0.65); + ctx.fill(); + } + + ctx.restore(); + } + + _drawBubbles() { + const { ctx } = this; + for (const b of this._bubbles) { + if (b.delay > 0) continue; + ctx.beginPath(); + ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(255,255,255,${b.life * 0.35})`; + ctx.lineWidth = 0.7; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.25, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${b.life * 0.3})`; + ctx.fill(); + } + // gas label above neck + if (this._gasLabel && this._bubbles.length > 0) { + const { cx, nt } = this._g; + ctx.save(); + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.textAlign = 'center'; + ctx.fillText(this._gasLabel + ' ↑', cx, nt - 14); + ctx.restore(); + } + } + + _drawPourDrops() { + const { ctx } = this; + for (const d of this._pourDrops) { + ctx.beginPath(); + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); + ctx.fillStyle = this._alphaColor(d.color, d.life * 0.7); + ctx.fill(); + } + } + + _drawSteam() { + const { ctx } = this; + for (const s of this._steamParts) { + if (s.delay > 0) continue; + ctx.beginPath(); + ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); + ctx.fillStyle = `rgba(200,200,220,${s.life * 0.13})`; + ctx.fill(); + } + } + + _drawSparks() { + const { ctx } = this; + for (const s of this._sparkParts) { + if (s.delay > 0) continue; + ctx.save(); + ctx.beginPath(); + ctx.arc(s.x, s.y, 2.2, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,200,60,${s.life * 0.85})`; + ctx.shadowColor = '#FFD060'; + ctx.shadowBlur = 7; + ctx.fill(); + ctx.restore(); + } + } + + _drawFlaskGlass() { + const { ctx } = this; + const g = this._g; + + // glass body + ctx.save(); + this._flaskPath(ctx); + + // glass fill + const gf = ctx.createLinearGradient(g.cx - g.r, g.nt, g.cx + g.r, g.cy + g.r); + gf.addColorStop(0, 'rgba(255,255,255,0.05)'); + gf.addColorStop(0.4, 'rgba(255,255,255,0.07)'); + gf.addColorStop(1, 'rgba(255,255,255,0.03)'); + ctx.fillStyle = gf; + ctx.fill(); + + // glass outline + ctx.strokeStyle = 'rgba(255,255,255,0.14)'; + ctx.lineWidth = 1.8; + ctx.stroke(); + + // heat glow + if (this._heatGlow > 0) { + ctx.shadowColor = `rgba(255,80,20,${this._heatGlow * 0.45})`; + ctx.shadowBlur = 30; + ctx.strokeStyle = `rgba(255,120,60,${this._heatGlow * 0.35})`; + ctx.stroke(); + ctx.shadowBlur = 0; + } + ctx.restore(); + + // specular highlight — left edge + ctx.save(); + ctx.beginPath(); + ctx.moveTo(g.cx - g.nw + 2, g.nt + 6); + ctx.lineTo(g.cx - g.nw + 2, g.nb + 5); + ctx.bezierCurveTo( + g.cx - g.nw + 2, g.cy - g.r * 0.3, + g.cx - g.r * 0.75, g.cy - g.r * 0.05, + g.cx - g.r + 5, g.cy + g.r * 0.3 + ); + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; + ctx.lineWidth = 2.5; + ctx.stroke(); + ctx.restore(); + + // neck rim + ctx.save(); + ctx.beginPath(); + ctx.ellipse(g.cx, g.nt, g.nw + 1, 3, 0, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.restore(); + + // graduated marks on neck + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + ctx.lineWidth = 0.5; + for (let i = 1; i <= 3; i++) { + const my = g.nt + (g.nb - g.nt) * i / 4; + ctx.beginPath(); + ctx.moveTo(g.cx - g.nw + 3, my); + ctx.lineTo(g.cx - g.nw + 10, my); + ctx.stroke(); + } + ctx.restore(); + } + + /* ── Чипы добавленных реагентов (с кнопкой удаления) ──────── */ + + _drawChips() { + if (this.mixContents.length === 0) return; + const { ctx, W } = this; + const { nt } = this._g; + + this._chipLayout = []; + const chipH = 22, gap = 6; + let totalW = 0; + const labels = this.mixContents.map(f => { + const lbl = this._shortFormula(f); + ctx.font = 'bold 10px "JetBrains Mono", monospace'; + const w = ctx.measureText(lbl).width + 28; // padding + "×" button + totalW += w + gap; + return { formula: f, label: lbl, w }; + }); + totalW -= gap; + + let x = (W - totalW) / 2; + const y = Math.max(6, nt - 32); + + for (const { formula, label, w } of labels) { + const sub = ChemSandboxSim.SUBSTANCES[formula]; + + // chip bg + ctx.save(); + ctx.beginPath(); + this._roundRect(ctx, x, y, w, chipH, 6); + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fill(); + ctx.strokeStyle = this._alphaColor(sub.color, 0.4); + ctx.lineWidth = 1; + ctx.stroke(); + + // color dot + ctx.beginPath(); + ctx.arc(x + 10, y + chipH / 2, 4, 0, Math.PI * 2); + ctx.fillStyle = sub.color; + ctx.fill(); + + // label + ctx.font = 'bold 10px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.textAlign = 'left'; + ctx.fillText(label, x + 18, y + chipH / 2 + 3.5); + + // "×" remove button + const xBtnX = x + w - 14; + ctx.font = 'bold 11px sans-serif'; + ctx.fillStyle = 'rgba(255,100,100,0.6)'; + ctx.textAlign = 'center'; + ctx.fillText('×', xBtnX, y + chipH / 2 + 4); + ctx.restore(); + + this._chipLayout.push({ formula, x, y, w, h: chipH, xBtnX }); + x += w + gap; + } + } + + _drawEquation() { + if (!this.lastReaction) return; + const { ctx, W } = this; + const { cy, r } = this._g; + const rx = this.lastReaction; + let y = cy + r + 14; + + ctx.save(); + ctx.textAlign = 'center'; + + // ── Молекулярное уравнение ── + 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); + y += 22; + + // ── Тип реакции + пояснение ── + const tp = rx.type; + const tpColor = tp === 'Нет реакции' ? '#EF476F' + : tp === 'Индикатор' ? '#9B59B6' + : tp === 'Нейтрализация' ? '#FFD166' + : tp === 'Замещение' ? '#06D6E0' + : tp === 'Обмен' ? '#7BF5A4' + : tp === 'Акт. металл' ? '#EF476F' : '#aaa'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillStyle = tpColor; + ctx.fillText(tp, W / 2, y); + y += 18; + + // why explanation + if (rx.why) { + ctx.font = '13px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.fillText(rx.why, W / 2, y); + y += 17; + } + + // ── Полное ионное уравнение ── + if (rx.ionFull) { + ctx.font = '13px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(155,200,255,0.60)'; + ctx.fillText('Полн.: ' + rx.ionFull, W / 2, y); + y += 16; + } + + // ── Сокращённое ионное уравнение ── + 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); + y += 16; + } + + ctx.restore(); + + // ── Мини ряд активности (для замещения) ── + if (rx.type === 'Замещение' || rx.type === 'Нет реакции' || rx.type === 'Акт. металл') { + this._drawActivitySeries(y + 2); + } + } + + _drawActivitySeries(topY) { + const { ctx, W } = this; + const series = ChemSandboxSim.ACTIVITY_SERIES; + const rx = this.lastReaction; + if (!rx) return; + + // find which metals are involved + const involved = new Set(); + for (const f of rx.r) { + for (const m of series) { + if (f === m.sym || f === m.sym.replace('₂', '2')) involved.add(m.sym); + } + } + const metalMap = { 'Zn': 'Zn', 'Fe': 'Fe', 'Cu': 'Cu', 'Mg': 'Mg', 'Na': 'Na', 'Ag': 'Ag' }; + for (const f of rx.r) { + if (metalMap[f]) involved.add(f); + } + for (const f of rx.r) { + if (f.startsWith('Cu')) involved.add('Cu'); + if (f.startsWith('Ag')) involved.add('Ag'); + if (f.startsWith('Fe') && !f.includes('Cl')) involved.add('Fe'); + } + + if (involved.size === 0) return; + + const cellW = 28, cellH = 18, gap = 2; + const totalW = series.length * (cellW + gap) - gap; + const startX = (W - totalW) / 2; + const y = topY; + + ctx.save(); + ctx.font = '8px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.textAlign = 'center'; + ctx.fillText('Ряд активности металлов', W / 2, y); + + const rowY = y + 5; + for (let i = 0; i < series.length; i++) { + const m = series[i]; + const x = startX + i * (cellW + gap); + const isInvolved = involved.has(m.sym) || involved.has(m.sym.replace('₂', '2')); + const isH = m.sym === 'H₂'; + + ctx.beginPath(); + this._roundRect(ctx, x, rowY, cellW, cellH, 3); + ctx.fillStyle = isInvolved ? 'rgba(6,214,224,0.15)' : isH ? 'rgba(255,200,60,0.08)' : 'rgba(255,255,255,0.03)'; + ctx.fill(); + ctx.strokeStyle = isInvolved ? 'rgba(6,214,224,0.45)' : isH ? 'rgba(255,200,60,0.20)' : 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 0.7; + ctx.stroke(); + + ctx.font = isInvolved ? 'bold 9px "JetBrains Mono", monospace' : '8px "JetBrains Mono", monospace'; + ctx.fillStyle = isInvolved ? '#06D6E0' : isH ? '#FFD166' : 'rgba(255,255,255,0.35)'; + ctx.textAlign = 'center'; + ctx.fillText(m.sym, x + cellW / 2, rowY + cellH / 2 + 3); + } + + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.font = '9px sans-serif'; + ctx.fillText('← активнее', startX + 40, rowY + cellH + 10); + ctx.fillText('менее активные →', startX + totalW - 60, rowY + cellH + 10); + + ctx.restore(); + } + + _drawShelf() { + const { ctx, W } = this; + const { shelfY, shelfH } = this._g; + if (shelfH < 30) return; + + // shelf background + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.012)'; + ctx.fillRect(0, shelfY - 4, W, shelfH + 8); + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(0, shelfY - 4); ctx.lineTo(W, shelfY - 4); ctx.stroke(); + ctx.restore(); + + const subs = ChemSandboxSim.SUBSTANCES; + const keys = Object.keys(subs).filter(k => + this.filterCat === 'all' || subs[k].cat === this.filterCat + ); + + // 2-row grid layout + const rows = 2; + const bW = 68, bH = 60, gapX = 7, gapY = 6; + const cols = Math.ceil(keys.length / rows); + const totalW = cols * (bW + gapX) - gapX; + const padX = 12; + const visW = W - padX * 2; + + // clamp scroll + const maxScroll = Math.max(0, totalW - visW); + this._shelfScroll = Math.max(0, Math.min(maxScroll, this._shelfScroll)); + const scrollOff = -this._shelfScroll; + + const gridTop = shelfY + Math.max(2, (shelfH - (bH * rows + gapY * (rows - 1))) / 2); + + // clip to shelf region + ctx.save(); + ctx.beginPath(); + ctx.rect(padX - 2, shelfY - 4, visW + 4, shelfH + 8); + ctx.clip(); + + // store layout for click detection + const layoutArr = []; + + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + const s = subs[k]; + const col = Math.floor(i / rows); + const row = i % rows; + const x = padX + col * (bW + gapX) + scrollOff; + const y = gridTop + row * (bH + gapY); + + // skip if off-screen + if (x + bW < padX - 10 || x > W - padX + 10) { + layoutArr.push({ formula: k, x, y, w: bW, h: bH }); + continue; + } + + const inMix = this.mixContents.includes(k); + const isHov = this._shelfHover === i; + + // card background + ctx.beginPath(); + this._roundRect(ctx, x, y, bW, bH, 8); + const bgAlpha = inMix ? 0.16 : isHov ? 0.07 : 0.035; + ctx.fillStyle = inMix + ? `rgba(75,205,155,${bgAlpha})` + : `rgba(255,255,255,${bgAlpha})`; + ctx.fill(); + ctx.strokeStyle = inMix + ? 'rgba(75,205,155,0.55)' + : isHov ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.07)'; + ctx.lineWidth = inMix ? 1.6 : 1; + ctx.stroke(); + + // color swatch (rounded rect, not dot) + const swX = x + 8, swY = y + 7, swW = bW - 16, swH = 14; + ctx.beginPath(); + this._roundRect(ctx, swX, swY, swW, swH, 4); + ctx.fillStyle = this._alphaColor(s.color, inMix ? 0.55 : 0.35); + ctx.fill(); + + // formula (main label) + ctx.font = 'bold 12px "JetBrains Mono", monospace'; + ctx.fillStyle = inMix ? '#7BF5A4' : 'rgba(255,255,255,0.75)'; + ctx.textAlign = 'center'; + ctx.fillText(this._shortFormula(k), x + bW / 2, y + 37); + + // name + ctx.font = '8.5px "Manrope", sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.30)'; + ctx.fillText(s.name.substring(0, 13), x + bW / 2, y + 50); + + // in-mix badge + if (inMix) { + ctx.beginPath(); + ctx.arc(x + bW - 9, y + 9, 7, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(75,205,155,0.65)'; + ctx.fill(); + ctx.font = 'bold 9px sans-serif'; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.fillText('✓', x + bW - 9, y + 12.5); + } + + layoutArr.push({ formula: k, x, y, w: bW, h: bH }); + } + + ctx.restore(); + + // scroll arrows if content overflows + if (maxScroll > 0) { + ctx.save(); + const arrowY = shelfY + shelfH / 2; + // left arrow + if (this._shelfScroll > 0) { + ctx.beginPath(); + ctx.moveTo(padX - 1, arrowY); + ctx.lineTo(padX + 7, arrowY - 8); + ctx.lineTo(padX + 7, arrowY + 8); + ctx.closePath(); + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.fill(); + } + // right arrow + if (this._shelfScroll < maxScroll) { + const rx = W - padX + 1; + ctx.beginPath(); + ctx.moveTo(rx, arrowY); + ctx.lineTo(rx - 8, arrowY - 8); + ctx.lineTo(rx - 8, arrowY + 8); + ctx.closePath(); + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.fill(); + } + ctx.restore(); + } + + this._shelfKeys = keys; + this._shelfLayout = layoutArr; + this._shelfStartX = padX; + this._shelfBottleW = bW; + this._shelfGap = gapX; + this._shelfBottleY = gridTop; + this._shelfBottleH = bH; + } + + _drawDragGhost() { + if (!this._drag) return; + const { ctx } = this; + const { formula, x, y } = this._drag; + const sub = ChemSandboxSim.SUBSTANCES[formula]; + if (!sub) return; + + ctx.save(); + ctx.globalAlpha = 0.7; + // small bottle ghost + ctx.beginPath(); + this._roundRect(ctx, x - 18, y - 22, 36, 44, 5); + ctx.fillStyle = 'rgba(75,205,155,0.15)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(75,205,155,0.5)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(x, y - 8, 5, 0, Math.PI * 2); + ctx.fillStyle = sub.color; + ctx.fill(); + ctx.font = '9px "JetBrains Mono", monospace'; + ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; + ctx.fillText(this._shortFormula(formula), x, y + 10); + ctx.globalAlpha = 1; + ctx.restore(); + } + + _drawHint() { + const { ctx, W } = this; + const { cy } = this._g; + ctx.save(); + ctx.textAlign = 'center'; + ctx.font = '14px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.fillText('Выберите реагенты на полке или панели', W / 2, cy); + ctx.font = '36px serif'; + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fillText('\u{1F9EA}', W / 2, cy - 30); + ctx.restore(); + } + + _drawQuizOverlay() { + if (!this._quizTask) return; + const { ctx, W } = this; + const { nt } = this._g; + + ctx.save(); + ctx.textAlign = 'center'; + + // question banner at top + const bannerY = Math.max(4, nt - 58); + ctx.fillStyle = 'rgba(155,93,229,0.12)'; + ctx.beginPath(); + this._roundRect(ctx, W / 2 - 180, bannerY, 360, 24, 8); + ctx.fill(); + ctx.strokeStyle = 'rgba(155,93,229,0.30)'; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.font = 'bold 11px "Manrope", sans-serif'; + ctx.fillStyle = '#C9A0FF'; + ctx.fillText(this._quizTask.question, W / 2, bannerY + 15); + + // score + ctx.font = '9px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.textAlign = 'right'; + ctx.fillText(`${this._quizScore}/${this._quizTotal}`, W - 12, bannerY + 14); + + // result flash + if (this._quizResult && this._quizResultT > 0) { + const alpha = Math.min(1, this._quizResultT / 0.5); + const isOk = this._quizResult === 'correct'; + ctx.textAlign = 'center'; + ctx.font = 'bold 18px "Manrope", sans-serif'; + ctx.fillStyle = isOk + ? `rgba(123,245,164,${alpha * 0.9})` + : `rgba(239,71,111,${alpha * 0.9})`; + ctx.fillText(isOk ? 'Верно!' : 'Неверно', W / 2, bannerY + 50); + if (!isOk && this._quizTask) { + ctx.font = '10px "JetBrains Mono", monospace'; + ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`; + ctx.fillText('Ответ: ' + this._quizTask.rx.eq, W / 2, bannerY + 65); + } + } + + ctx.restore(); + } + + /* ── Мышь ──────────────────────────────────────────────────── */ + + _hitShelfCard(mx, my) { + if (!this._shelfLayout) return null; + for (let i = 0; i < this._shelfLayout.length; i++) { + const c = this._shelfLayout[i]; + if (mx >= c.x && mx <= c.x + c.w && my >= c.y && my <= c.y + c.h) return i; + } + return null; + } + + handleClick(e) { + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + // check chip "×" buttons first + for (const chip of this._chipLayout) { + if (mx >= chip.xBtnX - 8 && mx <= chip.xBtnX + 8 && my >= chip.y && my <= chip.y + chip.h) { + this.removeFromMix(chip.formula); + return; + } + } + + // check shelf cards (2-row grid) + const idx = this._hitShelfCard(mx, my); + if (idx !== null && this._shelfLayout[idx]) { + this.addToMix(this._shelfLayout[idx].formula); + return; + } + } + + handleMouseDown(e) { + if (e.button !== 0) return; + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + const idx = this._hitShelfCard(mx, my); + if (idx !== null && this._shelfLayout[idx]) { + this._drag = { formula: this._shelfLayout[idx].formula, x: mx, y: my, startX: mx, startY: my }; + } + } + + handleMouseMove(e) { + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + if (this._drag) { + this._drag.x = mx; + this._drag.y = my; + return; + } + + // hover detection + const idx = this._hitShelfCard(mx, my); + this._shelfHover = idx !== null ? idx : -1; + } + + handleMouseUp(e) { + if (!this._drag) return; + const d = this._drag; + this._drag = null; + + // if dragged into flask area — add + const { cx, cy, r, nt } = this._g; + const dx = d.x - cx, dy = d.y - cy; + const inFlask = (dy > nt - cy - 20) && (dx * dx + dy * dy < (r + 30) * (r + 30)); + const movedEnough = Math.abs(d.x - d.startX) + Math.abs(d.y - d.startY) > 10; + + if (inFlask && movedEnough) { + this.addToMix(d.formula); + } + } + + handleWheel(e) { + const rect = this.canvas.getBoundingClientRect(); + const my = e.clientY - rect.top; + const { shelfY, shelfH } = this._g; + // only scroll when cursor is over shelf area + if (my >= shelfY - 10 && my <= shelfY + shelfH + 10) { + e.preventDefault(); + this._shelfScroll += e.deltaY * 0.8; + } + } + + handleContextMenu(e) { + e.preventDefault(); + this.reset(); + } + + /* ── Режим заданий (квиз) ──────────────────────────────────── */ + + startQuiz() { + this._quizMode = true; + this._quizScore = 0; + this._quizTotal = 0; + this.filterCat = 'all'; // show all reagents so quiz tasks are always solvable + this._nextQuizTask(); + this._fireQuizInfo(); + } + + stopQuiz() { + this._quizMode = false; + this._quizTask = null; + this._quizResult = null; + this.reset(); + this._fireQuizInfo(); + } + + _nextQuizTask() { + this.reset(); + // pick a random non-indicator, non-"no reaction" reaction + const pool = ChemSandboxSim.REACTIONS.filter(rx => + rx.type !== 'Индикатор' && rx.type !== 'Нет реакции' + ); + const rx = pool[Math.floor(Math.random() * pool.length)]; + const questions = this._generateQuestions(rx); + const q = questions[Math.floor(Math.random() * questions.length)]; + this._quizTask = { rx, question: q, answer: [...rx.r] }; + this._quizResult = null; + this._fireQuizInfo(); + } + + _generateQuestions(rx) { + const prods = this._productsStr(rx); + const questions = []; + if (rx.fx.precip) { + questions.push(`Получи осадок ${rx.fx.precip.n}`); + } + if (rx.fx.gas) { + questions.push(`Получи газ ${rx.fx.gas}`); + } + questions.push(`Проведи реакцию: ${prods}`); + if (rx.type === 'Нейтрализация') { + questions.push('Проведи реакцию нейтрализации'); + } + if (rx.type === 'Замещение') { + questions.push('Проведи реакцию замещения'); + } + return questions; + } + + _checkQuizAnswer() { + if (!this._quizMode || !this._quizTask) return; + const needed = this._quizTask.answer; + const has = this.mixContents; + if (has.length < 2) return; + + // check if the correct reagents are present + const correct = needed.every(f => has.includes(f)); + this._quizTotal++; + if (correct) { + this._quizScore++; + this._quizResult = 'correct'; + } else { + this._quizResult = 'wrong'; + } + this._quizResultT = 2.5; // seconds to show result + this._fireQuizInfo(); + } + + _fireQuizInfo() { + if (this.onQuizUpdate) { + this.onQuizUpdate({ + active: this._quizMode, + question: this._quizTask ? this._quizTask.question : null, + score: this._quizScore, + total: this._quizTotal, + result: this._quizResult, + answer: this._quizTask ? this._quizTask.rx.eq : null, + }); + } + } + + /* ── Утилиты ───────────────────────────────────────────────── */ + + _roundRect(ctx, x, y, w, h, rad) { + ctx.moveTo(x + rad, y); + ctx.lineTo(x + w - rad, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + rad); + ctx.lineTo(x + w, y + h - rad); + ctx.quadraticCurveTo(x + w, y + h, x + w - rad, y + h); + ctx.lineTo(x + rad, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - rad); + ctx.lineTo(x, y + rad); + ctx.quadraticCurveTo(x, y, x + rad, y); + } + + _alphaColor(hex, alpha) { + if (!hex || !hex.startsWith('#')) return `rgba(128,128,128,${alpha})`; + const rv = parseInt(hex.slice(1, 3), 16) || 0; + const gv = parseInt(hex.slice(3, 5), 16) || 0; + const bv = parseInt(hex.slice(5, 7), 16) || 0; + return `rgba(${rv},${gv},${bv},${alpha})`; + } + + _blendColors(c1, c2, t) { + const h = (s) => s.startsWith('#') ? s.slice(1) : '888888'; + const p = (s, o) => parseInt(s.slice(o, o + 2), 16) || 0; + const h1 = h(c1), h2 = h(c2); + const rv = Math.round(p(h1, 0) * (1 - t) + p(h2, 0) * t); + const gv = Math.round(p(h1, 2) * (1 - t) + p(h2, 2) * t); + const bv = Math.round(p(h1, 4) * (1 - t) + p(h2, 4) * t); + return '#' + [rv, gv, bv].map(v => v.toString(16).padStart(2, '0')).join(''); + } + + _lerpColor(from, to, t) { + const safe = c => (c && c.startsWith('#')) ? c : '#6EB4D7'; + return this._blendColors(safe(from), safe(to), Math.min(1, t)); + } + + _shortFormula(key) { + const map = { + 'CH3COOH': 'CH₃COOH', 'Ca(OH)2': 'Ca(OH)₂', 'NH3·H2O': 'NH₃·H₂O', + 'H2SO4': 'H₂SO₄', 'HNO3': 'HNO₃', 'Na2CO3': 'Na₂CO₃', + 'CuSO4': 'CuSO₄', 'BaCl2': 'BaCl₂', 'AgNO3': 'AgNO₃', + 'FeCl3': 'FeCl₃', 'Pb(NO3)2': 'Pb(NO₃)₂', 'K2CrO4': 'K₂CrO₄', + 'H2O': 'H₂O', 'Phenolphthalein': 'ФФт', 'Litmus': 'Лакм.', + 'MethylOrange': 'МетОр.', + }; + return map[key] || key; + } + + _fireInfo() { + if (this.onUpdate) this.onUpdate(this.info()); + } +} diff --git a/frontend/js/labs/circuit.js b/frontend/js/labs/circuit.js new file mode 100644 index 0000000..f0dac00 --- /dev/null +++ b/frontend/js/labs/circuit.js @@ -0,0 +1,1308 @@ +/** + * CircuitSim — Enhanced Electric Circuits Simulation v2 + * MNA solver · L-shape wires · Drag · Undo/Redo · Tooltip + * New: Capacitor · Diode · LED · AC source · Junction dots + * Keyboard: W R B S L C D A V E · Del · Ctrl+Z/Y + */ +'use strict'; + +function distToSegment(px, py, x1, y1, x2, y2) { + const dx = x2 - x1, dy = y2 - y1; + const len2 = dx * dx + dy * dy; + if (len2 < 1) return Math.hypot(px - x1, py - y1); + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2)); + return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)); +} + +function _compLen(c) { + return Math.max(1, Math.abs(c.x2 - c.x1) + Math.abs(c.y2 - c.y1)); +} + +class CircuitSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + // State + this.components = []; + this.addMode = 'wire'; + this.R_value = 10; + this.U_value = 9; + this.C_value = 100; // µF (display label) + this.acFreq = 2; // Hz for AC source + this.ledColor = '#7BF5A4'; + + // Interaction + this._drawing = null; // {x1, y1} while dragging new component + this._ghostEnd = null; // {x2, y2} cursor snap + this._hovered = null; // id of hovered component + this._selected = null; // id of selected component + this._dragIdx = null; // index of component being dragged + this._dragStart = null; // {gx, gy} grid anchor at drag start + this._dragOrigPos = null; // original {x1,y1,x2,y2} before drag + this._didDrag = false; // whether mouse actually moved during drag + + // Solver + this._nextId = 0; + this._solution = null; + this._diodeR = new Map(); // component id effective R + this._simTime = 0; + this._hasAC = false; + + // Animation + this._raf = null; + this._lastTs = null; + + // Undo stack + this._history = []; + this._historyIdx = -1; + + // Grid + this.GW = 22; + this.GH = 14; + this.CELL = 40; + this.ox = 0; + this.oy = 0; + this.W = 0; + this.H = 0; + + this.onUpdate = null; + this.onModeChange = null; // called when keyboard changes addMode + this._keyHandler = null; // stored for destroy() + + this.fit(); + this._bindEvents(); + } + + /* ─── Geometry ─────────────────────────────────────────────────────────── */ + + _nodePixel(gx, gy) { + return { x: this.ox + gx * this.CELL, y: this.oy + gy * this.CELL }; + } + + _snapGrid(px, py) { + return { + gx: Math.max(0, Math.min(this.GW, Math.round((px - this.ox) / this.CELL))), + gy: Math.max(0, Math.min(this.GH, Math.round((py - this.oy) / this.CELL))) + }; + } + + /* ─── Undo / Redo ──────────────────────────────────────────────────────── */ + + _pushHistory() { + this._history.splice(this._historyIdx + 1); + this._history.push(JSON.stringify(this.components.map(c => ({ + id: c.id, type: c.type, + x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2, + value: c.value, open: c.open, + ledColor: c.ledColor, acFreq: c.acFreq + })))); + if (this._history.length > 20) this._history.shift(); + this._historyIdx = this._history.length - 1; + } + + undo() { + if (this._historyIdx <= 0) return; + this._historyIdx--; + this._applyHistory(); + } + + redo() { + if (this._historyIdx >= this._history.length - 1) return; + this._historyIdx++; + this._applyHistory(); + } + + _applyHistory() { + const snap = JSON.parse(this._history[this._historyIdx]); + this._diodeR.clear(); + this.components = snap.map(s => { + if (s.type === 'diode' || s.type === 'led') this._diodeR.set(s.id, 1e9); + return { ...s, _I: 0, _v1: 0, _v2: 0, _t: Math.random() }; + }); + this._nextId = Math.max(0, ...this.components.map(c => c.id + 1)); + this._selected = null; + this._solve(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ─── Component resistance ─────────────────────────────────────────────── */ + + _compR(c) { + switch (c.type) { + case 'wire': return 0.001; + case 'ammeter': return 0.001; + case 'voltmeter': return 1e7; + case 'resistor': return Math.max(0.001, c.value || 10); + case 'lamp': return 20; + case 'capacitor': return 1e7; // open circuit in DC + case 'switch': return c.open ? 1e9 : 0.001; + case 'diode': + case 'led': return this._diodeR.get(c.id) ?? 1e9; + default: return 1; + } + } + + /* ─── MNA Solver ───────────────────────────────────────────────────────── */ + + _buildNodes() { + const allKeys = new Set(); + for (const c of this.components) { + allKeys.add(`${c.x1},${c.y1}`); + allKeys.add(`${c.x2},${c.y2}`); + } + const parent = new Map(); + for (const k of allKeys) parent.set(k, k); + + const find = k => { + while (parent.get(k) !== k) { + parent.set(k, parent.get(parent.get(k))); + k = parent.get(k); + } + return k; + }; + const union = (a, b) => parent.set(find(a), find(b)); + + for (const c of this.components) { + if (c.type === 'wire' || (c.type === 'switch' && !c.open)) { + union(`${c.x1},${c.y1}`, `${c.x2},${c.y2}`); + } + } + + const roots = new Set(); + for (const k of allKeys) roots.add(find(k)); + const nodeId = new Map(); + let n = 0; + for (const r of roots) nodeId.set(r, n++); + + return { nNodes: n, nodeOf: key => nodeId.get(find(key)) }; + } + + _buildMatrix(nodeOf, nNodes) { + const vsrcs = this.components.filter(c => c.type === 'battery' || c.type === 'ac'); + const nb = vsrcs.length; + if (nb === 0) return null; + const size = nNodes - 1 + nb; + if (size <= 0) return null; + + const G = Array.from({ length: size }, () => new Float64Array(size)); + const I = new Float64Array(size); + + const stamp = (r, c, v) => { + if (r >= 0 && c >= 0 && r < size && c < size) G[r][c] += v; + }; + + for (const comp of this.components) { + if (comp.type === 'battery' || comp.type === 'ac') continue; + const n1 = nodeOf(`${comp.x1},${comp.y1}`); + const n2 = nodeOf(`${comp.x2},${comp.y2}`); + const R = this._compR(comp); + if (R >= 1e7) continue; + const g = 1 / R; + stamp(n1 - 1, n1 - 1, g); + stamp(n2 - 1, n2 - 1, g); + stamp(n1 - 1, n2 - 1, -g); + stamp(n2 - 1, n1 - 1, -g); + } + + for (let b = 0; b < nb; b++) { + const vs = vsrcs[b]; + const n1 = nodeOf(`${vs.x1},${vs.y1}`); // negative + const n2 = nodeOf(`${vs.x2},${vs.y2}`); // positive + const row = nNodes - 1 + b; + const U = vs.type === 'ac' + ? (vs.value || 9) * Math.sin(2 * Math.PI * (vs.acFreq || this.acFreq) * this._simTime) + : (vs.value || 9); + if (n2 > 0) { stamp(row, n2 - 1, 1); stamp(n2 - 1, row, 1); } + if (n1 > 0) { stamp(row, n1 - 1, -1); stamp(n1 - 1, row, -1); } + I[row] = U; + } + + return { G, I, nNodes, nodeOf, vsrcs, nb, size }; + } + + _gaussElim(G, I) { + const n = I.length; + for (let col = 0; col < n; col++) { + let maxRow = col; + for (let r = col + 1; r < n; r++) { + if (Math.abs(G[r][col]) > Math.abs(G[maxRow][col])) maxRow = r; + } + [G[col], G[maxRow]] = [G[maxRow], G[col]]; + [I[col], I[maxRow]] = [I[maxRow], I[col]]; + if (Math.abs(G[col][col]) < 1e-12) continue; + for (let r = col + 1; r < n; r++) { + const f = G[r][col] / G[col][col]; + for (let c = col; c < n; c++) G[r][c] -= f * G[col][c]; + I[r] -= f * I[col]; + } + } + const x = new Float64Array(n); + for (let r = n - 1; r >= 0; r--) { + if (Math.abs(G[r][r]) < 1e-12) continue; + x[r] = I[r]; + for (let c = r + 1; c < n; c++) x[r] -= G[r][c] * x[c]; + x[r] /= G[r][r]; + } + return x; + } + + _solveOnce() { + if (this.components.length === 0) { this._solution = null; return; } + const { nNodes, nodeOf } = this._buildNodes(); + if (nNodes < 2) { this._solution = null; return; } + const mat = this._buildMatrix(nodeOf, nNodes); + if (!mat) { this._solution = null; return; } + const { G, I, vsrcs, nb } = mat; + let x; + try { x = this._gaussElim(G, I); } catch { this._solution = null; return; } + + const voltages = new Map(); + voltages.set(0, 0); + for (let i = 1; i < nNodes; i++) voltages.set(i, x[i - 1] || 0); + + for (const c of this.components) { + const n1 = nodeOf(`${c.x1},${c.y1}`); + const n2 = nodeOf(`${c.x2},${c.y2}`); + c._v1 = voltages.get(n1) ?? 0; + c._v2 = voltages.get(n2) ?? 0; + if (c.type === 'battery' || c.type === 'ac') { + const bi = vsrcs.indexOf(c); + c._I = bi >= 0 ? (x[nNodes - 1 + bi] || 0) : 0; + } else { + const R = this._compR(c); + c._I = R < 1e7 ? (c._v1 - c._v2) / R : 0; + } + } + this._solution = { solved: true, voltages }; + } + + _solve() { + // Init diode R + for (const c of this.components) { + if ((c.type === 'diode' || c.type === 'led') && !this._diodeR.has(c.id)) { + this._diodeR.set(c.id, 1e9); + } + } + // Iterative diode solve (Newton-style) + for (let iter = 0; iter < 6; iter++) { + this._solveOnce(); + if (!this._solution?.solved) break; + let changed = false; + for (const c of this.components) { + if (c.type !== 'diode' && c.type !== 'led') continue; + const vd = (c._v1 ?? 0) - (c._v2 ?? 0); + const oldR = this._diodeR.get(c.id) ?? 1e9; + const newR = vd > 0.5 ? 0.5 : 1e9; + if (newR !== oldR) { this._diodeR.set(c.id, newR); changed = true; } + } + if (!changed) break; + } + this._hasAC = this.components.some(c => c.type === 'ac'); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ─── Animation ────────────────────────────────────────────────────────── */ + + _tick(ts) { + if (!this._raf) return; + const dt = Math.min((ts - (this._lastTs || ts)) / 1000, 0.05); + this._lastTs = ts; + this._simTime += dt; + + if (this._hasAC) this._solveOnce(); // re-solve each frame for AC + + for (const c of this.components) { + if (!c._I || Math.abs(c._I) < 0.001) continue; + const speed = Math.min(Math.abs(c._I) * 0.6, 8) / (this.CELL * _compLen(c)); + c._t = ((c._t || 0) + speed * dt * 60) % 1; + } + this.draw(); + this._raf = requestAnimationFrame(ts => this._tick(ts)); + } + + start() { + if (this._raf) return; + this._lastTs = null; + this._raf = requestAnimationFrame(ts => this._tick(ts)); + } + + stop() { + cancelAnimationFrame(this._raf); + this._raf = null; + } + + destroy() { + this.stop(); + if (this._keyHandler) { + document.removeEventListener('keydown', this._keyHandler); + this._keyHandler = null; + } + } + + /* ─── Color helpers ────────────────────────────────────────────────────── */ + + _voltColor(v, alpha = 1) { + if (!this._solution?.solved) return `rgba(180,180,200,${alpha})`; + const t = Math.tanh(v / 6); + if (t >= 0) { + return `rgba(${Math.round(239+t*16)},${Math.round(71-t*30)},${Math.round(111-t*80)},${alpha})`; + } else { + const s = -t; + return `rgba(${Math.round(76-s*40)},${Math.round(201+s*20)},${Math.round(240-s*20)},${alpha})`; + } + } + + /* ─── Grid ─────────────────────────────────────────────────────────────── */ + + _drawGrid(ctx) { + for (let gx = 0; gx <= this.GW; gx++) { + for (let gy = 0; gy <= this.GH; gy++) { + const { x, y } = this._nodePixel(gx, gy); + ctx.beginPath(); + ctx.arc(x, y, 1.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.12)'; + ctx.fill(); + } + } + if (this._ghostEnd) { + const { x, y } = this._nodePixel(this._ghostEnd.x2, this._ghostEnd.y2); + ctx.beginPath(); + ctx.arc(x, y, 4.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.shadowBlur = 8; ctx.shadowColor = '#fff'; + ctx.fill(); ctx.shadowBlur = 0; + } + } + + /* ─── Junction dots ────────────────────────────────────────────────────── */ + + _drawJunctions(ctx) { + const count = new Map(); + for (const c of this.components) { + const k1 = `${c.x1},${c.y1}`, k2 = `${c.x2},${c.y2}`; + count.set(k1, (count.get(k1) || 0) + 1); + count.set(k2, (count.get(k2) || 0) + 1); + } + ctx.shadowBlur = 6; + ctx.shadowColor = '#fff'; + for (const [key, cnt] of count) { + if (cnt < 3) continue; + const [gx, gy] = key.split(',').map(Number); + const { x, y } = this._nodePixel(gx, gy); + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + } + ctx.shadowBlur = 0; + } + + /* ─── Node voltage labels ──────────────────────────────────────────────── */ + + _drawNodeLabels(ctx) { + if (!this._solution?.solved) return; + const shown = new Set(); + ctx.font = 'bold 8px Manrope, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + for (const c of this.components) { + for (const [gx, gy, v] of [[c.x1, c.y1, c._v1], [c.x2, c.y2, c._v2]]) { + const key = `${gx},${gy}`; + if (shown.has(key) || Math.abs(v) < 0.05) continue; + shown.add(key); + const { x, y } = this._nodePixel(gx, gy); + ctx.fillStyle = v >= 0 ? 'rgba(239,140,140,0.75)' : 'rgba(100,190,240,0.75)'; + ctx.fillText(v.toFixed(1) + 'V', x, y - 7); + } + } + ctx.textBaseline = 'alphabetic'; + } + + /* ─── Wire line ─────────────────────────────────────────────────────────── */ + + _drawWireLine(ctx, p1, p2, v1, v2, lineWidth, glow) { + if (Math.hypot(p2.x - p1.x, p2.y - p1.y) < 0.5) return; + ctx.save(); + ctx.lineWidth = lineWidth || 3; + ctx.lineCap = 'round'; + if (glow) { ctx.shadowBlur = 10; ctx.shadowColor = this._voltColor((v1 + v2) / 2, 1); } + try { + const grad = ctx.createLinearGradient(p1.x, p1.y, p2.x, p2.y); + grad.addColorStop(0, this._voltColor(v1, 1)); + grad.addColorStop(1, this._voltColor(v2, 1)); + ctx.strokeStyle = grad; + } catch { ctx.strokeStyle = this._voltColor((v1 + v2) / 2, 1); } + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + ctx.lineTo(p2.x, p2.y); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.restore(); + } + + /* ─── Component draw methods ───────────────────────────────────────────── */ + + _drawWire(ctx, c, p1, p2, hasI) { + this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI); + } + + _drawResistor(ctx, c, p1, p2, mx, my, hasI) { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + const ux = dx / len, uy = dy / len; + const half = len * 0.2; + const sP1 = { x: mx - ux*half, y: my - uy*half }; + const sP2 = { x: mx + ux*half, y: my + uy*half }; + this._drawWireLine(ctx, p1, sP1, c._v1, c._v1, 3, hasI); + this._drawWireLine(ctx, sP2, p2, c._v2, c._v2, 3, hasI); + + // Power heat color + const power = Math.abs((c._I ?? 0) ** 2 * this._compR(c)); + const heat = Math.min(1, power / 5); + + ctx.save(); + ctx.translate(mx, my); + ctx.rotate(Math.atan2(dy, dx)); + const rw = half * 2, rh = 12; + ctx.beginPath(); + ctx.roundRect(-rw/2, -rh/2, rw, rh, 2); + ctx.fillStyle = `rgb(${Math.round(13 + heat*200)},${Math.round(13 + heat*60)},13)`; + ctx.fill(); + ctx.strokeStyle = this._voltColor((c._v1+c._v2)/2, 0.9); + ctx.lineWidth = 1.5; ctx.stroke(); + + // Zigzag + ctx.beginPath(); + const zs = 6, zStep = rw / zs; + ctx.moveTo(-rw/2, 0); + for (let i = 0; i < zs; i++) ctx.lineTo(-rw/2+(i+0.5)*zStep, i%2===0?-4:4); + ctx.lineTo(rw/2, 0); + ctx.strokeStyle = this._voltColor((c._v1+c._v2)/2, 0.7); + ctx.lineWidth = 1; ctx.stroke(); + ctx.restore(); + + ctx.font = '9px Manrope,sans-serif'; + ctx.fillStyle = heat > 0.3 ? `rgba(255,${Math.round(200-heat*150)},80,0.85)` : 'rgba(255,255,255,0.55)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(`R=${c.value}\u202fΩ`, mx - uy*14, my + ux*14 - 4); + ctx.textBaseline = 'alphabetic'; + } + + _drawBattery(ctx, c, p1, p2, mx, my, hasI) { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + const ux = len>0?dx/len:1, uy = len>0?dy/len:0; + const half = Math.min(this.CELL*0.45, len*0.38); + const sP1 = {x:mx-ux*half, y:my-uy*half}; + const sP2 = {x:mx+ux*half, y:my+uy*half}; + this._drawWireLine(ctx, p1, sP1, c._v1, c._v1, 3, hasI); + this._drawWireLine(ctx, sP2, p2, c._v2, c._v2, 3, hasI); + + ctx.save(); + ctx.translate(mx, my); + ctx.rotate(Math.atan2(dy, dx)); + // Two cell pairs + for (let i = 0; i < 2; i++) { + const ox = (i - 0.5) * 12; + ctx.beginPath(); ctx.moveTo(ox+5,-11); ctx.lineTo(ox+5,11); + ctx.strokeStyle='#EF476F'; ctx.lineWidth=1.5; ctx.lineCap='round'; ctx.stroke(); + ctx.beginPath(); ctx.moveTo(ox-5,-7); ctx.lineTo(ox-5,7); + ctx.strokeStyle='#4CC9F0'; ctx.lineWidth=4; ctx.stroke(); + } + ctx.restore(); + + ctx.font = 'bold 10px Manrope,sans-serif'; ctx.textAlign='center'; + ctx.fillStyle='#EF476F'; + ctx.fillText('+', p2.x + uy*14 - ux*8, p2.y - ux*14 - uy*8); + ctx.fillStyle='#4CC9F0'; + ctx.fillText('\u2212', p1.x + uy*14 + ux*8, p1.y - ux*14 + uy*8); + ctx.font = '9px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.65)'; + ctx.textBaseline = 'bottom'; + ctx.fillText(`${c.value}\u202fV`, mx - uy*18, my + ux*18); + ctx.textBaseline = 'alphabetic'; + } + + _drawAC(ctx, c, p1, p2, mx, my, hasI) { + const dx = p2.x-p1.x, dy = p2.y-p1.y; + const len = Math.hypot(dx,dy); + const ux = dx/len, uy = dy/len; + const r = 15; + this._drawWireLine(ctx, p1, {x:mx-ux*r,y:my-uy*r}, c._v1,c._v1,3,hasI); + this._drawWireLine(ctx, {x:mx+ux*r,y:my+uy*r}, p2, c._v2,c._v2,3,hasI); + + ctx.save(); + ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx)); + ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); + ctx.fillStyle='#0d0d2b'; ctx.fill(); + ctx.strokeStyle='#FFD166'; ctx.lineWidth=1.5; ctx.stroke(); + ctx.beginPath(); ctx.strokeStyle='#FFD166'; ctx.lineWidth=1.2; + for (let i=0;i<=24;i++) { + const t=(i/24)*2*Math.PI; + const sx=(i/24-0.5)*r*1.5, sy=-Math.sin(t)*5; + i===0?ctx.moveTo(sx,sy):ctx.lineTo(sx,sy); + } + ctx.stroke(); + ctx.restore(); + + ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(255,213,102,0.75)'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText(`${c.value}V ${c.acFreq||this.acFreq}Hz`, mx-uy*20, my+ux*20-4); + ctx.textBaseline='alphabetic'; + } + + _drawSwitch(ctx, c, p1, p2, mx, my, hasI) { + if (!c.open) { + this._drawWireLine(ctx, p1, p2, c._v1, c._v2, 3, hasI); + } else { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=dx/len, uy=dy/len; + const gap=this.CELL*0.35; + const gapP1={x:mx-ux*gap,y:my-uy*gap}; + this._drawWireLine(ctx,p1,gapP1,c._v1,c._v1,3,false); + this._drawWireLine(ctx,{x:mx+ux*gap,y:my+uy*gap},p2,c._v2,c._v2,3,false); + // Open arm + const armAngle=Math.PI/5, armLen=gap*2; + const nx=-uy, ny=ux; + ctx.save(); + ctx.strokeStyle=this._voltColor(c._v1,0.9); + ctx.lineWidth=3; ctx.lineCap='round'; + ctx.beginPath(); + ctx.moveTo(gapP1.x, gapP1.y); + ctx.lineTo(gapP1.x+ux*armLen*Math.cos(armAngle)-nx*armLen*Math.sin(armAngle), + gapP1.y+uy*armLen*Math.cos(armAngle)-ny*armLen*Math.sin(armAngle)); + ctx.stroke(); + ctx.restore(); + } + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=len>0?dx/len:1, uy=len>0?dy/len:0; + ctx.font='9px Manrope,sans-serif'; + ctx.fillStyle=c.open?'rgba(239,71,111,0.7)':'rgba(76,201,240,0.7)'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText(c.open?'OPEN':'SW', mx-uy*14, my+ux*14-4); + ctx.textBaseline='alphabetic'; + } + + _drawLamp(ctx, c, p1, p2, mx, my, hasI) { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=len>0?dx/len:1, uy=len>0?dy/len:0; + const r=10; + this._drawWireLine(ctx,p1,{x:mx-ux*r,y:my-uy*r},c._v1,c._v1,3,hasI); + this._drawWireLine(ctx,{x:mx+ux*r,y:my+uy*r},p2,c._v2,c._v2,3,hasI); + + const power=Math.abs((c._I||0)**2*this._compR(c)); + const bright=Math.min(1, power/3); + ctx.save(); + if (bright>0.05) { + const grd=ctx.createRadialGradient(mx,my,r,mx,my,r+35*bright); + grd.addColorStop(0,`rgba(255,220,100,${bright*0.6})`); + grd.addColorStop(1,'rgba(255,200,50,0)'); + ctx.fillStyle=grd; + ctx.beginPath(); ctx.arc(mx,my,r+35*bright,0,Math.PI*2); ctx.fill(); + } + ctx.beginPath(); ctx.arc(mx,my,r,0,Math.PI*2); + if (bright>0.05) { + const ig=ctx.createRadialGradient(mx,my,0,mx,my,r); + ig.addColorStop(0,`rgba(255,240,180,${bright})`); + ig.addColorStop(1,'rgba(20,20,50,0.9)'); + ctx.fillStyle=ig; + ctx.shadowBlur=20*bright; ctx.shadowColor='#FFD166'; + } else { ctx.fillStyle='#0d0d2b'; } + ctx.fill(); + ctx.strokeStyle=this._voltColor((c._v1+c._v2)/2,0.9); + ctx.lineWidth=1.5; ctx.shadowBlur=0; ctx.stroke(); + const cr=r*0.6; + ctx.strokeStyle=bright>0.05?`rgba(255,240,180,${0.4+bright*0.6})`:'rgba(255,255,255,0.4)'; + ctx.lineWidth=1.5; + ctx.beginPath(); + ctx.moveTo(mx-cr,my-cr); ctx.lineTo(mx+cr,my+cr); + ctx.moveTo(mx+cr,my-cr); ctx.lineTo(mx-cr,my+cr); + ctx.stroke(); + ctx.restore(); + ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.5)'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText('L', mx-uy*14, my+ux*14-4); + if (this._solution?.solved && Math.abs(c._I)>0.001) { + ctx.fillStyle='rgba(255,209,102,0.8)'; + ctx.fillText(`${power.toFixed(2)}W`, mx-uy*14, my+ux*14+6); + } + ctx.textBaseline='alphabetic'; + } + + _drawCapacitor(ctx, c, p1, p2, mx, my) { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=dx/len, uy=dy/len; + const nx=-uy, ny=ux; + const pg=6, ph=12; // plate gap half, plate half-length + const sP1={x:mx-ux*pg,y:my-uy*pg}; + const sP2={x:mx+ux*pg,y:my+uy*pg}; + this._drawWireLine(ctx,p1,sP1,c._v1,c._v1,3,false); + this._drawWireLine(ctx,sP2,p2,c._v2,c._v2,3,false); + + // Plates + ctx.save(); + ctx.lineWidth=2.5; ctx.lineCap='round'; + ctx.strokeStyle=this._voltColor(c._v1,0.9); + ctx.beginPath(); ctx.moveTo(sP1.x-nx*ph,sP1.y-ny*ph); ctx.lineTo(sP1.x+nx*ph,sP1.y+ny*ph); ctx.stroke(); + ctx.strokeStyle=this._voltColor(c._v2,0.9); + ctx.beginPath(); ctx.moveTo(sP2.x-nx*ph,sP2.y-ny*ph); ctx.lineTo(sP2.x+nx*ph,sP2.y+ny*ph); ctx.stroke(); + + // Charge fill + const vd=Math.abs((c._v2||0)-(c._v1||0)); + if (vd>0.1 && this._solution?.solved) { + const alpha=Math.min(0.35, vd/12); + const cg=ctx.createLinearGradient(sP1.x,sP1.y,sP2.x,sP2.y); + cg.addColorStop(0,this._voltColor(c._v1,alpha)); + cg.addColorStop(1,this._voltColor(c._v2,alpha)); + ctx.fillStyle=cg; + ctx.beginPath(); + ctx.moveTo(sP1.x-nx*ph,sP1.y-ny*ph); ctx.lineTo(sP1.x+nx*ph,sP1.y+ny*ph); + ctx.lineTo(sP2.x+nx*ph,sP2.y+ny*ph); ctx.lineTo(sP2.x-nx*ph,sP2.y-ny*ph); + ctx.closePath(); ctx.fill(); + } + ctx.restore(); + + ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.55)'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText(`C=${c.value}µF`, mx-uy*16, my+ux*16-4); + ctx.textBaseline='alphabetic'; + } + + _drawDiode(ctx, c, p1, p2, mx, my, hasI) { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=dx/len, uy=dy/len; + const tw=10, th=11; + this._drawWireLine(ctx,p1,{x:mx-ux*tw,y:my-uy*tw},c._v1,c._v1,3,hasI); + this._drawWireLine(ctx,{x:mx+ux*tw,y:my+uy*tw},p2,c._v2,c._v2,3,hasI); + + const on=(this._diodeR.get(c.id)??1e9)<1; + const col=on?'#7BF5A4':'rgba(255,255,255,0.65)'; + + ctx.save(); + ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx)); + ctx.beginPath(); + ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath(); + ctx.fillStyle=on?'rgba(123,245,164,0.25)':'rgba(255,255,255,0.06)'; + ctx.fill(); + ctx.strokeStyle=col; ctx.lineWidth=1.6; ctx.stroke(); + ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke(); + ctx.restore(); + + ctx.font='9px Manrope,sans-serif'; + ctx.fillStyle=on?'rgba(123,245,164,0.8)':'rgba(255,255,255,0.4)'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText(on?'ON':'OFF', mx-uy*16, my+ux*16-4); + ctx.textBaseline='alphabetic'; + } + + _drawLED(ctx, c, p1, p2, mx, my, hasI) { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=dx/len, uy=dy/len; + const tw=9, th=10; + this._drawWireLine(ctx,p1,{x:mx-ux*tw,y:my-uy*tw},c._v1,c._v1,3,hasI); + this._drawWireLine(ctx,{x:mx+ux*tw,y:my+uy*tw},p2,c._v2,c._v2,3,hasI); + + const on=(this._diodeR.get(c.id)??1e9)<1; + const col=c.ledColor||this.ledColor; + + if (on) { + const grd=ctx.createRadialGradient(mx,my,0,mx,my,38); + grd.addColorStop(0,col+'50'); grd.addColorStop(1,col+'00'); + ctx.fillStyle=grd; + ctx.beginPath(); ctx.arc(mx,my,38,0,Math.PI*2); ctx.fill(); + } + + ctx.save(); + ctx.translate(mx,my); ctx.rotate(Math.atan2(dy,dx)); + ctx.beginPath(); + ctx.moveTo(-tw,-th); ctx.lineTo(-tw,th); ctx.lineTo(tw,0); ctx.closePath(); + ctx.fillStyle=on?col+'55':col+'18'; ctx.fill(); + ctx.strokeStyle=on?col:col+'90'; ctx.lineWidth=1.5; ctx.stroke(); + ctx.beginPath(); ctx.moveTo(tw,-th); ctx.lineTo(tw,th); ctx.stroke(); + + if (on) { + ctx.strokeStyle=col; ctx.lineWidth=1.2; + for (let s=-1;s<=1;s+=2) { + ctx.beginPath(); + ctx.moveTo(tw+3, s*5); + ctx.lineTo(tw+11, s*5-s*6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+8,s*5-s*6); + ctx.moveTo(tw+11,s*5-s*6); ctx.lineTo(tw+11,s*5-s*3); + ctx.stroke(); + } + } + ctx.restore(); + + ctx.font='9px Manrope,sans-serif'; ctx.fillStyle=col+'cc'; + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText('LED', mx-uy*16, my+ux*16-4); + ctx.textBaseline='alphabetic'; + } + + _drawAmmeter(ctx, c, p1, p2, mx, my) { + const dx=p2.x-p1.x, dy=p2.y-p1.y, len=Math.hypot(dx,dy); + const ux=len>0?dx/len:1, uy=len>0?dy/len:0, r=9; + this._drawWireLine(ctx,p1,{x:mx-ux*r,y:my-uy*r},c._v1,c._v1,3,false); + this._drawWireLine(ctx,{x:mx+ux*r,y:my+uy*r},p2,c._v2,c._v2,3,false); + ctx.beginPath(); ctx.arc(mx,my,r,0,Math.PI*2); + ctx.fillStyle='#0d0d2b'; ctx.fill(); + ctx.strokeStyle='rgba(76,201,240,0.9)'; ctx.lineWidth=1.5; ctx.stroke(); + ctx.font='bold 9px Manrope,sans-serif'; ctx.fillStyle='#4CC9F0'; + ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('A',mx,my); + if (this._solution?.solved) { + ctx.font='8px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.55)'; + ctx.textBaseline='top'; ctx.fillText(`${(c._I||0).toFixed(3)}A`,mx,my+r+3); + } + ctx.textBaseline='alphabetic'; + } + + _drawVoltmeter(ctx, c, p1, p2, mx, my) { + const dx=p2.x-p1.x, dy=p2.y-p1.y, len=Math.hypot(dx,dy); + const ux=len>0?dx/len:1, uy=len>0?dy/len:0, r=9; + this._drawWireLine(ctx,p1,{x:mx-ux*r,y:my-uy*r},c._v1,c._v1,2,false); + this._drawWireLine(ctx,{x:mx+ux*r,y:my+uy*r},p2,c._v2,c._v2,2,false); + ctx.beginPath(); ctx.arc(mx,my,r,0,Math.PI*2); + ctx.fillStyle='#0d0d2b'; ctx.fill(); + ctx.strokeStyle='rgba(239,71,111,0.9)'; ctx.lineWidth=1.5; ctx.stroke(); + ctx.font='bold 9px Manrope,sans-serif'; ctx.fillStyle='#EF476F'; + ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('V',mx,my); + if (this._solution?.solved) { + ctx.font='8px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.55)'; + ctx.textBaseline='top'; ctx.fillText(`${((c._v1||0)-(c._v2||0)).toFixed(2)}V`,mx,my+r+3); + } + ctx.textBaseline='alphabetic'; + } + + /* ─── Animated current (comet trail) ──────────────────────────────────── */ + + _drawAnimDots(ctx) { + if (!this._solution?.solved) return; + for (const c of this.components) { + if (!c._I || Math.abs(c._I) < 0.05) continue; + const p1=this._nodePixel(c.x1,c.y1), p2=this._nodePixel(c.x2,c.y2); + const N=Math.max(1,Math.min(5,Math.ceil(Math.abs(c._I)))); + const dir=c._I>0?1:-1; + for (let i=0;i0?phase:1-phase; + // Comet trail: 5 fading dots + for (let tr=0;tr<5;tr++) { + const trailT=dir>0?Math.max(0,tt-tr*0.035):Math.min(1,tt+tr*0.035); + const tx=p1.x+(p2.x-p1.x)*trailT; + const ty=p1.y+(p2.y-p1.y)*trailT; + const alpha=(1-tr/5)*(tr===0?1:0.5); + const sz=Math.max(0, tr===0?3:2.5-tr*0.4); + ctx.beginPath(); ctx.arc(tx,ty,sz,0,Math.PI*2); + if (tr===0) { + ctx.fillStyle='#FFD166'; + ctx.shadowColor='#FFD166'; ctx.shadowBlur=8; + } else { + ctx.shadowBlur=0; + ctx.fillStyle=`rgba(255,209,102,${alpha})`; + } + ctx.fill(); + } + ctx.shadowBlur=0; + } + } + } + + /* ─── Components dispatch ──────────────────────────────────────────────── */ + + _drawComponents(ctx) { + for (const c of this.components) { + const p1=this._nodePixel(c.x1,c.y1), p2=this._nodePixel(c.x2,c.y2); + const mx=(p1.x+p2.x)/2, my=(p1.y+p2.y)/2; + const isHov=this._hovered===c.id, isSel=this._selected===c.id; + const hasI=!!(this._solution?.solved && Math.abs(c._I)>0.001); + + // Selection / hover highlight + if (isSel || isHov) { + ctx.save(); + ctx.globalAlpha=isSel?0.28:0.15; + ctx.fillStyle=isSel?'#FFD166':'#fff'; + const bx=Math.min(p1.x,p2.x)-10, by=Math.min(p1.y,p2.y)-10; + const bw=Math.abs(p2.x-p1.x)+20, bh=Math.abs(p2.y-p1.y)+20; + ctx.beginPath(); ctx.roundRect(bx,by,Math.max(bw,20),Math.max(bh,20),5); ctx.fill(); + ctx.restore(); + } + + switch (c.type) { + case 'wire': this._drawWire(ctx,c,p1,p2,hasI); break; + case 'resistor': this._drawResistor(ctx,c,p1,p2,mx,my,hasI); break; + case 'battery': this._drawBattery(ctx,c,p1,p2,mx,my,hasI); break; + case 'ac': this._drawAC(ctx,c,p1,p2,mx,my,hasI); break; + case 'switch': this._drawSwitch(ctx,c,p1,p2,mx,my,hasI); break; + case 'lamp': this._drawLamp(ctx,c,p1,p2,mx,my,hasI); break; + case 'capacitor': this._drawCapacitor(ctx,c,p1,p2,mx,my); break; + case 'diode': this._drawDiode(ctx,c,p1,p2,mx,my,hasI); break; + case 'led': this._drawLED(ctx,c,p1,p2,mx,my,hasI); break; + case 'ammeter': this._drawAmmeter(ctx,c,p1,p2,mx,my); break; + case 'voltmeter': this._drawVoltmeter(ctx,c,p1,p2,mx,my); break; + } + } + } + + /* ─── Ghost while drawing ──────────────────────────────────────────────── */ + + _drawGhost(ctx) { + if (!this._drawing || !this._ghostEnd) return; + const p1=this._nodePixel(this._drawing.x1,this._drawing.y1); + const g2=this._ghostEnd; + ctx.save(); + ctx.globalAlpha=0.45; ctx.strokeStyle='#FFD166'; + ctx.lineWidth=3; ctx.lineCap='round'; ctx.setLineDash([6,4]); + + if (this.addMode==='wire' && this._drawing.x1!==g2.x2 && this._drawing.y1!==g2.y2) { + const corner=this._nodePixel(g2.x2, this._drawing.y1); + const p2=this._nodePixel(g2.x2,g2.y2); + ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(corner.x,corner.y); ctx.lineTo(p2.x,p2.y); ctx.stroke(); + } else { + const p2=this._nodePixel(g2.x2,g2.y2); + ctx.beginPath(); ctx.moveTo(p1.x,p1.y); ctx.lineTo(p2.x,p2.y); ctx.stroke(); + } + ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(p1.x,p1.y,5,0,Math.PI*2); + ctx.fillStyle='#FFD166'; ctx.globalAlpha=0.75; ctx.fill(); + ctx.restore(); + } + + /* ─── Tooltip ──────────────────────────────────────────────────────────── */ + + _drawTooltip(ctx) { + if (!this._hovered || !this._solution?.solved) return; + const c=this.components.find(x=>x.id===this._hovered); + if (!c) return; + const p1=this._nodePixel(c.x1,c.y1), p2=this._nodePixel(c.x2,c.y2); + const mx=(p1.x+p2.x)/2, my=(p1.y+p2.y)/2; + const R=this._compR(c), I=c._I??0; + const U=Math.abs((c._v1??0)-(c._v2??0)); + const P=Math.abs(I*I*Math.min(R,1e6)); + const lines=[]; + if (c.type==='resistor'||c.type==='lamp'||c.type==='wire') + lines.push(`R = ${R<1?R.toFixed(4):R.toFixed(1)} Ω`); + if (Math.abs(I)>0.0001) lines.push(`I = ${I.toFixed(4)} А`); + if (U>0.001) lines.push(`U = ${U.toFixed(3)} В`); + if (P>0.0001&&P<1e5) lines.push(`P = ${P.toFixed(4)} Вт`); + if (!lines.length) return; + + const lh=14, pad=7, tw=96, th=lines.length*lh+pad*2; + let tx=mx+18, ty=my-th/2; + if (tx+tw>this.W-4) tx=mx-tw-18; + if (ty<4) ty=4; + if (ty+th>this.H-4) ty=this.H-th-4; + + ctx.fillStyle='rgba(6,6,22,0.93)'; + ctx.strokeStyle='rgba(255,255,255,0.14)'; + ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(tx,ty,tw,th,5); ctx.fill(); ctx.stroke(); + ctx.font='10px Manrope,sans-serif'; ctx.textAlign='left'; ctx.textBaseline='top'; + lines.forEach((l,i)=>{ + ctx.fillStyle='rgba(255,255,255,0.78)'; + ctx.fillText(l, tx+pad, ty+pad+i*lh); + }); + ctx.textBaseline='alphabetic'; + } + + /* ─── Short circuit warning ────────────────────────────────────────────── */ + + _drawShortCircuitWarning(ctx) { + if (!this._solution?.solved) return; + const batt=this.components.find(c=>c.type==='battery'||c.type==='ac'); + if (!batt||Math.abs(batt._I)<50) return; + const a=0.12+0.08*Math.sin(this._simTime*12); + ctx.fillStyle=`rgba(239,71,111,${a})`; + ctx.fillRect(0,0,this.W,this.H); + ctx.fillStyle='rgba(239,71,111,0.92)'; + ctx.font='bold 18px Manrope,sans-serif'; + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('Короткое замыкание!', this.W/2, this.H/2); + ctx.textBaseline='alphabetic'; + } + + /* ─── Hint ─────────────────────────────────────────────────────────────── */ + + _drawHint(ctx) { + ctx.font='15px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.18)'; + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText('Выберите инструмент и нарисуйте схему', this.W/2, this.H/2-14); + ctx.font='11px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.09)'; + ctx.fillText('ПКМ — удалить · Dbl‑клик на ключе — переключить · Del — удалить выбранный', this.W/2, this.H/2+12); + ctx.font='10px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.07)'; + ctx.fillText('Ctrl+Z / Ctrl+Y — Undo/Redo · W R B S L C D A V E — горячие клавиши', this.W/2, this.H/2+30); + ctx.textBaseline='alphabetic'; + } + + /* ─── Main draw ────────────────────────────────────────────────────────── */ + + draw() { + const ctx=this.ctx, W=this.W, H=this.H; + if (!W||!H) return; + ctx.clearRect(0,0,W,H); + ctx.fillStyle='#080818'; ctx.fillRect(0,0,W,H); + this._drawGrid(ctx); + this._drawComponents(ctx); + this._drawJunctions(ctx); + this._drawNodeLabels(ctx); + this._drawAnimDots(ctx); + this._drawShortCircuitWarning(ctx); + if (this._drawing&&this._ghostEnd) this._drawGhost(ctx); + this._drawTooltip(ctx); + if (this.components.length===0) this._drawHint(ctx); + } + + /* ─── Events ───────────────────────────────────────────────────────────── */ + + _bindEvents() { + const cvs=this.canvas; + + const pos = e => { + const r=cvs.getBoundingClientRect(), s=e.touches?e.touches[0]:e; + return { x:s.clientX-r.left, y:s.clientY-r.top }; + }; + const snap = p => this._snapGrid(p.x,p.y); + const hitComp = p => { + for (let i=this.components.length-1;i>=0;i--) { + const c=this.components[i]; + const q1=this._nodePixel(c.x1,c.y1), q2=this._nodePixel(c.x2,c.y2); + if (distToSegment(p.x,p.y,q1.x,q1.y,q2.x,q2.y)<13) return i; + } + return -1; + }; + + // ── Keyboard shortcuts ── + this._keyHandler = e => { + const simEl=document.getElementById('sim-circuit'); + if (!simEl||simEl.style.display==='none') return; + if (e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA') return; + + if ((e.ctrlKey||e.metaKey)&&e.key==='z') { e.preventDefault(); this.undo(); return; } + if ((e.ctrlKey||e.metaKey)&&(e.key==='y'||(e.shiftKey&&e.key==='z'))) { e.preventDefault(); this.redo(); return; } + + const modeMap={w:'wire',r:'resistor',b:'battery',s:'switch',l:'lamp',c:'capacitor',d:'diode',a:'ammeter',v:'voltmeter',e:'erase'}; + const newMode=modeMap[e.key.toLowerCase()]; + if (newMode) { this.addMode=newMode; if (this.onModeChange) this.onModeChange(newMode); } + + if ((e.key==='Delete'||e.key==='Backspace')&&this._selected!==null) { + const idx=this.components.findIndex(c=>c.id===this._selected); + if (idx>=0) { this._pushHistory(); this.components.splice(idx,1); this._selected=null; this._solve(); this.draw(); } + } + if (e.key==='Escape') { + this._selected=null; this._drawing=null; this._ghostEnd=null; this._dragIdx=null; + if (!this._raf) this.draw(); + } + }; + document.addEventListener('keydown', this._keyHandler); + + // ── Mouse ── + cvs.addEventListener('mousedown', e=>{ + if (e.button!==0) return; + const p=pos(e), g=snap(p), hi=hitComp(p); + + if (this.addMode==='erase') { + if (hi>=0) { this._pushHistory(); this.components.splice(hi,1); this._solve(); this.draw(); } + return; + } + if (hi>=0) { + // Start potential drag / select + this._selected=this.components[hi].id; + this._dragIdx=hi; + this._dragStart=g; + this._dragOrigPos={...this.components[hi]}; + this._didDrag=false; + this.draw(); return; + } + this._selected=null; + this._drawing={x1:g.gx,y1:g.gy}; + this._ghostEnd={x2:g.gx,y2:g.gy}; + }); + + cvs.addEventListener('mousemove', e=>{ + const p=pos(e), g=snap(p); + this._ghostEnd={x2:g.gx,y2:g.gy}; + const hi=hitComp(p); + this._hovered=hi>=0?this.components[hi].id:null; + + if (this._dragIdx!==null&&this._dragStart) { + const dx=g.gx-this._dragStart.gx, dy=g.gy-this._dragStart.gy; + if (dx!==0||dy!==0) { + this._didDrag=true; + const c=this.components[this._dragIdx], o=this._dragOrigPos; + c.x1=Math.max(0,Math.min(this.GW,o.x1+dx)); + c.y1=Math.max(0,Math.min(this.GH,o.y1+dy)); + c.x2=Math.max(0,Math.min(this.GW,o.x2+dx)); + c.y2=Math.max(0,Math.min(this.GH,o.y2+dy)); + this._solve(); + } + } + if (!this._raf) this.draw(); + }); + + cvs.addEventListener('mouseup', e=>{ + if (e.button!==0) return; + + if (this._dragIdx!==null) { + if (this._didDrag) this._pushHistory(); + this._dragIdx=null; this._dragStart=null; this._dragOrigPos=null; this._didDrag=false; + if (!this._raf) this.draw(); return; + } + if (!this._drawing) return; + const p=pos(e), g=snap(p); + const {x1,y1}=this._drawing; + const x2=g.gx, y2=g.gy; + this._drawing=null; this._ghostEnd=null; + + if (x1===x2&&y1===y2) { if (!this._raf) this.draw(); return; } + this._pushHistory(); + + // L-shape wires: split diagonal into two orthogonal segments + if (this.addMode==='wire'&&x1!==x2&&y1!==y2) { + this._add('wire',x1,y1,x2,y1); + this._add('wire',x2,y1,x2,y2); + } else { + this.addComponent(this.addMode,x1,y1,x2,y2); + return; + } + this._solve(); this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + }); + + cvs.addEventListener('contextmenu', e=>{ + e.preventDefault(); + const p=pos(e), i=hitComp(p); + if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._selected=null; this._solve(); this.draw(); } + }); + + cvs.addEventListener('dblclick', e=>{ + const p=pos(e), i=hitComp(p); + if (i>=0&&this.components[i].type==='switch') { + this._pushHistory(); this.components[i].open=!this.components[i].open; this._solve(); this.draw(); + } + }); + + cvs.addEventListener('mouseleave', ()=>{ + this._ghostEnd=null; this._drawing=null; this._hovered=null; + if (this._dragIdx!==null) { this._dragIdx=null; this._dragStart=null; this._dragOrigPos=null; this._didDrag=false; } + if (!this._raf) this.draw(); + }); + + // Touch + cvs.addEventListener('touchstart', e=>{ + e.preventDefault(); + const p=pos(e), g=snap(p); + if (this.addMode==='erase') { + const i=this._hitCompFromSnap(p); + if (i>=0) { this._pushHistory(); this.components.splice(i,1); this._solve(); this.draw(); } + return; + } + this._drawing={x1:g.gx,y1:g.gy}; this._ghostEnd={x2:g.gx,y2:g.gy}; + },{passive:false}); + + cvs.addEventListener('touchmove', e=>{ + e.preventDefault(); + const p=pos(e), g=snap(p); + this._ghostEnd={x2:g.gx,y2:g.gy}; + if (!this._raf) this.draw(); + },{passive:false}); + + cvs.addEventListener('touchend', e=>{ + e.preventDefault(); + if (!this._drawing) return; + const {x1,y1}=this._drawing; + const x2=this._ghostEnd?.x2??x1, y2=this._ghostEnd?.y2??y1; + this._drawing=null; this._ghostEnd=null; + if (x1===x2&&y1===y2) { if (!this._raf) this.draw(); return; } + this._pushHistory(); + if (this.addMode==='wire'&&x1!==x2&&y1!==y2) { + this._add('wire',x1,y1,x2,y1); this._add('wire',x2,y1,x2,y2); + this._solve(); this.draw(); if (this.onUpdate) this.onUpdate(this.info()); + } else { + this.addComponent(this.addMode,x1,y1,x2,y2); + } + },{passive:false}); + } + + /* ─── CRUD ─────────────────────────────────────────────────────────────── */ + + addComponent(type, x1, y1, x2, y2) { + const value = type==='resistor'?this.R_value + : type==='battery'||type==='ac'?this.U_value + : type==='capacitor'?this.C_value + : undefined; + this._add(type,x1,y1,x2,y2,value); + this._solve(); this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + _add(type, x1, y1, x2, y2, value) { + const id=this._nextId++; + if (type==='diode'||type==='led') this._diodeR.set(id,1e9); + this.components.push({ + id, type, x1, y1, x2, y2, + value: value??undefined, + open: false, + ledColor: type==='led'?(this.ledColor||'#7BF5A4'):undefined, + acFreq: type==='ac'?this.acFreq:undefined, + _I:0, _v1:0, _v2:0, _t:Math.random() + }); + } + + /* ─── Presets ──────────────────────────────────────────────────────────── */ + + preset(name) { + this.components=[]; this._nextId=0; this._diodeR.clear(); this._selected=null; + + switch (name) { + case 'serial': + this._add('battery',1,7,1,4,9); + this._add('wire',1,4,8,4); + this._add('resistor',8,4,13,4,10); + this._add('resistor',13,4,19,4,20); + this._add('wire',19,4,19,7); + this._add('wire',19,7,1,7); + break; + + case 'parallel': + this._add('battery',1,7,1,4,12); + this._add('wire',1,4,8,4); + this._add('wire',8,4,8,3); + this._add('resistor',8,3,16,3,10); + this._add('wire',16,3,16,4); + this._add('wire',8,4,8,6); + this._add('resistor',8,6,16,6,20); + this._add('wire',16,6,16,4); + this._add('wire',16,4,19,4); + this._add('wire',19,4,19,7); + this._add('wire',19,7,1,7); + break; + + case 'lamp': + this._add('battery',2,8,2,4,9); + this._add('wire',2,4,8,4); + this._add('switch',8,4,13,4); + this._add('lamp',13,4,18,4); + this._add('wire',18,4,20,4); + this._add('wire',20,4,20,8); + this._add('wire',20,8,2,8); + break; + + case 'divider': + this._add('battery',2,7,2,4,12); + this._add('wire',2,4,9,4); + this._add('resistor',9,4,14,4,10); + this._add('resistor',14,4,19,4,10); + this._add('wire',19,4,19,7); + this._add('wire',19,7,2,7); + this._add('voltmeter',14,4,14,7); + this._add('wire',14,7,19,7); + break; + + case 'bridge': + this._add('battery',1,7,1,4,12); + this._add('wire',1,4,5,4); + this._add('resistor',5,4,10,2,10); + this._add('resistor',5,4,10,6,20); + this._add('resistor',10,2,16,4,10); + this._add('resistor',10,6,16,4,30); + this._add('ammeter',10,2,10,6); + this._add('wire',16,4,19,4); + this._add('wire',19,4,19,7); + this._add('wire',19,7,1,7); + break; + + case 'diode': + this._add('battery',2,7,2,4,9); + this._add('wire',2,4,7,4); + this._add('diode',7,4,13,4); + this._add('resistor',13,4,19,4,100); + this._add('ammeter',19,4,19,7); + this._add('wire',19,7,2,7); + break; + + case 'led': + this._add('battery',2,7,2,4,9); + this._add('wire',2,4,7,4); + this._add('led',7,4,13,4); + this._add('resistor',13,4,19,4,47); + this._add('wire',19,4,19,7); + this._add('wire',19,7,2,7); + break; + + case 'rc': + this._add('battery',2,7,2,4,9); + this._add('wire',2,4,6,4); + this._add('switch',6,4,10,4); + this._add('resistor',10,4,15,4,100); + this._add('capacitor',15,4,19,4,100); + this._add('wire',19,4,19,7); + this._add('wire',19,7,2,7); + break; + + case 'ac': + this._add('ac',2,7,2,4,9); + this._add('wire',2,4,9,4); + this._add('resistor',9,4,15,4,10); + this._add('wire',15,4,19,4); + this._add('wire',19,4,19,7); + this._add('wire',19,7,2,7); + break; + + default: break; + } + + this._pushHistory(); + this._solve(); this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ─── Fit ──────────────────────────────────────────────────────────────── */ + + fit() { + this.W=this.canvas.offsetWidth||800; + this.H=this.canvas.offsetHeight||500; + const dpr=window.devicePixelRatio||1; + this.canvas.width=this.W*dpr; + this.canvas.height=this.H*dpr; + this.ctx.setTransform(1,0,0,1,0,0); + this.ctx.scale(dpr,dpr); + this.CELL=Math.max(24,Math.floor(Math.min((this.W-40)/this.GW,(this.H-40)/this.GH))); + this.ox=Math.round((this.W-this.CELL*this.GW)/2); + this.oy=Math.round((this.H-this.CELL*this.GH)/2); + this._solve(); this.draw(); + } + + /* ─── Info ─────────────────────────────────────────────────────────────── */ + + info() { + const solved=this._solution?.solved??false; + const batt=this.components.find(c=>c.type==='battery'||c.type==='ac'); + return { + components: this.components.length, + solved, + voltage: batt?batt.value:0, + current: batt?Math.abs(batt._I).toFixed(3):'—', + power: batt?(Math.abs(batt._I)*(batt.value||0)).toFixed(2):'—', + totalR: (batt&&batt._I&&Math.abs(batt._I)>0.0001)?((batt.value||0)/Math.abs(batt._I)).toFixed(1):'—', + addMode: this.addMode + }; + } + + /* ─── Clear ────────────────────────────────────────────────────────────── */ + + clear() { + this._pushHistory(); + this.components=[]; this._nextId=0; this._diodeR.clear(); this._selected=null; + this._solve(); this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } +} diff --git a/frontend/js/labs/collision.js b/frontend/js/labs/collision.js new file mode 100644 index 0000000..082926f --- /dev/null +++ b/frontend/js/labs/collision.js @@ -0,0 +1,1009 @@ +'use strict'; + +/* ═══════════════════════════════════════════════ + CollisionSim — 2D elastic/inelastic ball collision + Conservation of momentum & energy demo + ═══════════════════════════════════════════════ */ + +class CollisionSim { + constructor(canvas) { + this.c = canvas; + this.ctx = canvas.getContext('2d'); + + /* physics params */ + this.m1 = 4; + this.m2 = 4; + this.v1 = 8; + this.v2 = 8; + this.angle = 0; + this.e = 1; + this.speed = 1; // sim speed multiplier (0.1 – 4) + + /* runtime */ + this.playing = false; + this._raf = null; + this._lastTs = null; + this._cooldown = 0; + this._colCount = 0; + + /* balls */ + this._b = []; + + /* visual effects */ + this._sparks = []; + this._rings = []; + this._dust = []; // tiny debris cloud + this._impactPt = null; + this._squish = [null, null]; // per-ball squish {ts, nx, ny} + this._launchTs = null; // when play() was called + + /* stats */ + this._snapBefore = null; + this._snapAfter = null; + + /* perfectly-inelastic merge state */ + this._merged = false; + this._mergeR = 0; + this._mergeNormal = null; // {nx, ny} at moment of merge + + this.onUpdate = null; + this.onPlayPause = null; // canvas click callback + + /* centre-of-mass trail */ + this._cmTrail = []; + + /* pre-collision ghost velocity arrows */ + this._ghostArrows = []; + + /* hover inspector */ + this._hoverBall = null; + + canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); }); + canvas.addEventListener('mousemove', e => this._onMouseMove(e)); + canvas.addEventListener('mouseleave', () => { this._hoverBall = null; this.draw(); }); + + new ResizeObserver(() => { this.fit(); this._initBalls(); this.draw(); }) + .observe(canvas.parentElement); + } + + /* ═══ public API ═══ */ + + fit() { + const r = this.c.parentElement.getBoundingClientRect(); + this.c.width = r.width || 700; + this.c.height = r.height || 420; + } + + setParams(p) { + if (p.m1 !== undefined) this.m1 = +p.m1; + if (p.m2 !== undefined) this.m2 = +p.m2; + if (p.v1 !== undefined) this.v1 = +p.v1; + if (p.v2 !== undefined) this.v2 = +p.v2; + if (p.angle !== undefined) this.angle = +p.angle; + if (p.e !== undefined) this.e = +p.e; + this.reset(); + } + + setSpeed(s) { + this.speed = Math.max(0.1, Math.min(4, +s)); + } + + play() { + if (this.playing) return; + this.playing = true; + this._lastTs = null; + this._launchTs = performance.now(); + this._spawnLaunchFx(); + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + reset() { + this.pause(); + this._sparks = []; + this._rings = []; + this._dust = []; + this._squish = [null, null]; + this._impactPt = null; + this._launchTs = null; + this._merged = false; + this._mergeR = 0; + this._mergeNormal = null; + this._cmTrail = []; + this._ghostArrows = []; + this._initBalls(); + this.draw(); + this._emit(); + } + + stats() { + if (this._b.length < 2) return { v1:0, v2:0, ke:0, p:0, colCount:0, before:null, after:null }; + const [b1, b2] = this._b; + const v1 = Math.hypot(b1.vx, b1.vy), v2 = Math.hypot(b2.vx, b2.vy); + const ke = 0.5 * b1.m * v1 * v1 + 0.5 * b2.m * v2 * v2; + const px = b1.m * b1.vx + b2.m * b2.vx; + const py = b1.m * b1.vy + b2.m * b2.vy; + const p = Math.hypot(px, py); + return { v1, v2, ke, p, colCount: this._colCount, + before: this._snapBefore, after: this._snapAfter }; + } + + /* ═══ init ═══ */ + + _r(m) { return Math.max(16, Math.min(42, 12 + m * 2.2)); } + + _initBalls() { + const W = this.c.width || 700, H = this.c.height || 420; + const r1 = this._r(this.m1), r2 = this._r(this.m2); + const gap = Math.max(r1 + r2 + 70, W * 0.30); + const cx = W / 2, cy = H / 2; + const rad = (this.angle * Math.PI) / 180; + const dy2 = Math.tan(rad) * (gap / 2); + + this._b = [ + { id:1, m:this.m1, r:r1, x:cx - gap/2, y:cy, + vx:this.v1, vy:0, angle: 0, angVel: 0, + color:'#9B5DE5', rgb:'155,93,229', trail:[] }, + { id:2, m:this.m2, r:r2, x:cx + gap/2, y:cy + dy2, + vx:-this.v2 * Math.cos(rad), vy:-this.v2 * Math.sin(rad), angle: 0, angVel: 0, + color:'#06D6E0', rgb:'6,214,224', trail:[] }, + ]; + + this._cooldown = 0; + this._colCount = 0; + this._snapBefore = null; + this._snapAfter = null; + } + + /* ═══ launch visual burst ═══ */ + + _spawnLaunchFx() { + const now = performance.now(); + for (const b of this._b) { + /* expanding ring per ball */ + this._rings.push({ x:b.x, y:b.y, ts:now, kind:'launch', + life:700, maxR:b.r * 4, col:b.rgb }); + /* radial spark burst */ + for (let k = 0; k < 14; k++) { + this._sparks.push({ + kind:'launch', + ang: (k / 14) * Math.PI * 2, + spd: 25 + Math.random() * 35, + x: b.x, y: b.y, ts: now, + col: b.rgb, life: 550, + }); + } + } + } + + /* ═══ tick / step ═══ */ + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (!this.playing) return; + if (this._lastTs === null) this._lastTs = ts; + const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed; + this._lastTs = ts; + this._step(dt); + this.draw(); + this._emit(); + if (this.playing) this._tick(); + }); + } + + _step(dt) { + const W = this.c.width, H = this.c.height; + const [b1, b2] = this._b; + if (this._cooldown > 0) this._cooldown--; + + /* trails */ + for (const b of this._b) { + b.trail.push({ x: b.x, y: b.y, spd: Math.hypot(b.vx, b.vy) }); + if (b.trail.length > 90) b.trail.shift(); + } + + /* centre-of-mass trail */ + const M = b1.m + b2.m; + const cmx = (b1.m * b1.x + b2.m * b2.x) / M; + const cmy = (b1.m * b1.y + b2.m * b2.y) / M; + this._cmTrail.push({ x: cmx, y: cmy }); + if (this._cmTrail.length > 180) this._cmTrail.shift(); + + /* integrate */ + for (const b of this._b) { + b.x += b.vx * dt; b.y += b.vy * dt; + b.angle += (b.angVel || 0) * dt; + if (b.angVel) b.angVel *= 0.997; // rotational air drag + } + + /* wall bounces — when merged, use combined radius and bounce both */ + if (this._merged) { + const r = this._mergeR; + const fx = [ + b1.x - r < 0 ? [b => { b.x = r; b.vx = Math.abs(b.vx); }, 'L'] : null, + b1.x + r > W ? [b => { b.x = W - r; b.vx = -Math.abs(b.vx); }, 'R'] : null, + b1.y - r < 0 ? [b => { b.y = r; b.vy = Math.abs(b.vy); }, 'T'] : null, + b1.y + r > H ? [b => { b.y = H - r; b.vy = -Math.abs(b.vy); }, 'B'] : null, + ].filter(Boolean); + for (const [fn, side] of fx) { + fn(b1); fn(b2); this._wallFx(b1, side); + } + } else { + const eW = Math.max(0.5, this.e); // wall restitution (at least 0.5) + const wallMu = 0.18; // wall surface friction spin + for (const b of this._b) { + if (b.x - b.r < 0) { b.x = b.r; const vn = Math.abs(b.vx); b.vx = vn * eW; b.angVel -= b.vy * wallMu / b.r; this._wallFx(b, 'L'); } + if (b.x + b.r > W) { b.x = W - b.r; const vn = Math.abs(b.vx); b.vx = -vn * eW; b.angVel += b.vy * wallMu / b.r; this._wallFx(b, 'R'); } + if (b.y - b.r < 0) { b.y = b.r; const vn = Math.abs(b.vy); b.vy = vn * eW; b.angVel += b.vx * wallMu / b.r; this._wallFx(b, 'T'); } + if (b.y + b.r > H) { b.y = H - b.r; const vn = Math.abs(b.vy); b.vy = -vn * eW; b.angVel -= b.vx * wallMu / b.r; this._wallFx(b, 'B'); } + } + } + + /* ball–ball collision — skip when already merged */ + if (this._cooldown === 0 && !this._merged) { + const dx = b2.x - b1.x, dy = b2.y - b1.y; + const dist = Math.hypot(dx, dy); + const min = b1.r + b2.r; + + if (dist < min && dist > 0.01) { + const nx = dx / dist, ny = dy / dist; + const dvn = (b1.vx - b2.vx) * nx + (b1.vy - b2.vy) * ny; + + if (dvn > 0) { + if (this._colCount === 0) this._snapBefore = this._snapshot(); + + /* save pre-collision ghost arrows */ + const _gnow = performance.now(); + this._ghostArrows = this._b.map(b => ({ + x: b.x, y: b.y, vx: b.vx, vy: b.vy, rgb: b.rgb, ts: _gnow, + })); + + const isPerfectlyInelastic = this.e < 0.02; + if (isPerfectlyInelastic) { + /* ── MERGE: conservation of momentum, balls stick ── */ + const M = b1.m + b2.m; + const mvx = (b1.m * b1.vx + b2.m * b2.vx) / M; + const mvy = (b1.m * b1.vy + b2.m * b2.vy) / M; + b1.vx = b2.vx = mvx; + b1.vy = b2.vy = mvy; + this._merged = true; + this._mergeR = Math.sqrt(b1.r * b1.r + b2.r * b2.r); + this._mergeNormal = { nx, ny }; + } else { + const J = (1 + this.e) * dvn / (1 / b1.m + 1 / b2.m); + b1.vx -= J * nx / b1.m; b1.vy -= J * ny / b1.m; + b2.vx += J * nx / b2.m; b2.vy += J * ny / b2.m; + + /* tangential friction spin */ + const tx = -ny, ty = nx; + const vRelT = (b1.vx - b2.vx) * tx + (b1.vy - b2.vy) * ty; + if (Math.abs(vRelT) > 0.5) { + const frMu = 0.32; + const jtMax = frMu * Math.abs(J); + const jt = -Math.sign(vRelT) * Math.min(jtMax, Math.abs(vRelT) / (1/b1.m + 1/b2.m)); + b1.vx += jt * tx / b1.m; b1.vy += jt * ty / b1.m; + b2.vx -= jt * tx / b2.m; b2.vy -= jt * ty / b2.m; + const I1 = 0.4 * b1.m * b1.r * b1.r; + const I2 = 0.4 * b2.m * b2.r * b2.r; + b1.angVel -= jt * b1.r / I1; + b2.angVel += jt * b2.r / I2; + } + } + + this._snapAfter = this._snapshot(); + this._colCount++; + this._cooldown = 8; + + const ix = (b1.x + b2.x) / 2, iy = (b1.y + b2.y) / 2; + this._spawnCollisionFx(ix, iy, nx, ny, dvn); + + /* squish (even on merge — visible one frame) */ + this._squish[0] = { ts: performance.now(), nx, ny, dv: dvn }; + this._squish[1] = { ts: performance.now(), nx: -nx, ny: -ny, dv: dvn }; + } + + /* overlap resolution */ + const ov = min - dist; + b1.x -= nx * ov / 2; b1.y -= ny * ov / 2; + b2.x += nx * ov / 2; b2.y += ny * ov / 2; + } + } + + /* if merged, lock both balls to centre-of-mass */ + if (this._merged) { + const M = b1.m + b2.m; + const cmx = (b1.m * b1.x + b2.m * b2.x) / M; + const cmy = (b1.m * b1.y + b2.m * b2.y) / M; + b1.x = b2.x = cmx; + b1.y = b2.y = cmy; + b2.vx = b1.vx; + b2.vy = b1.vy; + /* sync rotation of merged body */ + const avgAng = (b1.angVel * b1.m + b2.angVel * b2.m) / M; + b1.angVel = b2.angVel = avgAng; + b1.angle += avgAng * dt; b2.angle = b1.angle; + } + + /* expire effects */ + const now = performance.now(); + this._rings = this._rings.filter(r => (now - r.ts) < (r.life || 900)); + this._sparks = this._sparks.filter(sp => (now - sp.ts) < (sp.life || 800)); + this._dust = this._dust.filter(d => (now - d.ts) < 1400); + for (let i = 0; i < 2; i++) { + if (this._squish[i] && (now - this._squish[i].ts) > 300) this._squish[i] = null; + } + } + + _wallFx(b, side) { + const now = performance.now(); + const count = 6; + const baseAng = { L: 0, R: Math.PI, T: Math.PI / 2, B: -Math.PI / 2 }[side] ?? 0; + for (let k = 0; k < count; k++) { + this._sparks.push({ + kind: 'wall', + ang: baseAng + (Math.random() - 0.5) * Math.PI, + spd: 10 + Math.random() * 20, + x: b.x, y: b.y, ts: now, + col: b.rgb, life: 400, + }); + } + } + + _spawnCollisionFx(ix, iy, nx, ny, dvn) { + const now = performance.now(); + const intensity = Math.min(1, dvn / 22); + + this._impactPt = { x: ix, y: iy, ts: now, intensity }; + + /* 4 expanding rings (different radii, colors, lifetimes) */ + const ringDefs = [ + { life:1400, col:'255,255,255', maxR:160 }, + { life:1000, col:'155,93,229', maxR: 90 }, + { life: 750, col:'6,214,224', maxR: 65 }, + { life: 500, col:'241,91,181', maxR: 42 }, + ]; + for (const rd of ringDefs) { + this._rings.push({ x:ix, y:iy, ts:now, kind:'collision', + life:rd.life, col:rd.col, maxR:rd.maxR }); + } + + /* 40 sparks — multi-color, gravity arc */ + const pal = ['255,255,255','255,220,70','155,93,229','6,214,224','241,91,181']; + for (let k = 0; k < 40; k++) { + this._sparks.push({ + kind: 'collision', + ang: (k / 40) * Math.PI * 2 + (Math.random() - 0.5) * 0.35, + spd: (28 + Math.random() * 95) * (0.55 + intensity * 0.45), + len: 0.3 + Math.random() * 0.7, + x: ix, y: iy, ts: now, + col: pal[Math.floor(Math.random() * pal.length)], + grav: 35 + Math.random() * 65, + life: 900, + }); + } + + /* dust cloud — tiny slow particles */ + for (let k = 0; k < 20; k++) { + this._dust.push({ + ang: Math.random() * Math.PI * 2, + spd: 4 + Math.random() * 12, + x: ix + (Math.random() - 0.5) * 6, + y: iy + (Math.random() - 0.5) * 6, + r: 1 + Math.random() * 2.5, + ts: now, + grav: 8 + Math.random() * 18, + }); + } + } + + /* ═══ snapshot ═══ */ + + _snapshot() { + return this._b.map(b => { + const spd = Math.hypot(b.vx, b.vy); + return { m: b.m, vx: b.vx, vy: b.vy, spd, ke: 0.5 * b.m * spd * spd }; + }); + } + + _emit() { if (this.onUpdate) this.onUpdate(this.stats()); } + + /* ═══ RENDER ═══ */ + + draw() { + const W = this.c.width, H = this.c.height; + if (!W || !H || this._b.length < 2) return; + const ctx = this.ctx; + const now = performance.now(); + + /* ── 1. Background ── */ + /* radial dark-violet base */ + const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.hypot(W, H) / 1.7); + bg.addColorStop(0, '#130e22'); + bg.addColorStop(1, '#080812'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + /* impact bg flash */ + if (this._impactPt) { + const el = (now - this._impactPt.ts) / 220; + if (el < 1) { + const fa = Math.pow(1 - el, 2) * (this._impactPt.intensity || 0.5) * 0.22; + const fg = ctx.createRadialGradient( + this._impactPt.x, this._impactPt.y, 0, + this._impactPt.x, this._impactPt.y, Math.hypot(W, H)); + fg.addColorStop(0, `rgba(255,255,255,${fa})`); + fg.addColorStop(0.4, `rgba(155,93,229,${fa * 0.5})`); + fg.addColorStop(1, 'transparent'); + ctx.fillStyle = fg; + ctx.fillRect(0, 0, W, H); + } + } + + /* ── 2. Grid ── */ + ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; + for (let x = 60; x < W; x += 60) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); + } + for (let y = 60; y < H; y += 60) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + + /* ── 3. Arena border (pulses on impact) ── */ + let borderAlpha = 0.18; + if (this._impactPt) { + const el = (now - this._impactPt.ts) / 500; + if (el < 1) borderAlpha = 0.18 + (1 - el) * 0.55; + } + ctx.strokeStyle = `rgba(155,93,229,${borderAlpha})`; + ctx.lineWidth = 2; + ctx.strokeRect(2, 2, W - 4, H - 4); + + /* ── 4. Center dashes ── */ + ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1; + ctx.setLineDash([6, 7]); + ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke(); + ctx.setLineDash([]); + + /* ── 5. Idle pulsing halos ── */ + if (!this.playing && this._colCount === 0) { + const pulse = 0.5 + 0.5 * Math.sin(now / 420); + for (const b of this._b) { + const [r, g, bl] = b.rgb.split(',').map(Number); + for (let ring = 0; ring < 2; ring++) { + const ph = pulse * (ring === 0 ? 1 : 1 - pulse); + const hg = ctx.createRadialGradient(b.x, b.y, b.r * (1 + ring * 0.7), + b.x, b.y, b.r * (3.2 + ring)); + hg.addColorStop(0, `rgba(${r},${g},${bl},${0.28 * ph})`); + hg.addColorStop(0.5, `rgba(${r},${g},${bl},${0.08 * ph})`); + hg.addColorStop(1, 'transparent'); + ctx.fillStyle = hg; + ctx.beginPath(); + ctx.arc(b.x, b.y, b.r * (3.2 + ring), 0, Math.PI * 2); + ctx.fill(); + } + } + /* dashed approach lines */ + for (const b of this._b) { + const spd = Math.hypot(b.vx, b.vy); + if (spd < 0.01) continue; + const nx = b.vx / spd, ny = b.vy / spd; + const opa = 0.12 + 0.1 * Math.sin(now / 420); + ctx.strokeStyle = `rgba(255,255,255,${opa})`; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 5]); + ctx.beginPath(); ctx.moveTo(b.x, b.y); + ctx.lineTo(b.x + nx * 60, b.y + ny * 60); ctx.stroke(); + ctx.setLineDash([]); + } + } + + /* ── 6. Launch burst (first 600 ms after play()) ── */ + if (this._launchTs) { + const le = Math.min(1, (now - this._launchTs) / 600); + if (le < 1) { + for (const b of this._b) { + const [r, g, bl] = b.rgb.split(',').map(Number); + /* expanding ring */ + const lR = le * b.r * 5.5; + const lAlph = (1 - le) * 0.75; + ctx.strokeStyle = `rgba(${r},${g},${bl},${lAlph})`; + ctx.lineWidth = 3 * (1 - le); + ctx.beginPath(); ctx.arc(b.x, b.y, lR, 0, Math.PI * 2); ctx.stroke(); + /* 8 radial spokes */ + for (let k = 0; k < 8; k++) { + const ang = (k / 8) * Math.PI * 2; + const inner = b.r + 3; + const outer = b.r + 3 + le * 48; + ctx.strokeStyle = `rgba(${r},${g},${bl},${(1 - le) * 0.65})`; + ctx.lineWidth = 1.8 * (1 - le * 0.6); + ctx.beginPath(); + ctx.moveTo(b.x + Math.cos(ang) * inner, b.y + Math.sin(ang) * inner); + ctx.lineTo(b.x + Math.cos(ang) * outer, b.y + Math.sin(ang) * outer); + ctx.stroke(); + } + } + } + } + + /* ── 7. Trails (speed-colored: blueyellowred) ── */ + const _maxSpd = Math.max(this.v1, this.v2, 0.1) * 1.6; + for (const b of this._b) { + for (let i = 1; i < b.trail.length; i++) { + const frac = i / b.trail.length; + const spd = b.trail[i].spd || 0; + const tr = frac * Math.min(8, 1.5 + spd * 0.18); + /* hue: 220 (blue) 60 (yellow) 0 (red) */ + const t = Math.min(1, spd / _maxSpd); + const hue = 220 - t * 220; + const sat = 80 + t * 20; + ctx.fillStyle = `hsla(${hue},${sat}%,65%,${frac * 0.6})`; + ctx.beginPath(); + ctx.arc(b.trail[i].x, b.trail[i].y, tr, 0, Math.PI * 2); + ctx.fill(); + } + } + + /* ── 7b. Centre-of-mass trail ── */ + for (let i = 1; i < this._cmTrail.length; i++) { + const frac = i / this._cmTrail.length; + const p = this._cmTrail[i]; + ctx.fillStyle = `rgba(255,255,255,${frac * 0.35})`; + ctx.beginPath(); ctx.arc(p.x, p.y, 1.5 + frac, 0, Math.PI * 2); ctx.fill(); + } + if (this._cmTrail.length > 0) { + const cm = this._cmTrail[this._cmTrail.length - 1]; + ctx.strokeStyle = 'rgba(255,255,255,.65)'; ctx.lineWidth = 1.5; + const cs = 7; + ctx.beginPath(); + ctx.moveTo(cm.x - cs, cm.y); ctx.lineTo(cm.x + cs, cm.y); + ctx.moveTo(cm.x, cm.y - cs); ctx.lineTo(cm.x, cm.y + cs); + ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(cm.x, cm.y, 5, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,.35)'; + ctx.font = '8px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('CM', cm.x, cm.y + 8); + } + + /* ── 8. Impact rings ── */ + for (const ring of this._rings) { + const el = (now - ring.ts) / ring.life; + if (el >= 1) continue; + const maxR = ring.maxR || 110; + const ra = Math.pow(1 - el, ring.kind === 'launch' ? 1.2 : 1.6); + ctx.strokeStyle = `rgba(${ring.col},${ra * 0.7})`; + ctx.lineWidth = (ring.kind === 'launch' ? 1.5 : 2.8) * (1 - el * 0.75); + ctx.beginPath(); ctx.arc(ring.x, ring.y, el * maxR, 0, Math.PI * 2); ctx.stroke(); + } + + /* ── 9. Sparks ── */ + ctx.lineCap = 'round'; + for (const sp of this._sparks) { + const el = (now - sp.ts) / sp.life; + if (el >= 1) continue; + const sa = Math.pow(1 - el, sp.kind === 'launch' ? 1.1 : 1.4); + const dist = sp.spd * el; + const grav = sp.grav ? sp.grav * el * el : 0; + const ex = sp.x + Math.cos(sp.ang) * dist; + const ey = sp.y + Math.sin(sp.ang) * dist + grav; + const sx = sp.x + Math.cos(sp.ang) * dist * 0.4; + const sy = sp.y + Math.sin(sp.ang) * dist * 0.4 + grav * 0.16; + ctx.strokeStyle = `rgba(${sp.col},${sa * 0.92})`; + ctx.lineWidth = (sp.len || 1) * (sp.kind === 'launch' ? 1.2 : 2) * (1 - el * 0.4); + ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); + /* bright tip */ + if (el < 0.45) { + ctx.fillStyle = `rgba(255,255,255,${sa * 0.75})`; + ctx.beginPath(); ctx.arc(ex, ey, 1.4, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.lineCap = 'butt'; + + /* ── 10. Dust cloud ── */ + for (const d of this._dust) { + const el = (now - d.ts) / 1400; + if (el >= 1) continue; + const da = Math.pow(1 - el, 2) * 0.55; + const dist = d.spd * el; + const grav = d.grav * el * el; + ctx.fillStyle = `rgba(200,180,255,${da})`; + ctx.beginPath(); + ctx.arc(d.x + Math.cos(d.ang) * dist, + d.y + Math.sin(d.ang) * dist + grav, + d.r * (1 + el * 0.5), 0, Math.PI * 2); + ctx.fill(); + } + + /* ── 11. Central impact flash ── */ + if (this._impactPt) { + const el = (now - this._impactPt.ts) / 320; + if (el < 1) { + const fa = Math.pow(1 - el, 2.2); + const fgR = 90 * (1 + el * 0.6); + const fg = ctx.createRadialGradient( + this._impactPt.x, this._impactPt.y, 0, + this._impactPt.x, this._impactPt.y, fgR); + fg.addColorStop(0, `rgba(255,255,255,${fa})`); + fg.addColorStop(0.22, `rgba(255,215,80,${fa * 0.55})`); + fg.addColorStop(0.6, `rgba(155,93,229,${fa * 0.18})`); + fg.addColorStop(1, 'transparent'); + ctx.fillStyle = fg; + ctx.beginPath(); ctx.arc(this._impactPt.x, this._impactPt.y, fgR, 0, Math.PI * 2); + ctx.fill(); + } + } + + /* ── 12. Velocity arrows ── */ + for (const b of this._b) { + const [r, g, bl] = b.rgb.split(',').map(Number); + const spd = Math.hypot(b.vx, b.vy); + if (spd < 0.05) continue; + const pLen = Math.min(68, spd * b.m * 0.75 + 8); + const nx = b.vx / spd, ny = b.vy / spd; + const ox = nx * (b.r + 7), oy = ny * (b.r + 7); + _colArrow(ctx, + b.x + ox, b.y + oy, + b.x + ox + nx*pLen, b.y + oy + ny*pLen, + b.color, 2.5); + ctx.save(); + ctx.shadowColor = b.color; ctx.shadowBlur = 5; + ctx.fillStyle = `rgba(${r},${g},${bl},.9)`; + ctx.font = 'bold 10px Manrope'; + ctx.textAlign = nx > 0 ? 'left' : 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(spd.toFixed(1) + ' м/с', + b.x + ox + nx * (pLen + 8), + b.y + oy + ny * (pLen + 8)); + ctx.restore(); + } + + /* ── 12b. Ghost velocity arrows (pre-collision, fade out 1.5 s) ── */ + if (this._ghostArrows.length > 0) { + const ghostAge = (now - this._ghostArrows[0].ts) / 1500; + if (ghostAge < 1) { + const alpha = Math.pow(1 - ghostAge, 1.5) * 0.5; + for (const ga of this._ghostArrows) { + const spd = Math.hypot(ga.vx, ga.vy); + if (spd < 0.1) continue; + const pLen = Math.min(64, spd * 2.5 + 8); + const nx = ga.vx / spd, ny = ga.vy / spd; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = `rgba(${ga.rgb},.9)`; + ctx.lineWidth = 2; ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.moveTo(ga.x, ga.y); + ctx.lineTo(ga.x + nx * pLen, ga.y + ny * pLen); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } + } else { + this._ghostArrows = []; + } + } + + /* ── 12c. ΔKE loss badge near impact ── */ + if (this._snapBefore && this._snapAfter && this._impactPt) { + const keBefore = this._snapBefore.reduce((s, b) => s + b.ke, 0); + const keAfter = this._snapAfter.reduce((s, b) => s + b.ke, 0); + const lossPct = keBefore > 0.1 ? Math.round((1 - keAfter / keBefore) * 100) : 0; + if (lossPct > 0) { + const ix = this._impactPt.x, iy = this._impactPt.y - 42; + const label = 'ΔKE −' + lossPct + '%'; + ctx.font = 'bold 10px Manrope'; + const tw = ctx.measureText(label).width; + ctx.fillStyle = 'rgba(239,71,111,.18)'; + _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill(); + ctx.strokeStyle = 'rgba(239,71,111,.4)'; ctx.lineWidth = 1; + _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke(); + ctx.fillStyle = '#EF476F'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(label, ix, iy); + } else if (lossPct === 0 && keBefore > 0.1) { + const ix = this._impactPt.x, iy = this._impactPt.y - 42; + const label = 'KE сохранена '; + ctx.font = 'bold 10px Manrope'; + const tw = ctx.measureText(label).width; + ctx.fillStyle = 'rgba(123,245,164,.15)'; + _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.fill(); + ctx.strokeStyle = 'rgba(123,245,164,.4)'; ctx.lineWidth = 1; + _roundRect(ctx, ix - tw / 2 - 8, iy - 10, tw + 16, 20, 6); ctx.stroke(); + ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(label, ix, iy); + } + } + + /* ── 12d. Merged-body special rendering ── */ + if (this._merged && this._mergeNormal) { + const mn = this._mergeNormal; + const b1_ = this._b[0], b2_ = this._b[1]; + const cx = b1_.x, cy = b1_.y; // both at same CM pos + const off = (b1_.r + b2_.r) * 0.52; // visual separation + + /* connection beam — shimmering gradient bar */ + const bx1 = cx - mn.nx * off, by1 = cy - mn.ny * off; + const bx2 = cx + mn.nx * off, by2 = cy + mn.ny * off; + const pulse = 0.55 + 0.45 * Math.sin(now / 200); + const beamG = ctx.createLinearGradient(bx1, by1, bx2, by2); + beamG.addColorStop(0, `rgba(155,93,229,${pulse * 0.9})`); + beamG.addColorStop(0.5, `rgba(255,255,255,${pulse * 0.55})`); + beamG.addColorStop(1, `rgba(6,214,224,${pulse * 0.9})`); + ctx.save(); + ctx.strokeStyle = beamG; + ctx.lineWidth = Math.max(b1_.r, b2_.r) * 0.7; + ctx.lineCap = 'round'; + ctx.shadowColor = '#fff'; ctx.shadowBlur = 18 * pulse; + ctx.globalAlpha = 0.7; + ctx.beginPath(); ctx.moveTo(bx1, by1); ctx.lineTo(bx2, by2); ctx.stroke(); + ctx.restore(); + + /* outer merged-body glow */ + const gloBig = ctx.createRadialGradient(cx, cy, this._mergeR * 0.3, + cx, cy, this._mergeR * 3.2); + gloBig.addColorStop(0, `rgba(255,220,100,${0.2 * pulse})`); + gloBig.addColorStop(0.4, `rgba(155,93,229,${0.1 * pulse})`); + gloBig.addColorStop(1, 'transparent'); + ctx.save(); ctx.fillStyle = gloBig; + ctx.beginPath(); ctx.arc(cx, cy, this._mergeR * 3.2, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + + /* ── 13. Balls (with squish deform) ── */ + for (let i = 0; i < this._b.length; i++) { + const b = this._b[i]; + const [r, g, bl] = b.rgb.split(',').map(Number); + const sq = this._squish[i]; + + /* visual offset when merged — draw at slightly separated positions */ + let drawX = b.x, drawY = b.y; + if (this._merged && this._mergeNormal) { + const mn = this._mergeNormal; + const off = (this._b[0].r + this._b[1].r) * 0.52; + const sign = i === 0 ? -1 : 1; + drawX = b.x + sign * mn.nx * off; + drawY = b.y + sign * mn.ny * off; + } + + /* outer glow */ + const glo = ctx.createRadialGradient(drawX, drawY, b.r * 0.2, drawX, drawY, b.r * 3.4); + glo.addColorStop(0, `rgba(${r},${g},${bl},.55)`); + glo.addColorStop(0.35,`rgba(${r},${g},${bl},.16)`); + glo.addColorStop(1, 'transparent'); + ctx.fillStyle = glo; + ctx.beginPath(); ctx.arc(drawX, drawY, b.r * 3.4, 0, Math.PI * 2); ctx.fill(); + + /* squish transform */ + let sqEl = 0; + let sqAngle = 0; + if (sq) { + sqEl = Math.min(1, (now - sq.ts) / 300); + sqAngle = Math.atan2(sq.ny, sq.nx); + } + + ctx.save(); + ctx.translate(drawX, drawY); + if (sqEl > 0 && sqEl < 1) { + const squeeze = 1 - 0.38 * Math.sin(sqEl * Math.PI); + ctx.rotate(sqAngle); + ctx.scale(1 + (1 - squeeze) * 0.5, squeeze); + ctx.rotate(-sqAngle); + } + + /* body */ + const bodyG = ctx.createRadialGradient( + -b.r * 0.33, -b.r * 0.33, b.r * 0.06, + 0, 0, b.r); + bodyG.addColorStop(0, '#ffffff'); + bodyG.addColorStop(0.18, b.color); + bodyG.addColorStop(1, `rgba(${Math.round(r*0.3)},${Math.round(g*0.3)},${Math.round(bl*0.3)},1)`); + ctx.fillStyle = bodyG; + ctx.beginPath(); ctx.arc(0, 0, b.r, 0, Math.PI * 2); ctx.fill(); + + /* rim */ + ctx.strokeStyle = 'rgba(255,255,255,.5)'; ctx.lineWidth = 1.5; + ctx.stroke(); + + /* rotation indicator */ + const bAng = b.angle || 0; + ctx.strokeStyle = 'rgba(255,255,255,.18)'; ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(bAng) * b.r * 0.78, Math.sin(bAng) * b.r * 0.78); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(Math.cos(bAng) * b.r * 0.58, Math.sin(bAng) * b.r * 0.58, 2.2, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,.28)'; ctx.fill(); + + /* primary highlight */ + ctx.fillStyle = 'rgba(255,255,255,.25)'; + ctx.beginPath(); + ctx.ellipse(-b.r * 0.3, -b.r * 0.3, b.r * 0.35, b.r * 0.2, -0.6, 0, Math.PI * 2); + ctx.fill(); + + /* secondary glint */ + ctx.fillStyle = 'rgba(255,255,255,.1)'; + ctx.beginPath(); + ctx.ellipse(b.r * 0.22, b.r * 0.28, b.r * 0.14, b.r * 0.07, 0.9, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + + /* mass label (not squished) */ + ctx.fillStyle = 'rgba(255,255,255,.95)'; + ctx.font = `bold ${Math.max(10, Math.round(b.r * 0.60))}px Manrope`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(b.m + ' кг', drawX, drawY); + } + + /* ── 14. Total momentum vector from CoM ── */ + const [b1, b2] = this._b; + const px = b1.m * b1.vx + b2.m * b2.vx; + const py = b1.m * b1.vy + b2.m * b2.vy; + const pMag = Math.hypot(px, py); + if (pMag > 0.1) { + const cmx = (b1.m * b1.x + b2.m * b2.x) / (b1.m + b2.m); + const cmy = (b1.m * b1.y + b2.m * b2.y) / (b1.m + b2.m); + const pLen = Math.min(68, pMag * 1.5); + const pnx = px / pMag, pny = py / pMag; + ctx.save(); ctx.globalAlpha = 0.42; + _colArrow(ctx, cmx, cmy, cmx + pnx * pLen, cmy + pny * pLen, '#F15BB5', 1.5); + ctx.restore(); + ctx.fillStyle = 'rgba(241,91,181,.5)'; + ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('p⃗ = ' + pMag.toFixed(1), cmx + pnx * (pLen / 2 + 12), cmy + pny * (pLen / 2)); + } + + /* ── 15. Collision count badge ── */ + if (this._colCount > 0) { + /* badge bg pill */ + const txt = 'Столкновений: ' + this._colCount; + ctx.font = 'bold 11px Manrope'; + const tw = ctx.measureText(txt).width; + const bx = W - 14 - tw, by = 8; + ctx.fillStyle = 'rgba(255,200,50,.15)'; + _roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,200,50,.3)'; ctx.lineWidth = 1; + _roundRect(ctx, bx - 8, by - 2, tw + 16, 20, 6); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,200,50,.95)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText(txt, bx, by); + } + + /* ── 15b. СЛИПАНИЕ badge ── */ + if (this._merged) { + const pulse2 = 0.65 + 0.35 * Math.sin(now / 380); + const txt2 = 'СЛИПАНИЕ'; + ctx.font = 'bold 12px Manrope'; + const tw2 = ctx.measureText(txt2).width; + ctx.fillStyle = `rgba(255,220,100,${0.22 * pulse2})`; + _roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.fill(); + ctx.strokeStyle = `rgba(255,220,100,${0.55 * pulse2})`; ctx.lineWidth = 1.5; + _roundRect(ctx, W/2 - tw2/2 - 12, 8, tw2 + 24, 22, 8); ctx.stroke(); + ctx.fillStyle = `rgba(255,220,100,${pulse2})`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(txt2, W/2, 19); + } + + /* ── 16. Speed badge (top-left when speed ≠ 1×) ── */ + if (Math.abs(this.speed - 1) > 0.05) { + const label = this.speed.toFixed(2) + '×'; + ctx.font = 'bold 11px Manrope'; + const tw = ctx.measureText(label).width; + ctx.fillStyle = 'rgba(6,214,224,.12)'; + _roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.fill(); + ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1; + _roundRect(ctx, 8, 8, tw + 14, 20, 6); ctx.stroke(); + ctx.fillStyle = 'rgba(6,214,224,.9)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText(label, 15, 10); + } + + /* ── 17. Hover ball tooltip ── */ + if (this._hoverBall) { + this._drawBallTooltip(ctx, this._hoverBall, W, H); + } + } + + /* ── hover inspector ── */ + + _onMouseMove(e) { + const r = this.c.getBoundingClientRect(); + const mx = (e.clientX - r.left) * (this.c.width / r.width); + const my = (e.clientY - r.top) * (this.c.height / r.height); + let found = null; + for (const b of this._b) { + if (Math.hypot(mx - b.x, my - b.y) < b.r + 18) { found = b; break; } + } + if (found !== this._hoverBall) { this._hoverBall = found; this.draw(); } + } + + _drawBallTooltip(ctx, b, W, H) { + const [r, g, bl] = b.rgb.split(',').map(Number); + const spd = Math.hypot(b.vx, b.vy); + const ke = 0.5 * b.m * spd * spd; + const p = b.m * spd; + const ang = Math.atan2(b.vy, b.vx) * 180 / Math.PI; + + const rows = [ + { label: 'Масса m', val: b.m + ' кг', color: b.color }, + { label: '|v|', val: spd.toFixed(2) + ' м/с', color: '#ffffff' }, + { label: 'vx', val: b.vx.toFixed(2) + ' м/с', color: '#06D6E0' }, + { label: 'vy', val: b.vy.toFixed(2) + ' м/с', color: '#9B5DE5' }, + { label: 'KE', val: ke.toFixed(1) + ' Дж', color: '#FFD166' }, + { label: 'p = mv', val: p.toFixed(1) + ' кг·м/с', color: '#F15BB5' }, + { label: 'угол', val: ang.toFixed(1) + '°', color: 'rgba(255,255,255,.55)' }, + { label: 'ω', val: (b.angVel || 0).toFixed(1) + ' рад/с', color: '#F15BB5' }, + ]; + + const padX = 10, padY = 8, lineH = 17; + const tw = 148, th = padY * 2 + rows.length * lineH; + + let tx = b.x + b.r + 14, ty = b.y - th / 2; + if (tx + tw > W - 8) tx = b.x - b.r - tw - 14; + if (ty < 8) ty = 8; + if (ty + th > H - 8) ty = H - th - 8; + + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,.65)'; ctx.shadowBlur = 14; + ctx.fillStyle = 'rgba(8,8,18,.93)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill(); + ctx.restore(); + + ctx.strokeStyle = `rgba(${r},${g},${bl},.5)`; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke(); + + ctx.strokeStyle = `rgba(${r},${g},${bl},.8)`; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(tx + 9, ty + 1); ctx.lineTo(tx + tw - 9, ty + 1); ctx.stroke(); + + ctx.font = '10px Manrope, sans-serif'; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const ry = ty + padY + i * lineH + lineH / 2; + if (i > 0) { + ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke(); + } + ctx.fillStyle = 'rgba(255,255,255,.35)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(row.label, tx + padX, ry); + ctx.fillStyle = row.color; ctx.textAlign = 'right'; + ctx.fillText(row.val, tx + tw - padX, ry); + } + } +} + +/* ═══ helpers ═══ */ + +function _colArrow(ctx, x1, y1, x2, y2, color, lw) { + const ang = Math.atan2(y2 - y1, x2 - x1); + ctx.save(); + ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw; + ctx.shadowColor = color; ctx.shadowBlur = 9; + ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - 9 * Math.cos(ang - 0.42), y2 - 9 * Math.sin(ang - 0.42)); + ctx.lineTo(x2 - 9 * Math.cos(ang + 0.42), y2 - 9 * Math.sin(ang + 0.42)); + ctx.closePath(); ctx.fill(); + ctx.restore(); +} + +function _roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); ctx.arcTo(x+w, y, x+w, y+r, r); + ctx.lineTo(x + w, y + h - r); ctx.arcTo(x+w, y+h, x+w-r, y+h, r); + ctx.lineTo(x + r, y + h); ctx.arcTo(x, y+h, x, y+h-r, r); + ctx.lineTo(x, y + r); ctx.arcTo(x, y, x+r, y, r); + ctx.closePath(); +} diff --git a/frontend/js/labs/coulomb.js b/frontend/js/labs/coulomb.js new file mode 100644 index 0000000..53c1367 --- /dev/null +++ b/frontend/js/labs/coulomb.js @@ -0,0 +1,748 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════ + CoulombSim — Coulomb's Law interactive simulation + • Click canvas to place charge (+ or −) + • Drag to reposition, double-click / right-click to remove + • Layers: colormap, field lines, vector arrows, + equipotentials, force arrows + Electric field of point charge q at (cx,cy): + Ex = K·q·(x-cx)/r³, Ey = K·q·(y-cy)/r³ + Potential: V = K·q/r +══════════════════════════════════════════════════════════ */ + +class CoulombSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.charges = []; // [{x, y, q, id}] + this._nextId = 0; + this.addSign = +1; + + /* layers */ + this.layers = { + colormap: true, + fieldlines: true, + vectors: false, + equipotentials: true, + forces: false, + }; + + /* interaction */ + this._drag = null; // charge index being dragged + this._hovered = null; // charge index under mouse + this._downPos = null; // mousedown position for click vs drag detection + this._mousePos = null; // {x, y} + + /* colormap cache */ + this._cmDirty = true; + this._cmCache = null; // ImageData + + /* cursor reading */ + this._cursorE = null; // {ex, ey, mag, v} + + /* visual Coulomb constant */ + this.K = 60000; + + /* dimensions */ + this.W = 0; + this.H = 0; + + /* callback */ + this.onUpdate = null; + + this._bindEvents(); + } + + /* ── Resize ─────────────────────────────────────────────── */ + fit() { + this.W = this.canvas.offsetWidth; + this.H = this.canvas.offsetHeight; + this.canvas.width = this.W * devicePixelRatio; + this.canvas.height = this.H * devicePixelRatio; + this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + this._cmDirty = true; + this._cmCache = null; + this.draw(); + } + + /* ── Reset ──────────────────────────────────────────────── */ + reset() { + this.charges = []; + this._nextId = 0; + this._cmDirty = true; + this._cmCache = null; + this._drag = null; + this._hovered = null; + } + + /* ── Charge management ──────────────────────────────────── */ + addCharge(x, y, q) { + this.charges.push({ x, y, q, id: this._nextId++ }); + this._cmDirty = true; + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + removeCharge(i) { + if (i < 0 || i >= this.charges.length) return; + this.charges.splice(i, 1); + this._cmDirty = true; + this._drag = null; + this._hovered = null; + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + toggleLayer(name) { + if (name in this.layers) { + this.layers[name] = !this.layers[name]; + this.draw(); + } + } + + setSign(s) { + this.addSign = s >= 0 ? +1 : -1; + } + + /* ── Presets ────────────────────────────────────────────── */ + preset(name) { + this.reset(); + const cx = this.W / 2, cy = this.H / 2, d = this.W * 0.2; + if (name === 'dipole') { + this.addCharge(cx - d, cy, 1); + this.addCharge(cx + d, cy, -1); + } else if (name === 'equal') { + this.addCharge(cx - d, cy, 1); + this.addCharge(cx + d, cy, 1); + } else if (name === 'quadrupole') { + this.addCharge(cx - d, cy - d, 1); + this.addCharge(cx + d, cy - d, -1); + this.addCharge(cx + d, cy + d, 1); + this.addCharge(cx - d, cy + d, -1); + } else if (name === 'ring') { + for (let i = 0; i < 6; i++) { + const a = i * Math.PI / 3; + this.addCharge(cx + d * Math.cos(a), cy + d * Math.sin(a), i % 2 === 0 ? 1 : -1); + } + } + this._cmDirty = true; + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ── Info ───────────────────────────────────────────────── */ + info() { + const pos = this.charges.filter(c => c.q > 0).length; + const neg = this.charges.filter(c => c.q < 0).length; + let maxE = 0; + for (let x = 20; x < this.W; x += 40) + for (let y = 20; y < this.H; y += 40) { + const f = this._fieldAt(x, y); + if (f.mag > maxE) maxE = f.mag; + } + const ce = this._cursorE; + return { + total: this.charges.length, + positive: pos, + negative: neg, + maxE: maxE.toFixed(0), + cursorE: ce ? ce.mag.toFixed(0) : '—', + cursorV: ce ? ce.v.toFixed(0) : '—', + }; + } + + /* ── Physics ────────────────────────────────────────────── */ + _fieldAt(x, y) { + let ex = 0, ey = 0, v = 0; + for (const c of this.charges) { + const dx = x - c.x, dy = y - c.y; + const r2 = dx * dx + dy * dy; + if (r2 < 1) continue; + const r = Math.sqrt(r2); + const r3 = r2 * r; + ex += this.K * c.q * dx / r3; + ey += this.K * c.q * dy / r3; + v += this.K * c.q / r; + } + return { ex, ey, mag: Math.hypot(ex, ey), v }; + } + + /* ── HSL RGB helper ───────────────────────────────────── */ + _hslToRgb(h, s, l) { + h = ((h % 360) + 360) % 360; + s /= 100; l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ]; + } + + /* ── Colormap ───────────────────────────────────────────── */ + _drawColormap(ctx) { + const W = this.W, H = this.H; + const STEP = 3; + + if (this._cmDirty || !this._cmCache) { + const imgW = Math.ceil(W / STEP); + const imgH = Math.ceil(H / STEP); + const img = ctx.createImageData(imgW, imgH); + const d = img.data; + + for (let py = 0; py < imgH; py++) { + for (let px = 0; px < imgW; px++) { + const x = px * STEP + STEP / 2; + const y = py * STEP + STEP / 2; + const { mag, v } = this._fieldAt(x, y); + + /* hue based on potential sign */ + let hue; + if (v > 0) hue = 0 + (v / (v + 30000)) * 30; // 0–30 red-orange + else if (v < 0) hue = 240 - ((-v) / (-v + 30000)) * 20; // 220–240 blue + else hue = 0; + + const sat = 80; + const lit = Math.tanh(mag / 3000) * 40 + + Math.tanh(Math.abs(v) / 50000) * 25; + + const [r, g, b] = this._hslToRgb(hue, sat, lit); + const idx = (py * imgW + px) * 4; + d[idx] = r; + d[idx + 1] = g; + d[idx + 2] = b; + d[idx + 3] = 200; + } + } + + this._cmCache = { img, imgW, imgH, STEP }; + this._cmDirty = false; + } + + const { img, imgW, imgH } = this._cmCache; + + /* draw scaled up */ + const oc = document.createElement('canvas'); + oc.width = imgW; + oc.height = imgH; + oc.getContext('2d').putImageData(img, 0, 0); + + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'medium'; + ctx.drawImage(oc, 0, 0, W, H); + ctx.restore(); + } + + /* ── Equipotentials ─────────────────────────────────────── */ + _drawEquipotentials(ctx) { + const W = this.W, H = this.H; + const GRID = 8; + const LEVELS = [500, 2000, 8000, 30000, 100000, -500, -2000, -8000, -30000, -100000]; + + const cols = Math.ceil(W / GRID) + 1; + const rows = Math.ceil(H / GRID) + 1; + + /* build V grid */ + const vGrid = new Float64Array(cols * rows); + for (let r = 0; r < rows; r++) + for (let c = 0; c < cols; c++) + vGrid[r * cols + c] = this._fieldAt(c * GRID, r * GRID).v; + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; + ctx.lineWidth = 0.8; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + + for (const level of LEVELS) { + for (let r = 0; r < rows - 1; r++) { + for (let c = 0; c < cols - 1; c++) { + const v00 = vGrid[ r * cols + c ]; + const v10 = vGrid[ r * cols + c + 1]; + const v01 = vGrid[(r + 1) * cols + c ]; + const v11 = vGrid[(r + 1) * cols + c + 1]; + + /* crossed edges: top, right, bottom, left */ + const pts = []; + const interp = (va, vb, xa, ya, xb, yb) => { + const t = (level - va) / (vb - va); + return [xa + t * (xb - xa), ya + t * (yb - ya)]; + }; + + if ((v00 - level) * (v10 - level) < 0) + pts.push(interp(v00, v10, c * GRID, r * GRID, (c + 1) * GRID, r * GRID)); + if ((v10 - level) * (v11 - level) < 0) + pts.push(interp(v10, v11, (c + 1) * GRID, r * GRID, (c + 1) * GRID, (r + 1) * GRID)); + if ((v01 - level) * (v11 - level) < 0) + pts.push(interp(v01, v11, c * GRID, (r + 1) * GRID, (c + 1) * GRID, (r + 1) * GRID)); + if ((v00 - level) * (v01 - level) < 0) + pts.push(interp(v00, v01, c * GRID, r * GRID, c * GRID, (r + 1) * GRID)); + + if (pts.length >= 2) { + ctx.moveTo(pts[0][0], pts[0][1]); + ctx.lineTo(pts[1][0], pts[1][1]); + } + } + } + } + + ctx.stroke(); + ctx.restore(); + } + + /* ── Vector arrows ──────────────────────────────────────── */ + _drawVectors(ctx) { + const GRID = 45; + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1; + + for (let x = GRID / 2; x < this.W; x += GRID) { + for (let y = GRID / 2; y < this.H; y += GRID) { + const { ex, ey, mag } = this._fieldAt(x, y); + if (mag < 1e-6) continue; + const len = Math.tanh(mag / 8000) * 18; + const nx = ex / mag, ny = ey / mag; + const x2 = x + nx * len, y2 = y + ny * len; + + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x2, y2); + ctx.stroke(); + + /* arrowhead */ + const ax = -ny * 3, ay = nx * 3; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - nx * 6 + ax, y2 - ny * 6 + ay); + ctx.lineTo(x2 - nx * 6 - ax, y2 - ny * 6 - ay); + ctx.closePath(); + ctx.fill(); + } + } + ctx.restore(); + } + + /* ── Field lines ────────────────────────────────────────── */ + _drawFieldLines(ctx) { + const W = this.W, H = this.H, sim = this; + const RAYS = 12; + const STEP = 2.5; + const MAX = 2500; + const MARGIN = 5; + const HIT_R = 12; + const START_R = 18; + + function rkStep(x, y, h) { + const f = (px, py) => { + const e = sim._fieldAt(px, py); + const m = Math.hypot(e.ex, e.ey) || 1e-10; + return [e.ex / m, e.ey / m]; + }; + const [k1x, k1y] = f(x, y); + const [k2x, k2y] = f(x + h * k1x / 2, y + h * k1y / 2); + const [k3x, k3y] = f(x + h * k2x / 2, y + h * k2y / 2); + const [k4x, k4y] = f(x + h * k3x, y + h * k3y); + return [ + x + h * (k1x + 2 * k2x + 2 * k3x + k4x) / 6, + y + h * (k1y + 2 * k2y + 2 * k3y + k4y) / 6, + ]; + } + + const traceLine = (startX, startY, dir) => { + const pts = [[startX, startY]]; + let px = startX, py = startY; + for (let s = 0; s < MAX; s++) { + const [nx, ny] = rkStep(px, py, dir * STEP); + if (nx < -MARGIN || nx > W + MARGIN || ny < -MARGIN || ny > H + MARGIN) break; + + /* stop near negative charges */ + let hitNeg = false; + for (const c of sim.charges) { + if (c.q < 0 && Math.hypot(nx - c.x, ny - c.y) < HIT_R) { hitNeg = true; break; } + } + if (hitNeg) break; + + pts.push([nx, ny]); + px = nx; py = ny; + } + return pts; + }; + + ctx.save(); + ctx.lineWidth = 1.2; + + for (const charge of this.charges) { + const dir = charge.q > 0 ? 1 : -1; + for (let i = 0; i < RAYS; i++) { + const angle = (i / RAYS) * Math.PI * 2; + const sx = charge.x + START_R * Math.cos(angle); + const sy = charge.y + START_R * Math.sin(angle); + const pts = traceLine(sx, sy, dir); + if (pts.length < 2) continue; + + const grad = ctx.createLinearGradient(pts[0][0], pts[0][1], pts[pts.length - 1][0], pts[pts.length - 1][1]); + grad.addColorStop(0, 'rgba(255,255,255,0.75)'); + grad.addColorStop(0.5, 'rgba(255,255,255,0.35)'); + grad.addColorStop(1, 'rgba(255,255,255,0.0)'); + ctx.strokeStyle = grad; + + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]); + ctx.stroke(); + } + } + ctx.restore(); + } + + /* ── Force arrows ───────────────────────────────────────── */ + _drawForceArrows(ctx) { + ctx.save(); + for (let i = 0; i < this.charges.length; i++) { + const ci = this.charges[i]; + let fx = 0, fy = 0; + for (let j = 0; j < this.charges.length; j++) { + if (i === j) continue; + const cj = this.charges[j]; + const dx = ci.x - cj.x, dy = ci.y - cj.y; + const r2 = dx * dx + dy * dy; + if (r2 < 1) continue; + const r3 = r2 * Math.sqrt(r2); + const F = this.K * ci.q * cj.q; + fx += F * dx / r3; + fy += F * dy / r3; + } + const mag = Math.hypot(fx, fy); + if (mag < 1e-6) continue; + + const len = Math.tanh(mag / 50000) * 55; + const nx = fx / mag, ny = fy / mag; + const x2 = ci.x + nx * len, y2 = ci.y + ny * len; + + ctx.strokeStyle = '#FFD166'; + ctx.fillStyle = '#FFD166'; + ctx.lineWidth = 2; + ctx.shadowBlur = 10; + ctx.shadowColor = '#FFD166'; + + ctx.beginPath(); + ctx.moveTo(ci.x, ci.y); + ctx.lineTo(x2, y2); + ctx.stroke(); + + /* arrowhead */ + const ax = -ny * 5, ay = nx * 5; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - nx * 10 + ax, y2 - ny * 10 + ay); + ctx.lineTo(x2 - nx * 10 - ax, y2 - ny * 10 - ay); + ctx.closePath(); + ctx.fill(); + + ctx.shadowBlur = 0; + } + ctx.restore(); + } + + /* ── Draw charges ───────────────────────────────────────── */ + _drawCharges(ctx) { + for (let i = 0; i < this.charges.length; i++) { + const c = this.charges[i]; + const r = 14 + Math.tanh(Math.abs(c.q) / 5) * 4; + const pos = c.q > 0; + + ctx.save(); + ctx.shadowBlur = 18; + ctx.shadowColor = pos ? '#EF476F' : '#4CC9F0'; + + /* hovered outer ring */ + if (this._hovered === i) { + ctx.beginPath(); + ctx.arc(c.x, c.y, r + 6, 0, Math.PI * 2); + ctx.strokeStyle = pos ? 'rgba(239,71,111,0.45)' : 'rgba(76,201,240,0.45)'; + ctx.lineWidth = 2; + ctx.stroke(); + } + + /* body gradient */ + const grd = ctx.createRadialGradient(c.x - r * 0.3, c.y - r * 0.3, r * 0.1, c.x, c.y, r); + if (pos) { + grd.addColorStop(0, '#FF7FA3'); + grd.addColorStop(1, '#EF476F'); + } else { + grd.addColorStop(0, '#90E0FF'); + grd.addColorStop(1, '#4CC9F0'); + } + + ctx.beginPath(); + ctx.arc(c.x, c.y, r, 0, Math.PI * 2); + ctx.fillStyle = grd; + ctx.fill(); + + /* label */ + ctx.shadowBlur = 0; + ctx.fillStyle = '#fff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(pos ? '+' : '−', c.x, c.y + 1); + + ctx.restore(); + } + } + + /* ── Cursor E display ───────────────────────────────────── */ + _drawCursorE(ctx) { + const { ex, ey, mag, v } = this._cursorE; + const { x, y } = this._mousePos; + if (mag < 1e-6) return; + + const nx = ex / mag, ny = ey / mag; + const len = 20; + const x2 = x + nx * len, y2 = y + ny * len; + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.lineWidth = 1.5; + + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x2, y2); + ctx.stroke(); + + const ax = -ny * 4, ay = nx * 4; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - nx * 8 + ax, y2 - ny * 8 + ay); + ctx.lineTo(x2 - nx * 8 - ax, y2 - ny * 8 - ay); + ctx.closePath(); + ctx.fill(); + + /* text */ + const eStr = mag >= 1000 ? (mag / 1000).toFixed(1) + 'k' : mag.toFixed(0); + const vStr = Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0); + + ctx.font = '11px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.shadowBlur = 4; + ctx.shadowColor = '#000'; + ctx.fillText(`|E| = ${eStr}`, x + 6, y - 14); + ctx.fillText(`V = ${vStr}`, x + 6, y - 2); + ctx.restore(); + } + + /* ── Hint ───────────────────────────────────────────────── */ + _drawHint(ctx) { + const W = this.W, H = this.H; + ctx.save(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '16px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fillText('Нажмите чтобы добавить заряд', W / 2, H / 2 + 30); + + /* simple circle icon */ + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(W / 2, H / 2 - 14, 18, 0, Math.PI * 2); + ctx.stroke(); + ctx.font = 'bold 22px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillText('+', W / 2, H / 2 - 13); + ctx.restore(); + } + + /* ── Main draw ──────────────────────────────────────────── */ + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + ctx.clearRect(0, 0, W, H); + + /* 1. background radial gradient */ + const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) / 2); + bg.addColorStop(0, '#0D0D1A'); + bg.addColorStop(1, '#050508'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + /* 2. subtle grid */ + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.025)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < W; x += 30) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } + for (let y = 0; y < H; y += 30) { ctx.moveTo(0, y); ctx.lineTo(W, y); } + ctx.stroke(); + ctx.restore(); + + if (this.charges.length > 0) { + /* 3. colormap */ + if (this.layers.colormap) this._drawColormap(ctx); + /* 4. equipotentials */ + if (this.layers.equipotentials) this._drawEquipotentials(ctx); + /* 5. vectors */ + if (this.layers.vectors) this._drawVectors(ctx); + /* 6. field lines */ + if (this.layers.fieldlines) this._drawFieldLines(ctx); + /* 7. force arrows */ + if (this.layers.forces) this._drawForceArrows(ctx); + } + + /* 8. charges */ + this._drawCharges(ctx); + + /* 9. cursor E */ + if (this._cursorE && this._mousePos && this.charges.length > 0) + this._drawCursorE(ctx); + + /* 10. hint if empty */ + if (this.charges.length === 0) this._drawHint(ctx); + } + + /* ── Events ─────────────────────────────────────────────── */ + _bindEvents() { + const canvas = this.canvas; + + const pos = e => { + const r = canvas.getBoundingClientRect(); + const s = e.touches ? e.touches[0] : e; + return { x: s.clientX - r.left, y: s.clientY - r.top }; + }; + + const hitIdx = p => { + for (let i = this.charges.length - 1; i >= 0; i--) + if (Math.hypot(p.x - this.charges[i].x, p.y - this.charges[i].y) < 20) return i; + return -1; + }; + + /* ── mousedown ── */ + canvas.addEventListener('mousedown', e => { + if (e.button !== 0) return; + const p = pos(e); + const hi = hitIdx(p); + this._downPos = p; + if (hi >= 0) this._drag = hi; + }); + + /* ── mousemove ── */ + canvas.addEventListener('mousemove', e => { + const p = pos(e); + this._mousePos = p; + if (this._drag !== null) { + this.charges[this._drag].x = p.x; + this.charges[this._drag].y = p.y; + this._cmDirty = true; + this._cursorE = this._fieldAt(p.x, p.y); + this.draw(); + } else { + this._hovered = hitIdx(p); + this._cursorE = this.charges.length > 0 ? this._fieldAt(p.x, p.y) : null; + this.draw(); + } + }); + + /* ── mouseup ── */ + canvas.addEventListener('mouseup', e => { + if (e.button !== 0) return; + const p = pos(e); + const wasDragging = this._drag !== null; + if (wasDragging) { + this._drag = null; + this._cmDirty = true; + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + return; + } + /* click (no drag) */ + const dp = this._downPos || p; + const dist = Math.hypot(p.x - dp.x, p.y - dp.y); + if (dist < 5) { + const hi = hitIdx(p); + if (hi < 0) this.addCharge(p.x, p.y, this.addSign); + } + if (this.onUpdate) this.onUpdate(this.info()); + }); + + /* ── contextmenu (remove) ── */ + canvas.addEventListener('contextmenu', e => { + e.preventDefault(); + const p = pos(e); + const hi = hitIdx(p); + if (hi >= 0) this.removeCharge(hi); + }); + + /* ── dblclick (remove) ── */ + canvas.addEventListener('dblclick', e => { + const p = pos(e); + const hi = hitIdx(p); + if (hi >= 0) this.removeCharge(hi); + }); + + /* ── mouseleave ── */ + canvas.addEventListener('mouseleave', () => { + this._cursorE = null; + this._mousePos = null; + this._hovered = null; + this.draw(); + }); + + /* ── touch support ── */ + canvas.addEventListener('touchstart', e => { + e.preventDefault(); + const p = pos(e); + const hi = hitIdx(p); + this._downPos = p; + if (hi >= 0) this._drag = hi; + }, { passive: false }); + + canvas.addEventListener('touchmove', e => { + e.preventDefault(); + const p = pos(e); + this._mousePos = p; + if (this._drag !== null) { + this.charges[this._drag].x = p.x; + this.charges[this._drag].y = p.y; + this._cmDirty = true; + this._cursorE = this._fieldAt(p.x, p.y); + this.draw(); + } + }, { passive: false }); + + canvas.addEventListener('touchend', e => { + e.preventDefault(); + const wasDragging = this._drag !== null; + if (wasDragging) { + this._drag = null; + this._cmDirty = true; + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + return; + } + const p = pos({ touches: e.changedTouches }); + const dp = this._downPos || p; + const dist = Math.hypot(p.x - dp.x, p.y - dp.y); + if (dist < 10) { + const hi = hitIdx(p); + if (hi < 0) this.addCharge(p.x, p.y, this.addSign); + } + if (this.onUpdate) this.onUpdate(this.info()); + }, { passive: false }); + } +} diff --git a/frontend/js/labs/crystal.js b/frontend/js/labs/crystal.js new file mode 100644 index 0000000..93d91cf --- /dev/null +++ b/frontend/js/labs/crystal.js @@ -0,0 +1,315 @@ +'use strict'; + +/* ═══════════════════════════════════════════════ + CrystalSim — 3D crystal lattice (Three.js) + NaCl, Diamond, BCC metal, FCC metal + ═══════════════════════════════════════════════ */ + +class CrystalSim { + constructor(container) { + this.container = container; + this._running = false; + + /* Three.js */ + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x0D0D1A, 1); + container.appendChild(this.renderer.domElement); + + /* lighting */ + this.scene.add(new THREE.AmbientLight(0xffffff, 0.5)); + const dir = new THREE.DirectionalLight(0xffffff, 0.8); + dir.position.set(5, 8, 6); + this.scene.add(dir); + const pt = new THREE.PointLight(0x9B5DE5, 0.4, 50); + pt.position.set(-4, 3, 5); + this.scene.add(pt); + + this.camera.position.set(8, 6, 8); + this.camera.lookAt(0, 0, 0); + + /* orbit-like manual controls */ + this._drag = false; + this._prevX = 0; + this._prevY = 0; + this._rotY = 0.6; + this._rotX = 0.4; + this._dist = 12; + this._autoSpin = true; + + const el = this.renderer.domElement; + el.style.cursor = 'grab'; + el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; }); + window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; }); + window.addEventListener('pointermove', e => { + if (!this._drag) return; + this._rotY += (e.clientX - this._prevX) * 0.008; + this._rotX += (e.clientY - this._prevY) * 0.008; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._prevX = e.clientX; this._prevY = e.clientY; + }); + el.addEventListener('wheel', e => { + e.preventDefault(); + this._dist = Math.max(5, Math.min(30, this._dist + e.deltaY * 0.02)); + }, { passive: false }); + + /* touch */ + el.addEventListener('touchstart', e => { + if (e.touches.length === 1) { + this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false; + } + }, { passive: true }); + el.addEventListener('touchmove', e => { + if (!this._drag || e.touches.length !== 1) return; + const t = e.touches[0]; + this._rotY += (t.clientX - this._prevX) * 0.008; + this._rotX += (t.clientY - this._prevY) * 0.008; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._prevX = t.clientX; this._prevY = t.clientY; + }, { passive: true }); + el.addEventListener('touchend', () => { this._drag = false; }); + + /* resize */ + this._ro = new ResizeObserver(() => this.fit()); + this._ro.observe(container); + + /* state */ + this._lattice = 'nacl'; + this._group = new THREE.Group(); + this.scene.add(this._group); + + this._buildLattice('nacl'); + this.fit(); + this.play(); + } + + /* ── public ── */ + setLattice(type) { + this._lattice = type; + this._buildLattice(type); + } + + fit() { + const w = this.container.clientWidth || 600; + const h = this.container.clientHeight || 400; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(w, h); + } + + play() { if (!this._running) { this._running = true; this._loop(); } } + stop() { this._running = false; } + pause() { this._running = false; } + + /* ── lattice builders ── */ + _buildLattice(type) { + // clear + while (this._group.children.length) { + const c = this._group.children[0]; + c.geometry?.dispose(); c.material?.dispose(); + this._group.remove(c); + } + + const builders = { + nacl: () => this._buildNaCl(), + diamond: () => this._buildDiamond(), + bcc: () => this._buildBCC(), + fcc: () => this._buildFCC(), + }; + (builders[type] || builders.nacl)(); + } + + _sphere(r, color) { + const geo = new THREE.SphereGeometry(r, 24, 24); + const mat = new THREE.MeshPhysicalMaterial({ + color, metalness: 0.1, roughness: 0.3, + clearcoat: 0.6, clearcoatRoughness: 0.2, + }); + return new THREE.Mesh(geo, mat); + } + + _bond(from, to, color = 0x555555) { + const dir = new THREE.Vector3().subVectors(to, from); + const len = dir.length(); + const geo = new THREE.CylinderGeometry(0.04, 0.04, len, 8); + const mat = new THREE.MeshStandardMaterial({ color, opacity: 0.5, transparent: true }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.copy(from).add(dir.multiplyScalar(0.5)); + mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()); + return mesh; + } + + _buildNaCl() { + const n = 3; // 3×3×3 unit cells + const a = 2.0; // lattice constant + const offset = -(n - 1) * a / 2; + const positions = []; + + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) + for (let k = 0; k < n; k++) { + const x = offset + i * a, y = offset + j * a, z = offset + k * a; + const isNa = (i + j + k) % 2 === 0; + const s = this._sphere(isNa ? 0.28 : 0.38, isNa ? 0x9B5DE5 : 0x06D6E0); + s.position.set(x, y, z); + this._group.add(s); + positions.push({ x, y, z }); + } + + // bonds to nearest neighbors + for (let i = 0; i < positions.length; i++) + for (let j = i + 1; j < positions.length; j++) { + const dx = positions[i].x - positions[j].x; + const dy = positions[i].y - positions[j].y; + const dz = positions[i].z - positions[j].z; + const d2 = dx * dx + dy * dy + dz * dz; + if (Math.abs(d2 - a * a) < 0.01) { + const b = this._bond( + new THREE.Vector3(positions[i].x, positions[i].y, positions[i].z), + new THREE.Vector3(positions[j].x, positions[j].y, positions[j].z), + 0x444466 + ); + this._group.add(b); + } + } + } + + _buildDiamond() { + const a = 2.5; + const basis = [ + [0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5], + [0.25, 0.25, 0.25], [0.75, 0.75, 0.25], [0.75, 0.25, 0.75], [0.25, 0.75, 0.75], + ]; + const n = 2; + const offset = -(n * a) / 2; + const allPos = []; + + for (let ci = 0; ci < n; ci++) + for (let cj = 0; cj < n; cj++) + for (let ck = 0; ck < n; ck++) + for (const [bx, by, bz] of basis) { + const x = offset + (ci + bx) * a; + const y = offset + (cj + by) * a; + const z = offset + (ck + bz) * a; + const s = this._sphere(0.22, 0x34d399); + s.position.set(x, y, z); + this._group.add(s); + allPos.push(new THREE.Vector3(x, y, z)); + } + + // bonds + const bondLen = a * Math.sqrt(3) / 4; + const tol = bondLen * 0.15; + for (let i = 0; i < allPos.length; i++) + for (let j = i + 1; j < allPos.length; j++) { + const d = allPos[i].distanceTo(allPos[j]); + if (Math.abs(d - bondLen) < tol) { + this._group.add(this._bond(allPos[i], allPos[j], 0x228866)); + } + } + } + + _buildBCC() { + const a = 2.2, n = 3; + const offset = -(n - 1) * a / 2; + const allPos = []; + + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) + for (let k = 0; k < n; k++) { + // corner atoms + const x1 = offset + i * a, y1 = offset + j * a, z1 = offset + k * a; + const s1 = this._sphere(0.3, 0xF15BB5); + s1.position.set(x1, y1, z1); + this._group.add(s1); + allPos.push(new THREE.Vector3(x1, y1, z1)); + + // body center (except last cell in each dimension) + if (i < n - 1 && j < n - 1 && k < n - 1) { + const cx = x1 + a / 2, cy = y1 + a / 2, cz = z1 + a / 2; + const s2 = this._sphere(0.3, 0xF59E0B); + s2.position.set(cx, cy, cz); + this._group.add(s2); + allPos.push(new THREE.Vector3(cx, cy, cz)); + } + } + + // bonds + const bondLen = a * Math.sqrt(3) / 2; + const tol = bondLen * 0.1; + for (let i = 0; i < allPos.length; i++) + for (let j = i + 1; j < allPos.length; j++) { + const d = allPos[i].distanceTo(allPos[j]); + if (Math.abs(d - bondLen) < tol) { + this._group.add(this._bond(allPos[i], allPos[j], 0x664444)); + } + } + } + + _buildFCC() { + const a = 2.4, n = 3; + const offset = -(n - 1) * a / 2; + const allPos = []; + + for (let i = 0; i < n; i++) + for (let j = 0; j < n; j++) + for (let k = 0; k < n; k++) { + const x = offset + i * a, y = offset + j * a, z = offset + k * a; + // corner + const s = this._sphere(0.25, 0x60a5fa); + s.position.set(x, y, z); + this._group.add(s); + allPos.push(new THREE.Vector3(x, y, z)); + + // face centers (only for cell interiors) + if (i < n - 1 && j < n - 1) { + const f1 = this._sphere(0.25, 0x60a5fa); + f1.position.set(x + a / 2, y + a / 2, z); + this._group.add(f1); + allPos.push(new THREE.Vector3(x + a / 2, y + a / 2, z)); + } + if (i < n - 1 && k < n - 1) { + const f2 = this._sphere(0.25, 0x60a5fa); + f2.position.set(x + a / 2, y, z + a / 2); + this._group.add(f2); + allPos.push(new THREE.Vector3(x + a / 2, y, z + a / 2)); + } + if (j < n - 1 && k < n - 1) { + const f3 = this._sphere(0.25, 0x60a5fa); + f3.position.set(x, y + a / 2, z + a / 2); + this._group.add(f3); + allPos.push(new THREE.Vector3(x, y + a / 2, z + a / 2)); + } + } + + // bonds to nearest neighbors (a/√2) + const bondLen = a / Math.SQRT2; + const tol = bondLen * 0.1; + for (let i = 0; i < allPos.length; i++) + for (let j = i + 1; j < allPos.length; j++) { + const d = allPos[i].distanceTo(allPos[j]); + if (Math.abs(d - bondLen) < tol) { + this._group.add(this._bond(allPos[i], allPos[j], 0x334466)); + } + } + } + + /* ── animation ── */ + _loop() { + if (!this._running) return; + requestAnimationFrame(() => this._loop()); + + if (this._autoSpin) this._rotY += 0.003; + + this.camera.position.set( + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), + this._dist * Math.sin(this._rotX), + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) + ); + this.camera.lookAt(0, 0, 0); + + this.renderer.render(this.scene, this.camera); + } +} diff --git a/frontend/js/labs/diffusion.js b/frontend/js/labs/diffusion.js new file mode 100644 index 0000000..c89f403 --- /dev/null +++ b/frontend/js/labs/diffusion.js @@ -0,0 +1,465 @@ +'use strict'; + +/** + * DiffusionSim v2 — Diffusion simulation (two gases mixing). + * v2: entropy timeline on history chart, pore mode (gap in partition), density heatmap. + */ +class DiffusionSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + this.particles = []; + this.N = 60; + this.T = 1.0; + this.partitionOn = true; + this._history = []; // {step, fracA_left, entropy} + this._steps = 0; + this._raf = null; + this.onUpdate = null; + this._dpr = 1; + + // v2 + this._poreMode = false; // partition has a gap in the center + this._poreH = 40; // gap height in pixels + this._heatmap = null; // cached density heatmap + this._hmTick = 0; + } + + // ── public API ────────────────────────────────────────────────────────────── + fit() { + const dpr = window.devicePixelRatio || 1; + this._dpr = dpr; + const w = this.canvas.offsetWidth, h = this.canvas.offsetHeight; + this.canvas.width = w * dpr; this.canvas.height = h * dpr; + this.W = w; this.H = h; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.reset(); + } + + reset() { + const { W, H } = this; + this.partitionOn = true; + this._poreMode = false; + this._steps = 0; + this._history = [{ step: 0, fracA_left: 1.0, entropy: 0 }]; + this._heatmap = null; + + const particles = []; + const r = 5; + + let attA = 0; + while (particles.filter(p => p.type === 'A').length < this.N && attA < this.N * 30) { + attA++; + const x = r + Math.random() * (W / 2 - 2 * r); + const y = r + Math.random() * (H - 2 * r); + const a = Math.random() * Math.PI * 2, s = this.T * 3.5; + particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r, type: 'A' }); + } + + let attB = 0; + while (particles.filter(p => p.type === 'B').length < this.N && attB < this.N * 30) { + attB++; + const x = W / 2 + r + Math.random() * (W / 2 - 2 * r); + const y = r + Math.random() * (H - 2 * r); + const a = Math.random() * Math.PI * 2, s = this.T * 3.5; + particles.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, r, type: 'B' }); + } + + this.particles = particles; + } + + togglePartition() { + if (this._poreMode) { + // If pore is on, full toggle removes pore first + this._poreMode = false; + this.partitionOn = true; + } else { + this.partitionOn = !this.partitionOn; + } + } + + togglePore() { + if (!this.partitionOn && !this._poreMode) { + // Partition is fully off — re-enable with pore + this.partitionOn = true; + this._poreMode = true; + } else if (this.partitionOn && !this._poreMode) { + this._poreMode = true; // add pore to full partition + } else if (this._poreMode) { + this._poreMode = false; // remove pore, keep partition + } + } + + setN(n) { this.N = Math.max(10, Math.min(200, n)); this.reset(); } + + setT(t) { + const f = Math.sqrt(t / this.T); + for (const p of this.particles) { p.vx *= f; p.vy *= f; } + this.T = t; + } + + start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop.bind(this)); } + stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } + + // ── simulation ────────────────────────────────────────────────────────────── + _loop() { + this._step(); this._step(); + this.draw(); + this._raf = requestAnimationFrame(this._loop.bind(this)); + } + + _step() { + const { W, H, particles } = this; + + for (const p of particles) { + p.x += p.vx; p.y += p.vy; + + if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); } + if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); } + if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); } + if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); } + + // Partition logic + if (this.partitionOn) { + const mid = W / 2, hw = 3; + const inPore = this._poreMode + && p.y > H / 2 - this._poreH / 2 + && p.y < H / 2 + this._poreH / 2; + + if (!inPore) { + if (p.vx > 0 && p.x + p.r > mid - hw && p.x < mid) { + p.x = mid - hw - p.r; p.vx = -Math.abs(p.vx); + } else if (p.vx < 0 && p.x - p.r < mid + hw && p.x > mid) { + p.x = mid + hw + p.r; p.vx = Math.abs(p.vx); + } + } + } + } + + // Spatial grid collisions + const cs = 14, cols = Math.ceil(W / cs) + 1; + const grid = new Map(); + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols; + if (!grid.has(k)) grid.set(k, []); + grid.get(k).push(i); + } + + for (let i = 0; i < particles.length; i++) { + const p1 = particles[i]; + const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs); + for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) { + const cell = grid.get((cx + dcx) + (cy + dcy) * cols); + if (!cell) continue; + for (const j of cell) { + if (j <= i) continue; + const p2 = particles[j]; + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const d = Math.hypot(dx, dy), md = p1.r + p2.r; + if (d < md && d > 0.001) { + const nx = dx / d, ny = dy / d; + const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny; + if (dvn < 0) continue; + p1.vx -= dvn * nx; p1.vy -= dvn * ny; + p2.vx += dvn * nx; p2.vy += dvn * ny; + const ov = (md - d) / 2; + p1.x -= nx * ov; p1.y -= ny * ov; + p2.x += nx * ov; p2.y += ny * ov; + } + } + } + } + + // History (with entropy) + if (this._steps % 60 === 0) { + const left = particles.filter(p => p.x < W / 2); + const fracA_left = left.length > 0 + ? left.filter(p => p.type === 'A').length / left.length + : 0; + const f = fracA_left; + const entropy = -(f * Math.log(f + 1e-9) + (1 - f) * Math.log(1 - f + 1e-9)); + this._history.push({ step: this._steps, fracA_left, entropy }); + if (this._history.length > 200) this._history.shift(); + } + + // Heatmap update (every 30 steps) + if (this._steps % 30 === 0) this._updateHeatmap(); + + this._steps++; + if (this._steps % 30 === 0 && this.onUpdate) this.onUpdate(this.info()); + } + + _updateHeatmap() { + const { W, H, particles } = this; + const cols = 20, rows = 14; + const cw = W / cols, ch = H / rows; + const grid = []; + for (let r = 0; r < rows; r++) { + grid[r] = []; + for (let c = 0; c < cols; c++) grid[r][c] = { A: 0, B: 0 }; + } + for (const p of particles) { + const c = Math.min(cols - 1, Math.floor(p.x / cw)); + const r = Math.min(rows - 1, Math.floor(p.y / ch)); + grid[r][c][p.type]++; + } + const maxCount = Math.max(...grid.flat().map(c => c.A + c.B), 1); + this._heatmap = { grid, cols, rows, cw, ch, maxCount }; + } + + info() { + const { particles, W, N } = this; + const leftA = particles.filter(p => p.x < W / 2 && p.type === 'A').length; + const leftB = particles.filter(p => p.x < W / 2 && p.type === 'B').length; + const rightA = particles.filter(p => p.x >= W / 2 && p.type === 'A').length; + const rightB = particles.filter(p => p.x >= W / 2 && p.type === 'B').length; + const mixed = (leftB + rightA) / (2 * N); + const fracAL = leftA / ((leftA + leftB) || 1); + const entropy = -(fracAL * Math.log(fracAL + 1e-9) + (1 - fracAL) * Math.log(1 - fracAL + 1e-9)); + return { + leftA, leftB, rightA, rightB, + mixed: (mixed * 100).toFixed(0), + entropy: entropy.toFixed(3), + partitionOn: this.partitionOn, + poreMode: this._poreMode, + steps: this._steps, + }; + } + + // ── drawing ───────────────────────────────────────────────────────────────── + draw() { + const { ctx, W, H } = this; + const TAU = Math.PI * 2; + + ctx.fillStyle = '#080818'; ctx.fillRect(0, 0, W, H); + + // Background tints + ctx.fillStyle = 'rgba(6,214,224,0.04)'; ctx.fillRect(0, 0, W / 2, H); + ctx.fillStyle = 'rgba(241,91,181,0.04)'; ctx.fillRect(W / 2, 0, W / 2, H); + + // Density heatmap (subtle) + this._drawHeatmap(ctx); + + // Partition + if (this.partitionOn) this._drawPartition(ctx, W, H); + + // Particles + ctx.save(); + for (const p of this.particles) { + const color = p.type === 'A' ? '#06D6E0' : '#F15BB5'; + ctx.shadowColor = color; ctx.shadowBlur = 6; + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU); ctx.fill(); + } + ctx.restore(); + + // Concentration bar (right side) + this._drawConcBar(ctx, W, H); + + // History chart with entropy (bottom) + this._drawHistoryChart(ctx, W, H); + + // Stats overlay (top-left) + this._drawStats(ctx); + } + + _drawHeatmap(ctx) { + const hm = this._heatmap; + if (!hm) return; + for (let r = 0; r < hm.rows; r++) for (let c = 0; c < hm.cols; c++) { + const cell = hm.grid[r][c]; + const total = cell.A + cell.B; + if (total === 0) continue; + const frac = total / hm.maxCount; + // Color based on A vs B ratio + const fracA = cell.A / total; + // Mix cyan and pink by composition + const rr = Math.round(6 + (241 - 6) * (1 - fracA)); + const rg = Math.round(214 + (91 - 214) * (1 - fracA)); + const rb = Math.round(224 + (181 - 224) * (1 - fracA)); + ctx.fillStyle = `rgba(${rr},${rg},${rb},${frac * 0.08})`; + ctx.fillRect(c * hm.cw, r * hm.ch, hm.cw, hm.ch); + } + } + + _drawPartition(ctx, W, H) { + const mid = W / 2, pw = 6; + const poreOn = this._poreMode; + const poreY1 = H / 2 - this._poreH / 2; + const poreY2 = H / 2 + this._poreH / 2; + + ctx.save(); + ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(255,255,255,0.5)'; + + const grad = ctx.createLinearGradient(mid - pw / 2, 0, mid + pw / 2, 0); + grad.addColorStop(0, 'rgba(255,255,255,0.15)'); + grad.addColorStop(1, 'rgba(255,255,255,0.05)'); + ctx.fillStyle = grad; + + if (!poreOn) { + ctx.fillRect(mid - pw / 2, 0, pw, H); + } else { + // Two segments (above and below pore) + ctx.fillRect(mid - pw / 2, 0, pw, poreY1); + ctx.fillRect(mid - pw / 2, poreY2, pw, H - poreY2); + + // Pore opening highlight + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(mid - pw / 2, poreY1); ctx.lineTo(mid + pw / 2, poreY1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(mid - pw / 2, poreY2); ctx.lineTo(mid + pw / 2, poreY2); ctx.stroke(); + ctx.setLineDash([]); + + // Pore gap arrows (showing flow direction) + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.font = "bold 10px monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('⇌', mid, H / 2); + } + + if (!poreOn) { + // Door handle + const hx = mid - 10, hy = H / 2 - 14, hw = 20, hh = 28; + ctx.shadowBlur = 0; + ctx.fillStyle = 'rgba(255,255,255,0.12)'; + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 4); ctx.fill(); ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = "bold 10px monospace"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('||', mid, H / 2); + } + + ctx.restore(); + } + + _drawConcBar(ctx, W, H) { + const barX = W - 20, barHalf = H / 2; + const { particles } = this; + const lA = particles.filter(p => p.x < W / 2 && p.type === 'A').length; + const lT = particles.filter(p => p.x < W / 2).length || 1; + const rA = particles.filter(p => p.x >= W / 2 && p.type === 'A').length; + const rT = particles.filter(p => p.x >= W / 2).length || 1; + const fAL = lA / lT, fAR = rA / rT; + + ctx.fillStyle = '#06D6E0'; ctx.fillRect(barX, 0, 20, barHalf * fAL); + ctx.fillStyle = '#F15BB5'; ctx.fillRect(barX, barHalf * fAL, 20, barHalf * (1 - fAL)); + ctx.fillStyle = '#06D6E0'; ctx.fillRect(barX, barHalf, 20, barHalf * fAR); + ctx.fillStyle = '#F15BB5'; ctx.fillRect(barX, barHalf + barHalf * fAR, 20, barHalf * (1 - fAR)); + + ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.fillRect(barX, barHalf - 1, 20, 2); + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; + ctx.strokeRect(barX, 0, 20, H); + + ctx.save(); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = "9px 'Manrope', sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.translate(barX + 10, H / 2); ctx.rotate(-Math.PI / 2); + ctx.fillText('Концентрация', 0, 0); + ctx.restore(); + } + + _drawHistoryChart(ctx, W, H) { + const graphH = 100, graphY = H - graphH, graphW = W - 24; + + ctx.save(); + ctx.fillStyle = 'rgba(0,0,10,0.76)'; + ctx.beginPath(); ctx.roundRect(0, graphY, graphW, graphH, 8); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = "10px 'Manrope', sans-serif"; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('Доля A в левой половине', 10, graphY + 6); + + // Y-axis labels + ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = "9px 'Manrope', sans-serif"; + ctx.fillText('1.0', 4, graphY + 18); + ctx.fillText('0.0', 4, graphY + graphH - 10); + + const refY = graphY + graphH * 0.5 - 2; + ctx.setLineDash([4, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(24, refY); ctx.lineTo(graphW - 10, refY); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = "9px 'Manrope', sans-serif"; + ctx.textAlign = 'left'; ctx.fillText('равновесие', 28, refY - 10); + + const hist = this._history; + if (hist.length > 1) { + const plotX0 = 28, plotW = graphW - 38; + const plotY0 = graphY + 18, plotH2 = graphH - 28; + + // Concentration line (cyan) + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < hist.length; i++) { + const hx = plotX0 + (i / (hist.length - 1)) * plotW; + const hy = plotY0 + plotH2 * (1 - hist[i].fracA_left); + if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); + } + ctx.stroke(); + + // Entropy line (orange, dashed, scaled to 0..ln(2) ≈ 0.693) + const maxEnt = Math.log(2); + ctx.strokeStyle = '#FFB347'; ctx.lineWidth = 1.2; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + for (let i = 0; i < hist.length; i++) { + const hx = plotX0 + (i / (hist.length - 1)) * plotW; + const hy = plotY0 + plotH2 * (1 - hist[i].entropy / maxEnt); + if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); + } + ctx.stroke(); + ctx.setLineDash([]); + + // Legend + ctx.fillStyle = '#06D6E0'; ctx.beginPath(); ctx.arc(plotX0 + plotW - 50, graphY + 8, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = "8px sans-serif"; ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; ctx.fillText('X(A)', plotX0 + plotW - 44, graphY + 8); + + ctx.fillStyle = '#FFB347'; ctx.beginPath(); ctx.arc(plotX0 + plotW - 22, graphY + 8, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillText('S', plotX0 + plotW - 16, graphY + 8); + + // Current value + const last = hist[hist.length - 1]; + const endX = plotX0 + plotW; + const endY = plotY0 + plotH2 * (1 - last.fracA_left); + ctx.fillStyle = '#06D6E0'; ctx.font = "bold 10px 'Manrope', sans-serif"; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillText((last.fracA_left * 100).toFixed(0) + '%', endX - 2, endY); + } + + ctx.restore(); + } + + _drawStats(ctx) { + const info = this.info(); + const pad = 10, panelW = 180, panelH = 90, px = 14, py = 14; + + ctx.save(); + ctx.fillStyle = 'rgba(0,0,10,0.72)'; + ctx.beginPath(); ctx.roundRect(px, py, panelW, panelH, 8); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke(); + + const lineH = 18; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.font = "11px 'Manrope', sans-serif"; + + ctx.fillStyle = '#06D6E0'; + ctx.fillText(`Лево: A=${info.leftA} B=${info.leftB}`, px + pad, py + pad); + + ctx.fillStyle = '#F15BB5'; + ctx.fillText(`Право: A=${info.rightA} B=${info.rightB}`, px + pad, py + pad + lineH); + + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.fillText(`Смешивание: ${info.mixed}%`, px + pad, py + pad + lineH * 2); + + const stateLabel = !info.partitionOn ? 'Снята' : info.poreMode ? 'С порой' : 'Вкл'; + const stateColor = !info.partitionOn ? '#F15BB5' : info.poreMode ? '#FFB347' : '#06D6E0'; + ctx.fillStyle = stateColor; + ctx.fillText(`Раздел: ${stateLabel}`, px + pad, py + pad + lineH * 3); + + ctx.restore(); + } +} + +if (typeof module !== 'undefined') module.exports = DiffusionSim; diff --git a/frontend/js/labs/electrolysis.js b/frontend/js/labs/electrolysis.js new file mode 100644 index 0000000..a06bc54 --- /dev/null +++ b/frontend/js/labs/electrolysis.js @@ -0,0 +1,540 @@ +'use strict'; +/** + * ElectrolysisSim v2 — Электролиз водных растворов + * Закон Фарадея: m = M·I·t / (n·F), F = 96485 Кл/моль + * Чистый рерайт: стабильная физика, ионная анимация, пузырьки, осадок. + */ +class ElectrolysisSim { + static F = 96485; + static BG = '#0b0b1a'; + static FONT = 'Manrope, system-ui, sans-serif'; + + static ELECTROLYTES = { + NaCl: { + name: 'NaCl', displayName: 'NaCl (водный р-р)', + cation: 'Na\u207A', anion: 'Cl\u207B', + M: 2, n: 2, R: 8, + solColor: [160, 200, 230], + cathodeProduct: 'H\u2082', anodeProduct: 'Cl\u2082', + depositColor: null, + cathodeBubColor: 'rgba(160,210,255,0.55)', + anodeBubColor: 'rgba(180,255,140,0.50)', + cathodeEq: '2H\u2082O + 2e\u207B \u2192 H\u2082 + 2OH\u207B', + anodeEq: '2Cl\u207B \u2212 2e\u207B \u2192 Cl\u2082', + }, + CuSO4: { + name: 'CuSO\u2084', displayName: 'CuSO\u2084 (водный р-р)', + cation: 'Cu\u00B2\u207A', anion: 'SO\u2084\u00B2\u207B', + M: 63.546, n: 2, R: 12, + solColor: [55, 120, 210], + cathodeProduct: 'Cu\u2193', anodeProduct: 'O\u2082', + depositColor: '#b87333', + cathodeBubColor: null, + anodeBubColor: 'rgba(200,210,255,0.50)', + cathodeEq: 'Cu\u00B2\u207A + 2e\u207B \u2192 Cu\u2193', + anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A', + }, + H2SO4: { + name: 'H\u2082SO\u2084', displayName: 'H\u2082SO\u2084 (водный р-р)', + cation: 'H\u207A', anion: 'SO\u2084\u00B2\u207B', + M: 2, n: 2, R: 6, + solColor: [200, 200, 215], + cathodeProduct: 'H\u2082', anodeProduct: 'O\u2082', + depositColor: null, + cathodeBubColor: 'rgba(160,210,255,0.55)', + anodeBubColor: 'rgba(200,210,255,0.50)', + cathodeEq: '2H\u207A + 2e\u207B \u2192 H\u2082', + anodeEq: '2H\u2082O \u2212 4e\u207B \u2192 O\u2082 + 4H\u207A', + }, + }; + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.voltage = 6; + this.electrolyte = 'NaCl'; + this.speed = 1; + + this._time = 0; + this._massDeposit = 0; + this._gasVolume = 0; + this._depositH = 0; + this._ions = []; + this._bubbles = []; + this._electronPhase = 0; + + this.playing = false; + this._raf = null; + this._lastTs = null; + this.onUpdate = null; + + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + // ── public API ──────────────────────────────────────────────── + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 640; + const h = this.canvas.offsetHeight || 420; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + this._initIons(); + } + + setParams({ voltage, electrolyte } = {}) { + if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage)); + if (electrolyte !== undefined) { + // accept both 'nacl' (from lab.html) and 'NaCl' (canonical) + const keyMap = { nacl: 'NaCl', cuso4: 'CuSO4', h2so4: 'H2SO4' }; + const key = keyMap[String(electrolyte).toLowerCase()] || electrolyte; + if (ElectrolysisSim.ELECTROLYTES[key] && this.electrolyte !== key) { + this.electrolyte = key; + this.reset(); return; + } + } + this.draw(); this._emit(); + } + + preset(name) { + const map = { nacl: ['NaCl', 6], cuso4: ['CuSO4', 4], h2so4: ['H2SO4', 3] }; + const [el, v] = map[name] || map.nacl; + this.voltage = v; this.electrolyte = el; + this.reset(); + } + + reset() { + this.pause(); + this._time = 0; this._massDeposit = 0; + this._gasVolume = 0; this._depositH = 0; + this._bubbles = []; this._electronPhase = 0; + this._initIons(); + this.draw(); this._emit(); + } + + play() { if (this.playing) return; this.playing = true; this._lastTs = null; this._tick(); } + pause() { this.playing = false; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } } + start() { this.play(); } + stop() { this.pause(); } + + info() { + return { + voltage: this.voltage, + current: +this._current().toFixed(3), + electrolyte: this.electrolyte, + massDeposited: +this._massDeposit.toFixed(4), + gasVolume: +this._gasVolume.toFixed(2), + time: +this._time.toFixed(1), + }; + } + + // ── internals ───────────────────────────────────────────────── + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + _el() { return ElectrolysisSim.ELECTROLYTES[this.electrolyte]; } + _current() { return this.voltage / this._el().R; } + + _cell() { + const { W, H } = this; + const cw = Math.min(W * 0.50, 300); + const ch = Math.min(H * 0.48, 210); + return { cx: (W - cw) / 2, cy: H * 0.28, cw, ch }; + } + + _electrodes() { + const { cx, cy, cw, ch } = this._cell(); + const ew = 13, eh = ch * 0.70, gap = cw * 0.12; + const ey = cy + ch - eh - 8; + return { + cathode: { x: cx + gap, y: ey, w: ew, h: eh }, + anode: { x: cx + cw - gap - ew, y: ey, w: ew, h: eh }, + }; + } + + _initIons() { + this._ions = []; + const { cx, cy, cw, ch } = this._cell(); + if (!cw || !ch) return; + const el = this._el(); + for (let i = 0; i < 30; i++) { + const isCat = i < 15; + this._ions.push({ + x: cx + 18 + Math.random() * (cw - 36), + y: cy + 12 + Math.random() * (ch - 24), + vx: (Math.random() - 0.5) * 0.7, + vy: (Math.random() - 0.5) * 0.5, + charge: isCat ? 1 : -1, + label: isCat ? el.cation : el.anion, + color: isCat ? '#EF476F' : '#06D6E0', + }); + } + } + + _spawnIon(charge) { + const { cx, cy, cw, ch } = this._cell(); + const el = this._el(); + this._ions.push({ + x: charge > 0 ? cx + 8 : cx + cw - 8, + y: cy + 12 + Math.random() * (ch - 24), + vx: charge > 0 ? 0.55 : -0.55, + vy: (Math.random() - 0.5) * 0.4, + charge, + label: charge > 0 ? el.cation : el.anion, + color: charge > 0 ? '#EF476F' : '#06D6E0', + }); + } + + _spawnBubble(x, y, color) { + this._bubbles.push({ + x, y, + r: 1.5 + Math.random() * 2.5, + vx: (Math.random() - 0.5) * 0.3, + vy: -(0.5 + Math.random() * 0.9), + life: 1, + decay: 0.005 + Math.random() * 0.007, + color, + }); + } + + // ── simulation tick ──────────────────────────────────────────── + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (!this._lastTs) this._lastTs = ts; + const dt = Math.min((ts - this._lastTs) / 1000, 0.05) * this.speed; + this._lastTs = ts; + this._step(dt); this.draw(); this._emit(); this._tick(); + }); + } + + _step(dt) { + const el = this._el(), I = this._current(); + const { cx, cy, cw, ch } = this._cell(); + const elec = this._electrodes(); + + this._time += dt; + this._electronPhase = (this._electronPhase + dt * I * 1.2) % 1; + + // Faraday's law + const molesPS = I / (el.n * ElectrolysisSim.F); + if (el.depositColor) { + this._massDeposit += el.M * molesPS * dt; + this._depositH = Math.min(elec.cathode.h * 0.72, this._depositH + dt * 0.14 * I); + } + this._gasVolume += molesPS * 22400 * dt; + + // Ion drift + thermal jitter + const drift = I * 0.45; + for (const ion of this._ions) { + ion.vx += (ion.charge > 0 ? -drift : drift) * dt + (Math.random() - 0.5) * 0.18; + ion.vy += (Math.random() - 0.5) * 0.14; + ion.vx = Math.max(-3.5, Math.min(3.5, ion.vx * 0.96)); + ion.vy = Math.max(-3.5, Math.min(3.5, ion.vy * 0.96)); + ion.x += ion.vx; ion.y += ion.vy; + ion.x = Math.max(cx + 4, Math.min(cx + cw - 4, ion.x)); + ion.y = Math.max(cy + 4, Math.min(cy + ch - 4, ion.y)); + } + + // Ions reaching electrodes → discharge + bubbles + const rm = new Set(); + for (let i = 0; i < this._ions.length; i++) { + const ion = this._ions[i]; + if (ion.charge > 0 && ion.x <= elec.cathode.x + elec.cathode.w + 5) { + rm.add(i); + if (el.cathodeBubColor) { + for (let b = 0; b < 2; b++) + this._spawnBubble( + elec.cathode.x + elec.cathode.w + 2 + Math.random() * 4, + elec.cathode.y + Math.random() * elec.cathode.h, + el.cathodeBubColor); + } + } + if (ion.charge < 0 && ion.x >= elec.anode.x - 5) { + rm.add(i); + if (el.anodeBubColor) { + for (let b = 0; b < 2; b++) + this._spawnBubble( + elec.anode.x - 2 - Math.random() * 4, + elec.anode.y + Math.random() * elec.anode.h, + el.anodeBubColor); + } + } + } + this._ions = this._ions.filter((_, i) => !rm.has(i)); + + // Replenish ions to keep count ~15 each + let cat = 0, an = 0; + for (const ion of this._ions) ion.charge > 0 ? cat++ : an++; + while (cat < 15) { this._spawnIon(1); cat++; } + while (an < 15) { this._spawnIon(-1); an++; } + + // Bubble physics + this._bubbles = this._bubbles.filter(b => { + b.x += b.vx + Math.sin(b.life * 22) * 0.12; + b.y += b.vy; + b.life -= b.decay; + return b.life > 0 && b.y > cy + 2; + }); + } + + // ── draw ────────────────────────────────────────────────────── + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + + // Background + ctx.fillStyle = ElectrolysisSim.BG; ctx.fillRect(0, 0, W, H); + + // Dot grid + ctx.fillStyle = 'rgba(255,255,255,0.018)'; + for (let x = 22; x < W; x += 22) + for (let y = 22; y < H; y += 22) { + ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill(); + } + + this._drawWiresAndBattery(); + this._drawCellBody(); + this._drawSolution(); + this._drawDeposit(); + this._drawElectrodes(); + this._drawBubbles(); + this._drawIons(); + this._drawLabels(); + this._drawInfoPanel(); + } + + _drawCellBody() { + const { ctx } = this; + const { cx, cy, cw, ch } = this._cell(); + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke(); + // glass shimmer + const gg = ctx.createLinearGradient(cx, cy, cx + 14, cy); + gg.addColorStop(0, 'rgba(255,255,255,0.05)'); + gg.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gg; ctx.fillRect(cx + 1, cy + 1, 14, ch - 2); + ctx.restore(); + } + + _drawSolution() { + const { ctx } = this; + const { cx, cy, cw, ch } = this._cell(); + const [r, g, b] = this._el().solColor; + ctx.save(); + ctx.beginPath(); ctx.roundRect(cx + 2, cy + 2, cw - 4, ch - 4, 4); ctx.clip(); + const sg = ctx.createLinearGradient(cx, cy, cx, cy + ch); + sg.addColorStop(0, `rgba(${r},${g},${b},0.06)`); + sg.addColorStop(1, `rgba(${r},${g},${b},0.22)`); + ctx.fillStyle = sg; ctx.fillRect(cx + 2, cy + 2, cw - 4, ch - 4); + ctx.restore(); + } + + _drawElectrodes() { + const { ctx } = this; + const e = this._electrodes(); + const FN = ElectrolysisSim.FONT; + + ctx.fillStyle = '#42425a'; + ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.fill(); + ctx.strokeStyle = 'rgba(6,214,224,0.3)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(e.cathode.x, e.cathode.y, e.cathode.w, e.cathode.h, 3); ctx.stroke(); + + ctx.fillStyle = '#525268'; + ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.fill(); + ctx.strokeStyle = 'rgba(239,71,111,0.3)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(e.anode.x, e.anode.y, e.anode.w, e.anode.h, 3); ctx.stroke(); + + ctx.font = `bold 16px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillStyle = '#06D6E0'; + ctx.fillText('\u2212', e.cathode.x + e.cathode.w / 2, e.cathode.y - 3); + ctx.fillStyle = '#EF476F'; + ctx.fillText('+', e.anode.x + e.anode.w / 2, e.anode.y - 3); + } + + _drawDeposit() { + const el = this._el(); + if (!el.depositColor || this._depositH < 1) return; + const { ctx } = this; + const c = this._electrodes().cathode; + const dh = Math.min(this._depositH, c.h * 0.72); + ctx.save(); + const dg = ctx.createLinearGradient(c.x + c.w, c.y + c.h - dh, c.x + c.w + 10, c.y + c.h); + dg.addColorStop(0, 'rgba(184,115,51,0.35)'); + dg.addColorStop(1, 'rgba(184,115,51,0.85)'); + ctx.fillStyle = dg; + ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, dh, [2, 2, 0, 0]); ctx.fill(); + ctx.shadowColor = '#b87333'; ctx.shadowBlur = 6; + ctx.fillStyle = 'rgba(210,150,80,0.5)'; + ctx.beginPath(); ctx.roundRect(c.x + c.w, c.y + c.h - dh, 10, 3, [2, 2, 0, 0]); ctx.fill(); + ctx.restore(); + } + + _drawIons() { + const { ctx } = this; + const FN = ElectrolysisSim.FONT; + ctx.save(); + for (const ion of this._ions) { + const g = ctx.createRadialGradient(ion.x, ion.y, 0, ion.x, ion.y, 11); + g.addColorStop(0, ion.color + '2a'); g.addColorStop(1, ion.color + '00'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(ion.x, ion.y, 11, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = ion.color; + ctx.beginPath(); ctx.arc(ion.x, ion.y, 4, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.68)'; + ctx.font = `8px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(ion.label, ion.x, ion.y - 5); + } + ctx.restore(); + } + + _drawBubbles() { + const { ctx } = this; + ctx.save(); + for (const b of this._bubbles) { + ctx.globalAlpha = b.life * 0.65; + ctx.strokeStyle = b.color; ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = `rgba(255,255,255,${b.life * 0.18})`; + ctx.beginPath(); ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.3, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + } + + _drawWiresAndBattery() { + const { ctx } = this; + const { cx, cy, cw } = this._cell(); + const e = this._electrodes(); + const FN = ElectrolysisSim.FONT; + + const cXt = e.cathode.x + e.cathode.w / 2; // cathode top center + const aXt = e.anode.x + e.anode.w / 2; // anode top center + const bx = cx + cw / 2; // battery center x + const by = cy - Math.max(42, this.H * 0.09); // battery y + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5; + + // Cathode wire: up then right to battery − + ctx.beginPath(); + ctx.moveTo(cXt, e.cathode.y); ctx.lineTo(cXt, by); ctx.lineTo(bx - 22, by); + ctx.stroke(); + + // Anode wire: up then left to battery + + ctx.beginPath(); + ctx.moveTo(aXt, e.anode.y); ctx.lineTo(aXt, by); ctx.lineTo(bx + 22, by); + ctx.stroke(); + + // Electron flow dots (cathode side: from battery − toward cathode) + const dist = (bx - 22) - cXt; + for (let i = 0; i < 4; i++) { + const t = ((this._electronPhase + i / 4) % 1); + const ex = (bx - 22) - t * dist; + const ey = by; + if (ex >= cXt - 1 && ex <= bx - 22 + 1) { + ctx.fillStyle = '#4CC9F0'; ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 5; + ctx.beginPath(); ctx.arc(ex, ey, 2.5, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + } + } + + // Battery symbol — two plates + ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(bx + 22, by - 14); ctx.lineTo(bx + 22, by + 14); ctx.stroke(); + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.8; + ctx.beginPath(); ctx.moveTo(bx - 22, by - 8); ctx.lineTo(bx - 22, by + 8); ctx.stroke(); + // Connecting wire between battery plates + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(bx - 22, by); ctx.lineTo(bx + 22, by); ctx.stroke(); + + // Voltage label + ctx.fillStyle = '#FFD166'; + ctx.font = `bold 12px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(this.voltage.toFixed(1) + ' V', bx, by - 18); + + // +/− labels on battery + ctx.fillStyle = '#EF476F'; ctx.font = `bold 10px ${FN}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('+', bx + 26, by); + ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right'; + ctx.fillText('\u2212', bx - 26, by); + + ctx.restore(); + } + + _drawLabels() { + const { ctx } = this; + const el = this._el(), e = this._electrodes(); + const { cx, cy, cw, ch } = this._cell(); + const FN = ElectrolysisSim.FONT; + + ctx.save(); + ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + + ctx.fillStyle = '#06D6E0'; + ctx.fillText('\u041A\u0430\u0442\u043E\u0434 (\u2212)', e.cathode.x + e.cathode.w / 2, cy + ch + 6); + ctx.fillStyle = '#EF476F'; + ctx.fillText('\u0410\u043D\u043E\u0434 (+)', e.anode.x + e.anode.w / 2, cy + ch + 6); + + ctx.fillStyle = 'rgba(255,255,255,0.42)'; + ctx.fillText(el.cathodeProduct, e.cathode.x + e.cathode.w / 2, cy + ch + 20); + ctx.fillText(el.anodeProduct, e.anode.x + e.anode.w / 2, cy + ch + 20); + + ctx.fillStyle = '#9B5DE5'; ctx.font = `bold 11px ${FN}`; + ctx.fillText(el.displayName, cx + cw / 2, cy + ch + 36); + + ctx.font = `8px ${FN}`; + ctx.fillStyle = 'rgba(6,214,224,0.48)'; + ctx.fillText(el.cathodeEq, e.cathode.x + e.cathode.w / 2, cy + ch + 52); + ctx.fillStyle = 'rgba(239,71,111,0.48)'; + ctx.fillText(el.anodeEq, e.anode.x + e.anode.w / 2, cy + ch + 52); + + ctx.restore(); + } + + _drawInfoPanel() { + const { ctx } = this; + const inf = this.info(), el = this._el(); + const FN = ElectrolysisSim.FONT; + const px = 12, py = 10, pw = 170, lh = 17; + + const rows = [ + ['U', inf.voltage.toFixed(1) + ' \u0412'], + ['I', this._current().toFixed(3) + ' \u0410'], + ['\u0422\u0432\u0440\u0435\u043C\u044F', this._fmtTime(inf.time)], + ]; + if (el.depositColor) rows.push(['m(Cu)', inf.massDeposited.toFixed(4) + ' \u0433']); + rows.push(['V(\u0433\u0430\u0437)', inf.gasVolume.toFixed(2) + ' \u043C\u043B']); + + const ph = 12 + rows.length * lh + 8; + ctx.save(); + ctx.fillStyle = 'rgba(5,5,20,0.86)'; + ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.stroke(); + + ctx.font = `10px ${FN}`; ctx.textBaseline = 'middle'; + rows.forEach(([k, v], i) => { + const ry = py + 10 + i * lh + lh / 2; + ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, px + 10, ry); + ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.textAlign = 'right'; ctx.fillText(v, px + pw - 10, ry); + }); + + ctx.fillStyle = 'rgba(255,255,255,0.16)'; + ctx.font = `italic 8px ${FN}`; ctx.textAlign = 'left'; + ctx.fillText('m = M\u00B7I\u00B7t / (n\u00B7F)', px + 10, py + ph + 10); + ctx.restore(); + } + + _fmtTime(s) { + if (s < 60) return s.toFixed(1) + ' \u0441'; + return Math.floor(s / 60) + ' \u043C\u0438\u043D ' + (s % 60).toFixed(0) + ' \u0441'; + } +} + +if (typeof module !== 'undefined') module.exports = ElectrolysisSim; diff --git a/frontend/js/labs/equilibrium.js b/frontend/js/labs/equilibrium.js new file mode 100644 index 0000000..3237a4e --- /dev/null +++ b/frontend/js/labs/equilibrium.js @@ -0,0 +1,475 @@ +'use strict'; + +/** + * EquilibriumSim — Chemical equilibrium simulation. + * A + B ⇌ C + D with Arrhenius kinetics, Le Chatelier principle. + * Left: particle animation with collisions & reactions. + * Right (30%): live concentration graph over time. + */ +class EquilibriumSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.particles = []; + this.flashes = []; // [{x, y, t, maxT, color}] + this._history = []; // [{step, nA, nB, nC, nD}] + this._nextId = 0; + + /* parameters */ + this.T = 300; // temperature K + this.nA = 20; // initial A count + this.nB = 20; // initial B count + this.Ea_f = 50; // forward activation energy + this.Ea_r = 55; // reverse activation energy + + /* runtime */ + this._steps = 0; + this._raf = null; + this._dpr = 1; + this.playing = false; + this.onUpdate = null; + + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ═══════════════════════ public API ═══════════════════════ */ + + fit() { + const dpr = window.devicePixelRatio || 1; + this._dpr = dpr; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + this.reset(); + } + + setParams({ T, nA, nB, Ea_f, Ea_r } = {}) { + let needReset = false; + if (T !== undefined) this.T = Math.max(200, Math.min(500, +T)); + if (Ea_f !== undefined) this.Ea_f = +Ea_f; + if (Ea_r !== undefined) this.Ea_r = +Ea_r; + if (nA !== undefined) { this.nA = Math.max(10, Math.min(40, +nA)); needReset = true; } + if (nB !== undefined) { this.nB = Math.max(10, Math.min(40, +nB)); needReset = true; } + if (needReset) this.reset(); + this.draw(); + this._emit(); + } + + preset(name) { + const presets = { + default: { T: 300, nA: 20, nB: 20, Ea_f: 50, Ea_r: 55 }, + exothermic: { T: 280, nA: 20, nB: 20, Ea_f: 35, Ea_r: 65 }, + endothermic: { T: 350, nA: 20, nB: 20, Ea_f: 65, Ea_r: 35 }, + excess_A: { T: 300, nA: 35, nB: 15, Ea_f: 50, Ea_r: 55 }, + }; + const p = presets[name] || presets.default; + Object.assign(this, p); + this.reset(); + } + + reset() { + this.pause(); + const { W, H } = this; + if (!W || !H) return; + this.particles = []; + this.flashes = []; + this._history = []; + this._steps = 0; + this._nextId = 0; + + const simW = W * 0.7; + this._spawnType('A', this.nA, simW); + this._spawnType('B', this.nB, simW); + this._recordHistory(); + this.draw(); + this._emit(); + } + + play() { + if (this.playing) return; + this.playing = true; + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + start() { this.play(); } + stop() { this.pause(); } + + info() { + let nA = 0, nB = 0, nC = 0, nD = 0; + for (const p of this.particles) { + if (p.type === 'A') nA++; + else if (p.type === 'B') nB++; + else if (p.type === 'C') nC++; + else nD++; + } + const cA = nA || 0.001, cB = nB || 0.001; + const cC = nC || 0.001, cD = nD || 0.001; + const Q = (cC * cD) / (cA * cB); + const keq = Math.exp((this.Ea_f - this.Ea_r) / (this.T * 0.05)); + const direction = Q < keq * 0.95 ? '\u2192' : Q > keq * 1.05 ? '\u2190' : '\u21CC'; + return { keq: +keq.toFixed(3), Q: +Q.toFixed(3), direction, nA, nB, nC, nD }; + } + + /* ═══════════════════════ internals ═══════════════════════ */ + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(() => { + for (let i = 0; i < 3; i++) this._step(); + this.draw(); + this._tick(); + }); + } + + _color(type) { + return { A: '#EF476F', B: '#9B5DE5', C: '#7BF5A4', D: '#FFD166' }[type] || '#aaa'; + } + + _radius() { return 5; } + + _spawnType(type, count, maxX) { + const { H } = this; + const r = this._radius(); + const margin = 10; + let placed = 0, att = 0; + while (placed < count && att < count * 60) { + att++; + const x = margin + r + Math.random() * (maxX - 2 * r - margin * 2); + const y = margin + r + Math.random() * (H - 2 * r - margin * 2); + let overlap = false; + for (const p of this.particles) { + if ((p.x - x) ** 2 + (p.y - y) ** 2 < (p.r + r + 1) ** 2) { overlap = true; break; } + } + if (overlap) continue; + const a = Math.random() * Math.PI * 2; + const spd = 1.5 + Math.random() * 1.5; + this.particles.push({ x, y, vx: Math.cos(a) * spd, vy: Math.sin(a) * spd, r, type, id: this._nextId++ }); + placed++; + } + } + + _step() { + const { W, H } = this; + const simW = W * 0.7; + const dt = 0.6; + + /* move + walls */ + for (const p of this.particles) { + p.x += p.vx * dt; + p.y += p.vy * dt; + if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); } + if (p.x > simW - p.r) { p.x = simW - p.r; p.vx = -Math.abs(p.vx); } + if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); } + if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); } + } + + /* spatial grid */ + const cs = 18; + const cols = Math.ceil(simW / cs) + 1; + const grid = new Map(); + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + const k = Math.floor(p.x / cs) + Math.floor(p.y / cs) * cols; + if (!grid.has(k)) grid.set(k, []); + grid.get(k).push(i); + } + + const toRemove = new Set(); + const toAdd = []; + + /* collisions + reactions */ + for (let i = 0; i < this.particles.length; i++) { + const p1 = this.particles[i]; + if (toRemove.has(p1.id)) continue; + const cx = Math.floor(p1.x / cs), cy = Math.floor(p1.y / cs); + for (let dcx = -1; dcx <= 1; dcx++) for (let dcy = -1; dcy <= 1; dcy++) { + const cell = grid.get((cx + dcx) + (cy + dcy) * cols); + if (!cell) continue; + for (const j of cell) { + if (j <= i) continue; + const p2 = this.particles[j]; + if (toRemove.has(p2.id)) continue; + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const dist2 = dx * dx + dy * dy; + const minD = p1.r + p2.r; + if (dist2 >= minD * minD) continue; + const dist = Math.sqrt(dist2); + + /* forward: A + B C + D */ + const isAB = (p1.type === 'A' && p2.type === 'B') || (p1.type === 'B' && p2.type === 'A'); + if (isAB) { + const kf = Math.exp(-this.Ea_f / (this.T * 0.08)) * 0.35; + if (Math.random() < kf) { + toRemove.add(p1.id); toRemove.add(p2.id); + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + const a1 = Math.random() * Math.PI * 2; + const spd = 1.2 + Math.random(); + toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'C', id: this._nextId++ }); + toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'D', id: this._nextId++ }); + this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '123,245,164' }); + continue; + } + } + + /* reverse: C + D A + B */ + const isCD = (p1.type === 'C' && p2.type === 'D') || (p1.type === 'D' && p2.type === 'C'); + if (isCD) { + const kr = Math.exp(-this.Ea_r / (this.T * 0.08)) * 0.35; + if (Math.random() < kr) { + toRemove.add(p1.id); toRemove.add(p2.id); + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + const a1 = Math.random() * Math.PI * 2; + const spd = 1.2 + Math.random(); + toAdd.push({ x: mx + Math.cos(a1) * 4, y: my + Math.sin(a1) * 4, vx: Math.cos(a1) * spd, vy: Math.sin(a1) * spd, r: 5, type: 'A', id: this._nextId++ }); + toAdd.push({ x: mx - Math.cos(a1) * 4, y: my - Math.sin(a1) * 4, vx: -Math.cos(a1) * spd, vy: -Math.sin(a1) * spd, r: 5, type: 'B', id: this._nextId++ }); + this.flashes.push({ x: mx, y: my, t: 0, maxT: 18, color: '239,71,111' }); + continue; + } + } + + /* elastic bounce */ + if (dist > 0.001) { + const nx = dx / dist, ny = dy / dist; + const dvn = (p1.vx - p2.vx) * nx + (p1.vy - p2.vy) * ny; + if (dvn > 0) { + p1.vx -= dvn * nx; p1.vy -= dvn * ny; + p2.vx += dvn * nx; p2.vy += dvn * ny; + } + const ov = (minD - dist) * 0.5; + p1.x -= nx * ov; p1.y -= ny * ov; + p2.x += nx * ov; p2.y += ny * ov; + } + } + } + } + + if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id)); + for (const p of toAdd) this.particles.push(p); + this.flashes = this.flashes.filter(f => ++f.t < f.maxT); + + this._steps++; + if (this._steps % 20 === 0) { + this._recordHistory(); + this._emit(); + } + } + + _recordHistory() { + let nA = 0, nB = 0, nC = 0, nD = 0; + for (const p of this.particles) { + if (p.type === 'A') nA++; + else if (p.type === 'B') nB++; + else if (p.type === 'C') nC++; + else nD++; + } + this._history.push({ step: this._steps, nA, nB, nC, nD }); + if (this._history.length > 300) this._history.shift(); + } + + /* ═══════════════════════ rendering ═══════════════════════ */ + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + const simW = W * 0.7; + + /* background */ + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + /* dot grid */ + ctx.fillStyle = 'rgba(255,255,255,0.025)'; + for (let x = 30; x < simW; x += 30) + for (let y = 30; y < H; y += 30) { + ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill(); + } + + /* divider */ + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fillRect(simW - 1, 0, 2, H); + + /* flashes */ + for (const f of this.flashes) { + const prog = f.t / f.maxT; + const radius = prog * 38 + 4; + const alpha = (1 - prog) * 0.55; + const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius); + g.addColorStop(0, `rgba(${f.color},${alpha * 1.5})`); + g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.4})`); + g.addColorStop(1, `rgba(${f.color},0)`); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(f.x, f.y, radius, 0, Math.PI * 2); ctx.fill(); + } + + /* particles */ + for (const p of this.particles) this._drawParticle(ctx, p); + + /* right panel: concentration graph */ + this._drawGraph(ctx, simW, W, H); + + /* stats overlay */ + this._drawStats(ctx); + + /* equation label */ + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.font = "bold 11px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'center'; + ctx.fillText('A + B \u21CC C + D', simW / 2, H - 12); + } + + _drawParticle(ctx, p) { + const col = this._color(p.type); + const { x, y, r } = p; + + /* outer glow */ + const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3.2); + glow.addColorStop(0, col + '44'); + glow.addColorStop(1, col + '00'); + ctx.fillStyle = glow; + ctx.beginPath(); ctx.arc(x, y, r * 3.2, 0, Math.PI * 2); ctx.fill(); + + /* body gradient */ + const body = ctx.createRadialGradient(x - r * 0.25, y - r * 0.25, r * 0.05, x, y, r); + body.addColorStop(0, col + 'ff'); + body.addColorStop(0.6, col + 'cc'); + body.addColorStop(1, col + '88'); + ctx.fillStyle = body; + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + + /* specular */ + ctx.fillStyle = 'rgba(255,255,255,0.38)'; + ctx.beginPath(); ctx.arc(x - r * 0.28, y - r * 0.28, r * 0.28, 0, Math.PI * 2); ctx.fill(); + + /* label */ + ctx.fillStyle = 'rgba(0,0,0,0.65)'; + ctx.font = `bold ${Math.round(r * 1.1)}px sans-serif`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(p.type, x, y + 0.5); + ctx.textBaseline = 'alphabetic'; + } + + _drawGraph(ctx, x0, W, H) { + const gW = W - x0, pad = { l: 36, r: 10, t: 32, b: 28 }; + const px = x0 + pad.l, py = pad.t; + const pw = gW - pad.l - pad.r; + const ph = H - pad.t - pad.b; + + /* panel bg */ + ctx.fillStyle = 'rgba(5,5,20,0.85)'; + ctx.fillRect(x0, 0, gW, H); + + /* title */ + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'left'; + ctx.fillText('\u041A\u043E\u043D\u0446\u0435\u043D\u0442\u0440\u0430\u0446\u0438\u044F', x0 + 10, 16); + + /* grid */ + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const yl = py + ph * (i / 4); + ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke(); + } + + /* y-axis labels */ + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.font = "8px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'right'; + const maxN = Math.max(this.nA, this.nB) * 1.2 + 2; + for (let i = 0; i <= 4; i++) { + const v = Math.round(maxN * (4 - i) / 4); + ctx.fillText(v, px - 4, py + ph * (i / 4) + 3); + } + + if (this._history.length < 2) return; + const n = this._history.length; + + const lines = [ + { key: 'nA', color: '#EF476F', label: 'A' }, + { key: 'nB', color: '#9B5DE5', label: 'B' }, + { key: 'nC', color: '#7BF5A4', label: 'C' }, + { key: 'nD', color: '#FFD166', label: 'D' }, + ]; + + for (const { key, color } of lines) { + ctx.beginPath(); + ctx.strokeStyle = color; ctx.lineWidth = 1.6; + for (let i = 0; i < n; i++) { + const lx = px + (i / Math.max(n - 1, 1)) * pw; + const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph; + i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly); + } + ctx.stroke(); + } + + /* legend */ + lines.forEach(({ color, label }, i) => { + const lx = x0 + 10 + i * 38; + const ly = H - 14; + ctx.fillStyle = color; + ctx.fillRect(lx, ly, 10, 2.5); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'left'; + ctx.fillText(label, lx + 13, ly + 3); + }); + + /* current values */ + const last = this._history[n - 1]; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = "8px monospace"; + ctx.textAlign = 'right'; + ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC} D:${last.nD}`, x0 + gW - 8, H - 14); + } + + _drawStats(ctx) { + const info = this.info(); + const px = 10, py = 10, pw = 160, ph = 82; + + ctx.fillStyle = 'rgba(5,5,20,0.82)'; + ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + const lh = 16; + + ctx.fillStyle = '#7BF5A4'; + ctx.fillText(`K\u2091\u2071 = ${info.keq}`, px + 10, py + 8); + + ctx.fillStyle = '#FFD166'; + ctx.fillText(`Q = ${info.Q}`, px + 10, py + 8 + lh); + + ctx.fillStyle = '#06D6E0'; + ctx.fillText(`\u041D\u0430\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435: ${info.direction}`, px + 10, py + 8 + lh * 2); + + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.fillText(`T = ${this.T} K`, px + 10, py + 8 + lh * 3); + } + + /* ═══════════════════════ utility ═══════════════════════ */ + + _rrect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } +} + +if (typeof module !== 'undefined') module.exports = EquilibriumSim; diff --git a/frontend/js/labs/flask.js b/frontend/js/labs/flask.js new file mode 100644 index 0000000..0821ac3 --- /dev/null +++ b/frontend/js/labs/flask.js @@ -0,0 +1,1156 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + FlaskSim v2 — «Химия в колбе» + • Реалистичная вода: 3 слоя волн, каустики, мениск, SSS + • Пар при нагреве, всплески пузырьков + • Толстое стекло, пульсирующий glow реакции + • Specular highlight металла, dissolution edge + ════════════════════════════════════════════════════════════════ */ + +class FlaskSim { + + /* ── Реагенты ─────────────────────────────────────────────────── */ + + static METALS = { + Zn: { name: 'Цинк', color: '#9BB8CC', k: 0.50, Ea: 0.9, rho: 7.13, dH: 155, h2: 1, acids: ['HCl','H2SO4'] }, + Fe: { name: 'Железо', color: '#A08060', k: 0.08, Ea: 1.4, rho: 7.87, dH: 87, h2: 1, acids: ['HCl'], rust: true }, + Mg: { name: 'Магний', color: '#D6D6D6', k: 1.50, Ea: 0.5, rho: 1.74, dH: 467, h2: 1, acids: ['HCl','H2SO4','H2O'] }, + Cu: { name: 'Медь', color: '#C87840', k: 0, Ea: 99, rho: 8.96, dH: 0, h2: 0, acids: [] }, + Na: { name: 'Натрий', color: '#F5F0C8', k: 6.00, Ea: 0.05, rho: 0.97, dH: 883, h2: 0.5, acids: ['HCl','H2SO4','H2O'], boom: true }, + Al: { name: 'Алюминий', color: '#C0C0C0', k: 0.60, Ea: 1.0, rho: 2.70, dH: 300, h2: 1.5, acids: ['HCl','H2SO4'] }, + }; + + static ACIDS = { + HCl: { name: 'Соляная кислота HCl', rgb: [120, 210, 120], pHf: 1.0, label: 'HCl' }, + H2SO4: { name: 'Серная кислота H₂SO₄', rgb: [210, 195, 120], pHf: 1.2, label: 'H₂SO₄' }, + H2O: { name: 'Вода H₂O', rgb: [110, 180, 215], pHf: 0.0, label: 'H₂O' }, + }; + + static EQ = { + Zn_HCl: 'Zn + 2HCl ZnCl₂ + H₂', + Zn_H2SO4: 'Zn + H₂SO₄ ZnSO₄ + H₂', + Fe_HCl: 'Fe + 2HCl FeCl₂ + H₂', + Mg_HCl: 'Mg + 2HCl MgCl₂ + H₂', + Mg_H2SO4: 'Mg + H₂SO₄ MgSO₄ + H₂', + Mg_H2O: 'Mg + 2H₂O Mg(OH)₂ + H₂', + Al_HCl: '2Al + 6HCl 2AlCl₃ + 3H₂', + Al_H2SO4: '2Al + 3H₂SO₄ Al₂(SO₄)₃ + 3H₂', + Na_HCl: '2Na + 2HCl 2NaCl + H₂', + Na_H2SO4: '2Na + H₂SO₄ Na₂SO₄ + H₂', + Na_H2O: '2Na + 2H₂O 2NaOH + H₂', + Cu_HCl: 'Cu + HCl — реакция не идёт', + Cu_H2SO4: 'Cu + H₂SO₄(разб.) — реакция не идёт', + Cu_H2O: 'Cu + H₂O — реакция не идёт', + Fe_H2SO4: 'Fe + H₂SO₄(конц.) — пассивация!', + Fe_H2O: 'Fe + H₂O — реакция не идёт при 20°C', + Al_H2O: 'Al + H₂O — не реагирует (оксидная плёнка)', + Zn_H2O: 'Zn + H₂O — реакция не идёт', + }; + + /* ── Конструктор ──────────────────────────────────────────────── */ + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.metalType = 'Zn'; + this.acidType = 'HCl'; + this.concLevel = 0.35; + this.envTemp = 20; + + /* Частицы и волны */ + this._metal = null; + this._bubbles = []; + this._dusts = []; + this._sparks = []; + this._steam = []; + this._splashes = []; + this._caustics = []; + + /* Фазы волн (3 независимые) */ + this._wave = 0; + this._wave2 = 0; + this._wave3 = 0; + + /* Анимационные таймеры */ + this._glowPulse = 0; + this._causticTmr = 0; + this._steamTmr = 0; + + /* Физическое состояние */ + this._passiv = false; + this._ignited = false; + this._flameOn = false; + this._boomCD = 0; + this._conc = this.concLevel; + this._temp = this.envTemp; + this._pH = 1.0; + this._rxRate = 0; + this._h2 = 0; + this._bubTmr = 0; + + /* Анимация */ + this._raf = null; + this._last = 0; + this._paused = false; + + this.onUpdate = null; + this.W = 0; this.H = 0; + this._g = {}; + + this.fit(); + } + + /* ── Геометрия ────────────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 600; + const H = this.canvas.offsetHeight || 400; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._calcGeom(); + } + + _calcGeom() { + const { W, H } = this; + const r = Math.min(W * 0.195, H * 0.285); + const cx = W * 0.50; + const cy = H * 0.615; + const nw = r * 0.26; // ширина горлышка + const nh = r * 1.05; // высота горлышка + const nt = cy - r - nh; // верх горлышка + const nb = cy - r * 0.80; // точка начала плеч (где шея переходит в колбу) + const liqTop = cy - r * 0.42; + this._g = { r, cx, cy, nw, nh, nt, nb, liqTop }; + } + + _flaskPath(ctx) { + const { r, cx, cy, nw, nt, nb } = this._g; + ctx.beginPath(); + ctx.moveTo(cx - nw, nt); + ctx.lineTo(cx - nw, nb); + /* Левое плечо: плавная кривая Безье от шеи до экватора колбы */ + ctx.bezierCurveTo( + cx - nw, cy - r * 0.42, // CP1: продолжаем вниз по шее + cx - r * 0.85, cy - r * 0.10, // CP2: выходим к экватору + cx - r, cy // конец: левый экватор окружности + ); + /* Нижняя дуга колбы: слева направо через дно (anticlockwise=true в canvas = через низ) */ + ctx.arc(cx, cy, r, Math.PI, 0, true); + /* Правое плечо: симметрично */ + ctx.bezierCurveTo( + cx + r * 0.85, cy - r * 0.10, + cx + nw, cy - r * 0.42, + cx + nw, nb + ); + ctx.lineTo(cx + nw, nt); + ctx.closePath(); + } + + /* ── Запуск / остановка ───────────────────────────────────────── */ + + start() { + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + /* ── Публичный API ────────────────────────────────────────────── */ + + dropMetal() { + const { cx, nt } = this._g; + const md = FlaskSim.METALS[this.metalType]; + const mass = 5; + this._metal = { + type: this.metalType, + mass, init: mass, + x: cx + (Math.random() - 0.5) * 8, + y: nt - 32, + vx: (Math.random() - 0.5) * 22, + vy: 0, + r: this._m2r(mass), + _v: Array.from({ length: 10 }, (_, i) => ({ + a: (i / 10) * Math.PI * 2, + j: 0.68 + Math.random() * 0.32, + })), + }; + this._passiv = false; + this._ignited = false; + this._h2 = 0; + this._bubTmr = 0; + this._boomCD = 0; + } + + reset() { + this._metal = null; + this._bubbles = []; + this._dusts = []; + this._sparks = []; + this._steam = []; + this._splashes = []; + this._caustics = []; + this._passiv = false; + this._ignited = false; + this._flameOn = false; + this._h2 = 0; + this._rxRate = 0; + this._boomCD = 0; + this._causticTmr = 0; + this._steamTmr = 0; + this._conc = this.concLevel; + this._temp = this.envTemp; + this._pH = this._startPH(); + if (this.onUpdate) this.onUpdate(this.info()); + this.draw(); + } + + togglePause() { this._paused = !this._paused; } + toggleFlame() { this._flameOn = !this._flameOn; } + setMetal(t) { this.metalType = t; } + setAcid(t) { this.acidType = t; this.reset(); } + setConc(v) { this.concLevel = v; this._conc = v; if (!this._metal) this._pH = this._startPH(); } + setEnvTemp(v) { this.envTemp = v; if (!this._metal) this._temp = v; } + + _startPH() { + const a = FlaskSim.ACIDS[this.acidType]; + if (a.pHf === 0) return 7.0; + return Math.max(0, -Math.log10(this.concLevel * 10 * a.pHf + 1e-10)); + } + + _m2r(mass) { return 8 + 24 * Math.cbrt(Math.max(0, mass) / 5); } + + /* ── Тик физики ───────────────────────────────────────────────── */ + + _tick(now) { + const dt = Math.min((now - this._last) / 1000, 0.05); + this._last = now; + if (!this._paused) { + this._wave += dt * 1.7; + this._wave2 += dt * 2.3; + this._wave3 += dt * 0.88; + this._glowPulse += dt * 3.2; + this._stepMetal(dt); + this._stepBubbles(dt); + this._stepDusts(dt); + this._stepSparks(dt); + this._stepSteam(dt); + this._stepSplashes(dt); + this._stepCaustics(dt); + } + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ── Физика металла ───────────────────────────────────────────── */ + + _stepMetal(dt) { + const m = this._metal; + if (!m || m.mass <= 0.01) { if (m) m.mass = 0; return; } + + const md = FlaskSim.METALS[m.type]; + const { cy, r, liqTop, cx } = this._g; + + const liqRho = 1.12; + const inLiq = m.y + m.r > liqTop; + const grav = 400; + const buoy = inLiq ? grav * (liqRho / md.rho) : 0; + const drag = inLiq ? 4.5 : 0.25; + + m.vy += (grav - buoy) * dt; + m.vy -= drag * m.vy * dt; + m.vx -= drag * m.vx * dt; + m.y += m.vy * dt; + m.x += m.vx * dt; + + const botY = cy + r - m.r; + if (m.y > botY) { m.y = botY; m.vy *= -0.22; } + const hw = Math.sqrt(Math.max(0, r * r - (m.y - cy) ** 2)); + m.x = Math.max(cx - hw + m.r, Math.min(cx + hw - m.r, m.x)); + + if (md.rho < liqRho && inLiq) { + const sfY = liqTop - m.r; + if (m.y < sfY) { m.y = sfY; m.vy = Math.abs(m.vy) * 0.25; } + } + + const reacts = md.acids.includes(this.acidType) && !this._passiv && this._conc > 4e-4; + if (!reacts) { this._rxRate = 0; return; } + + const T_K = this._temp + 273.15; + const rate = md.k * this._conc * Math.exp(-md.Ea * 3000 / (8.314 * T_K)); + this._rxRate = Math.min(1, rate * 4); + + const surf = (m.r / 26) ** 2; + const dmdt = rate * surf * 0.95; + + m.mass = Math.max(0, m.mass - dmdt * dt); + m.r = this._m2r(m.mass); + + /* Слегка деформировать вершины при реакции */ + if (this._rxRate > 0.1 && Math.random() < dt * 6) { + const vi = Math.floor(Math.random() * m._v.length); + m._v[vi].j = Math.max(0.45, Math.min(1.0, m._v[vi].j + (Math.random() - 0.5) * 0.08)); + } + + const heatW = md.dH * dmdt * 0.055; + const cool = 0.30 * (this._temp - this.envTemp); + this._temp = Math.min(150, Math.max(this.envTemp, this._temp + (heatW - cool) * dt)); + + this._conc = Math.max(0, this._conc - dmdt * 0.07 * dt); + + const ad = FlaskSim.ACIDS[this.acidType]; + if (ad.pHf > 0) { + this._pH = Math.min(7, Math.max(0, -Math.log10(this._conc * 10 * ad.pHf + 1e-10))); + } else { + this._pH = Math.min(14, 7 + Math.min(7, m.mass < 0.1 ? 7 : dmdt * 12)); + } + + this._h2 = Math.min(1, this._h2 + md.h2 * dmdt * 0.065 * dt); + + this._bubTmr += rate * 32 * dt; + while (this._bubTmr > 1 && this._bubbles.length < 180) { + this._spawnBubble(m.x, m.y - m.r * 0.6); + this._bubTmr--; + } + + if (md.rust && Math.random() < rate * dt * 14) { + this._spawnDust(m.x + (Math.random() - 0.5) * m.r, m.y + m.r * 0.3, '#8B3A0A', 0.65); + } + + if (m.type === 'Fe' && this.acidType === 'H2SO4' && this.concLevel > 0.82) { + this._passiv = true; + } + + if (md.boom && this._boomCD <= 0 && rate > 0.28) { + this._boom(m.x, m.y); + this._boomCD = 0.45; + } + if (this._boomCD > 0) this._boomCD -= dt; + + if (this._flameOn && this._h2 > 0.22 && !this._ignited) { + this._igniteH2(); + } + } + + /* ── Частицы ─────────────────────────────────────────────────── */ + + _spawnBubble(x, y) { + this._bubbles.push({ + x: x + (Math.random() - 0.5) * 14, + y, + r: 1.4 + Math.random() * 3.8, + vy: -(16 + Math.random() * 38), + vx: (Math.random() - 0.5) * 9, + wobble: Math.random() * Math.PI * 2, + wFreq: 3.5 + Math.random() * 3, + life: 1, + }); + } + + _stepBubbles(dt) { + const { liqTop } = this._g; + for (const b of this._bubbles) { + b.wobble += dt * b.wFreq; + b.x += b.vx * dt + Math.sin(b.wobble) * b.r * 0.35 * dt * 10; + b.y += b.vy * dt; + b.vx += (Math.random() - 0.5) * 28 * dt; + if (b.y - b.r < liqTop) { + this._spawnSplash(b.x, liqTop, b.r); + b.life = 0; + } else { + b.life -= dt * 0.14; + } + } + this._bubbles = this._bubbles.filter(b => b.life > 0); + } + + _spawnSplash(x, y, r) { + if (r < 2) return; + const n = Math.floor(2 + r); + for (let i = 0; i < n; i++) { + const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI * 1.2; + const s = 10 + r * 4 + Math.random() * 20; + this._splashes.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 12, r: 0.8 + Math.random() * 1.4, life: 1 }); + } + } + + _stepSplashes(dt) { + for (const s of this._splashes) { + s.x += s.vx * dt; + s.y += s.vy * dt; + s.vy += 55 * dt; + s.life -= dt * 4.0; + } + this._splashes = this._splashes.filter(s => s.life > 0); + } + + _spawnSteam(x, y) { + this._steam.push({ + x: x + (Math.random() - 0.5) * 16, + y, + vx: (Math.random() - 0.5) * 12, + vy: -(6 + Math.random() * 18), + r: 2.5 + Math.random() * 6, + life: 0.9 + Math.random() * 0.1, + }); + } + + _stepSteam(dt) { + if (this._temp > 70) { + this._steamTmr += (this._temp - 70) / 30 * dt * 7; + while (this._steamTmr > 1 && this._steam.length < 55) { + const { liqTop, cx, nw } = this._g; + this._spawnSteam(cx + (Math.random() - 0.5) * nw * 1.6, liqTop - 4); + this._steamTmr--; + } + } + for (const s of this._steam) { + s.x += s.vx * dt; + s.y += s.vy * dt; + s.vx += (Math.random() - 0.5) * 12 * dt; + s.r += dt * 5; + s.life -= dt * (0.7 + (1 - s.life) * 0.4); + } + this._steam = this._steam.filter(s => s.life > 0 && s.r < 50); + } + + _spawnCaustic(x, y) { + this._caustics.push({ + x, y, + r: 7 + Math.random() * 20, + vx: (Math.random() - 0.5) * 16, + vy: (Math.random() - 0.5) * 7, + life: 0.4 + Math.random() * 0.6, + a: 0.05 + Math.random() * 0.09, + }); + } + + _stepCaustics(dt) { + this._causticTmr += dt * 3.5; + while (this._causticTmr > 1 && this._caustics.length < 20) { + const { cx, cy, r, liqTop } = this._g; + const px = cx + (Math.random() - 0.5) * r * 1.5; + const py = liqTop + 8 + Math.random() * (cy + r * 0.6 - liqTop - 16); + this._spawnCaustic(px, py); + this._causticTmr--; + } + for (const c of this._caustics) { + c.x += c.vx * dt; + c.y += c.vy * dt; + c.r += dt * 4; + c.life -= dt * 0.38; + } + this._caustics = this._caustics.filter(c => c.life > 0); + } + + _spawnDust(x, y, col, a) { + this._dusts.push({ + x, y, + vx: (Math.random() - 0.5) * 14, + vy: 4 + Math.random() * 20, + r: 1.0 + Math.random() * 2.2, + col, a, life: 1, + }); + } + + _stepDusts(dt) { + for (const d of this._dusts) { + d.x += d.vx * dt; + d.y += d.vy * dt; + d.vy += 28 * dt; + d.vx *= 1 - dt * 2.2; + d.life -= dt * 0.22; + } + this._dusts = this._dusts.filter(d => d.life > 0); + if (this._dusts.length > 300) this._dusts.splice(0, 60); + } + + _stepSparks(dt) { + for (const s of this._sparks) { + s.x += s.vx * dt; + s.y += s.vy * dt; + s.vy += 210 * dt; + s.vx *= 1 - dt * 0.8; + s.life -= dt * 2.0; + } + this._sparks = this._sparks.filter(s => s.life > 0); + } + + _boom(x, y) { + for (let i = 0; i < 36; i++) { + const a = Math.random() * Math.PI * 2; + const s = 90 + Math.random() * 240; + this._sparks.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 90, + r: 2 + Math.random() * 4, col: Math.random() < 0.55 ? '#FFD166' : '#EF476F', life: 1 }); + } + } + + _igniteH2() { + this._ignited = true; + this._h2 = 0; + const { cx, nt } = this._g; + for (let i = 0; i < 60; i++) { + const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9; + const s = 130 + Math.random() * 360; + this._sparks.push({ + x: cx + (Math.random() - 0.5) * 18, y: nt - 10, + vx: Math.cos(a) * s, vy: Math.sin(a) * s, + r: 3 + Math.random() * 5, col: i < 30 ? '#FFD166' : '#FF6B35', life: 1, + }); + } + } + + /* ════════════════════════════════════════════════════════════════ + РЕНДЕРИНГ + ════════════════════════════════════════════════════════════════ */ + + draw() { + const ctx = this.ctx; + const { W, H, _g: g } = this; + ctx.clearRect(0, 0, W, H); + + /* Фон */ + const bg = ctx.createRadialGradient(W * 0.5, H * 0.35, 0, W * 0.5, H * 0.5, W * 0.75); + bg.addColorStop(0, '#0d1320'); + bg.addColorStop(1, '#05080f'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + + /* Сетка лаборатории */ + ctx.strokeStyle = 'rgba(255,255,255,0.018)'; ctx.lineWidth = 1; + for (let x = 0; x < W; x += 28) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } + for (let y = 0; y < H; y += 28) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } + + /* Стол */ + const tableY = g.cy + g.r + 8; + const tg = ctx.createLinearGradient(0, tableY, 0, tableY + 48); + tg.addColorStop(0, '#19223a'); tg.addColorStop(1, '#0c101e'); + ctx.fillStyle = tg; ctx.fillRect(0, tableY, W, H - tableY); + ctx.strokeStyle = 'rgba(90,120,200,0.20)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, tableY); ctx.lineTo(W, tableY); ctx.stroke(); + + this._drawFlaskShadow(ctx, tableY); + this._drawLiquid(ctx); + this._drawCaustics(ctx); + this._drawDusts(ctx); + this._drawBubbles(ctx); + this._drawSplashes(ctx); + this._drawMetal(ctx); + this._drawFlaskGlass(ctx); + this._drawSteam(ctx); + this._drawSparks(ctx); + this._drawThermometer(ctx); + this._drawPHStrip(ctx); + this._drawH2Bar(ctx); + this._drawInfoPanel(ctx); + if (!this._metal || this._metal.mass <= 0.01) this._drawHint(ctx); + } + + /* ── Тень/отражение колбы на столе ── */ + _drawFlaskShadow(ctx, tableY) { + const { _g: g } = this; + ctx.save(); + ctx.scale(1, 0.26); + const shadowGrad = ctx.createRadialGradient(g.cx, tableY / 0.26, 0, g.cx, tableY / 0.26, g.r * 1.15); + shadowGrad.addColorStop(0, 'rgba(0,0,0,0.50)'); + shadowGrad.addColorStop(0.55, 'rgba(0,0,0,0.22)'); + shadowGrad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = shadowGrad; + ctx.beginPath(); ctx.arc(g.cx, tableY / 0.26, g.r * 1.15, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + /* Реакционный glow на столе */ + if (this._rxRate > 0.05) { + const ad = FlaskSim.ACIDS[this.acidType]; + const [ri, gi, bi] = ad.rgb; + ctx.save(); + ctx.scale(1, 0.28); + const gg = ctx.createRadialGradient(g.cx, tableY / 0.28, 0, g.cx, tableY / 0.28, g.r * 0.9); + gg.addColorStop(0, `rgba(${ri},${gi},${bi},${this._rxRate * 0.18})`); + gg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gg; + ctx.beginPath(); ctx.arc(g.cx, tableY / 0.28, g.r * 0.9, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + } + + /* ── Жидкость: 3 волны + SSS + каустики + мениск ── */ + _drawLiquid(ctx) { + const { _g: g } = this; + const ad = FlaskSim.ACIDS[this.acidType]; + const [ri, gi, bi] = ad.rgb; + const heat = Math.min(1, (this._temp - 20) / 80); + const lr = Math.min(255, ri + heat * 80); + const lg = Math.max(0, gi - heat * 58); + const lb = Math.max(0, bi - heat * 58); + const al = 0.20 + this._conc * 0.40; + + const amp = 1.8 + this._rxRate * 8; + + /* Функция волновой поверхности */ + const waveY = (x) => { + const wx = x - g.cx; + return g.liqTop + + Math.sin(wx * 0.065 + this._wave) * amp + + Math.sin(wx * 0.130 - this._wave2 * 1.38) * amp * 0.36 + + Math.sin(wx * 0.046 + this._wave3 * 0.75) * amp * 0.20; + }; + + const step = 2; + const x0 = g.cx - g.r - 2, x1 = g.cx + g.r + 2; + + ctx.save(); + this._flaskPath(ctx); + ctx.clip(); + + /* ── Слой 1: основное тело жидкости ── */ + ctx.beginPath(); + ctx.moveTo(x0, waveY(x0)); + for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x)); + ctx.lineTo(x1, g.cy + g.r + 12); + ctx.lineTo(x0, g.cy + g.r + 12); + ctx.closePath(); + + const depthGrad = ctx.createLinearGradient(0, g.liqTop, 0, g.cy + g.r); + depthGrad.addColorStop(0, `rgba(${lr},${lg},${lb},${al * 0.40})`); + depthGrad.addColorStop(0.30, `rgba(${lr},${lg},${lb},${al * 0.65})`); + depthGrad.addColorStop(1, `rgba(${lr},${lg},${lb},${al})`); + ctx.fillStyle = depthGrad; ctx.fill(); + + /* ── Слой 2: subsurface scattering (22px полоса под поверхностью) ── */ + ctx.beginPath(); + ctx.moveTo(x0, waveY(x0)); + for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x)); + for (let x = x1; x >= x0; x -= step) ctx.lineTo(x, waveY(x) + 22); + ctx.closePath(); + const sssGrad = ctx.createLinearGradient(0, g.liqTop, 0, g.liqTop + 22); + sssGrad.addColorStop(0, `rgba(${Math.min(255,lr+90)},${Math.min(255,lg+80)},${Math.min(255,lb+80)},0.22)`); + sssGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0)`); + ctx.fillStyle = sssGrad; ctx.fill(); + + /* ── Слой 3: радиальный тинт дна (имитация рассеяния) ── */ + const botGrad = ctx.createRadialGradient(g.cx, g.cy + g.r * 0.55, 0, g.cx, g.cy + g.r * 0.55, g.r * 0.80); + botGrad.addColorStop(0, `rgba(${Math.min(255,lr+30)},${Math.min(255,lg+30)},${Math.min(255,lb+40)},0.14)`); + botGrad.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = botGrad; + ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); ctx.fill(); + + /* ── Основной блик поверхности ── */ + ctx.beginPath(); + ctx.moveTo(x0, waveY(x0)); + for (let x = x0; x <= x1; x += step) ctx.lineTo(x, waveY(x)); + ctx.strokeStyle = `rgba(${Math.min(255,lr+100)},${Math.min(255,lg+95)},${Math.min(255,lb+95)},0.50)`; + ctx.lineWidth = 1.6; ctx.stroke(); + + /* ── Вторая, более тонкая волна-блик (чуть ниже) ── */ + ctx.beginPath(); + for (let x = x0; x <= x1; x += step) { + const wy = waveY(x) + 3 + + Math.sin((x - g.cx) * 0.11 - this._wave2 * 1.1) * amp * 0.55; + if (x === x0) ctx.moveTo(x, wy); else ctx.lineTo(x, wy); + } + ctx.strokeStyle = `rgba(${Math.min(255,lr+55)},${Math.min(255,lg+55)},${Math.min(255,lb+55)},0.18)`; + ctx.lineWidth = 1; ctx.stroke(); + + /* ── Мениск у стенок колбы ── */ + const mY = waveY(g.cx); + ctx.beginPath(); + ctx.moveTo(g.cx - g.r + 2, waveY(g.cx - g.r) + 6); + ctx.quadraticCurveTo(g.cx - g.r + 14, mY - 4, g.cx - g.r * 0.38, mY); + ctx.strokeStyle = `rgba(${Math.min(255,lr+70)},${Math.min(255,lg+70)},${Math.min(255,lb+70)},0.32)`; + ctx.lineWidth = 2.2; ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(g.cx + g.r - 2, waveY(g.cx + g.r) + 6); + ctx.quadraticCurveTo(g.cx + g.r - 14, mY - 4, g.cx + g.r * 0.38, mY); + ctx.stroke(); + + ctx.restore(); + } + + /* ── Каустики (световые пятна в толще жидкости) ── */ + _drawCaustics(ctx) { + if (this._caustics.length === 0) return; + ctx.save(); + this._flaskPath(ctx); ctx.clip(); + for (const c of this._caustics) { + const alpha = c.a * c.life; + const cg = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.r); + cg.addColorStop(0, `rgba(255,255,255,${alpha * 0.9})`); + cg.addColorStop(0.45,`rgba(210,235,255,${alpha * 0.4})`); + cg.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = cg; + ctx.beginPath(); ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2); ctx.fill(); + } + ctx.globalAlpha = 1; + ctx.restore(); + } + + /* ── Пузырьки с wobble, specular, вторичным бликом ── */ + _drawBubbles(ctx) { + ctx.save(); + this._flaskPath(ctx); ctx.clip(); + for (const b of this._bubbles) { + const a = Math.min(1, b.life * 2.5); + + /* Тело пузырька — градиент */ + const bg = ctx.createRadialGradient( + b.x - b.r * 0.30, b.y - b.r * 0.30, 0, + b.x, b.y, b.r + ); + bg.addColorStop(0, `rgba(220,240,255,${a * 0.18})`); + bg.addColorStop(0.65,`rgba(180,215,255,${a * 0.09})`); + bg.addColorStop(1, `rgba(130,185,255,${a * 0.04})`); + ctx.fillStyle = bg; + ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill(); + + /* Контур */ + ctx.strokeStyle = `rgba(200,230,255,${a * 0.70})`; + ctx.lineWidth = 0.85; + ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.stroke(); + + /* Specular highlight (верхний левый) */ + const hg = ctx.createRadialGradient( + b.x - b.r * 0.30, b.y - b.r * 0.32, 0, + b.x - b.r * 0.30, b.y - b.r * 0.32, b.r * 0.30 + ); + hg.addColorStop(0, `rgba(255,255,255,${a * 0.88})`); + hg.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = hg; + ctx.beginPath(); ctx.arc(b.x - b.r * 0.30, b.y - b.r * 0.32, b.r * 0.30, 0, Math.PI * 2); ctx.fill(); + + /* Малый блик (нижний правый) */ + ctx.fillStyle = `rgba(200,225,255,${a * 0.28})`; + ctx.beginPath(); ctx.arc(b.x + b.r * 0.24, b.y + b.r * 0.30, b.r * 0.12, 0, Math.PI * 2); ctx.fill(); + } + ctx.restore(); + } + + /* ── Всплески на поверхности ── */ + _drawSplashes(ctx) { + if (this._splashes.length === 0) return; + ctx.save(); + this._flaskPath(ctx); ctx.clip(); + const ad = FlaskSim.ACIDS[this.acidType]; + const [ri, gi, bi] = ad.rgb; + for (const s of this._splashes) { + ctx.globalAlpha = s.life * 0.75; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); + ctx.fillStyle = `rgb(${Math.min(255,ri+70)},${Math.min(255,gi+70)},${Math.min(255,bi+70)})`; + ctx.fill(); + } + ctx.globalAlpha = 1; + ctx.restore(); + } + + /* ── Пар ── */ + _drawSteam(ctx) { + if (this._steam.length === 0) return; + const { _g: g } = this; + for (const s of this._steam) { + /* Пар выходит только выше горлышка или через нагрев (внутри колбы у шейки) */ + const inNeck = s.x > g.cx - g.nw - 6 && s.x < g.cx + g.nw + 6; + if (!inNeck && s.y > g.nt - 4) continue; + ctx.save(); + ctx.globalAlpha = s.life * 0.38 * Math.min(1, s.r / 8); + const sg = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.r); + sg.addColorStop(0, 'rgba(200,215,255,0.9)'); + sg.addColorStop(0.6,'rgba(180,200,240,0.4)'); + sg.addColorStop(1, 'rgba(160,190,230,0)'); + ctx.fillStyle = sg; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + } + + /* ── Колба: толстое стекло, glow реакции, highlights ── */ + _drawFlaskGlass(ctx) { + const { _g: g } = this; + const { r, cx, cy, nw, nt, nb } = g; + const heat = Math.min(1, (this._temp - 20) / 80); + const ad = FlaskSim.ACIDS[this.acidType]; + const [ri, gi, bi] = ad.rgb; + + /* ── Reaction glow (пульсирующий) ── */ + if (this._rxRate > 0.04) { + ctx.save(); + const pulse = 0.5 + 0.5 * Math.sin(this._glowPulse); + ctx.shadowColor = `rgb(${ri},${gi},${bi})`; + ctx.shadowBlur = 10 + this._rxRate * 30 + pulse * 10; + this._flaskPath(ctx); + ctx.strokeStyle = `rgba(${ri},${gi},${bi},${this._rxRate * 0.50 + pulse * 0.12})`; + ctx.lineWidth = 1.2; + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.restore(); + } + + /* ── Внешний контур ── */ + this._flaskPath(ctx); + ctx.strokeStyle = 'rgba(100,165,255,0.68)'; + ctx.lineWidth = 3.0; + ctx.stroke(); + + /* ── Внутренний контур (толщина стекла) ── */ + const tk = 4; + const r_i = r - tk; + const nw_i = nw - tk * 0.70; + const nb_i = cy - r_i * 0.80; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(cx - nw_i, nt + tk * 0.9); + ctx.lineTo(cx - nw_i, nb_i); + ctx.bezierCurveTo( + cx - nw_i, cy - r_i * 0.42, + cx - r_i * 0.85, cy - r_i * 0.10, + cx - r_i, cy + ); + ctx.arc(cx, cy, r_i, Math.PI, 0, true); + ctx.bezierCurveTo( + cx + r_i * 0.85, cy - r_i * 0.10, + cx + nw_i, cy - r_i * 0.42, + cx + nw_i, nb_i + ); + ctx.lineTo(cx + nw_i, nt + tk * 0.9); + ctx.strokeStyle = 'rgba(75,125,215,0.16)'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); + + /* ── Большой левый блик (gradient arc) ── */ + ctx.save(); + ctx.beginPath(); + ctx.moveTo(cx - nw * 0.50, nt + 10); + ctx.lineTo(cx - nw * 0.50, nb); + ctx.bezierCurveTo( + cx - nw * 0.50, nb + r * 0.18, + cx - r * 0.72, cy - r * 0.42, + cx - r * 0.74, cy - r * 0.05 + ); + const hlGrad = ctx.createLinearGradient(cx - r * 0.73, nt, cx - r * 0.73, cy); + hlGrad.addColorStop(0, 'rgba(225,242,255,0.40)'); + hlGrad.addColorStop(0.40, 'rgba(215,235,255,0.22)'); + hlGrad.addColorStop(0.75, 'rgba(200,225,255,0.10)'); + hlGrad.addColorStop(1, 'rgba(200,225,255,0.02)'); + ctx.strokeStyle = hlGrad; + ctx.lineWidth = 5; + ctx.stroke(); + ctx.restore(); + + /* ── Правый мягкий блик ── */ + ctx.save(); + ctx.beginPath(); + ctx.moveTo(cx + r * 0.62, cy - r * 0.56); + ctx.quadraticCurveTo(cx + r * 0.83, cy - r * 0.18, cx + r * 0.78, cy + r * 0.20); + ctx.strokeStyle = 'rgba(200,225,255,0.11)'; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.restore(); + + /* ── Горлышко — ободок ── */ + ctx.beginPath(); + ctx.moveTo(cx - nw - 5, nt); + ctx.lineTo(cx + nw + 5, nt); + ctx.strokeStyle = 'rgba(100,165,255,0.68)'; + ctx.lineWidth = 3.2; + ctx.stroke(); + + /* ── Блик горлышка ── */ + ctx.save(); + ctx.beginPath(); + ctx.moveTo(cx - nw * 0.42, nt + 4); + ctx.lineTo(cx - nw * 0.42, nb - 4); + ctx.strokeStyle = 'rgba(220,240,255,0.26)'; + ctx.lineWidth = 2.2; + ctx.stroke(); + ctx.restore(); + + /* ── Тепловой тинт (стекло краснеет при нагреве) ── */ + if (heat > 0.15) { + ctx.save(); + this._flaskPath(ctx); + ctx.fillStyle = `rgba(255,${Math.round(155 - heat * 110)},40,${heat * 0.072})`; + ctx.fill(); + ctx.restore(); + } + } + + /* ── Металл: specular, dissolution edge ── */ + _drawMetal(ctx) { + const m = this._metal; + if (!m || m.mass <= 0.01) return; + const md = FlaskSim.METALS[m.type]; + + ctx.save(); + + /* Dissolution edge glow */ + if (this._rxRate > 0.08) { + ctx.shadowColor = '#FFD166'; + ctx.shadowBlur = 6 + this._rxRate * 22; + } + + /* Тело */ + ctx.beginPath(); + for (let i = 0; i < m._v.length; i++) { + const v = m._v[i]; + const px = m.x + Math.cos(v.a) * m.r * v.j; + const py = m.y + Math.sin(v.a) * m.r * v.j; + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + ctx.closePath(); + + const mg = ctx.createRadialGradient(m.x - m.r * 0.32, m.y - m.r * 0.30, 0, m.x, m.y, m.r); + mg.addColorStop(0, this._tint(md.color, 80)); + mg.addColorStop(0.28,this._tint(md.color, 48)); + mg.addColorStop(0.68,md.color); + mg.addColorStop(1, this._tint(md.color, -62)); + ctx.fillStyle = mg; ctx.fill(); + ctx.strokeStyle = this._tint(md.color, 32); ctx.lineWidth = 1.5; ctx.stroke(); + ctx.shadowBlur = 0; + + /* Specular dot */ + ctx.save(); + ctx.globalAlpha = 0.68; + const sg = ctx.createRadialGradient( + m.x - m.r * 0.28, m.y - m.r * 0.30, 0, + m.x - m.r * 0.28, m.y - m.r * 0.30, m.r * 0.40 + ); + sg.addColorStop(0, 'rgba(255,255,255,0.95)'); + sg.addColorStop(0.5,'rgba(255,255,255,0.35)'); + sg.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = sg; + ctx.beginPath(); ctx.arc(m.x - m.r * 0.28, m.y - m.r * 0.30, m.r * 0.40, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + + /* Dissolution edge */ + if (this._rxRate > 0.12) { + ctx.save(); + ctx.globalAlpha = this._rxRate * 0.55; + ctx.beginPath(); + for (let i = 0; i < m._v.length; i++) { + const v = m._v[i]; + const px = m.x + Math.cos(v.a) * m.r * v.j; + const py = m.y + Math.sin(v.a) * m.r * v.j; + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.strokeStyle = '#FFD166'; + ctx.lineWidth = 1.5 + this._rxRate * 3.5; + ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 10; + ctx.stroke(); + ctx.restore(); + } + + /* Пассивирующая плёнка */ + if (this._passiv) { + ctx.beginPath(); + for (let i = 0; i < m._v.length; i++) { + const v = m._v[i]; + if (i === 0) ctx.moveTo(m.x + Math.cos(v.a) * m.r * v.j, m.y + Math.sin(v.a) * m.r * v.j); + else ctx.lineTo(m.x + Math.cos(v.a) * m.r * v.j, m.y + Math.sin(v.a) * m.r * v.j); + } + ctx.closePath(); + ctx.fillStyle = 'rgba(55,40,25,0.65)'; ctx.fill(); + } + + ctx.restore(); + } + + _drawDusts(ctx) { + ctx.save(); + this._flaskPath(ctx); ctx.clip(); + for (const d of this._dusts) { + ctx.globalAlpha = d.a * d.life; + ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); + ctx.fillStyle = d.col; ctx.fill(); + } + ctx.globalAlpha = 1; + ctx.restore(); + } + + _drawSparks(ctx) { + for (const s of this._sparks) { + ctx.save(); + ctx.globalAlpha = s.life; + ctx.shadowColor = s.col; + ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r * s.life, 0, Math.PI * 2); + ctx.fillStyle = s.col; ctx.fill(); + ctx.restore(); + } + if (this._flameOn) { + const { cx, nt, nw } = this._g; + ctx.font = '22px serif'; + ctx.fillText('*', cx + nw + 8, nt + 8); + } + } + + /* ── Термометр ── */ + _drawThermometer(ctx) { + const { _g: g } = this; + const tx = g.cx + g.r + 44; + const ty = g.nt + 8; + const th = g.cy + g.r - ty - 16; + const tw = 11; + const frac = Math.min(1, Math.max(0, (this._temp - 10) / 140)); + const fillH = th * frac; + const col = `hsl(${Math.round(55 - frac * 55)},92%,56%)`; + + _flask_rrect(ctx, tx - tw / 2, ty, tw, th, tw / 2); + ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill(); + ctx.strokeStyle = 'rgba(120,175,255,0.38)'; ctx.lineWidth = 1.5; ctx.stroke(); + + if (fillH > 0) { + _flask_rrect(ctx, tx - tw / 2 + 2, ty + th - fillH, tw - 4, fillH, (tw - 4) / 2); + ctx.fillStyle = col; ctx.fill(); + } + + ctx.beginPath(); ctx.arc(tx, ty + th + tw * 0.68, tw * 0.74, 0, Math.PI * 2); + ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 10; ctx.fill(); ctx.shadowBlur = 0; + + ctx.strokeStyle = 'rgba(150,185,255,0.3)'; ctx.lineWidth = 1; + for (let deg = 20; deg <= 150; deg += 20) { + const fy = ty + th - th * (deg - 10) / 140; + ctx.beginPath(); ctx.moveTo(tx + tw / 2, fy); ctx.lineTo(tx + tw / 2 + 5, fy); ctx.stroke(); + } + + ctx.font = 'bold 10.5px monospace'; ctx.fillStyle = 'rgba(195,215,255,0.82)'; + ctx.textAlign = 'center'; + ctx.fillText(Math.round(this._temp) + '°C', tx, ty + th + tw * 2 + 17); + ctx.fillText('T', tx, ty - 5); + ctx.textAlign = 'left'; + } + + /* ── pH-полоска ── */ + _drawPHStrip(ctx) { + const { _g: g } = this; + const px = g.cx - g.r - 44; + const py = g.liqTop - 6; + const pw = 14; const ph = 88; + const hue = Math.round(this._pH / 14 * 270); + const col = `hsl(${hue},80%,52%)`; + + _flask_rrect(ctx, px - pw / 2, py, pw, ph, 3); + ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 8; ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 0.8; + for (let i = 0; i <= 14; i += 2) { + const ry = py + ph * (1 - i / 14); + ctx.beginPath(); ctx.moveTo(px - pw / 2 + 2, ry); ctx.lineTo(px + pw / 2 - 2, ry); ctx.stroke(); + } + + ctx.font = 'bold 10.5px monospace'; ctx.fillStyle = 'rgba(195,215,255,0.82)'; + ctx.textAlign = 'center'; + ctx.fillText('pH', px, py - 5); + ctx.fillText(this._pH.toFixed(1), px, py + ph + 15); + ctx.textAlign = 'left'; + } + + /* ── Бар H₂ ── */ + _drawH2Bar(ctx) { + const { _g: g } = this; + const bx = g.cx - 46, by = g.nt - 30; + const bw = 92, bh = 10; + + _flask_rrect(ctx, bx, by, bw, bh, 4); + ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill(); + + if (this._h2 > 0) { + const col = this._ignited ? '#EF476F' : '#4CC9F0'; + ctx.shadowColor = col; ctx.shadowBlur = this._h2 > 0.45 ? 10 : 4; + _flask_rrect(ctx, bx, by, bw * this._h2, bh, 4); + ctx.fillStyle = col; ctx.fill(); ctx.shadowBlur = 0; + } + + ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(190,215,255,0.72)'; + ctx.textAlign = 'center'; ctx.fillText('H₂', g.cx, by - 5); ctx.textAlign = 'left'; + + if (this._h2 > 0.65 && !this._ignited) { + ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; + ctx.fillText('Поднести огонь!', g.cx, by - 16); + ctx.textAlign = 'left'; + } + if (this._ignited) { + ctx.font = 'bold 10px sans-serif'; ctx.fillStyle = '#EF476F'; + ctx.textAlign = 'center'; ctx.fillText('H₂ воспламенился!', g.cx, by - 16); ctx.textAlign = 'left'; + } + } + + /* ── Информационная панель ── */ + _drawInfoPanel(ctx) { + const { _g: g, W } = this; + const eq = FlaskSim.EQ[`${this.metalType}_${this.acidType}`] || '—'; + const eqY = g.cy + g.r + 26; + + ctx.font = '12.5px monospace'; ctx.fillStyle = 'rgba(185,215,255,0.78)'; + ctx.textAlign = 'center'; ctx.fillText(eq, W * 0.44, eqY); ctx.textAlign = 'left'; + + if (this._passiv) { + ctx.font = 'bold 11px sans-serif'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; + ctx.fillText('Пассивация: Fe покрыт оксидной плёнкой — реакция прекратилась', W * 0.44, eqY + 19); + ctx.textAlign = 'left'; + } + + if (this._metal && this._metal.mass > 0.1 && this._rxRate > 0) { + const bx = g.cx - g.r, by = g.cy + g.r - 6; + const bw = g.r * 2; + _flask_rrect(ctx, bx, by, bw, 5, 2); + ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fill(); + const col = this._rxRate > 0.6 ? '#EF476F' : this._rxRate > 0.3 ? '#FFD166' : '#7BF5A4'; + _flask_rrect(ctx, bx, by, bw * this._rxRate, 5, 2); + ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 4; ctx.fill(); ctx.shadowBlur = 0; + } + } + + _drawHint(ctx) { + const { _g: g } = this; + ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.32)'; + ctx.textAlign = 'center'; + ctx.fillText('Нажмите «Бросить металл» для начала реакции', g.cx, g.cy + 14); + ctx.textAlign = 'left'; + } + + /* ── Вспомогательные ──────────────────────────────────────────── */ + + _tint(hex, d) { + const n = parseInt(hex.slice(1), 16); + const c = v => Math.max(0, Math.min(255, v)); + return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`; + } + + info() { + const m = this._metal; + const md = m ? FlaskSim.METALS[m.type] : null; + return { + metal: md?.name ?? '—', + mass: m ? m.mass.toFixed(2) : '0', + temp: this._temp.toFixed(1), + pH: this._pH.toFixed(2), + h2pct: (this._h2 * 100).toFixed(0), + rate: (this._rxRate * 100).toFixed(0), + reacts: md ? md.acids.includes(this.acidType) : false, + }; + } +} + +/* ── Util: скруглённый прямоугольник ─────────────────────────── */ +function _flask_rrect(ctx, x, y, w, h, r) { + if (w <= 0 || h <= 0) return; + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} diff --git a/frontend/js/labs/forcesandbox.js b/frontend/js/labs/forcesandbox.js new file mode 100644 index 0000000..b362cd9 --- /dev/null +++ b/frontend/js/labs/forcesandbox.js @@ -0,0 +1,2099 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + ForceSandboxSim v3 — полная физика твёрдого тела + Velocity-Verlet · 4 подшага · Вращение · OBB/SAT коллизии + Трение с угловым импульсом · Качение · Рампа с вращением + ════════════════════════════════════════════════════════════════ */ + +class ForceSandboxSim { + + static SCALE = 58; // px / metre + static G = 9.81; + static COLORS = ['#EF476F','#4CC9F0','#9B5DE5','#FFD166','#7BF5A4','#FF6B35']; + static SUB_STEPS = 4; // physics sub-steps per rendered frame + + /* ── Конструктор ─────────────────────────────────────────── */ + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.bodies = []; + this._nextId = 0; + this._colorIdx = 0; + + /* Мировые параметры */ + this.gravity = true; + this.gVal = 9.81; + this.hasFloor = true; + this.hasWalls = true; + this.floorMu = 0.30; + this.showForces = true; + this.showVelocity = true; + this.showFBD = false; + this.showEnergy = true; + this.showTrail = true; + this.showDecomp = true; + this.timeScale = 1; + this.airDrag = false; + + /* Пружины */ + this.springs = []; + this._nextSpringId = 0; + this._springStart = null; + this.newSpringK = 120; + this.newSpringDamp = 4; + + /* Верёвки / нити / блоки */ + this.ropes = []; + this._nextRopeId = 0; + this._ropeStart = null; + this.newRopeK = 3000; // жёсткость нити (квазинерастяжимая) + this.newRopeDamp = 12; + + /* Наклонная плоскость */ + this.ramp = false; + this.rampAngle = 30; + this.rampMu = 0.20; + this._rampGeom = null; + + /* Инструменты */ + this.tool = 'box'; + this.forceMode = 'constant'; + this.newMass = 5; + this.newRestitution = 0.65; + + /* Drag / hover */ + this._drag = null; + this._hovered = null; + this._ghostPos = null; + this._selected = null; + + /* Timing */ + this._raf = null; + this._evAbort = new AbortController(); + this._last = 0; + this._paused = false; + this._simTime = 0; + this._strobeTimer = 0; + this._energyLoss = 0; + + /* Geometry */ + this.W = 0; this.H = 0; + this._floorY = 0; + + this.onUpdate = null; + this.fit(); + this._bindEvents(); + } + + /* ── Ramp geometry ───────────────────────────────────────── */ + + _calcRampGeom() { + if (!this.ramp) { this._rampGeom = null; return; } + const { W, _floorY: fY } = this; + const a = this.rampAngle * Math.PI / 180; + const margin = W * 0.08, L = W * 0.78; + const x1 = margin, y1 = fY; + const x2 = margin + L * Math.cos(a), y2 = fY - L * Math.sin(a); + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy); + // Normal pointing ABOVE surface: rotate tangent 90° CW in screen coords + const nx = dy / len, ny = -dx / len; // = (-sin a, -cos a) + this._rampGeom = { x1, y1, x2, y2, nx, ny, len, + cos: Math.cos(a), sin: Math.sin(a), angle: a }; + } + + /* ── Geometry ────────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 700; + const H = this.canvas.offsetHeight || 440; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._floorY = H * 0.85; + this._calcRampGeom(); + } + + /* ── Lifecycle ───────────────────────────────────────────── */ + + start() { + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + togglePause() { this._paused = !this._paused; } + + reset() { + this.bodies = []; + this._nextId = 0; + this._colorIdx = 0; + this._simTime = 0; + this._energyLoss = 0; + this._drag = null; + this._hovered = null; + this._selected = null; + this.springs = []; + this._springStart = null; + this.ropes = []; + this._ropeStart = null; + this.ramp = false; + this._rampGeom = null; + } + + /* ── Ramp API ────────────────────────────────────────────── */ + + setRamp(on) { this.ramp = on; this._calcRampGeom(); } + setRampAngle(deg) { this.rampAngle = Math.max(5, Math.min(80, deg)); this._calcRampGeom(); } + setRampMu(v) { this.rampMu = v; } + + /* ── Body creation ───────────────────────────────────────── */ + + addBody(x, y, type) { + type = type || this.tool; + if (type === 'erase') return null; + const mass = this.newMass; + const color = ForceSandboxSim.COLORS[this._colorIdx++ % ForceSandboxSim.COLORS.length]; + const w = type === 'box' ? 32 + mass * 2.4 : 0; + const h = type === 'box' ? 28 + mass * 1.8 : 0; + const r = type === 'ball' ? 14 + mass * 1.6 : 0; + // Момент инерции (kg·px²): box = m(w²+h²)/12, ball = m·r²/2 + const I = type === 'box' ? mass * (w * w + h * h) / 12 + : 0.5 * mass * r * r; + const body = { + id: this._nextId++, type, x, y, + vx: 0, vy: 0, + angle: 0, omega: 0, // вращение + mass, w, h, r, I, + mu: 0.3, + restitution: this.newRestitution, + color, + pinned: false, + trail: [], + forces: [], + }; + this.bodies.push(body); + return body; + } + + removeBody(id) { + this.bodies = this.bodies.filter(b => b.id !== id); + this.springs = this.springs.filter(s => s.b1id !== id && s.b2id !== id); + this.ropes = this.ropes.filter(r => r.b1id !== id && r.b2id !== id); + if (this._selected === id) this._selected = null; + } + + /* ── Springs ─────────────────────────────────────────────── */ + + // L0m — natural length in metres (null = current distance) + // opts: { lx1, ly1, lx2, ly2 } — local attachment offsets (px, default 0,0 = center) + addSpring(b1id, b2id, k, L0m, damp, opts) { + const b1 = this.bodies.find(b => b.id === b1id); + const b2 = this.bodies.find(b => b.id === b2id); + if (!b1 || !b2) return null; + const S = ForceSandboxSim.SCALE; + const lx1 = opts?.lx1 || 0, ly1 = opts?.ly1 || 0; + const lx2 = opts?.lx2 || 0, ly2 = opts?.ly2 || 0; + // World attachment positions + const p1 = this._localToWorld(b1, lx1, ly1); + const p2 = this._localToWorld(b2, lx2, ly2); + const L0 = (L0m != null) ? L0m * S + : Math.hypot(p2.x - p1.x, p2.y - p1.y); + const sp = { id: this._nextSpringId++, b1id, b2id, + k: k != null ? k : this.newSpringK, + damp: damp != null ? damp : this.newSpringDamp, + L0, lx1, ly1, lx2, ly2 }; + this.springs.push(sp); + return sp; + } + + removeSpring(id) { + this.springs = this.springs.filter(s => s.id !== id); + } + + /* ── Ropes / Strings / Pulleys ───────────────────────────── */ + + // opts: { type:'direct'|'pulley', px, py, L0px, L0m, k, damp } + // type='direct' — straight inextensible string between two bodies + // type='pulley' — string over fixed pulley at (px, py), Atwood-style + addRope(b1id, b2id, opts = {}) { + const b1 = this.bodies.find(b => b.id === b1id); + const b2 = this.bodies.find(b => b.id === b2id); + if (!b1 || !b2) return null; + const S = ForceSandboxSim.SCALE; + const type = opts.type || 'direct'; + const px = opts.px != null ? opts.px : 0; + const py = opts.py != null ? opts.py : 0; + let L0; + if (opts.L0px != null) { + L0 = opts.L0px; + } else if (opts.L0m != null) { + L0 = opts.L0m * S; + } else if (type === 'pulley') { + L0 = Math.hypot(b1.x - px, b1.y - py) + Math.hypot(b2.x - px, b2.y - py); + } else { + L0 = Math.hypot(b2.x - b1.x, b2.y - b1.y); + } + const rope = { id: this._nextRopeId++, type, b1id, b2id, L0, px, py, + k: opts.k != null ? opts.k : this.newRopeK, + damp: opts.damp != null ? opts.damp : this.newRopeDamp }; + this.ropes.push(rope); + return rope; + } + + removeRope(id) { + this.ropes = this.ropes.filter(r => r.id !== id); + } + + clearForces(id) { + const b = this.bodies.find(b => b.id === id); + if (b) b.forces = []; + } + + /* ── Presets ─────────────────────────────────────────────── */ + + preset(name) { + this.reset(); + const S = ForceSandboxSim.SCALE; + const { W, H, _floorY: fY } = this; + + switch (name) { + case 'freefall': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; + const b1 = this.addBody(W * 0.35, fY - 280, 'ball'); + b1.mass = 3; b1.r = 14 + 3 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.color = '#4CC9F0'; + const b2 = this.addBody(W * 0.65, fY - 280, 'ball'); + b2.mass = 15; b2.r = 14 + 15 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.color = '#EF476F'; + break; + } + case 'collision': { + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const b1 = this.addBody(W * 0.15, H * 0.45, 'ball'); + b1.mass = 5; b1.r = 14 + 5 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.vx = 180; b1.color = '#4CC9F0'; + const b2 = this.addBody(W * 0.85, H * 0.45, 'ball'); + b2.mass = 12; b2.r = 14 + 12 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.vx = -80; b2.color = '#EF476F'; + break; + } + case 'friction': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.floorMu = 0.35; + const b1 = this.addBody(W * 0.12, fY - 36, 'box'); + b1.mass = 8; b1.w = 32 + 8 * 2.4; b1.h = 28 + 8 * 1.8; + b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; + b1.vx = 240; b1.color = '#9B5DE5'; + break; + } + case 'tug': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; + const b1 = this.addBody(W * 0.35, fY - 36, 'box'); + b1.mass = 6; b1.w = 32 + 6 * 2.4; b1.h = 28 + 6 * 1.8; + b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; + b1.color = '#EF476F'; + b1.forces.push({ fx: 120 * S, fy: 0, label: 'F₁', color: '#FFD166' }); + b1.forces.push({ fx: -80 * S, fy: 0, label: 'F₂', color: '#4CC9F0' }); + break; + } + case 'balance': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; + const b1 = this.addBody(W * 0.5, fY - 36, 'box'); + b1.mass = 10; b1.w = 32 + 10 * 2.4; b1.h = 28 + 10 * 1.8; + b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; + b1.color = '#7BF5A4'; + b1.forces.push({ fx: 60 * S, fy: 0, label: 'F₁', color: '#FFD166' }); + b1.forces.push({ fx: -60 * S, fy: 0, label: 'F₂', color: '#4CC9F0' }); + b1.forces.push({ fx: 0, fy: -50 * S, label: 'F₃', color: '#EF476F' }); + break; + } + case 'ramp_slide': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; + this.ramp = true; this.rampAngle = 30; this.rampMu = 0.15; + this._calcRampGeom(); + const rg = this._rampGeom; + if (rg) { + const b1 = this.addBody(0, 0, 'box'); + b1.mass = 5; b1.w = 32 + 5 * 2.4; b1.h = 28 + 5 * 1.8; + b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; + b1.color = '#EF476F'; + const rad1 = Math.abs(b1.w / 2 * rg.nx) + Math.abs(b1.h / 2 * rg.ny); + const t1 = 0.82; + b1.x = rg.x1 + (rg.x2 - rg.x1) * t1 + rg.nx * (rad1 + 2); + b1.y = rg.y1 + (rg.y2 - rg.y1) * t1 + rg.ny * (rad1 + 2); + } + break; + } + case 'ramp_angle': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; + this.ramp = true; this.rampAngle = 45; this.rampMu = 0.30; + this._calcRampGeom(); + const rg2 = this._rampGeom; + if (rg2) { + const b1 = this.addBody(0, 0, 'ball'); + b1.mass = 8; b1.r = 14 + 8 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.color = '#4CC9F0'; + const t2 = 0.75; + b1.x = rg2.x1 + (rg2.x2 - rg2.x1) * t2 + rg2.nx * (b1.r + 2); + b1.y = rg2.y1 + (rg2.y2 - rg2.y1) * t2 + rg2.ny * (b1.r + 2); + } + break; + } + case 'ramp_friction': { + this.gravity = true; this.hasFloor = true; this.hasWalls = true; + this.ramp = true; this.rampAngle = 25; this.rampMu = 0.50; + this._calcRampGeom(); + const rg3 = this._rampGeom; + if (rg3) { + const b1 = this.addBody(0, 0, 'box'); + b1.mass = 6; b1.w = 32 + 6 * 2.4; b1.h = 28 + 6 * 1.8; + b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; + b1.color = '#FFD166'; + const rad3 = Math.abs(b1.w / 2 * rg3.nx) + Math.abs(b1.h / 2 * rg3.ny); + b1.x = rg3.x1 + (rg3.x2 - rg3.x1) * 0.8 + rg3.nx * (rad3 + 2); + b1.y = rg3.y1 + (rg3.y2 - rg3.y1) * 0.8 + rg3.ny * (rad3 + 2); + } + break; + } + case 'atwood': { + // Машина Атвуда: m1 ≠ m2, нить через неподвижный блок + this.gravity = true; this.hasFloor = true; this.hasWalls = false; + const px = W * 0.5, py = H * 0.06; + const arm = H * 0.48; + const b1 = this.addBody(px - 55, py + arm, 'ball'); + b1.mass = 3; b1.r = 14 + 3 * 1.6; b1.I = 0.5 * b1.mass * b1.r * b1.r; b1.color = '#4CC9F0'; + const b2 = this.addBody(px + 55, py + arm - 60, 'ball'); + b2.mass = 8; b2.r = 14 + 8 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.color = '#EF476F'; + this.addRope(b1.id, b2.id, { type: 'pulley', px, py }); + break; + } + case 'two_body': { + // Два тела на нити через блок: одно на горизонтальной плоскости + this.gravity = true; this.hasFloor = true; this.hasWalls = false; + const fY = this._floorY; + const px = W * 0.75, py = fY; // блок у края стола + const b1 = this.addBody(W * 0.33, fY - 28, 'box'); + b1.mass = 5; b1.w = 32 + 5 * 2.4; b1.h = 28 + 5 * 1.8; + b1.I = b1.mass * (b1.w * b1.w + b1.h * b1.h) / 12; + b1.color = '#9B5DE5'; b1.mu = 0.05; + const b2 = this.addBody(px, fY + 130, 'ball'); + b2.mass = 3; b2.r = 14 + 3 * 1.6; b2.I = 0.5 * b2.mass * b2.r * b2.r; b2.color = '#EF476F'; + this.addRope(b1.id, b2.id, { type: 'pulley', px, py }); + break; + } + case 'spring_bounce': { + // Два шара соединены пружиной — колебания по горизонтали + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const a = this.addBody(W * 0.3, H * 0.5, 'ball'); + a.mass = 6; a.r = 14 + 6 * 1.6; a.I = 0.5 * a.mass * a.r * a.r; + a.color = '#4CC9F0'; a.pinned = true; + const b = this.addBody(W * 0.72, H * 0.5, 'ball'); + b.mass = 6; b.r = 14 + 6 * 1.6; b.I = 0.5 * b.mass * b.r * b.r; + b.color = '#EF476F'; + this.addSpring(a.id, b.id, 80, null, 2); + break; + } + case 'spring_chain': { + // Цепочка тел, связанных пружинами — волна + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const n = 5; + let prev = null; + for (let i = 0; i < n; i++) { + const bx = W * (0.12 + i * 0.16), by = H * 0.5; + const bd = this.addBody(bx, by, 'ball'); + bd.mass = 4; bd.r = 14 + 4 * 1.6; bd.I = 0.5 * bd.mass * bd.r * bd.r; + bd.color = ForceSandboxSim.COLORS[i % ForceSandboxSim.COLORS.length]; + if (i === 0) bd.pinned = true; + if (prev) this.addSpring(prev.id, bd.id, 100, null, 3); + prev = bd; + } + // Толчок последнего + if (prev) prev.vx = -220; + break; + } + case 'pendulum': { + // Маятник: закреплённая точка + шар на жёсткой пружине + this.gravity = true; this.hasFloor = false; this.hasWalls = true; + const anchor = this.addBody(W * 0.5, H * 0.1, 'ball'); + anchor.mass = 1; anchor.r = 7; anchor.I = 0.5 * anchor.mass * anchor.r * anchor.r; + anchor.color = '#FFD166'; anchor.pinned = true; anchor._isAnchor = true; + const bob = this.addBody(W * 0.5 + 170, H * 0.1 + 240, 'ball'); + bob.mass = 6; bob.r = 14 + 6 * 1.6; bob.I = 0.5 * bob.mass * bob.r * bob.r; + bob.color = '#9B5DE5'; + this.addSpring(anchor.id, bob.id, 1800, null, 18); + break; + } + case 'elastic_collision': { + // Абсолютно упругое столкновение: m1 = m2, e = 1 + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const a = this.addBody(W * 0.2, H * 0.5, 'ball'); + a.mass = 5; a.r = 14 + 5 * 1.6; a.I = 0.5 * a.mass * a.r * a.r; + a.vx = 160; a.restitution = 1.0; a.color = '#4CC9F0'; + const b = this.addBody(W * 0.7, H * 0.5, 'ball'); + b.mass = 5; b.r = 14 + 5 * 1.6; b.I = 0.5 * b.mass * b.r * b.r; + b.restitution = 1.0; b.color = '#EF476F'; + break; + } + case 'inelastic_collision': { + // Абсолютно неупругое столкновение: e = 0 + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const a = this.addBody(W * 0.15, H * 0.5, 'ball'); + a.mass = 8; a.r = 14 + 8 * 1.6; a.I = 0.5 * a.mass * a.r * a.r; + a.vx = 180; a.restitution = 0; a.color = '#4CC9F0'; + const b = this.addBody(W * 0.75, H * 0.5, 'ball'); + b.mass = 4; b.r = 14 + 4 * 1.6; b.I = 0.5 * b.mass * b.r * b.r; + b.vx = -60; b.restitution = 0; b.color = '#EF476F'; + break; + } + case 'newton_cradle': { + // Колыбель Ньютона: 5 шаров на пружинных подвесах + this.gravity = true; this.hasFloor = false; this.hasWalls = true; + const n = 5, gap = 30, anchorY = H * 0.08, bobY = H * 0.55; + const startX = W * 0.5 - (n - 1) * gap * 0.5; + const balls = []; + for (let i = 0; i < n; i++) { + const ax = startX + i * gap; + const anc = this.addBody(ax, anchorY, 'ball'); + anc.mass = 0.5; anc.r = 5; anc.I = 0.5 * anc.mass * anc.r * anc.r; + anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; + const bl = this.addBody(ax, bobY, 'ball'); + bl.mass = 5; bl.r = 14; bl.I = 0.5 * bl.mass * bl.r * bl.r; + bl.restitution = 1.0; bl.color = ForceSandboxSim.COLORS[i % 6]; + this.addSpring(anc.id, bl.id, 2400, null, 6); + balls.push(bl); + } + // Поднять первый шар влево + balls[0].x -= 120; balls[0].y -= 30; + break; + } + case 'harmonic_oscillator': { + // Простой гармонический осциллятор: масса на пружине + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const anc = this.addBody(W * 0.15, H * 0.5, 'ball'); + anc.mass = 0.5; anc.r = 6; anc.I = 0.5 * anc.mass * anc.r * anc.r; + anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; + const m = this.addBody(W * 0.65, H * 0.5, 'ball'); + m.mass = 4; m.r = 14 + 4 * 1.6; m.I = 0.5 * m.mass * m.r * m.r; + m.color = '#7BF5A4'; + this.addSpring(anc.id, m.id, 60, null, 0.5); + // Начальное смещение + m.x += 80; + break; + } + case 'double_pendulum': { + // Двойной маятник (хаотическое движение) + this.gravity = true; this.hasFloor = false; this.hasWalls = true; + const ax = W * 0.5, ay = H * 0.1; + const anc = this.addBody(ax, ay, 'ball'); + anc.mass = 0.5; anc.r = 6; anc.I = 0.5 * anc.mass * anc.r * anc.r; + anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; + const m1 = this.addBody(ax + 100, ay + 140, 'ball'); + m1.mass = 4; m1.r = 14 + 4 * 1.6; m1.I = 0.5 * m1.mass * m1.r * m1.r; + m1.color = '#4CC9F0'; + const m2 = this.addBody(ax + 180, ay + 280, 'ball'); + m2.mass = 3; m2.r = 14 + 3 * 1.6; m2.I = 0.5 * m2.mass * m2.r * m2.r; + m2.color = '#EF476F'; + // Жёсткие пружины ≈ стержни + this.addSpring(anc.id, m1.id, 2200, null, 12); + this.addSpring(m1.id, m2.id, 2200, null, 12); + break; + } + case 'coupled_oscillators': { + // Связанные осцилляторы: два тела + 3 пружины + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const a1 = this.addBody(W * 0.08, H * 0.5, 'ball'); + a1.mass = 0.5; a1.r = 6; a1.I = 0.5 * a1.mass * a1.r * a1.r; + a1.color = '#FFD166'; a1.pinned = true; a1._isAnchor = true; + const a2 = this.addBody(W * 0.92, H * 0.5, 'ball'); + a2.mass = 0.5; a2.r = 6; a2.I = 0.5 * a2.mass * a2.r * a2.r; + a2.color = '#FFD166'; a2.pinned = true; a2._isAnchor = true; + const m1 = this.addBody(W * 0.33, H * 0.5, 'ball'); + m1.mass = 5; m1.r = 14 + 5 * 1.6; m1.I = 0.5 * m1.mass * m1.r * m1.r; + m1.color = '#4CC9F0'; + const m2 = this.addBody(W * 0.67, H * 0.5, 'ball'); + m2.mass = 5; m2.r = 14 + 5 * 1.6; m2.I = 0.5 * m2.mass * m2.r * m2.r; + m2.color = '#EF476F'; + this.addSpring(a1.id, m1.id, 80, null, 1); + this.addSpring(m1.id, m2.id, 40, null, 0.5); + this.addSpring(m2.id, a2.id, 80, null, 1); + // Толчок первого + m1.x += 60; + break; + } + case 'stacked_boxes': { + // Стопка ящиков: демонстрация нормальных сил + this.gravity = true; this.hasFloor = true; this.hasWalls = true; this.floorMu = 0.40; + const sizes = [10, 7, 4]; + for (let i = 0; i < sizes.length; i++) { + const mass = sizes[i]; + const bx = this.addBody(W * 0.45, 0, 'box'); + bx.mass = mass; bx.w = 32 + mass * 2.4; bx.h = 28 + mass * 1.8; + bx.I = bx.mass * (bx.w * bx.w + bx.h * bx.h) / 12; + bx.color = ForceSandboxSim.COLORS[i]; + bx.y = fY - bx.h * 0.5; + for (let j = 0; j < i; j++) { + const prev = this.bodies[1 + j * 1]; // не считаем правильно, сделаем проще + } + } + // Расставим вручную от пола вверх + const boxes = this.bodies; + let curY = fY; + for (let i = 0; i < boxes.length; i++) { + const bx = boxes[i]; + curY -= bx.h * 0.5; + bx.y = curY; + curY -= bx.h * 0.5 + 1; + } + break; + } + case 'pulley_ramp': { + // Ящик на рампе + подвес через блок + this.gravity = true; this.hasFloor = true; this.hasWalls = false; + this.ramp = true; this.rampAngle = 30; this.rampMu = 0.15; + this._calcRampGeom(); + const rg = this._rampGeom; + if (rg) { + const bx = this.addBody(0, 0, 'box'); + bx.mass = 6; bx.w = 32 + 6 * 2.4; bx.h = 28 + 6 * 1.8; + bx.I = bx.mass * (bx.w * bx.w + bx.h * bx.h) / 12; + bx.color = '#9B5DE5'; bx.mu = 0.15; + const rad = Math.abs(bx.w / 2 * rg.nx) + Math.abs(bx.h / 2 * rg.ny); + bx.x = rg.x1 + (rg.x2 - rg.x1) * 0.5 + rg.nx * (rad + 2); + bx.y = rg.y1 + (rg.y2 - rg.y1) * 0.5 + rg.ny * (rad + 2); + // Блок наверху рампы + const px = rg.x2, py = rg.y2 - 20; + // Подвешенный шар + const bl = this.addBody(rg.x2 + 50, rg.y2 + 100, 'ball'); + bl.mass = 3; bl.r = 14 + 3 * 1.6; bl.I = 0.5 * bl.mass * bl.r * bl.r; + bl.color = '#EF476F'; + this.addRope(bx.id, bl.id, { type: 'pulley', px, py }); + } + break; + } + case 'circular_motion': { + // Круговое движение: шар на пружине вокруг якоря + this.gravity = false; this.hasFloor = false; this.hasWalls = true; + const anc = this.addBody(W * 0.5, H * 0.45, 'ball'); + anc.mass = 0.5; anc.r = 6; anc.I = 0.5 * anc.mass * anc.r * anc.r; + anc.color = '#FFD166'; anc.pinned = true; anc._isAnchor = true; + const m = this.addBody(W * 0.5 + 150, H * 0.45, 'ball'); + m.mass = 3; m.r = 14 + 3 * 1.6; m.I = 0.5 * m.mass * m.r * m.r; + m.color = '#7BF5A4'; + // Тангенциальная начальная скорость (вверх) + m.vy = -180; + this.addSpring(anc.id, m.id, 350, null, 2); + break; + } + case 'projectile_angle': { + // Запуск под углом: параболическая траектория + this.gravity = true; this.hasFloor = true; this.hasWalls = false; + const angle = 45 * Math.PI / 180; + const v0 = 280; + const bl = this.addBody(W * 0.08, fY - 20, 'ball'); + bl.mass = 3; bl.r = 14 + 3 * 1.6; bl.I = 0.5 * bl.mass * bl.r * bl.r; + bl.vx = v0 * Math.cos(angle); + bl.vy = -v0 * Math.sin(angle); + bl.color = '#9B5DE5'; + break; + } + } + } + + /* ── Tick ─────────────────────────────────────────────────── */ + + _tick(now) { + let dt = Math.min((now - this._last) / 1000, 0.05); + this._last = now; + if (this._paused) { this.draw(); return; } + dt *= this.timeScale; + this._simTime += dt; + this._step(dt); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ════════════════════════════════════════════════════════════ + RIGID-BODY HELPERS + ════════════════════════════════════════════════════════════ */ + + // 4 угла повёрнутого прямоугольника + _getCorners(b) { + const c = Math.cos(b.angle), s = Math.sin(b.angle); + const hw = b.w / 2, hh = b.h / 2; + return [ + { x: b.x + c * (-hw) - s * (-hh), y: b.y + s * (-hw) + c * (-hh) }, + { x: b.x + c * ( hw) - s * (-hh), y: b.y + s * ( hw) + c * (-hh) }, + { x: b.x + c * ( hw) - s * ( hh), y: b.y + s * ( hw) + c * ( hh) }, + { x: b.x + c * (-hw) - s * ( hh), y: b.y + s * (-hw) + c * ( hh) }, + ]; + } + + // Скорость точки тела, заданной смещением (rx, ry) от центра масс + _velAtPoint(b, rx, ry) { + return { x: b.vx - b.omega * ry, y: b.vy + b.omega * rx }; + } + + // Local-to-world: transform local offset (lx,ly) by body's rotation + _localToWorld(b, lx, ly) { + if (lx === 0 && ly === 0) return { x: b.x, y: b.y }; + const c = Math.cos(b.angle), s = Math.sin(b.angle); + return { x: b.x + c * lx - s * ly, y: b.y + s * lx + c * ly }; + } + + // Применить импульс J·(nx,ny) в точке (rx,ry) относительно ЦМ + _applyImpulse(b, J, nx, ny, rx, ry) { + if (b.pinned) return; + b.vx += J * nx / b.mass; + b.vy += J * ny / b.mass; + b.omega += (rx * ny - ry * nx) * J / b.I; // r × n = torque + } + + // Находит точку на OBB, ближайшую к (px, py) (world space) + _closestOnBox(b, px, py) { + const cos = Math.cos(-b.angle), sin = Math.sin(-b.angle); + const dx = px - b.x, dy = py - b.y; + const lx = Math.max(-b.w / 2, Math.min(b.w / 2, cos * dx - sin * dy)); + const ly = Math.max(-b.h / 2, Math.min(b.h / 2, sin * dx + cos * dy)); + const cos2 = Math.cos(b.angle), sin2 = Math.sin(b.angle); + return { x: b.x + cos2 * lx - sin2 * ly, y: b.y + sin2 * lx + cos2 * ly }; + } + + // SAT для двух OBB: возвращает {nx,ny,pen,cx,cy} или null + _satTest(a, b) { + const ca = this._getCorners(a), cb = this._getCorners(b); + const axes = [ + { x: Math.cos(a.angle), y: Math.sin(a.angle) }, + { x: -Math.sin(a.angle), y: Math.cos(a.angle) }, + { x: Math.cos(b.angle), y: Math.sin(b.angle) }, + { x: -Math.sin(b.angle), y: Math.cos(b.angle) }, + ]; + let minPen = Infinity, minAxis = null; + for (const ax of axes) { + const pA = ca.map(c => c.x * ax.x + c.y * ax.y); + const pB = cb.map(c => c.x * ax.x + c.y * ax.y); + const minA = Math.min(...pA), maxA = Math.max(...pA); + const minB = Math.min(...pB), maxB = Math.max(...pB); + const pen = Math.min(maxA, maxB) - Math.max(minA, minB); + if (pen <= 0) return null; + if (pen < minPen) { minPen = pen; minAxis = { ...ax }; } + } + // Нормаль от a к b + const dab = { x: b.x - a.x, y: b.y - a.y }; + if (dab.x * minAxis.x + dab.y * minAxis.y < 0) { + minAxis.x = -minAxis.x; minAxis.y = -minAxis.y; + } + // Контактная точка: среднее по углам b внутри a и углам a внутри b + const pts = []; + for (const c of cb) { if (this._ptInBox(c, a)) pts.push(c); } + for (const c of ca) { if (this._ptInBox(c, b)) pts.push(c); } + let cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; + if (pts.length) { + cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; + cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; + } + return { nx: minAxis.x, ny: minAxis.y, pen: minPen, cx, cy }; + } + + _ptInBox(p, box) { + const cos = Math.cos(-box.angle), sin = Math.sin(-box.angle); + const dx = p.x - box.x, dy = p.y - box.y; + return Math.abs(cos * dx - sin * dy) <= box.w / 2 + 0.5 && + Math.abs(sin * dx + cos * dy) <= box.h / 2 + 0.5; + } + + /* ════════════════════════════════════════════════════════════ + PHYSICS STEP (4 подшага) + ════════════════════════════════════════════════════════════ */ + + _step(dt) { + this._strobeTimer += dt; + const sub = ForceSandboxSim.SUB_STEPS; + const subDt = dt / sub; + for (let i = 0; i < sub; i++) this._subStep(subDt); + + // Стробоскопические следы — один раз за полный кадр + if (this._strobeTimer >= 0.12) { + this._strobeTimer = 0; + for (const b of this.bodies) { + if (!this.showTrail || b.pinned) continue; + if (Math.hypot(b.vx, b.vy) > 8) { + b.trail.push({ x: b.x, y: b.y, a: b.angle }); + if (b.trail.length > 40) b.trail.shift(); + } + } + } + } + + _subStep(dt) { + const S = ForceSandboxSim.SCALE; + const GV = this.gVal * S; + + // ── Пружинные + верёвочные ускорения ─────────────────────── + // k[N/m] * ext[px] / mass[kg] = a[px/s²] (SCALE cancels) + const _spAcc = (this.springs.length || this.ropes.length) ? new Map() : null; + if (_spAcc) { + // Пружины (двусторонние — сжатие и растяжение, с точками крепления) + for (const sp of this.springs) { + const b1 = this.bodies.find(b => b.id === sp.b1id); + const b2 = this.bodies.find(b => b.id === sp.b2id); + if (!b1 || !b2) continue; + // Мировые точки крепления + const p1 = this._localToWorld(b1, sp.lx1, sp.ly1); + const p2 = this._localToWorld(b2, sp.lx2, sp.ly2); + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const dist = Math.hypot(dx, dy); + if (dist < 1) continue; + const nx = dx / dist, ny = dy / dist; + const ext = dist - sp.L0; + // Скорости точек крепления (с учётом вращения) + const r1x = p1.x - b1.x, r1y = p1.y - b1.y; + const r2x = p2.x - b2.x, r2y = p2.y - b2.y; + const v1 = this._velAtPoint(b1, r1x, r1y); + const v2 = this._velAtPoint(b2, r2x, r2y); + const vRel = (v2.x - v1.x) * nx + (v2.y - v1.y) * ny; + const F = sp.k * ext + sp.damp * vRel; + const Fx = F * nx, Fy = F * ny; + if (!b1.pinned) { + const e = _spAcc.get(b1.id) || { ax: 0, ay: 0, ta: 0 }; + e.ax += Fx / b1.mass; e.ay += Fy / b1.mass; + e.ta += (r1x * Fy - r1y * Fx) / b1.I; // момент от нецентральной силы + _spAcc.set(b1.id, e); + } + if (!b2.pinned) { + const e = _spAcc.get(b2.id) || { ax: 0, ay: 0, ta: 0 }; + e.ax -= Fx / b2.mass; e.ay -= Fy / b2.mass; + e.ta -= (r2x * Fy - r2y * Fx) / b2.I; // момент + _spAcc.set(b2.id, e); + } + } + // Верёвки/нити (только растяжение — slack = 0 force) + for (const rp of this.ropes) { + const b1 = this.bodies.find(b => b.id === rp.b1id); + const b2 = this.bodies.find(b => b.id === rp.b2id); + if (!b1 || !b2) continue; + if (rp.type === 'direct') { + const dx = b2.x - b1.x, dy = b2.y - b1.y; + const dist = Math.hypot(dx, dy); + if (dist < 1) continue; + const ext = dist - rp.L0; + if (ext <= 0) continue; // slack + const nx = dx / dist, ny = dy / dist; + const vRel = (b2.vx - b1.vx) * nx + (b2.vy - b1.vy) * ny; + const T = rp.k * ext + rp.damp * Math.max(0, vRel); + if (!b1.pinned) { const e = _spAcc.get(b1.id) || { ax:0, ay:0, ta:0 }; e.ax += T*nx/b1.mass; e.ay += T*ny/b1.mass; _spAcc.set(b1.id, e); } + if (!b2.pinned) { const e = _spAcc.get(b2.id) || { ax:0, ay:0, ta:0 }; e.ax -= T*nx/b2.mass; e.ay -= T*ny/b2.mass; _spAcc.set(b2.id, e); } + } else { // pulley + const d1x = b1.x - rp.px, d1y = b1.y - rp.py; + const r1 = Math.hypot(d1x, d1y); + const d2x = b2.x - rp.px, d2y = b2.y - rp.py; + const r2 = Math.hypot(d2x, d2y); + if (r1 < 1 || r2 < 1) continue; + const ext = (r1 + r2) - rp.L0; + if (ext <= 0) continue; // slack + const n1x = d1x/r1, n1y = d1y/r1; // away from pulley b1 + const n2x = d2x/r2, n2y = d2y/r2; + const vRel = (b1.vx*n1x + b1.vy*n1y) + (b2.vx*n2x + b2.vy*n2y); + const T = rp.k * ext + rp.damp * Math.max(0, vRel); + if (!b1.pinned) { const e = _spAcc.get(b1.id) || { ax:0, ay:0, ta:0 }; e.ax -= T*n1x/b1.mass; e.ay -= T*n1y/b1.mass; _spAcc.set(b1.id, e); } + if (!b2.pinned) { const e = _spAcc.get(b2.id) || { ax:0, ay:0, ta:0 }; e.ax -= T*n2x/b2.mass; e.ay -= T*n2y/b2.mass; _spAcc.set(b2.id, e); } + } + } + } + + for (const b of this.bodies) { + if (b.pinned) { b.vx = b.vy = b.omega = 0; continue; } + b._onRamp = false; + + // ── Интегрирование сил ── + let ax = 0, ay = 0; + if (this.gravity) ay += GV; + for (const f of b.forces) { ax += f.fx / b.mass; ay += f.fy / b.mass; } + let omegaAcc = 0; + if (_spAcc) { const sf = _spAcc.get(b.id); if (sf) { ax += sf.ax; ay += sf.ay; omegaAcc += sf.ta; } } + + // Воздушное торможение + if (this.airDrag) { + const spd = Math.hypot(b.vx, b.vy); + if (spd > 1) { + const A = b.type === 'box' ? (b.w + b.h) * 0.5 : b.r; + const drag = 0.0015 * A * spd * spd; + ax -= drag * b.vx / spd / b.mass; + ay -= drag * b.vy / spd / b.mass; + } + } + + // Velocity Verlet — линейное движение + b.vx += ax * dt; b.x += b.vx * dt; + b.vy += ay * dt; b.y += b.vy * dt; + + // Угловое движение: момент от пружин + лёгкое демпфирование (≈7%/с) + b.omega += omegaAcc * dt; + b.omega *= (1 - 0.07 * dt); + b.angle += b.omega * dt; + + // Кэп скорости (защита от туннелирования) + const spd = Math.hypot(b.vx, b.vy); + if (spd > 1800) { b.vx = b.vx / spd * 1800; b.vy = b.vy / spd * 1800; } + b.omega = Math.max(-35, Math.min(35, b.omega)); + + // ── Коллизии с поверхностями ── + if (this.ramp && this._rampGeom) this._rampCollide(b, dt, GV); + this._resolveFloor(b, dt, GV); + this._resolveCeiling(b); + this._resolveWalls(b); + } + + // Коллизии тел друг с другом + this._collide(); + + // Жёсткий клэмп — гарантия: ни одно тело не уходит за границы + if (this.hasFloor) { + const fY = this._floorY; + for (const b of this.bodies) { + if (b.pinned) continue; + if (b.type === 'ball') { + if (b.y + b.r > fY) { b.y = fY - b.r; if (b.vy > 0) b.vy = 0; } + } else { + const corners = this._getCorners(b); + let maxPen = 0; + for (const p of corners) if (p.y - fY > maxPen) maxPen = p.y - fY; + if (maxPen > 0) { b.y -= maxPen; if (b.vy > 0) b.vy = 0; } + } + } + } + } + + /* ── Пол ─────────────────────────────────────────────────── */ + + _resolveFloor(b, dt, GV) { + if (!this.hasFloor) return; + const S = ForceSandboxSim.SCALE; + const fY = this._floorY; + + if (b.type === 'ball') { + const pen = (b.y + b.r) - fY; + if (pen <= 0) return; + b.y -= pen; + const e = b.restitution; + if (b.vy > 3) { + this._energyLoss += 0.5 * b.mass * b.vy * b.vy * (1 - e * e) / (S * S); + b.vy = -b.vy * e; + } else { b.vy = 0; } + + // Трение качения: контактная точка = низ шара + const rx = 0, ry = b.r; + const vCx = b.vx - b.omega * ry; // скорость точки контакта (горизонт.) + if (Math.abs(vCx) > 0.5) { + const denomT = 1 / b.mass + ry * ry / b.I; + let Jt = -vCx / denomT; + const mu = Math.max(b.mu, this.floorMu); + const maxJt = mu * b.mass * GV * dt; + Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), maxJt); + b.vx += Jt / b.mass; + b.omega += (-ry) * Jt / b.I; + this._energyLoss += Math.abs(Jt * vCx) / (S * S); + } + return; + } + + // Ящик: многоточечный контакт — усреднение всех углов у пола + // (одноточечный контакт всегда брал один угол постоянный крутящий момент) + const corners = this._getCorners(b); + let maxPen = 0; + for (const p of corners) { const dp = p.y - fY; if (dp > maxPen) maxPen = dp; } + if (maxPen <= 0) return; + + // Собираем все углы в пределах 1.5px от максимального проникновения + let cntX = 0, cntY = 0, cnt = 0; + for (const p of corners) { + if (p.y - fY >= maxPen - 1.5) { cntX += p.x - b.x; cntY += p.y - b.y; cnt++; } + } + // Для плоского ящика оба нижних угла попадают rx0 = 0, нет крутящего момента + const rx0 = cntX / cnt; + const ry0 = cntY / cnt; + + // Полная позиционная коррекция (предотвращает уход под пол) + b.y -= maxPen; + + // Скорость угла контакта, нормаль пола = (0, -1) + const vC = this._velAtPoint(b, rx0, ry0); + const vn = -vC.y; // dot(vC, (0,-1)) + + if (vn < -2) { + // Отскок: J = -(1+e)*vn/denom (vn < 0 J > 0 импульс вверх) + const e = b.restitution; + const rCrossN = -rx0; // rx*ny - ry*nx = rx0*(-1) - ry0*0 + const denom = 1 / b.mass + rCrossN * rCrossN / b.I; + const J = -(1 + e) * vn / denom; + this._energyLoss += 0.5 * b.mass * vn * vn * (1 - e * e) / (denom * S * S); + this._applyImpulse(b, J, 0, -1, rx0, ry0); + } else if (vn < 0.5) { + // Покой: убираем нормальную составляющую (J = -vn/denom kills sinking) + const rCrossN = -rx0; + const denom = 1 / b.mass + rCrossN * rCrossN / b.I; + const J = -vn / denom; + this._applyImpulse(b, J, 0, -1, rx0, ry0); + } + + // Трение скольжения + const vC2 = this._velAtPoint(b, rx0, ry0); + const vCxt = vC2.x; + if (Math.abs(vCxt) > 0.5) { + const mu = Math.max(b.mu, this.floorMu); + const N = b.mass * GV; + const rCrossT = -ry0; // r × t = rx0*0 - ry0*1 + const denomT = 1 / b.mass + rCrossT * rCrossT / b.I; + let Jt = -vCxt / denomT; + const maxJt = mu * N * dt; + Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), maxJt); + this._applyImpulse(b, Jt, 1, 0, rx0, ry0); + this._energyLoss += Math.abs(Jt * vCxt) / (S * S); + } + } + + /* ── Потолок ─────────────────────────────────────────────── */ + + _resolveCeiling(b) { + if (!this.hasWalls) return; + let top; + if (b.type === 'ball') { top = b.y - b.r; } + else { top = Math.min(...this._getCorners(b).map(c => c.y)); } + if (top < 0) { + b.y -= top; + if (b.vy < 0) { b.vy = Math.abs(b.vy) * b.restitution; b.omega *= -0.5; } + } + } + + /* ── Стены ───────────────────────────────────────────────── */ + + _resolveWalls(b) { + if (!this.hasWalls) return; + const S = ForceSandboxSim.SCALE; + const { W } = this; + + if (b.type === 'ball') { + if (b.x - b.r < 0) { + b.x = b.r; + if (b.vx < 0) { this._energyLoss += 0.5 * b.mass * b.vx * b.vx * (1 - b.restitution * b.restitution) / (S * S); b.vx = Math.abs(b.vx) * b.restitution; } + } + if (b.x + b.r > W) { + b.x = W - b.r; + if (b.vx > 0) { this._energyLoss += 0.5 * b.mass * b.vx * b.vx * (1 - b.restitution * b.restitution) / (S * S); b.vx = -Math.abs(b.vx) * b.restitution; } + } + return; + } + + const corners = this._getCorners(b); + const leftmost = corners.reduce((m, c) => c.x < m.x ? c : m, corners[0]); + const rightmost = corners.reduce((m, c) => c.x > m.x ? c : m, corners[0]); + + const _wallImpulse = (corner, nx, ny, penFix) => { + const rx = corner.x - b.x, ry = corner.y - b.y; + b.x -= penFix * nx; // коррекция + const vC = this._velAtPoint(b, rx, ry); + const vn = vC.x * nx + vC.y * ny; + if (vn < -1) { + const e = b.restitution; + const rCN = rx * ny - ry * nx; + const denom = 1 / b.mass + rCN * rCN / b.I; + const J = -(1 + e) * vn / denom; + this._energyLoss += 0.5 * b.mass * vn * vn * (1 - e * e) / (denom * S * S); + this._applyImpulse(b, J, nx, ny, rx, ry); + // Трение о стену (вертикальное) + const vCt = vC.y; + if (Math.abs(vCt) > 0.5) { + const ty = Math.sign(vCt); + const rCT = rx * ty - ry * 0; + const domT = 1 / b.mass + rCT * rCT / b.I; + let Jt = -vCt / domT; + Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), 0.3 * Math.abs(J)); + this._applyImpulse(b, Jt, 0, ty, rx, ry); + } + } else if (vn < 0) { + const rCN = rx * ny - ry * nx; + const denom = 1 / b.mass + rCN * rCN / b.I; + this._applyImpulse(b, -vn / denom, nx, ny, rx, ry); + } + }; + + if (leftmost.x < 0) _wallImpulse(leftmost, 1, 0, leftmost.x); + if (rightmost.x > W) _wallImpulse(rightmost, -1, 0, W - rightmost.x); + } + + /* ── Рампа с вращением ───────────────────────────────────── */ + + _rampCollide(b, dt, GV) { + const rg = this._rampGeom; + if (!rg) return; + const S = ForceSandboxSim.SCALE; + const dx = rg.x2 - rg.x1, dy = rg.y2 - rg.y1; + + let contactX, contactY, pen; + + const lenSq = rg.len * rg.len; + + if (b.type === 'ball') { + const tRaw = ((b.x - rg.x1) * dx + (b.y - rg.y1) * dy) / lenSq; + // Вышел за пределы рампы — отдать полу/потолку + if (tRaw < -0.05 || tRaw > 1.05) { b._onRamp = false; return; } + const t = Math.max(0, Math.min(1, tRaw)); + const px = rg.x1 + t * dx, py = rg.y1 + t * dy; + const dist = (b.x - px) * rg.nx + (b.y - py) * rg.ny; + if (dist >= b.r || dist < -b.r) { b._onRamp = false; return; } + pen = b.r - dist; + contactX = px; contactY = py; + b._rampT = tRaw; + } else { + // Проверяем, не вышел ли центр масс за пределы рампы + const tCOM = ((b.x - rg.x1) * dx + (b.y - rg.y1) * dy) / lenSq; + if (tCOM < -0.15 || tCOM > 1.15) { b._onRamp = false; return; } + + // Ящик: ищем угол с минимальным signed distance (максимальным проникновением) + const corners = this._getCorners(b); + let minDist = Infinity, minCorner = null, minTRaw = 0; + for (const c of corners) { + const tRaw = ((c.x - rg.x1) * dx + (c.y - rg.y1) * dy) / lenSq; + const t = Math.max(0, Math.min(1, tRaw)); + const px = rg.x1 + t * dx, py = rg.y1 + t * dy; + const dist = (c.x - px) * rg.nx + (c.y - py) * rg.ny; + if (dist < minDist) { minDist = dist; minCorner = { ...c }; contactX = px; contactY = py; minTRaw = tRaw; } + } + if (!minCorner || minDist >= 0) { b._onRamp = false; return; } + pen = -minDist; + if (pen > 30) { b._onRamp = false; return; } + b._rampT = minTRaw; + } + + if (pen <= 0) { b._onRamp = false; return; } + + // Полная коррекция позиции + b.x += rg.nx * pen; + b.y += rg.ny * pen; + + // Смещение точки контакта от ЦМ (b.x/b.y уже скорректированы выше) + const rx = contactX - b.x; + const ry = contactY - b.y; + + const vC = this._velAtPoint(b, rx, ry); + const vn = vC.x * rg.nx + vC.y * rg.ny; + + if (vn < -1) { + // Отскок от рампы + const e = b.restitution * 0.45; + const rCN = rx * rg.ny - ry * rg.nx; + const denom = 1 / b.mass + rCN * rCN / b.I; + const J = -(1 + e) * vn / denom; + this._energyLoss += 0.5 * b.mass * vn * vn * (1 - e * e) / (denom * S * S); + this._applyImpulse(b, J, rg.nx, rg.ny, rx, ry); + + // Трение при отскоке + const vCt = { x: vC.x - vn * rg.nx, y: vC.y - vn * rg.ny }; + const vtLen = Math.hypot(vCt.x, vCt.y); + if (vtLen > 0.5) { + const tx = vCt.x / vtLen, ty = vCt.y / vtLen; + const rCT = rx * ty - ry * tx; + const domT = 1 / b.mass + rCT * rCT / b.I; + let Jt = -vtLen / domT; + Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), this.rampMu * Math.abs(J)); + this._applyImpulse(b, Jt, tx, ty, rx, ry); + } + } else { + // На поверхности рампы + const rCN = rx * rg.ny - ry * rg.nx; + const denom = 1 / b.mass + rCN * rCN / b.I; + const J_n = -vn / denom; + this._applyImpulse(b, J_n, rg.nx, rg.ny, rx, ry); + + // Трение по рампе (кинетическое / статическое) + const vC2 = this._velAtPoint(b, rx, ry); + const tx = dx / rg.len, ty = dy / rg.len; + const vt = vC2.x * tx + vC2.y * ty; + const N = b.mass * GV * rg.cos; + const gPar = b.mass * GV * rg.sin; + const fFrMax = this.rampMu * N; + const rCT = rx * ty - ry * tx; + const domT = 1 / b.mass + rCT * rCT / b.I; + + if (gPar > fFrMax) { + let Jt = -vt / domT; + const maxJt = this.rampMu * N * dt; + Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), maxJt); + this._applyImpulse(b, Jt, tx, ty, rx, ry); + this._energyLoss += Math.abs(Jt * vt) / (S * S); + } else { + // Статика: обнуляем касательную скорость в точке контакта + const Jt = -vt / domT; + this._applyImpulse(b, Jt, tx, ty, rx, ry); + } + + // Качение без проскальзывания для шаров: + // v_contact = v_cm + omega × r должна быть 0 + // Для шара на рампе: v_t + omega * R = 0 omega = -v_t / R + if (b.type === 'ball' && fFrMax >= gPar) { + const vC3 = this._velAtPoint(b, rx, ry); + const vtC = vC3.x * tx + vC3.y * ty; + if (Math.abs(vtC) > 0.3) { + // Enforce no-slip: impulse to match omega = -v_tangential / R + const rCT2 = rx * ty - ry * tx; + const dom2 = 1 / b.mass + rCT2 * rCT2 / b.I; + const Jfix = -vtC / dom2; + this._applyImpulse(b, Jfix, tx, ty, rx, ry); + } + } + } + + b._onRamp = true; + } + + /* ════════════════════════════════════════════════════════════ + КОЛЛИЗИИ ТЕЛО–ТЕЛО + ════════════════════════════════════════════════════════════ */ + + _collide() { + const bodies = this.bodies; + for (let i = 0; i < bodies.length; i++) { + for (let j = i + 1; j < bodies.length; j++) { + const a = bodies[i], b = bodies[j]; + if (a.pinned && b.pinned) continue; + if (a.type === 'ball' && b.type === 'ball') this._colBallBall(a, b); + else if (a.type === 'ball') this._colBallBox(a, b); + else if (b.type === 'ball') this._colBallBox(b, a); + else this._colBoxBox(a, b); + } + } + } + + _applyContactImpulse(a, b, nx, ny, cx, cy) { + const S = ForceSandboxSim.SCALE; + const rAx = cx - a.x, rAy = cy - a.y; + const rBx = cx - b.x, rBy = cy - b.y; + const vCA = this._velAtPoint(a, rAx, rAy); + const vCB = this._velAtPoint(b, rBx, rBy); + const dvx = vCA.x - vCB.x, dvy = vCA.y - vCB.y; + const dvn = dvx * nx + dvy * ny; + if (dvn <= 0) return; + + const e = Math.min(a.restitution, b.restitution); + const rACN = rAx * ny - rAy * nx, rBCN = rBx * ny - rBy * nx; + const denom = (a.pinned ? 0 : 1 / a.mass + rACN * rACN / a.I) + + (b.pinned ? 0 : 1 / b.mass + rBCN * rBCN / b.I); + if (denom < 1e-9) return; + const J = (1 + e) * dvn / denom; + const keBefore = 0.5 * a.mass * (a.vx * a.vx + a.vy * a.vy) + 0.5 * b.mass * (b.vx * b.vx + b.vy * b.vy); + if (!a.pinned) this._applyImpulse(a, -J, nx, ny, rAx, rAy); + if (!b.pinned) this._applyImpulse(b, J, nx, ny, rBx, rBy); + const keAfter = 0.5 * a.mass * (a.vx * a.vx + a.vy * a.vy) + 0.5 * b.mass * (b.vx * b.vx + b.vy * b.vy); + this._energyLoss += Math.max(0, keBefore - keAfter) / (S * S); + + // Трение между телами + const vCA2 = this._velAtPoint(a, rAx, rAy); + const vCB2 = this._velAtPoint(b, rBx, rBy); + const dv2x = vCA2.x - vCB2.x, dv2y = vCA2.y - vCB2.y; + const dvt = dv2x * (-ny) + dv2y * nx; // касательная (perpendicular to n) + const tx = -ny, ty = nx; + const rACT = rAx * ty - rAy * tx, rBCT = rBx * ty - rBy * tx; + const domT = (a.pinned ? 0 : 1 / a.mass + rACT * rACT / a.I) + + (b.pinned ? 0 : 1 / b.mass + rBCT * rBCT / b.I); + if (domT > 1e-9 && Math.abs(dvt) > 0.5) { + const mu = 0.35 * (a.mu + b.mu); + let Jt = dvt / domT; + Jt = Math.sign(Jt) * Math.min(Math.abs(Jt), mu * J); + if (!a.pinned) this._applyImpulse(a, -Jt, tx, ty, rAx, rAy); + if (!b.pinned) this._applyImpulse(b, Jt, tx, ty, rBx, rBy); + } + } + + _colBallBall(a, b) { + const dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.hypot(dx, dy); + const minD = a.r + b.r; + if (dist >= minD || dist < 0.01) return; + const nx = dx / dist, ny = dy / dist; + const ov = minD - dist; + const totM = (a.pinned ? 0 : a.mass) + (b.pinned ? 0 : b.mass); + if (!a.pinned) { a.x -= nx * ov * (b.pinned ? 1 : b.mass / totM); a.y -= ny * ov * (b.pinned ? 1 : b.mass / totM); } + if (!b.pinned) { b.x += nx * ov * (a.pinned ? 1 : a.mass / totM); b.y += ny * ov * (a.pinned ? 1 : a.mass / totM); } + this._applyContactImpulse(a, b, nx, ny, a.x + nx * a.r, a.y + ny * a.r); + } + + _colBallBox(ball, box) { + const cp = this._closestOnBox(box, ball.x, ball.y); + const dx = ball.x - cp.x, dy = ball.y - cp.y; + const dist = Math.hypot(dx, dy); + if (dist >= ball.r || dist < 0.001) return; + const nx = dx / dist, ny = dy / dist; + const pen = ball.r - dist; + const totM = (ball.pinned ? 0 : ball.mass) + (box.pinned ? 0 : box.mass); + if (!ball.pinned) { ball.x += nx * pen * (box.pinned ? 1 : box.mass / totM); ball.y += ny * pen * (box.pinned ? 1 : box.mass / totM); } + if (!box.pinned) { box.x -= nx * pen * (ball.pinned ? 1 : ball.mass / totM); box.y -= ny * pen * (ball.pinned ? 1 : ball.mass / totM); } + this._applyContactImpulse(ball, box, nx, ny, cp.x, cp.y); + } + + _colBoxBox(a, b) { + const res = this._satTest(a, b); + if (!res) return; + const { nx, ny, pen, cx, cy } = res; + const totM = (a.pinned ? 0 : a.mass) + (b.pinned ? 0 : b.mass); + if (!a.pinned) { a.x -= nx * pen * (b.pinned ? 1 : b.mass / totM); a.y -= ny * pen * (b.pinned ? 1 : b.mass / totM); } + if (!b.pinned) { b.x += nx * pen * (a.pinned ? 1 : a.mass / totM); b.y += ny * pen * (a.pinned ? 1 : a.mass / totM); } + this._applyContactImpulse(a, b, nx, ny, cx, cy); + } + + /* ════════════════════════════════════════════════════════════ + EVENTS + ════════════════════════════════════════════════════════════ */ + + _bindEvents() { + const c = this.canvas; + const sig = { signal: this._evAbort.signal }; + c.addEventListener('mousedown', e => this._onDown(e), sig); + c.addEventListener('mousemove', e => this._onMove(e), sig); + c.addEventListener('mouseup', e => this._onUp(e), sig); + c.addEventListener('contextmenu', e => { e.preventDefault(); this._onRightClick(e); }, sig); + c.addEventListener('dblclick', e => this._onDblClick(e), sig); + c.addEventListener('mouseleave', () => { this._ghostPos = null; this._hovered = null; }, sig); + c.addEventListener('touchstart', e => { + e.preventDefault(); + const t = e.touches[0]; + this._onDown({ clientX: t.clientX, clientY: t.clientY, button: 0, shiftKey: false }); + }, { passive: false, signal: this._evAbort.signal }); + c.addEventListener('touchmove', e => { + e.preventDefault(); + const t = e.touches[0]; + this._onMove({ clientX: t.clientX, clientY: t.clientY }); + }, { passive: false, signal: this._evAbort.signal }); + c.addEventListener('touchend', e => { e.preventDefault(); this._onUp({}); }, + { passive: false, signal: this._evAbort.signal }); + } + + destroy() { + this.stop(); + this._evAbort.abort(); + } + + _pos(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } + + _bodyAt(x, y) { + for (let i = this.bodies.length - 1; i >= 0; i--) { + const b = this.bodies[i]; + if (b.type === 'ball') { + if (Math.hypot(x - b.x, y - b.y) < b.r + 4) return b; + } else { + if (this._ptInBox({ x, y }, b)) return b; + } + } + return null; + } + + _onDown(e) { + const { x, y } = this._pos(e); + const body = this._bodyAt(x, y); + if (this.tool === 'erase') { if (body) this.removeBody(body.id); return; } + if (this.tool === 'spring') { + if (body) { + if (this._springStart === null) { + this._springStart = body.id; + } else if (this._springStart !== body.id) { + this.addSpring(this._springStart, body.id); + this._springStart = null; + } + } else { + this._springStart = null; + } + return; + } + if (this.tool === 'rope') { + if (body) { + if (this._ropeStart === null) { + this._ropeStart = body.id; + } else if (this._ropeStart !== body.id) { + this.addRope(this._ropeStart, body.id, { type: 'direct' }); + this._ropeStart = null; + } + } else { + this._ropeStart = null; + } + return; + } + if (this.tool === 'anchor') { + // Создать небольшой закреплённый якорь (для пружин/верёвок) + const a = this.addBody(x, y, 'ball'); + a.mass = 0.5; a.r = 6; a.I = 0.5 * a.mass * a.r * a.r; + a.color = '#FFD166'; a.pinned = true; a._isAnchor = true; + return; + } + if (body) { + this._selected = body.id; + this._drag = { bodyId: body.id, startX: x, startY: y, curX: x, curY: y, + type: (e.shiftKey || this.forceMode === 'impulse') ? 'impulse' : 'force' }; + return; + } + this.addBody(x, y, this.tool); + } + + _onMove(e) { + const { x, y } = this._pos(e); + this._ghostPos = { x, y }; + if (this._drag) { this._drag.curX = x; this._drag.curY = y; return; } + const body = this._bodyAt(x, y); + this._hovered = body ? body.id : null; + } + + _onUp(e) { + if (!this._drag) return; + const d = this._drag; + const body = this.bodies.find(b => b.id === d.bodyId); + this._drag = null; + if (!body) return; + const dx = d.curX - d.startX, dy = d.curY - d.startY; + const len = Math.hypot(dx, dy); + if (len < 8) return; + const S = ForceSandboxSim.SCALE; + const forceMag = len * 2.5; + if (d.type === 'impulse') { + body.vx += (dx / len) * forceMag * 1.8; + body.vy += (dy / len) * forceMag * 1.8; + } else { + const fx = (dx / len) * forceMag * S, fy = (dy / len) * forceMag * S; + const idx = body.forces.length + 1; + const fColors = ['#FFD166','#4CC9F0','#7BF5A4','#FF6B35','#EF476F','#9B5DE5']; + body.forces.push({ fx, fy, label: `F${idx}`, color: fColors[(idx - 1) % fColors.length] }); + } + } + + _onRightClick(e) { + const { x, y } = this._pos(e); + const body = this._bodyAt(x, y); + if (body) { + if (body.forces.length > 0) body.forces = []; + else this.removeBody(body.id); + } + } + + _onDblClick(e) { + const { x, y } = this._pos(e); + const body = this._bodyAt(x, y); + if (body) { + body.pinned = !body.pinned; + if (body.pinned) { body.vx = body.vy = body.omega = 0; } + } + } + + /* ════════════════════════════════════════════════════════════ + RENDERING + ════════════════════════════════════════════════════════════ */ + + draw() { + const ctx = this.ctx; + const { W, H, _floorY: fY } = this; + ctx.clearRect(0, 0, W, H); + this._drawBg(ctx, W, H); + if (this.hasFloor) this._drawFloor(ctx, W, fY); + if (this.hasWalls) this._drawWalls(ctx, W, H, fY); + if (this.ramp) this._drawRamp(ctx); + if (this.showTrail) this._drawTrails(ctx); + this._drawRopes(ctx); + this._drawSprings(ctx); + this._drawBodies(ctx); + if (this.showForces) this._drawForceArrows(ctx); + if (this.showVelocity) this._drawVelocities(ctx); + if (this._drag) this._drawDragArrow(ctx); + if (this.showFBD && this._selected !== null) this._drawFBD(ctx); + if (this.showEnergy) this._drawEnergyBar(ctx); + if (this._ghostPos && !this._drag && !this._hovered && this.tool !== 'erase') this._drawGhost(ctx); + if (this.bodies.length === 0) this._drawHint(ctx); + } + + _drawBg(ctx, W, H) { + const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82); + bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + for (let x = 20; x < W; x += 40) + for (let y = 20; y < H; y += 40) + { ctx.beginPath(); ctx.arc(x, y, 1.2, 0, Math.PI * 2); ctx.fill(); } + } + + _drawFloor(ctx, W, fY) { + const gg = ctx.createLinearGradient(0, fY, 0, fY + 42); + gg.addColorStop(0, '#1c1f2d'); gg.addColorStop(1, '#0c101a'); + ctx.fillStyle = gg; ctx.fillRect(0, fY, W, 55); + ctx.strokeStyle = 'rgba(155,93,229,0.42)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, fY); ctx.lineTo(W, fY); ctx.stroke(); + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; + for (let x = 0; x < W; x += 22) + { ctx.beginPath(); ctx.moveTo(x, fY); ctx.lineTo(x + 12, fY + 12); ctx.stroke(); } + ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.35)'; + ctx.fillText(`μ = ${this.floorMu.toFixed(2)}`, 8, fY + 18); + } + + _drawWalls(ctx, W, H, fY) { + ctx.strokeStyle = 'rgba(76,201,240,0.18)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, fY); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(W, 0); ctx.lineTo(W, fY); ctx.stroke(); + } + + _drawTrails(ctx) { + for (const b of this.bodies) { + if (b.trail.length < 2) continue; + for (let i = 0; i < b.trail.length; i++) { + const alpha = (i / b.trail.length) * 0.28; + const t = b.trail[i]; + ctx.save(); ctx.globalAlpha = alpha; + if (b.type === 'ball') { + ctx.beginPath(); ctx.arc(t.x, t.y, b.r * 0.8, 0, Math.PI * 2); + ctx.strokeStyle = b.color; ctx.lineWidth = 1.5; ctx.stroke(); + } else { + ctx.save(); + ctx.translate(t.x, t.y); + ctx.rotate(t.a || 0); + _fsb_rrect(ctx, -b.w * 0.42, -b.h * 0.42, b.w * 0.84, b.h * 0.84, 5); + ctx.strokeStyle = b.color; ctx.lineWidth = 1.5; ctx.stroke(); + ctx.restore(); + } + ctx.restore(); + } + } + } + + _drawRopes(ctx) { + const hasPreview = this.tool === 'rope' && this._ropeStart !== null && this._ghostPos; + if (!this.ropes.length && !hasPreview) return; + ctx.save(); + ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + + for (const rp of this.ropes) { + const b1 = this.bodies.find(b => b.id === rp.b1id); + const b2 = this.bodies.find(b => b.id === rp.b2id); + if (!b1 || !b2) continue; + + // Compute tension for color coding + let taut = false, T = 0; + if (rp.type === 'direct') { + const ext = Math.hypot(b2.x - b1.x, b2.y - b1.y) - rp.L0; + taut = ext > 0.5; T = taut ? rp.k * ext : 0; + } else { + const r1 = Math.hypot(b1.x - rp.px, b1.y - rp.py); + const r2 = Math.hypot(b2.x - rp.px, b2.y - rp.py); + const ext = (r1 + r2) - rp.L0; + taut = ext > 0.5; T = taut ? rp.k * ext : 0; + } + const alpha = taut ? 0.9 : 0.4; + const ropeColor = taut ? `rgba(255,209,102,${alpha})` : `rgba(180,180,180,${alpha})`; + + ctx.strokeStyle = ropeColor; + ctx.lineWidth = taut ? 2.5 : 1.5; + ctx.shadowColor = taut ? '#FFD166' : 'transparent'; + ctx.shadowBlur = taut ? 5 : 0; + + if (rp.type === 'direct') { + ctx.beginPath(); + ctx.moveTo(b1.x, b1.y); + ctx.lineTo(b2.x, b2.y); + ctx.stroke(); + } else { + // Pulley: draw rope segments + pulley wheel + ctx.beginPath(); + ctx.moveTo(b1.x, b1.y); + ctx.lineTo(rp.px, rp.py); + ctx.lineTo(b2.x, b2.y); + ctx.stroke(); + ctx.shadowBlur = 0; + // Pulley wheel + ctx.beginPath(); + ctx.arc(rp.px, rp.py, 12, 0, Math.PI * 2); + ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 2; ctx.stroke(); + // Axle + ctx.beginPath(); + ctx.arc(rp.px, rp.py, 3, 0, Math.PI * 2); + ctx.fillStyle = '#FFD166'; ctx.fill(); + // Pulley mount to ceiling + ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(rp.px, rp.py - 12); + ctx.lineTo(rp.px, rp.py - 28); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(rp.px - 14, rp.py - 28); + ctx.lineTo(rp.px + 14, rp.py - 28); + ctx.stroke(); + } + + // Tension label + if (taut && T > 1) { + const S = ForceSandboxSim.SCALE; + const TN = (T / S).toFixed(0); + const mx = rp.type === 'pulley' + ? (b1.x + b2.x) / 2 + : (b1.x + b2.x) / 2; + const my = rp.type === 'pulley' + ? rp.py + 18 + : (b1.y + b2.y) / 2 - 12; + ctx.shadowBlur = 0; + ctx.font = '9px monospace'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; + ctx.fillText(`T≈${TN}Н`, mx, my); + ctx.textAlign = 'left'; + } + } + + // Preview: dashed line from first body to cursor + if (hasPreview) { + const b = this.bodies.find(b => b.id === this._ropeStart); + if (b) { + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,209,102,0.55)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 5]); + ctx.beginPath(); + ctx.moveTo(b.x, b.y); + ctx.lineTo(this._ghostPos.x, this._ghostPos.y); + ctx.stroke(); + ctx.setLineDash([]); + const br = b.type === 'ball' ? b.r + 5 : Math.hypot(b.w, b.h) * 0.5 + 5; + ctx.beginPath(); ctx.arc(b.x, b.y, br, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,209,102,0.5)'; ctx.lineWidth = 2; ctx.stroke(); + } + } + ctx.restore(); + } + + _drawSprings(ctx) { + const hasPreview = this.tool === 'spring' && this._springStart !== null && this._ghostPos; + if (!this.springs.length && !hasPreview) return; + ctx.save(); + ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + + for (const sp of this.springs) { + const b1 = this.bodies.find(b => b.id === sp.b1id); + const b2 = this.bodies.find(b => b.id === sp.b2id); + if (!b1 || !b2) continue; + const p1 = this._localToWorld(b1, sp.lx1, sp.ly1); + const p2 = this._localToWorld(b2, sp.lx2, sp.ly2); + const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y; + const dx = x2 - x1, dy = y2 - y1; + const dist = Math.hypot(dx, dy); + if (dist < 4) continue; + const ux = dx / dist, uy = dy / dist; + const px = -uy, py = ux; + + // Color: cyan = relaxed, red = stretched, blue = compressed + const strain = Math.max(-1.5, Math.min(1.5, (dist - sp.L0) / Math.max(sp.L0, 1))); + let cr, cg, cb; + if (strain >= 0) { + cr = Math.round(6 + strain / 1.5 * 239); + cg = Math.round(214 - strain / 1.5 * 214); + cb = Math.round(224 - strain / 1.5 * 100); + } else { + cr = 6; cg = Math.round(214 + Math.abs(strain) / 1.5 * 41); cb = 224; + } + ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.9)`; + ctx.lineWidth = 2; + ctx.shadowColor = `rgb(${cr},${cg},${cb})`; + ctx.shadowBlur = 6; + + // Zigzag coil rendering + const COILS = 8; + const headLen = Math.min(dist * 0.08, 16); + const zigDist = dist - 2 * headLen; + const amp = Math.max(3, Math.min(14, 10 / (1 + Math.abs(strain) * 3))); + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x1 + ux * headLen, y1 + uy * headLen); + for (let i = 0; i < COILS * 2; i++) { + const frac = (i + 0.5) / (COILS * 2); + const along = headLen + frac * zigDist; + const side = (i % 2 === 0) ? amp : -amp; + ctx.lineTo(x1 + ux * along + px * side, y1 + uy * along + py * side); + } + ctx.lineTo(x2 - ux * headLen, y2 - uy * headLen); + ctx.lineTo(x2, y2); + ctx.stroke(); + + // Label + ctx.shadowBlur = 0; + ctx.font = '9px monospace'; + ctx.fillStyle = `rgba(${cr},${cg},${cb},0.85)`; + ctx.textAlign = 'center'; + const extM = (dist - sp.L0) / ForceSandboxSim.SCALE; + ctx.fillText(`k=${sp.k} · ${extM >= 0 ? '+' : ''}${extM.toFixed(2)}м`, + (x1 + x2) / 2 - py * 18, (y1 + y2) / 2 + px * 18); + ctx.textAlign = 'left'; + } + + // Preview: dashed line from first body to cursor + if (hasPreview) { + const b = this.bodies.find(b => b.id === this._springStart); + if (b) { + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(6,214,224,0.6)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 5]); + ctx.beginPath(); + ctx.moveTo(b.x, b.y); + ctx.lineTo(this._ghostPos.x, this._ghostPos.y); + ctx.stroke(); + ctx.setLineDash([]); + // Highlight start body + const br = b.type === 'ball' ? b.r + 5 : Math.hypot(b.w, b.h) * 0.5 + 5; + ctx.beginPath(); ctx.arc(b.x, b.y, br, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(6,214,224,0.5)'; ctx.lineWidth = 2; ctx.stroke(); + } + } + ctx.restore(); + } + + _drawBodies(ctx) { + for (const b of this.bodies) { + const isHover = b.id === this._hovered; + const isSel = b.id === this._selected; + ctx.save(); + ctx.shadowColor = b._onRamp ? '#06D6E0' : b.color; + ctx.shadowBlur = isHover ? 22 : isSel ? 18 : b._onRamp ? 14 : 10; + + if (b._isAnchor) { + // Якорь: маленький ромб с крестиком + ctx.shadowBlur = isHover ? 16 : 8; + ctx.beginPath(); + ctx.moveTo(b.x, b.y - 7); ctx.lineTo(b.x + 7, b.y); + ctx.lineTo(b.x, b.y + 7); ctx.lineTo(b.x - 7, b.y); ctx.closePath(); + ctx.fillStyle = b.color; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); + ctx.restore(); + continue; + } + if (b.type === 'ball') { + ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); + const bg = ctx.createRadialGradient(b.x - b.r * 0.3, b.y - b.r * 0.3, 0, b.x, b.y, b.r); + bg.addColorStop(0, _fsb_lighten(b.color, 55)); bg.addColorStop(1, b.color); + ctx.fillStyle = bg; ctx.fill(); + if (isHover || isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } + // Индикатор вращения: линия от центра шара + if (Math.abs(b.omega) > 0.3) { + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(b.x, b.y); + ctx.lineTo(b.x + Math.cos(b.angle) * b.r * 0.72, b.y + Math.sin(b.angle) * b.r * 0.72); + ctx.stroke(); + } + } else { + // Рисуем повёрнутый прямоугольник + ctx.save(); + ctx.translate(b.x, b.y); + ctx.rotate(b.angle); + _fsb_rrect(ctx, -b.w / 2, -b.h / 2, b.w, b.h, 7); + const bg = ctx.createLinearGradient(-b.w / 2, -b.h / 2, b.w / 2, b.h / 2); + bg.addColorStop(0, _fsb_lighten(b.color, 40)); bg.addColorStop(1, b.color); + ctx.fillStyle = bg; ctx.fill(); + if (isHover || isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); } + // Точка-индикатор вращения + ctx.shadowBlur = 0; + ctx.beginPath(); ctx.arc(b.w * 0.28, 0, 4, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fill(); + ctx.restore(); + ctx.shadowBlur = 0; + } + + ctx.shadowBlur = 0; + ctx.font = 'bold 10px monospace'; ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(`${b.mass}кг`, b.x, b.y); + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + + if (b.pinned) { + ctx.font = '14px sans-serif'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; + const py = b.type === 'ball' ? b.y - b.r - 10 : b.y - b.h / 2 - 10; + ctx.fillText('\u25C9', b.x, py); + ctx.textAlign = 'left'; + } + ctx.restore(); + } + } + + _drawForceArrows(ctx) { + const S = ForceSandboxSim.SCALE; + const GV = this.gVal * S; + for (const b of this.bodies) { + if (b._onRamp && this.ramp) { + this._drawRampForceDecomp(ctx, b); + for (const f of b.forces) { + const fMag = Math.hypot(f.fx, f.fy) / S; + const fLen = Math.min(fMag * 2.5, 120); + if (fLen < 3) continue; + this._arrow(ctx, b.x, b.y, b.x + Math.cos(Math.atan2(f.fy, f.fx)) * fLen, + b.y + Math.sin(Math.atan2(f.fy, f.fx)) * fLen, f.color, f.label + '=' + fMag.toFixed(0) + 'Н', 2.2); + } + continue; + } + const ancY = b.y; + if (this.gravity) { + const mg = b.mass * this.gVal; + this._arrow(ctx, b.x, ancY, b.x, ancY + Math.min(mg * 2.5, 80), 'rgba(180,180,180,0.45)', 'mg', 1.5); + } + if (this.hasFloor && this.gravity) { + const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; + if (Math.abs(bottom - this._floorY) < 5 && Math.abs(b.vy) < 8) { + const nLen = Math.min(b.mass * this.gVal * 2.5, 80); + this._arrow(ctx, b.x, ancY, b.x, ancY - nLen, 'rgba(180,180,180,0.45)', 'N', 1.5); + } + } + for (const f of b.forces) { + const fMag = Math.hypot(f.fx, f.fy) / S; + const fLen = Math.min(fMag * 2.5, 120); + if (fLen < 3) continue; + const dir = Math.atan2(f.fy, f.fx); + this._arrow(ctx, b.x, ancY, b.x + Math.cos(dir) * fLen, ancY + Math.sin(dir) * fLen, + f.color, f.label + '=' + fMag.toFixed(0) + 'Н', 2.2); + } + if (this.hasFloor && this.gravity) { + const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; + if (Math.abs(bottom - this._floorY) < 5 && Math.abs(b.vx) > 5) { + const fFr = Math.max(b.mu, this.floorMu) * b.mass * this.gVal; + this._arrow(ctx, b.x, ancY, b.x - Math.sign(b.vx) * Math.min(fFr * 2.5, 70), ancY, + 'rgba(239,71,111,0.7)', `Fтр=${fFr.toFixed(0)}`, 1.8); + } + } + } + } + + _drawVelocities(ctx) { + const S = ForceSandboxSim.SCALE; + for (const b of this.bodies) { + const spd = Math.hypot(b.vx, b.vy); + const hasV = spd > 10; + const hasOmg = Math.abs(b.omega) > 0.15; + if (!hasV && !hasOmg) continue; + + const topY = b.type === 'box' ? b.y - b.h / 2 - 6 : b.y - b.r - 6; + const halfW = b.type === 'box' ? b.w / 2 : b.r; + + if (hasV) { + // Вектор скорости v — жёлтый + this._arrow(ctx, b.x, topY, b.x + b.vx * 0.22, topY + b.vy * 0.22, + '#FFD166', `v=${(spd / S).toFixed(1)}м/с`, 2); + + // Вектор импульса p = mv — малиновый, смещён вниз от v + // Длина пропорциональна m: тяжёлое тело длиннее стрелка + const pMag = b.mass * spd / S; // кг·м/с + const pScale = Math.min(0.65, 0.22 * b.mass / 5); + this._arrow(ctx, b.x, topY + 14, + b.x + b.vx * pScale, topY + 14 + b.vy * pScale, + '#EF476F', `p=${pMag.toFixed(1)}кг·м/с`, 1.8); + } + + // Угловая скорость ω — фиолетовая метка справа от тела + if (hasOmg) { + const sym = b.omega > 0 ? '' : ''; + 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(); + ctx.font = 'bold 9px monospace'; + ctx.fillStyle = '#9B5DE5'; + ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 4; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(`${sym} ω=${Math.abs(b.omega).toFixed(1)}рад/с`, labX, labY); + ctx.shadowBlur = 0; ctx.textBaseline = 'alphabetic'; + ctx.restore(); + } + } + } + + _drawDragArrow(ctx) { + const d = this._drag; if (!d) return; + const body = this.bodies.find(b => b.id === d.bodyId); if (!body) return; + const dx = d.curX - d.startX, dy = d.curY - d.startY; + if (Math.hypot(dx, dy) < 5) return; + ctx.save(); ctx.setLineDash([5, 5]); + this._arrow(ctx, body.x, body.y, body.x + dx, body.y + dy, + d.type === 'impulse' ? '#FF6B35' : '#FFD166', d.type === 'impulse' ? 'импульс' : 'сила', 2.5); + ctx.setLineDash([]); ctx.restore(); + } + + _drawFBD(ctx) { + const body = this.bodies.find(b => b.id === this._selected); if (!body) return; + const S = ForceSandboxSim.SCALE; + const cx = this.W - 120, cy = 80, r = 55; + _fsb_rrect(ctx, cx - r - 10, cy - r - 10, (r + 10) * 2, (r + 10) * 2 + 28, 8); + ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.font = 'bold 9px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.7)'; + ctx.textAlign = 'center'; ctx.fillText('Диаграмма сил', cx, cy - r - 2); ctx.textAlign = 'left'; + ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2); ctx.fillStyle = body.color; ctx.fill(); + const forces = []; + if (this.gravity) forces.push({ fx: 0, fy: body.mass * this.gVal, label: 'mg', color: 'rgba(180,180,180,0.7)' }); + const bottom = body.type === 'box' ? body.y + body.h / 2 : body.y + body.r; + if (this.hasFloor && Math.abs(bottom - this._floorY) < 5 && Math.abs(body.vy) < 8) + forces.push({ fx: 0, fy: -body.mass * this.gVal, label: 'N', color: 'rgba(180,180,180,0.7)' }); + for (const f of body.forces) forces.push({ fx: f.fx / S, fy: f.fy / S, label: f.label, color: f.color }); + const maxF = Math.max(...forces.map(f => Math.hypot(f.fx, f.fy)), 1); + for (const f of forces) { + const len = (Math.hypot(f.fx, f.fy) / maxF) * (r - 8); + if (len < 2) continue; + const dir = Math.atan2(f.fy, f.fx); + this._arrow(ctx, cx, cy, cx + Math.cos(dir) * len, cy + Math.sin(dir) * len, f.color, f.label, 1.8); + } + let sfx = 0, sfy = 0; + for (const f of forces) { sfx += f.fx; sfy += f.fy; } + const smag = Math.hypot(sfx, sfy); + if (smag > 0.5) { + const len = (smag / maxF) * (r - 8); + ctx.save(); ctx.setLineDash([3, 3]); + this._arrow(ctx, cx, cy, cx + Math.cos(Math.atan2(sfy, sfx)) * len, cy + Math.sin(Math.atan2(sfy, sfx)) * len, + '#fff', `ΣF=${smag.toFixed(0)}`, 2); + ctx.setLineDash([]); ctx.restore(); + } + // ω и p под диаграммой + const spd = Math.hypot(body.vx, body.vy); + ctx.font = '8px monospace'; ctx.textAlign = 'center'; + 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 ? '' : ''; + ctx.fillStyle = '#9B5DE5'; + ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22); + } + ctx.textAlign = 'left'; + } + + _drawEnergyBar(ctx) { + if (!this.bodies.length) return; + const S = ForceSandboxSim.SCALE, fY = this._floorY; + let KE = 0, PE = 0; + for (const b of this.bodies) { + const v = Math.hypot(b.vx, b.vy) / S; + KE += 0.5 * b.mass * v * v; + // Вращательная KE: ½Iω² (I в px², переводим в м²) + KE += 0.5 * (b.I / (S * S)) * b.omega * b.omega; + if (this.gravity && this.hasFloor) { + const bot = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; + PE += b.mass * this.gVal * Math.max(0, fY - bot) / S; + } + } + const total = KE + PE + this._energyLoss; + if (total < 0.01) return; + const bx = 12, by = 12, bw = 110, bh = 52; + _fsb_rrect(ctx, bx, by, bw, bh, 6); + ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.font = 'bold 8px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.55)'; + ctx.fillText('Энергия', bx + 4, by + 10); + const barX = bx + 4, barY = by + 16, barW = bw - 8, barH = 8; + const keW = (KE / total) * barW, peW = (PE / total) * barW; + const lossW = barW - keW - peW; + ctx.fillStyle = '#4CC9F0'; if (keW > 0) { _fsb_rrect(ctx, barX, barY, Math.max(keW, 1), barH, 2); ctx.fill(); } + ctx.fillStyle = '#7BF5A4'; if (peW > 0) { _fsb_rrect(ctx, barX + keW, barY, Math.max(peW, 1), barH, 2); ctx.fill(); } + ctx.fillStyle = '#EF476F'; if (lossW > 0.5) { _fsb_rrect(ctx, barX + keW + peW, barY, Math.max(lossW, 1), barH, 2); ctx.fill(); } + ctx.font = '8px monospace'; + ctx.fillStyle = '#4CC9F0'; ctx.fillText(`KE=${KE.toFixed(1)}Дж`, bx + 4, barY + barH + 10); + ctx.fillStyle = '#7BF5A4'; ctx.fillText(`PE=${PE.toFixed(1)}`, bx + 4, barY + barH + 20); + ctx.fillStyle = '#EF476F'; ctx.fillText(`Q=${this._energyLoss.toFixed(1)}`, bx + 56, barY + barH + 10); + } + + /* ── Рампа ───────────────────────────────────────────────── */ + + _drawRamp(ctx) { + const rg = this._rampGeom; if (!rg) return; + const { _floorY: fY } = this; + const tx = (rg.x2 - rg.x1) / rg.len, ty = (rg.y2 - rg.y1) / rg.len; + ctx.save(); + + // Заливка + ctx.beginPath(); + ctx.moveTo(rg.x1, rg.y1); ctx.lineTo(rg.x2, rg.y2); ctx.lineTo(rg.x2, fY); ctx.closePath(); + const gg = ctx.createLinearGradient(rg.x1, fY, rg.x2, rg.y2); + gg.addColorStop(0, 'rgba(6,214,224,0.07)'); gg.addColorStop(1, 'rgba(6,214,224,0.02)'); + ctx.fillStyle = gg; ctx.fill(); + + // Линия поверхности + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 2.5; + ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.moveTo(rg.x1, rg.y1); ctx.lineTo(rg.x2, rg.y2); ctx.stroke(); + ctx.shadowBlur = 0; + + // Вспомогательные линии + ctx.strokeStyle = 'rgba(6,214,224,0.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(rg.x1, fY); ctx.lineTo(rg.x2, fY); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(rg.x2, rg.y2); ctx.lineTo(rg.x2, fY); ctx.stroke(); + + // Дуга угла + ctx.strokeStyle = '#FFD166'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(rg.x1, rg.y1, 35, -rg.angle, 0); ctx.stroke(); + ctx.font = 'bold 11px monospace'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; + ctx.fillText(`α=${this.rampAngle}°`, + rg.x1 + 35 * 1.15 * Math.cos(-rg.angle / 2), + rg.y1 + 35 * 1.15 * Math.sin(-rg.angle / 2)); + ctx.textAlign = 'left'; + + // Прямой угол у основания + const rm = 12; + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(rg.x2 - rm, fY); ctx.lineTo(rg.x2 - rm, fY - rm); ctx.lineTo(rg.x2, fY - rm); ctx.stroke(); + + // Штриховка (В твёрдый материал — противоположно нормали) + ctx.strokeStyle = 'rgba(6,214,224,0.10)'; ctx.lineWidth = 1; + for (let d = 20; d < rg.len; d += 22) { + const sx = rg.x1 + tx * d, sy = rg.y1 + ty * d; + ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx - rg.nx * 11, sy - rg.ny * 11); ctx.stroke(); + } + + // μ-метка над поверхностью + ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(6,214,224,0.55)'; + ctx.save(); + ctx.translate(rg.x1 + tx * rg.len * 0.5 + rg.nx * 20, rg.y1 + ty * rg.len * 0.5 + rg.ny * 20); + ctx.rotate(Math.atan2(ty, tx)); + ctx.fillText(`μ = ${this.rampMu.toFixed(2)}`, 0, 0); + ctx.restore(); + + // Свечение в точках контакта + for (const b of this.bodies) { + if (!b._onRamp || b._rampT === undefined) continue; + const cx = rg.x1 + tx * rg.len * b._rampT; + const cy = rg.y1 + ty * rg.len * b._rampT; + const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, 20); + grd.addColorStop(0, 'rgba(6,214,224,0.38)'); grd.addColorStop(1, 'rgba(6,214,224,0)'); + ctx.beginPath(); ctx.arc(cx, cy, 20, 0, Math.PI * 2); ctx.fillStyle = grd; ctx.fill(); + } + ctx.restore(); + } + + /* ── Разложение сил на рампе ─────────────────────────────── */ + + _drawRampForceDecomp(ctx, b) { + if (!this.showDecomp || !b._onRamp || !this._rampGeom) return; + const rg = this._rampGeom; + const mg = b.mass * this.gVal; + const tx = (rg.x2 - rg.x1) / rg.len, ty = (rg.y2 - rg.y1) / rg.len; + const { nx, ny } = rg; + const mgPar = mg * rg.sin, mgPerp = mg * rg.cos; + const mgLen = Math.min(mg * 3, 90); + const parLen = Math.min(mgPar * 3, 70), perpLen = Math.min(mgPerp * 3, 70); + + this._arrow(ctx, b.x, b.y, b.x, b.y + mgLen, 'rgba(255,255,255,0.5)', `mg=${mg.toFixed(0)}Н`, 2); + this._arrow(ctx, b.x, b.y, b.x - tx * parLen, b.y - ty * parLen, '#EF476F', `mg·sinα=${mgPar.toFixed(0)}`, 1.8); + this._arrow(ctx, b.x, b.y, b.x - nx * perpLen, b.y - ny * perpLen, '#4CC9F0', `mg·cosα=${mgPerp.toFixed(0)}`, 1.8); + this._arrow(ctx, b.x, b.y, b.x + nx * perpLen, b.y + ny * perpLen, 'rgba(180,180,180,0.5)', `N=${mgPerp.toFixed(0)}`, 1.5); + + const fFr = this.rampMu * mgPerp; + const vt = b.vx * tx + b.vy * ty; + if (Math.abs(vt) > 0.5 || mgPar > fFr + 0.1) { + const frDir = Math.abs(vt) > 0.5 ? -Math.sign(vt) : 1; + this._arrow(ctx, b.x, b.y, b.x + tx * frDir * Math.min(fFr * 3, 55), b.y + ty * frDir * Math.min(fFr * 3, 55), + '#FF6B35', `Fтр=${fFr.toFixed(0)}`, 1.8); + } + const netPar = mgPar - (Math.abs(vt) > 0.5 || mgPar > fFr ? fFr : mgPar); + if (netPar > 0.5) { + ctx.font = 'bold 10px monospace'; ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; + ctx.fillText(`a = ${(netPar / b.mass).toFixed(1)} м/с²`, b.x, + b.y - (b.type === 'box' ? b.h / 2 : b.r) - 20); + ctx.textAlign = 'left'; + } + } + + _drawGhost(ctx) { + if (!this._ghostPos) return; + const { x, y } = this._ghostPos; + ctx.save(); ctx.globalAlpha = 0.25; + if (this.tool === 'ball') { + ctx.beginPath(); ctx.arc(x, y, 14 + this.newMass * 1.6, 0, Math.PI * 2); + ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 2; ctx.stroke(); + } else { + const w = 32 + this.newMass * 2.4, h = 28 + this.newMass * 1.8; + _fsb_rrect(ctx, x - w / 2, y - h / 2, w, h, 7); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2; ctx.stroke(); + } + ctx.restore(); + } + + _drawHint(ctx) { + ctx.save(); + ctx.font = '14px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.3)'; + ctx.textAlign = 'center'; + ctx.fillText('Кликни — создай тело. Тяни от тела — приложи силу.', this.W / 2, this.H * 0.45); + ctx.fillText('Shift+drag = импульс · ПКМ = удалить · DblClick = закрепить', this.W / 2, this.H * 0.45 + 22); + ctx.fillText('Инструмент «Пружина» — кликни два тела, чтобы соединить.', this.W / 2, this.H * 0.45 + 44); + ctx.textAlign = 'left'; + ctx.restore(); + } + + /* ── Arrow helper ────────────────────────────────────────── */ + + _arrow(ctx, x1, y1, x2, y2, color, label, lw) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy); + if (len < 4) return; + const ux = dx / len, uy = dy / len; + const hw = 5, hl = 10; + ctx.save(); + ctx.strokeStyle = color; ctx.lineWidth = lw || 2; + ctx.shadowColor = color; ctx.shadowBlur = 4; + ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - ux * hl, y2 - uy * hl); ctx.stroke(); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ux * hl - uy * hw, y2 - uy * hl + ux * hw); + ctx.lineTo(x2 - ux * hl + uy * hw, y2 - uy * hl - ux * hw); + ctx.closePath(); ctx.fill(); + if (label) { + ctx.shadowBlur = 0; ctx.font = '9px monospace'; ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.fillText(label, (x1 + x2) / 2 - uy * 12, (y1 + y2) / 2 + ux * 12); + ctx.textAlign = 'left'; + } + ctx.restore(); + } + + /* ── Info ────────────────────────────────────────────────── */ + + info() { + const S = ForceSandboxSim.SCALE, fY = this._floorY; + let KE = 0, PE = 0; + for (const b of this.bodies) { + const v = Math.hypot(b.vx, b.vy) / S; + KE += 0.5 * b.mass * v * v + 0.5 * (b.I / (S * S)) * b.omega * b.omega; + if (this.gravity && this.hasFloor) { + const bot = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r; + PE += b.mass * this.gVal * Math.max(0, fY - bot) / S; + } + } + let netF = '—'; + if (this._selected !== null) { + const body = this.bodies.find(b => b.id === this._selected); + if (body) { + let fx = 0, fy = 0; + if (this.gravity) fy += body.mass * this.gVal; + for (const f of body.forces) { fx += f.fx / S; fy += f.fy / S; } + netF = Math.hypot(fx, fy).toFixed(1) + ' Н'; + } + } + return { bodies: this.bodies.length, springs: this.springs.length, ropes: this.ropes.length, + KE: KE.toFixed(1), PE: PE.toFixed(1), + loss: this._energyLoss.toFixed(1), netF, time: this._simTime.toFixed(1) }; + } +} + +/* ── Utilities ───────────────────────────────────────────────── */ + +function _fsb_rrect(ctx, x, y, w, h, r) { + if (w <= 0 || h <= 0) return; + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function _fsb_lighten(hex, d) { + const n = parseInt(hex.slice(1), 16); + const c = v => Math.max(0, Math.min(255, v)); + return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`; +} diff --git a/frontend/js/labs/gas.js b/frontend/js/labs/gas.js new file mode 100644 index 0000000..84c5546 --- /dev/null +++ b/frontend/js/labs/gas.js @@ -0,0 +1,462 @@ +/** + * GasSim v2 — Ideal Gas simulation (PV=nRT, Maxwell-Boltzmann distribution) + * v2: hover inspector, velocity vectors, movable piston, v_mp/v_rms markers. + */ +class GasSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; + this.H = 0; + this.particles = []; + this.N = 80; + this.T = 1.0; + this._wallImpulse = 0; + this._pressureSmooth = 0; + this._raf = null; + this._updateTick = 0; + this.onUpdate = null; + this._loop = this._loop.bind(this); + + // v2 + this._showVectors = false; + this._pistonFrac = 1.0; // fraction of W — right wall position + this._hover = null; // hovered particle + this._pistonDrag = false; + + canvas.addEventListener('mousemove', e => this._onMouseMove(e)); + canvas.addEventListener('mouseleave', () => { this._hover = null; this._pistonDrag = false; }); + canvas.addEventListener('mousedown', e => this._onMouseDown(e)); + canvas.addEventListener('mouseup', () => { this._pistonDrag = false; }); + } + + // ── canvas coordinate helper ──────────────────────────────────────────────── + _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), + }; + } + + _onMouseDown(e) { + const { x } = this._cp(e); + const px = this.W * this._pistonFrac; + if (Math.abs(x - px) < 16) this._pistonDrag = true; + } + + _onMouseMove(e) { + const { x, y } = this._cp(e); + + if (this._pistonDrag) { + this.setPiston(x / this.W); + return; + } + + // nearest particle within 28px + let best = null, bestD = 28; + for (const p of this.particles) { + const d = Math.hypot(p.x - x, p.y - y); + if (d < bestD) { bestD = d; best = p; } + } + this._hover = best; + } + + // ── public API ────────────────────────────────────────────────────────────── + fit() { + this.W = this.canvas.offsetWidth; + this.H = this.canvas.offsetHeight; + this.canvas.width = this.W * devicePixelRatio; + this.canvas.height = this.H * devicePixelRatio; + this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + this.reset(); + } + + reset() { + this.particles = []; + const px = this.W * this._pistonFrac; + for (let i = 0; i < this.N; i++) { + const a = Math.random() * Math.PI * 2; + const s = this._maxwellSpeed(); + this.particles.push({ + x: 20 + Math.random() * (px - 40), + y: 20 + Math.random() * (this.H - 40), + vx: s * Math.cos(a), + vy: s * Math.sin(a), + r: 5, + }); + } + this._wallImpulse = 0; + this._pressureSmooth = 0; + this._updateTick = 0; + this._hover = null; + } + + setN(n) { this.N = n; this.reset(); } + + setT(t) { + const oldT = this.T; + if (oldT <= 0) { this.T = t; this.reset(); return; } + const f = Math.sqrt(t / oldT); + for (const p of this.particles) { p.vx *= f; p.vy *= f; } + this.T = t; + } + + setPiston(frac) { + this._pistonFrac = Math.max(0.3, Math.min(1.0, frac)); + const px = this.W * this._pistonFrac; + for (const p of this.particles) { + if (p.x + p.r > px) { p.x = px - p.r; if (p.vx > 0) p.vx = -p.vx; } + } + } + + toggleVectors() { this._showVectors = !this._showVectors; } + + start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop); } + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + // ── simulation ────────────────────────────────────────────────────────────── + _loop() { + this._step(); + this._step(); + this.draw(); + this._raf = requestAnimationFrame(this._loop); + } + + _maxwellSpeed() { + const u1 = Math.max(1e-10, Math.random()); + const sigma = this.T * 60; + return Math.abs(Math.sqrt(-2 * Math.log(u1)) * Math.cos(Math.PI * 2 * Math.random()) * sigma + sigma); + } + + _step() { + const { W, H, particles } = this; + const px = W * this._pistonFrac; + + for (const p of particles) { p.x += p.vx; p.y += p.vy; } + + for (const p of particles) { + if (p.x < p.r) { + p.x = p.r; p.vx = Math.abs(p.vx); + this._wallImpulse += 2 * Math.abs(p.vx); + } else if (p.x > px - p.r) { + p.x = px - p.r; p.vx = -Math.abs(p.vx); + this._wallImpulse += 2 * Math.abs(p.vx); + } + if (p.y < p.r) { + p.y = p.r; p.vy = Math.abs(p.vy); + this._wallImpulse += 2 * Math.abs(p.vy); + } else if (p.y > H - p.r) { + p.y = H - p.r; p.vy = -Math.abs(p.vy); + this._wallImpulse += 2 * Math.abs(p.vy); + } + } + + // Spatial grid collision + const cell = 14, cols = Math.ceil(W / cell), rows = Math.ceil(H / cell); + const grid = new Map(); + const key = (cx, cy) => cy * cols + cx; + + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + const k = key(Math.floor(p.x / cell), Math.floor(p.y / cell)); + if (!grid.has(k)) grid.set(k, []); + grid.get(k).push(i); + } + + const checked = new Set(); + for (let i = 0; i < particles.length; i++) { + const p = particles[i]; + const cx = Math.floor(p.x / cell); + const cy = Math.floor(p.y / cell); + for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; + const cell2 = grid.get(key(nx, ny)); + if (!cell2) continue; + for (const j of cell2) { + if (j <= i) continue; + const pk = i * 100000 + j; + if (checked.has(pk)) continue; + checked.add(pk); + const q = particles[j]; + const ddx = q.x - p.x, ddy = q.y - p.y; + const d2 = ddx * ddx + ddy * ddy; + const md = p.r + q.r; + if (d2 < md * md && d2 > 0) { + const d = Math.sqrt(d2), nx2 = ddx / d, ny2 = ddy / d; + const dvn = (q.vx - p.vx) * nx2 + (q.vy - p.vy) * ny2; + if (dvn >= 0) continue; + p.vx += dvn * nx2; p.vy += dvn * ny2; + q.vx -= dvn * nx2; q.vy -= dvn * ny2; + const ov = (md - d) / 2; + p.x -= ov * nx2; p.y -= ov * ny2; + q.x += ov * nx2; q.y += ov * ny2; + } + } + } + } + + this._pressureSmooth = this._pressureSmooth * 0.92 + this._wallImpulse * 0.08; + this._wallImpulse = 0; + + if (++this._updateTick % 30 === 0 && this.onUpdate) this.onUpdate(this.info()); + } + + info() { + const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy)); + const avgSpeed = speeds.length ? speeds.reduce((a, b) => a + b) / speeds.length : 0; + const pf = this._pistonFrac; + const P = this._pressureSmooth / (2 * (this.W * pf + this.H)) * 100; + const V = (this.W * pf * this.H) / 10000; + return { + N: this.N, T: this.T, + P: P.toFixed(1), V: V.toFixed(1), PV: (P * V).toFixed(1), + avgSpeed: avgSpeed.toFixed(0), + speedData: this._speedHistogram(speeds), + }; + } + + _speedHistogram(speeds) { + const maxSpeed = this.T * 200; + const numBins = 12; + const binWidth = maxSpeed / numBins; + const bins = new Array(numBins).fill(0); + for (const s of speeds) { + const idx = Math.floor(s / binWidth); + if (idx >= 0 && idx < numBins) bins[idx]++; + } + return { bins, max: Math.max(...bins, 1), binWidth }; + } + + _mbCurve(v) { + const sigma = this.T * 60; + return (v / (sigma * sigma)) * Math.exp(-v * v / (2 * sigma * sigma)); + } + + // ── drawing ───────────────────────────────────────────────────────────────── + draw() { + const { ctx, W, H } = this; + const pistonX = W * this._pistonFrac; + + // Background + const bg = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7); + bg.addColorStop(0, '#080818'); bg.addColorStop(1, '#030308'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + // Grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x <= W; x += 20) { ctx.moveTo(x, 0); ctx.lineTo(x, H); } + for (let y = 0; y <= H; y += 20) { ctx.moveTo(0, y); ctx.lineTo(W, y); } + ctx.stroke(); + + // Dead zone beyond piston + if (this._pistonFrac < 0.99) { + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(pistonX, 0, W - pistonX, H); + } + + // Pressure wall glow + const P = parseFloat(this.info().P); + const wi = Math.min(1, P / 50); + if (wi > 0) { + const a = wi * 0.3, gd = 30; + const glows = [ + [ctx.createLinearGradient(0, 0, gd, 0), 0, 0, gd, H], + [ctx.createLinearGradient(pistonX, 0, pistonX - gd, 0), pistonX - gd, 0, gd, H], + [ctx.createLinearGradient(0, 0, 0, gd), 0, 0, W, gd], + [ctx.createLinearGradient(0, H, 0, H - gd), 0, H - gd, W, gd], + ]; + for (const [g, rx, ry, rw, rh] of glows) { + g.addColorStop(0, `rgba(155,93,229,${a})`); + g.addColorStop(1, 'rgba(155,93,229,0)'); + ctx.fillStyle = g; ctx.fillRect(rx, ry, rw, rh); + } + } + + // Velocity vectors + if (this._showVectors) { + ctx.save(); + for (const p of this.particles) { + const scale = 3; + const ex = p.x + p.vx * scale, ey = p.y + p.vy * scale; + const ang = Math.atan2(p.vy, p.vx); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke(); + const hl = 4; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - hl * Math.cos(ang - 0.4), ey - hl * Math.sin(ang - 0.4)); + ctx.lineTo(ex - hl * Math.cos(ang + 0.4), ey - hl * Math.sin(ang + 0.4)); + ctx.closePath(); ctx.fill(); + } + ctx.restore(); + } + + // Particles + for (const p of this.particles) { + const spd = Math.hypot(p.vx, p.vy); + const T = this.T; + const color = spd < T * 40 ? '#4CC9F0' : spd < T * 80 ? '#7BF5A4' : spd < T * 120 ? '#FFD166' : '#EF476F'; + const isH = this._hover === p; + ctx.save(); + ctx.shadowBlur = isH ? 20 : 8; + ctx.shadowColor = color; + ctx.beginPath(); ctx.arc(p.x, p.y, isH ? p.r + 2 : p.r, 0, Math.PI * 2); + ctx.fillStyle = color; ctx.fill(); + if (isH) { ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5; ctx.stroke(); } + ctx.restore(); + } + + // Piston + this._drawPiston(ctx, pistonX, H); + + // Hover inspector + if (this._hover) this._drawInspector(ctx, this._hover, W, H); + + // Histogram + this._drawHistogram(ctx, W, H); + } + + _drawPiston(ctx, pistonX, H) { + if (this._pistonFrac >= 0.99) return; + ctx.save(); + const pw = 8; + ctx.shadowBlur = 16; ctx.shadowColor = 'rgba(255,209,102,0.5)'; + const g = ctx.createLinearGradient(pistonX - pw, 0, pistonX + pw, 0); + g.addColorStop(0, 'rgba(255,209,102,0.4)'); + g.addColorStop(0.5, 'rgba(255,209,102,0.9)'); + g.addColorStop(1, 'rgba(255,209,102,0.3)'); + ctx.fillStyle = g; ctx.fillRect(pistonX - pw / 2, 0, pw, H); + + // Handle + const hh = 44, hw = 18, hx = pistonX - hw / 2, hy = H / 2 - hh / 2; + ctx.shadowBlur = 0; + ctx.fillStyle = 'rgba(255,209,102,0.88)'; + ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 4); ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1.5; + for (let i = 0; i < 3; i++) { + const gy = hy + 10 + i * 10; + ctx.beginPath(); ctx.moveTo(hx + 4, gy); ctx.lineTo(hx + hw - 4, gy); ctx.stroke(); + } + + ctx.fillStyle = 'rgba(255,209,102,0.7)'; + ctx.font = "bold 9px 'Manrope', sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('⇌', pistonX, hy - 12); + ctx.restore(); + } + + _drawInspector(ctx, p, W, H) { + const spd = Math.hypot(p.vx, p.vy); + const ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI; + const ke = 0.5 * spd * spd; + const T = this.T; + const clr = spd < T * 40 ? '#4CC9F0' : spd < T * 80 ? '#7BF5A4' : spd < T * 120 ? '#FFD166' : '#EF476F'; + + const rows = [ + ['|v|', spd.toFixed(1) + ' у.е.'], + ['vx', p.vx.toFixed(1)], + ['vy', p.vy.toFixed(1)], + ['KE', ke.toFixed(0) + ' у.е.'], + ['угол', ang.toFixed(1) + '°'], + ]; + + const tw = 132, th = 18 + rows.length * 17 + 8; + let tx = p.x + 14, ty = p.y - th / 2; + if (tx + tw > W - 10) tx = p.x - tw - 14; + ty = Math.max(8, Math.min(H - th - 8, ty)); + + ctx.save(); + ctx.fillStyle = 'rgba(6,8,28,0.92)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill(); + + ctx.fillStyle = clr; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8, 8, 0, 0]); ctx.fill(); + + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke(); + + ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.font = "11px 'Manrope', monospace"; ctx.textBaseline = 'middle'; + for (let i = 0; i < rows.length; i++) { + const ry = ty + 18 + i * 17; + ctx.fillStyle = 'rgba(255,255,255,0.42)'; ctx.textAlign = 'left'; + ctx.fillText(rows[i][0], tx + 10, ry); + ctx.fillStyle = 'rgba(255,255,255,0.92)'; ctx.textAlign = 'right'; + ctx.fillText(rows[i][1], tx + tw - 10, ry); + } + ctx.restore(); + } + + _drawHistogram(ctx, W, H) { + const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy)); + const hist = this._speedHistogram(speeds); + + const hw = 204, hh = 102; + const hx = W - hw - 12, hy = H - hh - 12; + const pad = { l: 8, r: 8, t: 20, b: 18 }; + const barW = (hw - pad.l - pad.r) / hist.bins.length; + const barAreaH = hh - pad.t - pad.b; + const maxV = this.T * 200; + + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.58)'; + ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 6); ctx.fill(); + + ctx.fillStyle = 'rgba(255,255,255,0.72)'; ctx.font = '9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Распределение скоростей', hx + hw / 2, hy + 11); + + ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '8px sans-serif'; + ctx.fillText('v (у.е.)', hx + hw / 2, hy + hh - 2); + + // Bars + for (let i = 0; i < hist.bins.length; i++) { + const ratio = hist.bins[i] / hist.max; + const bh = ratio * barAreaH; + const bx = hx + pad.l + i * barW; + const by = hy + pad.t + barAreaH - bh; + ctx.fillStyle = 'rgba(155,93,229,0.75)'; + ctx.beginPath(); ctx.roundRect(bx + 0.5, by, barW - 1, bh, 2); ctx.fill(); + } + + // MB theoretical curve + ctx.strokeStyle = 'rgba(255,209,102,0.9)'; ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); ctx.beginPath(); + let first = true; + for (let i = 0; i <= 80; i++) { + const v = (i / 80) * maxV; + const sc = this._mbCurve(v) * speeds.length * hist.binWidth / hist.max; + const cx2 = hx + pad.l + (v / maxV) * (hw - pad.l - pad.r); + const cy2 = hy + pad.t + barAreaH - sc * barAreaH; + if (first) { ctx.moveTo(cx2, cy2); first = false; } + else ctx.lineTo(cx2, cy2); + } + ctx.stroke(); ctx.setLineDash([]); + + // Characteristic speed lines + const sigma = this.T * 60; + const v_mp = sigma; // v most probable (mode) + const v_rms = sigma * Math.sqrt(2); // v_rms in 2D = sqrt(2) * sigma + + const vline = (v, color, label) => { + if (v > maxV) return; + const vx2 = hx + pad.l + (v / maxV) * (hw - pad.l - pad.r); + ctx.strokeStyle = color; ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + ctx.beginPath(); ctx.moveTo(vx2, hy + pad.t); ctx.lineTo(vx2, hy + pad.t + barAreaH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = color; ctx.font = '7px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText(label, vx2, hy + pad.t - 3); + }; + vline(v_mp, 'rgba(76,201,240,0.9)', 'v_mp'); + vline(v_rms, 'rgba(239,71,111,0.9)', 'v_rms'); + + ctx.restore(); + } +} diff --git a/frontend/js/labs/graph.js b/frontend/js/labs/graph.js new file mode 100644 index 0000000..edc42f4 --- /dev/null +++ b/frontend/js/labs/graph.js @@ -0,0 +1,493 @@ +'use strict'; + +/* ═══════════════════════════════════════════════ + GraphSim — interactive function plotter + Usage: + const sim = new GraphSim(canvasElement); + sim.setFn(0, 'sin(x)', '#9B5DE5'); + sim.onHover = (mx, yVals) => { ... }; + ═══════════════════════════════════════════════ */ + +class GraphSim { + constructor(canvas) { + this.c = canvas; + this.ctx = canvas.getContext('2d'); + this.ox = 0; // viewport centre x (math units) + this.oy = 0; // viewport centre y (math units) + this.scl = 50; // px per unit + this.fns = []; // [{ color, fn } | null] + this.hx = null; // hovered x (math) or null + this._dg = null; // drag state + this.onHover = null; // callback(mx, [y0,y1,…]) or (null, null) + + this._bind(); + new ResizeObserver(() => { this.fit(); this.draw(); }) + .observe(canvas.parentElement); + } + + /* ── public ────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const r = this.c.parentElement.getBoundingClientRect(); + const w = r.width || 600, h = r.height || 400; + this.c.width = w * dpr; + this.c.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this._cw = w; this._ch = h; + } + + /** idx 0-2, expr string, color hex. Returns error string or null. */ + setFn(idx, expr, color) { + if (!expr || !expr.trim()) { + this.fns[idx] = null; + this.draw(); + return null; + } + try { + const fn = this._compile(expr); + fn(0); fn(1); fn(-1); fn(Math.PI); // smoke-test + this.fns[idx] = { color, fn }; + this.draw(); + return null; + } catch { + this.fns[idx] = null; + this.draw(); + return 'Синтаксическая ошибка'; + } + } + + resetView() { this.ox = 0; this.oy = 0; this.scl = 50; this.draw(); } + zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); } + zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); } + + /* ── formula compiler (CSP-safe: no eval / new Function) ── */ + + _compile(raw) { + const tokens = this._tokenize(raw.trim()); + const expanded = this._insertImplicit(tokens); + return this._parseExpr(expanded); + } + + _tokenize(src) { + const out = []; + let i = 0; + while (i < src.length) { + const ch = src[i]; + if (/\s/.test(ch)) { i++; continue; } + + /* number */ + if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(src[i + 1] || ''))) { + let j = i; + while (j < src.length && /[0-9]/.test(src[j])) j++; + if (j < src.length && src[j] === '.') { + j++; + while (j < src.length && /[0-9]/.test(src[j])) j++; + } + if (j < src.length && /[eE]/.test(src[j])) { + j++; + if (j < src.length && /[+\-]/.test(src[j])) j++; + while (j < src.length && /[0-9]/.test(src[j])) j++; + } + out.push({ type: 'num', val: parseFloat(src.slice(i, j)) }); + i = j; + continue; + } + + /* identifier */ + if (/[a-zA-Z_]/.test(ch)) { + let j = i; + while (j < src.length && /[a-zA-Z_0-9]/.test(src[j])) j++; + out.push({ type: 'id', val: src.slice(i, j) }); + i = j; + continue; + } + + /* operator / bracket */ + if ('+-*/^()'.includes(ch)) { + out.push({ type: 'op', val: ch }); + i++; + continue; + } + + throw new Error('Unknown character: ' + ch); + } + return out; + } + + /* Insert implicit '*' where adjacent tokens imply multiplication. + Covers: 2x 2*x, 2( 2*(, )( )*(, )x )*x */ + _insertImplicit(tokens) { + const FUNS = new Set([ + 'sin','cos','tan','tg','ctg', + 'asin','acos','atan','arcsin','arccos','arctan','arctg', + 'sqrt','abs','exp','ln','log','log2','log10', + 'ceil','floor','round','sign', + ]); + const out = []; + for (let i = 0; i < tokens.length; i++) { + out.push(tokens[i]); + const cur = tokens[i], nxt = tokens[i + 1]; + if (!nxt) continue; + const curEnds = cur.type === 'num' || + (cur.type === 'id' && !FUNS.has(cur.val)) || + (cur.type === 'op' && cur.val === ')'); + const nxtStarts = nxt.type === 'num' || + nxt.type === 'id' || + (nxt.type === 'op' && nxt.val === '('); + if (curEnds && nxtStarts) out.push({ type: 'op', val: '*' }); + } + return out; + } + + /* Recursive-descent parser returns a closure x => number */ + _parseExpr(tokens) { + let pos = 0; + const peek = () => tokens[pos]; + const next = () => tokens[pos++]; + const eat = v => { + if (!peek() || peek().val !== v) throw new Error('Expected ' + v); + pos++; + }; + + /* All supported functions including Russian aliases */ + const FN = { + sin: Math.sin, cos: Math.cos, tan: Math.tan, tg: Math.tan, + asin: Math.asin, acos: Math.acos, atan: Math.atan, + arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan, + sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, + ln: Math.log, log: Math.log10, log2: Math.log2, log10: Math.log10, + ceil: Math.ceil, floor: Math.floor, round: Math.round, sign: Math.sign, + ctg: t => 1 / Math.tan(t), + }; + + /* additive: left-associative +/- */ + const addSub = () => { + let l = mulDiv(); + while (peek() && (peek().val === '+' || peek().val === '-')) { + const op = next().val; + const r = mulDiv(); + const ll = l; + l = op === '+' ? x => ll(x) + r(x) : x => ll(x) - r(x); + } + return l; + }; + + /* multiplicative: left-associative */ + const mulDiv = () => { + let l = power(); + while (peek() && (peek().val === '*' || peek().val === '/')) { + const op = next().val; + const r = power(); + const ll = l; + l = op === '*' ? x => ll(x) * r(x) : x => ll(x) / r(x); + } + return l; + }; + + /* power: right-associative ^ */ + const power = () => { + const base = unary(); + if (peek() && peek().val === '^') { + next(); + const exp = power(); // right-recursive right-associative + return x => Math.pow(base(x), exp(x)); + } + return base; + }; + + /* unary minus / plus */ + const unary = () => { + if (peek()?.val === '-') { next(); const v = unary(); return x => -v(x); } + if (peek()?.val === '+') { next(); return unary(); } + return primary(); + }; + + /* primary: number | variable | constant | fn(…) | (…) */ + const primary = () => { + const t = peek(); + if (!t) throw new Error('Unexpected end of expression'); + + if (t.type === 'num') { + next(); + const v = t.val; + return () => v; + } + + if (t.type === 'id') { + next(); + if (t.val === 'x') return x => x; + if (t.val === 'pi' || t.val === 'PI') return () => Math.PI; + if (t.val === 'e') return () => Math.E; + if (FN[t.val]) { + eat('('); + const arg = addSub(); + eat(')'); + const f = FN[t.val]; + return x => f(arg(x)); + } + throw new Error('Unknown identifier: ' + t.val); + } + + if (t.type === 'op' && t.val === '(') { + next(); + const v = addSub(); + eat(')'); + return v; + } + + throw new Error('Unexpected token: ' + t.val); + }; + + const fn = addSub(); + if (pos !== tokens.length) throw new Error('Unexpected tokens after expression'); + return fn; + } + + /* ── coordinate transforms ─────────────────── */ + + _toPx(mx, my) { + const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2; + return [cx + (mx - this.ox) * this.scl, + cy - (my - this.oy) * this.scl]; + } + + _toMx(px, py) { + const cx = (this._cw || this.c.width) / 2, cy = (this._ch || this.c.height) / 2; + return [(px - cx) / this.scl + this.ox, + -(py - cy) / this.scl + this.oy]; + } + + /* ── main render ───────────────────────────── */ + + draw() { + const c = this.ctx, W = this._cw || this.c.width, H = this._ch || this.c.height; + if (!W || !H) return; + + c.fillStyle = '#0D0D1A'; + c.fillRect(0, 0, W, H); + + this._drawGrid(c, W, H); + this._drawAxes(c, W, H); + for (const f of this.fns) if (f) this._drawCurve(c, W, H, f); + if (this.hx !== null) this._drawHover(c, W, H); + } + + /* ── grid ──────────────────────────────────── */ + + _niceStep() { + const raw = (this._cw || this.c.width) / this.scl / 8; + const p = Math.pow(10, Math.floor(Math.log10(raw))); + for (const n of [1, 2, 5, 10]) if (n * p >= raw) return n * p; + return p; + } + + _drawGrid(c, W, H) { + const step = this._niceStep(); + const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0); + const [, y0] = this._toMx(0, H), [, y1] = this._toMx(0, 0); + const gx = Math.floor(x0 / step) * step; + const gy = Math.floor(y0 / step) * step; + + c.strokeStyle = 'rgba(255,255,255,0.065)'; + c.lineWidth = 1; + for (let x = gx; x <= x1 + step; x += step) { + const [px] = this._toPx(x, 0); + c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke(); + } + for (let y = gy; y <= y1 + step; y += step) { + const [, py] = this._toPx(0, y); + c.beginPath(); c.moveTo(0, py); c.lineTo(W, py); c.stroke(); + } + + /* number labels */ + c.font = '11px Manrope, system-ui, sans-serif'; + c.fillStyle = 'rgba(255,255,255,0.3)'; + const [axX, axY] = this._toPx(0, 0); + const lblY = Math.max(4, Math.min(H - 18, axY + 5)); + const lblX = Math.max(28, Math.min(W - 6, axX - 5)); + + c.textAlign = 'center'; c.textBaseline = 'top'; + for (let x = gx; x <= x1; x += step) { + if (Math.abs(x) < step * 0.01) continue; + const [px] = this._toPx(x, 0); + if (px < 18 || px > W - 18) continue; + c.fillText(this._fmtN(x, step), px, lblY); + } + c.textAlign = 'right'; c.textBaseline = 'middle'; + for (let y = gy; y <= y1; y += step) { + if (Math.abs(y) < step * 0.01) continue; + const [, py] = this._toPx(0, y); + if (py < 12 || py > H - 12) continue; + c.fillText(this._fmtN(y, step), lblX, py); + } + } + + _fmtN(n, step) { + if (n === 0) return '0'; + if (step >= 1 && Number.isInteger(n)) return String(n); + if (step < 0.001) return n.toExponential(1); + const dec = Math.max(0, -Math.floor(Math.log10(step))); + return n.toFixed(dec); + } + + /* ── axes ──────────────────────────────────── */ + + _drawAxes(c, W, H) { + const [ax, ay] = this._toPx(0, 0); + c.strokeStyle = 'rgba(255,255,255,0.4)'; + c.lineWidth = 1.5; + c.beginPath(); c.moveTo(0, ay); c.lineTo(W - 10, ay); c.stroke(); + c.beginPath(); c.moveTo(ax, H); c.lineTo(ax, 8); c.stroke(); + + c.fillStyle = 'rgba(255,255,255,0.4)'; + this._arrowHead(c, W - 8, ay, 0); + this._arrowHead(c, ax, 6, -Math.PI / 2); + + c.fillStyle = 'rgba(255,255,255,0.55)'; + c.font = 'bold 12px Manrope, sans-serif'; + c.textBaseline = 'middle'; c.textAlign = 'left'; + c.fillText('x', W - 10, ay - 13); + c.textBaseline = 'top'; c.textAlign = 'left'; + c.fillText('y', ax + 7, 4); + } + + _arrowHead(c, x, y, angle) { + const s = 5; + c.save(); c.translate(x, y); c.rotate(angle); + c.beginPath(); + c.moveTo(0, 0); c.lineTo(-s * 1.6, -s * 0.6); c.lineTo(-s * 1.6, s * 0.6); + c.closePath(); c.fill(); + c.restore(); + } + + /* ── curve ─────────────────────────────────── */ + + _drawCurve(c, W, H, { fn, color }) { + const steps = Math.min(W * 2, 2000); + const [x0] = this._toMx(0, 0), [x1] = this._toMx(W, 0); + const dx = (x1 - x0) / steps; + const maxJmp = (H / this.scl) * 2; // discontinuity threshold (math units) + + c.strokeStyle = color; + c.lineWidth = 2.5; + c.lineJoin = 'round'; + c.beginPath(); + + let pen = false, pyPrev = null; + for (let i = 0; i <= steps; i++) { + const mx = x0 + i * dx; + let my; + try { my = fn(mx); } catch { pen = false; pyPrev = null; continue; } + if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; } + + // discontinuity guard + if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) { + pen = false; + } + + const [px, py] = this._toPx(mx, my); + pen ? c.lineTo(px, py) : c.moveTo(px, py); + pen = true; pyPrev = my; + } + c.stroke(); + } + + /* ── hover crosshair ───────────────────────── */ + + _drawHover(c, W, H) { + const [px] = this._toPx(this.hx, 0); + + c.strokeStyle = 'rgba(255,255,255,0.15)'; + c.lineWidth = 1; + c.setLineDash([5, 5]); + c.beginPath(); c.moveTo(px, 0); c.lineTo(px, H); c.stroke(); + c.setLineDash([]); + + for (const f of this.fns) { + if (!f) continue; + let my; + try { my = f.fn(this.hx); } catch { continue; } + if (!isFinite(my) || isNaN(my)) continue; + + const [, py] = this._toPx(this.hx, my); + if (py < -20 || py > H + 20) continue; + + c.fillStyle = f.color; + c.beginPath(); c.arc(px, py, 5.5, 0, 2 * Math.PI); c.fill(); + c.strokeStyle = 'rgba(255,255,255,0.8)'; + c.lineWidth = 1.5; c.stroke(); + } + } + + /* ── events ─────────────────────────────────── */ + + _bind() { + const cv = this.c; + + /* wheel zoom — zoom toward cursor */ + cv.addEventListener('wheel', e => { + e.preventDefault(); + const [mx, my] = this._toMx(e.offsetX, e.offsetY); + this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15))); + const [nx, ny] = this._toMx(e.offsetX, e.offsetY); + this.ox -= nx - mx; this.oy -= ny - my; + this.draw(); + }, { passive: false }); + + /* mouse drag */ + cv.addEventListener('mousedown', e => { + this._dg = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy }; + cv.style.cursor = 'grabbing'; + }); + window.addEventListener('mousemove', e => { + if (this._dg) { + this.ox = this._dg.ox - (e.clientX - this._dg.x) / this.scl; + this.oy = this._dg.oy + (e.clientY - this._dg.y) / this.scl; + this.draw(); + } else { + const r = cv.getBoundingClientRect(); + const lx = e.clientX - r.left, ly = e.clientY - r.top; + if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) { + this.hx = this._toMx(lx, ly)[0]; + this.draw(); + this._emitHover(); + } + } + }); + window.addEventListener('mouseup', () => { + this._dg = null; + cv.style.cursor = 'crosshair'; + }); + cv.addEventListener('mouseleave', () => { + this.hx = null; this.draw(); + if (this.onHover) this.onHover(null, null); + }); + cv.style.cursor = 'crosshair'; + + /* touch drag */ + let t0 = null; + cv.addEventListener('touchstart', e => { + if (e.touches.length === 1) + t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; + }, { passive: true }); + cv.addEventListener('touchmove', e => { + e.preventDefault(); + if (e.touches.length === 1 && t0) { + this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl; + this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl; + this.draw(); + } + }, { passive: false }); + cv.addEventListener('touchend', () => { t0 = null; }); + } + + _emitHover() { + if (!this.onHover) return; + const vals = this.fns.map(f => { + if (!f) return null; + try { const v = f.fn(this.hx); return isFinite(v) ? v : null; } catch { return null; } + }); + this.onHover(this.hx, vals); + } +} diff --git a/frontend/js/labs/graphtransform.js b/frontend/js/labs/graphtransform.js new file mode 100644 index 0000000..50d0482 --- /dev/null +++ b/frontend/js/labs/graphtransform.js @@ -0,0 +1,355 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + GraphTransformSim — graph transformations explorer + y = a·f(k·x + b) + c with sliders for a, k, b, c + Original f(x) shown faded, transformed shown bold. + ══════════════════════════════════════════════════════════════ */ + +class GraphTransformSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* base function */ + this._baseFn = x => Math.sin(x); + this._baseLabel = 'sin(x)'; + + /* transform params */ + this.a = 1; + this.k = 1; + this.b = 0; + this.c = 0; + + /* view */ + this.ox = 0; + this.oy = 0; + this.scl = 40; + + this.hx = null; + this._drag = null; + this.onUpdate = null; + + this._bind(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public ──────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setParams({ a, k, b, c } = {}) { + if (a !== undefined) this.a = +a; + if (k !== undefined) this.k = +k; + if (b !== undefined) this.b = +b; + if (c !== undefined) this.c = +c; + this.draw(); + this._emit(); + } + + setBase(name) { + const BASES = { + 'sin': { fn: x => Math.sin(x), label: 'sin(x)' }, + 'cos': { fn: x => Math.cos(x), label: 'cos(x)' }, + 'x^2': { fn: x => x * x, label: 'x²' }, + 'sqrt': { fn: x => x >= 0 ? Math.sqrt(x) : NaN, label: '√x' }, + '|x|': { fn: x => Math.abs(x), label: '|x|' }, + '1/x': { fn: x => x !== 0 ? 1 / x : NaN, label: '1/x' }, + 'x^3': { fn: x => x * x * x, label: 'x³' }, + }; + const b = BASES[name]; + if (b) { this._baseFn = b.fn; this._baseLabel = b.label; this.draw(); this._emit(); } + } + + resetView() { this.ox = 0; this.oy = 0; this.scl = 40; this.draw(); } + zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); } + zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); } + + info() { + const { a, k, b, c } = this; + const parts = []; + if (a !== 1) parts.push(a === -1 ? '−' : a.toFixed(1) + '·'); + parts.push(this._baseLabel.replace('x', this._innerStr())); + if (c > 0) parts.push(' + ' + c.toFixed(1)); + if (c < 0) parts.push(' − ' + Math.abs(c).toFixed(1)); + return { + base: this._baseLabel, + equation: 'y = ' + parts.join(''), + a: a.toFixed(1), + k: k.toFixed(1), + b: b.toFixed(1), + c: c.toFixed(1), + }; + } + + /* ── internals ──────────────────────────────────── */ + + _innerStr() { + const { k, b } = this; + let s = ''; + if (k !== 1) s += (k === -1 ? '−' : k.toFixed(1) + '·'); + s += 'x'; + if (b > 0) s += ' + ' + b.toFixed(1); + if (b < 0) s += ' − ' + Math.abs(b).toFixed(1); + return s; + } + + _fBase(x) { try { return this._baseFn(x); } catch { return NaN; } } + + _fTransformed(x) { + const inner = this.k * x + this.b; + const base = this._fBase(inner); + return this.a * base + this.c; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + /* ── coordinate transforms ────────────────────── */ + + _toPx(mx, my) { + return [ + this.W / 2 + (mx - this.ox) * this.scl, + this.H / 2 - (my - this.oy) * this.scl, + ]; + } + + _toMath(px, py) { + return [ + (px - this.W / 2) / this.scl + this.ox, + -(py - this.H / 2) / this.scl + this.oy, + ]; + } + + /* ── draw ────────────────────────────────────── */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + this._drawGrid(ctx, W, H); + this._drawAxes(ctx, W, H); + this._drawCurve(ctx, W, H, x => this._fBase(x), 'rgba(255,255,255,0.18)', 2); // original faded + this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); // transformed bold + this._drawEquation(ctx, W, H); + if (this.hx !== null) this._drawHover(ctx, W, H); + } + + _drawGrid(ctx, W, H) { + const step = this._niceStep(); + const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0); + const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0); + const gx = Math.floor(x0 / step) * step; + const gy = Math.floor(y0 / step) * step; + + ctx.strokeStyle = 'rgba(255,255,255,0.065)'; + ctx.lineWidth = 1; + for (let x = gx; x <= x1 + step; x += step) { + const [px] = this._toPx(x, 0); + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + } + for (let y = gy; y <= y1 + step; y += step) { + const [, py] = this._toPx(0, y); + ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); + } + + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + const [axX, axY] = this._toPx(0, 0); + const lblY = Math.max(4, Math.min(H - 18, axY + 5)); + const lblX = Math.max(28, Math.min(W - 6, axX - 5)); + + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let x = gx; x <= x1; x += step) { + if (Math.abs(x) < step * 0.01) continue; + const [px] = this._toPx(x, 0); + if (px < 18 || px > W - 18) continue; + ctx.fillText(this._fmtLabel(x, step), px, lblY); + } + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let y = gy; y <= y1; y += step) { + if (Math.abs(y) < step * 0.01) continue; + const [, py] = this._toPx(0, y); + if (py < 12 || py > H - 12) continue; + ctx.fillText(this._fmtLabel(y, step), lblX, py); + } + } + + _niceStep() { + const raw = this.W / this.scl / 8; + const p = Math.pow(10, Math.floor(Math.log10(raw))); + for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; + return p; + } + + _fmtLabel(n, step) { + if (n === 0) return '0'; + if (step >= 1 && Number.isInteger(n)) return String(n); + if (step < 0.001) return n.toExponential(1); + const dec = Math.max(0, -Math.floor(Math.log10(step))); + return n.toFixed(dec); + } + + _drawAxes(ctx, W, H) { + const [ax, ay] = this._toPx(0, 0); + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke(); + + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + const s = 5; + // x arrow + ctx.save(); ctx.translate(W - 8, ay); ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore(); + // y arrow + ctx.save(); ctx.translate(ax, 6); ctx.rotate(-Math.PI / 2); ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore(); + + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = 'bold 12px Manrope, sans-serif'; + ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; + ctx.fillText('x', W - 10, ay - 13); + ctx.textBaseline = 'top'; ctx.textAlign = 'left'; + ctx.fillText('y', ax + 7, 4); + } + + _drawCurve(ctx, W, H, fn, color, lw) { + const steps = Math.min(W * 2, 2000); + const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0); + const dx = (x1 - x0) / steps; + const maxJmp = (H / this.scl) * 2; + + ctx.strokeStyle = color; + ctx.lineWidth = lw; + ctx.lineJoin = 'round'; + ctx.beginPath(); + + let pen = false, pyPrev = null; + for (let i = 0; i <= steps; i++) { + const mx = x0 + i * dx; + const my = fn(mx); + if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; } + if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) pen = false; + const [px, py] = this._toPx(mx, my); + pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py); + pen = true; pyPrev = my; + } + ctx.stroke(); + } + + _drawEquation(ctx, W, H) { + const info = this.info(); + ctx.font = 'bold 13px Manrope, sans-serif'; + const text = info.equation; + const tw = ctx.measureText(text).width; + const x = W - tw - 24, y = 14; + + ctx.fillStyle = 'rgba(22,22,38,0.85)'; + ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.fill(); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.stroke(); + ctx.fillStyle = '#ddd'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(text, x + 8, y + 13); + + // base function label (faded) + const base = 'f(x) = ' + this._baseLabel; + ctx.font = '11px Manrope, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fillText(base, x + 8, y + 38); + } + + _drawHover(ctx, W, H) { + const [px] = this._toPx(this.hx, 0); + const myOrig = this._fBase(this.hx); + const myTrans = this._fTransformed(this.hx); + + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + ctx.setLineDash([]); + + // original point + if (isFinite(myOrig)) { + const [, py] = this._toPx(this.hx, myOrig); + if (py > -20 && py < H + 20) { + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fill(); + } + } + + // transformed point + if (isFinite(myTrans)) { + const [, py2] = this._toPx(this.hx, myTrans); + if (py2 > -20 && py2 < H + 20) { + ctx.fillStyle = '#9B5DE5'; + ctx.beginPath(); ctx.arc(px, py2, 5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); + } + } + } + + /* ── events ──────────────────────────────────── */ + + _bind() { + const cv = this.canvas; + + cv.addEventListener('wheel', e => { + e.preventDefault(); + const [mx, my] = this._toMath(e.offsetX, e.offsetY); + this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15))); + const [nx, ny] = this._toMath(e.offsetX, e.offsetY); + this.ox -= nx - mx; this.oy -= ny - my; + this.draw(); + }, { passive: false }); + + cv.addEventListener('mousedown', e => { + this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy }; + cv.style.cursor = 'grabbing'; + }); + window.addEventListener('mousemove', e => { + if (this._drag) { + this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl; + this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl; + this.draw(); + } else { + const r = cv.getBoundingClientRect(); + const lx = e.clientX - r.left, ly = e.clientY - r.top; + if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) { + this.hx = this._toMath(lx, ly)[0]; + this.draw(); + } + } + }); + window.addEventListener('mouseup', () => { this._drag = null; cv.style.cursor = 'crosshair'; }); + cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); }); + cv.style.cursor = 'crosshair'; + + let t0 = null; + cv.addEventListener('touchstart', e => { + if (e.touches.length === 1) + t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; + }, { passive: true }); + cv.addEventListener('touchmove', e => { + e.preventDefault(); + if (e.touches.length === 1 && t0) { + this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl; + this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl; + this.draw(); + } + }, { passive: false }); + cv.addEventListener('touchend', () => { t0 = null; }); + } +} diff --git a/frontend/js/labs/ionexchange.js b/frontend/js/labs/ionexchange.js new file mode 100644 index 0000000..2a16140 --- /dev/null +++ b/frontend/js/labs/ionexchange.js @@ -0,0 +1,484 @@ +'use strict'; +/* ==================================================================== + IonExSim — Реакции ионного обмена + ==================================================================== */ + +class IonExSim { + + /* ── Данные реакций ──────────────────────────────────────────────── */ + + static RXN = { + ba_so4: { + name: 'BaCl₂ + Na₂SO₄', + left: [{ f: 'Ba²⁺', color: '#4FC3F7', count: 7 }, { f: 'Cl⁻', color: '#AED581', count: 14 }], + right: [{ f: 'Na⁺', color: '#FFD54F', count: 14 }, { f: 'SO₄²⁻', color: '#F48FB1', count: 7 }], + reacts: ['Ba²⁺', 'SO₄²⁻'], + spectators: ['Cl⁻', 'Na⁺'], + product: { f: 'BaSO₄', color: '#E0E0E0' }, + mol: 'BaCl₂ + Na₂SO₄ BaSO₄ + 2NaCl', + full_ion: 'Ba²⁺ + 2Cl⁻ + 2Na⁺ + SO₄²⁻ BaSO₄ + 2Na⁺ + 2Cl⁻', + net_ion: 'Ba²⁺ + SO₄²⁻ BaSO₄', + type: 'precip', pcolor: '#E0E0E0', pname: 'BaSO₄ — белый осадок', + sign: '', signColor: '#E0E0E0', + }, + ag_cl: { + name: 'AgNO₃ + NaCl', + left: [{ f: 'Ag⁺', color: '#E0E0E0', count: 10 }, { f: 'NO₃⁻', color: '#FFCC02', count: 10 }], + right: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }], + reacts: ['Ag⁺', 'Cl⁻'], + spectators: ['NO₃⁻', 'Na⁺'], + product: { f: 'AgCl', color: '#F5F5F5' }, + mol: 'AgNO₃ + NaCl AgCl + NaNO₃', + full_ion: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ AgCl + Na⁺ + NO₃⁻', + net_ion: 'Ag⁺ + Cl⁻ AgCl', + type: 'precip', pcolor: '#F5F5F5', pname: 'AgCl — белый творожистый осадок', + sign: '', signColor: '#F5F5F5', + }, + co3_hcl: { + name: 'Na₂CO₃ + HCl', + left: [{ f: 'Na⁺', color: '#FFD54F', count: 10 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 5 }], + right: [{ f: 'H⁺', color: '#EF5350', count: 10 }, { f: 'Cl⁻', color: '#AED581', count: 10 }], + reacts: ['CO₃²⁻', 'H⁺'], + spectators: ['Na⁺', 'Cl⁻'], + product: { f: 'CO₂', color: '#B0BEC5' }, + mol: 'Na₂CO₃ + 2HCl 2NaCl + CO₂ + H₂O', + full_ion: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ 2Na⁺ + 2Cl⁻ + CO₂ + H₂O', + net_ion: 'CO₃²⁻ + 2H⁺ CO₂ + H₂O', + type: 'gas', gcolor: '#B0BEC5', gname: 'CO₂ — углекислый газ', + sign: '', signColor: '#B0BEC5', + }, + pb_i: { + name: 'Pb(NO₃)₂ + KI', + left: [{ f: 'Pb²⁺', color: '#F48FB1', count: 6 }, { f: 'NO₃⁻', color: '#FFCC02', count: 12 }], + right: [{ f: 'K⁺', color: '#80CBC4', count: 12 }, { f: 'I⁻', color: '#CE93D8', count: 12 }], + reacts: ['Pb²⁺', 'I⁻'], + spectators: ['NO₃⁻', 'K⁺'], + product: { f: 'PbI₂', color: '#F9A825' }, + mol: 'Pb(NO₃)₂ + 2KI PbI₂ + 2KNO₃', + full_ion: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + 2I⁻ PbI₂ + 2K⁺ + 2NO₃⁻', + net_ion: 'Pb²⁺ + 2I⁻ PbI₂', + type: 'precip', pcolor: '#F9A825', pname: 'PbI₂ — ярко-жёлтый осадок', + sign: '', signColor: '#F9A825', + }, + ca_co3: { + name: 'CaCl₂ + Na₂CO₃', + left: [{ f: 'Ca²⁺', color: '#FF8A65', count: 8 }, { f: 'Cl⁻', color: '#AED581', count: 16 }], + right: [{ f: 'Na⁺', color: '#FFD54F', count: 16 }, { f: 'CO₃²⁻', color: '#CE93D8', count: 8 }], + reacts: ['Ca²⁺', 'CO₃²⁻'], + spectators: ['Cl⁻', 'Na⁺'], + product: { f: 'CaCO₃', color: '#F5F5F5' }, + mol: 'CaCl₂ + Na₂CO₃ CaCO₃ + 2NaCl', + full_ion: 'Ca²⁺ + 2Cl⁻ + 2Na⁺ + CO₃²⁻ CaCO₃ + 2Na⁺ + 2Cl⁻', + net_ion: 'Ca²⁺ + CO₃²⁻ CaCO₃', + type: 'precip', pcolor: '#F5F5F5', pname: 'CaCO₃ — белый осадок (мел)', + sign: '', signColor: '#F5F5F5', + }, + }; + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.rxnId = 'ba_so4'; + this._raf = null; + this._last = 0; + this._t = 0; + this._phase = 'idle'; // idle | mixing | pairing | done + this._prog = 0; + this._stepIdx = 0; + this._stepTimer = 0; + this._ions = []; + this._pairs = []; + this._precip = []; + this._gas = []; + this.W = 0; this.H = 0; + this.onUpdate = null; + this.fit(); + this._initIons(); + } + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 600; + const H = this.canvas.offsetHeight || 400; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._initIons(); + } + + setReaction(id) { + if (!IonExSim.RXN[id]) return; + this.rxnId = id; + this.reset(); + } + + reset() { + this._phase = 'idle'; this._prog = 0; + this._stepIdx = 0; this._stepTimer = 0; + this._pairs = []; this._precip = []; this._gas = []; + this._initIons(); + this.draw(); + } + + _initIons() { + const { W, H } = this; + const rxn = IonExSim.RXN[this.rxnId]; + const bTop = H * 0.10, bBot = H * 0.78; + this._ions = []; + /* Left beaker ions */ + rxn.left.forEach(spec => { + for (let i = 0; i < spec.count; i++) { + this._ions.push({ + x: W * 0.10 + Math.random() * W * 0.36, + y: bTop + 20 + Math.random() * (bBot - bTop - 40), + vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, + spec: spec.f, color: spec.color, + r: 8 + Math.random() * 3, + phase: Math.random() * Math.PI * 2, + active: true, side: 'L', + reacts: rxn.reacts.includes(spec.f), + paired: false, + }); + } + }); + /* Right beaker ions */ + rxn.right.forEach(spec => { + for (let i = 0; i < spec.count; i++) { + this._ions.push({ + x: W * 0.54 + Math.random() * W * 0.36, + y: bTop + 20 + Math.random() * (bBot - bTop - 40), + vx: (Math.random() - 0.5) * 0.8, vy: (Math.random() - 0.5) * 0.8, + spec: spec.f, color: spec.color, + r: 8 + Math.random() * 3, + phase: Math.random() * Math.PI * 2, + active: true, side: 'R', + reacts: rxn.reacts.includes(spec.f), + paired: false, + }); + } + }); + } + + start() { + if (this._phase !== 'idle') this.reset(); + this._phase = 'mixing'; this._prog = 0; + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + /* ── Физика ─────────────────────────────────────────────────────── */ + + _tick(t) { + const dt = Math.min((t - this._last) / 1000, 0.05); + this._last = t; this._t += dt; + const { W, H } = this; + const rxn = IonExSim.RXN[this.rxnId]; + + if (this._phase === 'mixing') { + this._prog = Math.min(1, this._prog + dt * 0.32); + this._ions.forEach(ion => { + if (!ion.active) return; + const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.72; + const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.38; + ion.vx += (tx - ion.x) * 0.003 * this._prog; + ion.vy += (ty - ion.y) * 0.003 * this._prog; + ion.vx += (Math.random() - 0.5) * 0.7; + ion.vy += (Math.random() - 0.5) * 0.7; + ion.vx *= 0.88; ion.vy *= 0.88; + ion.x += ion.vx; ion.y += ion.vy; + ion.phase += dt * 2; + this._clampIon(ion); + }); + if (this._prog >= 1) { this._phase = 'pairing'; this._prog = 0; } + } + + if (this._phase === 'pairing') { + this._prog = Math.min(1, this._prog + dt * 0.16); + this._stepTimer += dt; + if (this._stepTimer > 1.5 && this._stepIdx < 2) { this._stepIdx++; this._stepTimer = 0; } + + this._ions.forEach(ion => { + if (!ion.active || ion.paired) return; + ion.vx += (Math.random() - 0.5) * 0.8; + ion.vy += (Math.random() - 0.5) * 0.8; + ion.vx *= 0.88; ion.vy *= 0.88; + ion.x += ion.vx; ion.y += ion.vy; + ion.phase += dt * 2; + this._clampIon(ion); + }); + + /* Pair up reactive ions */ + if (Math.random() < 0.10 * (0.5 + this._prog)) this._doPair(rxn); + + /* Animate pairs */ + this._pairs.forEach(p => { + p.flashT = Math.max(0, p.flashT - dt * 2.5); + if (rxn.type === 'precip') { + p.vy = Math.min(p.vy + 0.15, 5); + p.y += p.vy; + if (p.y >= H * 0.78 && !p.settled) { + p.y = H * 0.78; p.vy = 0; p.settled = true; + this._precip.push({ x: p.x, y: p.y, r: p.r, id: p.id }); + } + } else if (rxn.type === 'gas') { + p.vy = Math.max(p.vy - 0.08, -4); + p.y += p.vy; + p.alpha = Math.max(0, p.alpha - 0.004); + } + }); + this._pairs = this._pairs.filter(p => !p.settled && (p.alpha === undefined || p.alpha > 0)); + + if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 2; } + } + + if (this._phase === 'done') { + this._ions.forEach(ion => { + if (!ion.active || ion.paired) return; + ion.vx += (Math.random() - 0.5) * 0.45; + ion.vy += (Math.random() - 0.5) * 0.45; + ion.vx *= 0.92; ion.vy *= 0.92; + ion.x += ion.vx; ion.y += ion.vy; + ion.phase += dt; + this._clampIon(ion); + }); + } + + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + _clampIon(ion) { + const { W, H } = this; + const bTop = H * 0.10, bBot = H * 0.78; + if (ion.x < ion.r + 6) { ion.x = ion.r + 6; ion.vx *= -0.5; } + if (ion.x > W - ion.r - 6) { ion.x = W - ion.r - 6; ion.vx *= -0.5; } + if (ion.y < bTop + ion.r) { ion.y = bTop + ion.r; ion.vy *= -0.5; } + if (ion.y > bBot - ion.r) { ion.y = bBot - ion.r; ion.vy *= -0.5; } + } + + _doPair(rxn) { + const r1 = rxn.reacts[0], r2 = rxn.reacts[1]; + const pool1 = this._ions.filter(i => i.active && !i.paired && i.spec === r1); + const pool2 = this._ions.filter(i => i.active && !i.paired && i.spec === r2); + if (!pool1.length || !pool2.length) return; + const a = pool1[Math.floor(Math.random() * pool1.length)]; + const b = pool2[Math.floor(Math.random() * pool2.length)]; + a.paired = true; b.paired = true; + a.active = false; b.active = false; + this._pairs.push({ + id: this._t + Math.random(), + x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, + vy: rxn.type === 'gas' ? -2 : 0, + r: 7, flashT: 1, settled: false, alpha: 1, + }); + } + + /* ── Рендеринг ──────────────────────────────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + const rxn = IonExSim.RXN[this.rxnId]; + + ctx.fillStyle = '#07071A'; + ctx.fillRect(0, 0, W, H); + + /* Dot grid */ + ctx.fillStyle = 'rgba(255,255,255,0.07)'; + for (let x = 0; x < W; x += 28) { + for (let y = 0; y < H; y += 28) { + ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill(); + } + } + + if (this._phase === 'idle') { + this._drawTwoBeakers(ctx, W, H, rxn); + } else { + this._drawSingleBeaker(ctx, W, H); + } + + this._drawIons(ctx, rxn); + this._drawPairs(ctx, rxn); + this._drawPrecipitate(ctx, rxn); + this._drawPanel(ctx, W, H, rxn); + } + + _drawTwoBeakers(ctx, W, H, rxn) { + const drawB = (x, y, w, h, ions) => { + ctx.save(); + ctx.strokeStyle = 'rgba(120,185,255,0.55)'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x, y); ctx.lineTo(x, y + h); + ctx.lineTo(x + w, y + h); ctx.lineTo(x + w, y); + ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x - 4, y); ctx.lineTo(x + w + 4, y); ctx.stroke(); + /* Rim highlight */ + const hlg = ctx.createLinearGradient(x, y, x + 14, y + h); + hlg.addColorStop(0, 'rgba(200,230,255,0.15)'); + hlg.addColorStop(1, 'rgba(200,230,255,0.02)'); + ctx.strokeStyle = hlg; ctx.lineWidth = 5; + ctx.beginPath(); ctx.moveTo(x + 7, y + 6); ctx.lineTo(x + 7, y + h - 6); ctx.stroke(); + /* Formula label */ + ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = 'bold 11px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + const label = ions.map(s => s.f).join(', '); + ctx.fillText(label, x + w / 2, y - 4); + ctx.restore(); + }; + const bTop = H * 0.10, bH = H * 0.70; + drawB(W * 0.04, bTop, W * 0.40, bH, rxn.left); + drawB(W * 0.56, bTop, W * 0.40, bH, rxn.right); + + /* Mix arrow */ + ctx.save(); + const mx = W * 0.50, my = H * 0.44; + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 2; + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.beginPath(); ctx.moveTo(mx - 16, my); ctx.lineTo(mx + 16, my); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(mx + 10, my - 5); ctx.lineTo(mx + 16, my); ctx.lineTo(mx + 10, my + 5); ctx.fill(); + ctx.restore(); + } + + _drawSingleBeaker(ctx, W, H) { + const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.72; + ctx.save(); + ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh); + ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by); + ctx.stroke(); + ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke(); + const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh); + hlg.addColorStop(0, 'rgba(200,230,255,0.18)'); + hlg.addColorStop(1, 'rgba(200,230,255,0.02)'); + ctx.strokeStyle = hlg; ctx.lineWidth = 6; + ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke(); + ctx.restore(); + } + + _drawIons(ctx, rxn) { + this._ions.forEach(ion => { + if (!ion.active) return; + const isSpec = rxn.spectators.includes(ion.spec); + ctx.save(); + ctx.globalAlpha = (isSpec && this._phase !== 'idle') ? 0.40 : 0.88; + ctx.shadowColor = ion.color; + ctx.shadowBlur = 7 + Math.sin(ion.phase) * 3; + ctx.beginPath(); ctx.arc(ion.x, ion.y, ion.r, 0, Math.PI * 2); + ctx.fillStyle = ion.color; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.shadowBlur = 0; ctx.globalAlpha = 1; + /* Formula label */ + const fs = Math.min(Math.round(ion.r * 0.60), 9); + ctx.fillStyle = 'rgba(0,0,0,0.80)'; + ctx.font = `bold ${fs}px monospace`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(ion.spec, ion.x, ion.y); + ctx.restore(); + }); + } + + _drawPairs(ctx, rxn) { + const pcolor = rxn.pcolor || rxn.gcolor || '#FFF'; + this._pairs.forEach(p => { + ctx.save(); + const alpha = p.alpha !== undefined ? p.alpha : 1; + ctx.globalAlpha = alpha * 0.92; + ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : pcolor; + ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : pcolor; + ctx.fill(); + ctx.shadowBlur = 0; ctx.globalAlpha = 1; + /* Product label */ + ctx.fillStyle = 'rgba(0,0,0,0.80)'; + ctx.font = 'bold 7px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(rxn.product.f, p.x, p.y); + ctx.restore(); + }); + } + + _drawPrecipitate(ctx, rxn) { + if (rxn.type !== 'precip' || !this._precip.length) return; + ctx.save(); + this._precip.forEach(p => { + ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 3; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = rxn.pcolor; ctx.fill(); + }); + ctx.restore(); + if (this._precip.length > 4) { + ctx.save(); + ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6; + ctx.fillText(`↓ ${rxn.pname}`, this.W / 2, this.H * 0.80 - 4); + ctx.restore(); + } + } + + _drawPanel(ctx, W, H, rxn) { + const py = H * 0.82; + ctx.fillStyle = 'rgba(7,7,26,0.95)'; + ctx.fillRect(0, py, W, H - py); + ctx.strokeStyle = 'rgba(100,165,255,0.22)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); + + if (this._phase === 'idle') { + ctx.fillStyle = '#37474F'; ctx.font = '11px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('← Нажми «Смешать» для начала реакции →', W / 2, py + (H - py) / 2); + return; + } + + const steps = [ + { lbl: 'Молекулярное:', txt: rxn.mol, col: '#B0BEC5' }, + { lbl: 'Полное ионное:', txt: rxn.full_ion, col: '#CE93D8' }, + { lbl: 'Краткое ионное:', txt: rxn.net_ion, col: '#FFD166' }, + ]; + + const panH = H - py; + const n = Math.min(this._stepIdx + 1, steps.length); + for (let i = 0; i < n; i++) { + const s = steps[i]; + const y = py + 11 + i * (panH * 0.29); + ctx.save(); + if (i === this._stepIdx && this._phase !== 'done') { + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + ctx.fillRect(8, y - 9, W - 16, 20); + } + ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(s.lbl, 14, y); + ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)'; + ctx.font = '9.5px monospace'; + ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y); + ctx.restore(); + } + + if (this._phase === 'done') { + ctx.save(); + ctx.fillStyle = rxn.signColor; ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'right'; ctx.textBaseline = 'top'; + ctx.shadowColor = rxn.signColor; ctx.shadowBlur = 8; + const label = rxn.type === 'precip' ? ` ${rxn.sign} осадок` : ` ${rxn.sign} газ`; + ctx.fillText(label, W - 14, py + 3); + ctx.restore(); + } + } + + info() { + const rxn = IonExSim.RXN[this.rxnId]; + return { + rxn: rxn.name, + phase: this._phase, + prog: Math.round(this._prog * 100), + precip: this._precip.length, + }; + } +} diff --git a/frontend/js/labs/isoprocess.js b/frontend/js/labs/isoprocess.js new file mode 100644 index 0000000..611fe15 --- /dev/null +++ b/frontend/js/labs/isoprocess.js @@ -0,0 +1,463 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + IsoprocessSim — PV-diagram for 4 ideal-gas isoprocesses + n = 1, R = 0.0821 L·atm/mol·K; energies in Joules + Isothermal PV = const ΔU=0, W=nRT·ln(V2/V1), Q=W + Isochoric V = const W=0, ΔU=νCvΔT, Q=ΔU + Isobaric P = const W=PΔV, ΔU=νCvΔT, Q=ΔU+W + Adiabatic PV^γ = const Q=0, ΔU=-W, W=PΔV/(γ-1) + ══════════════════════════════════════════════════════════════ */ + +class IsoprocessSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* physics */ + this.n = 1; + this.R = 0.0821; // L·atm / mol·K + this.R_J = 8.314; // J / mol·K + this.gamma = 1.4; // 7/5 diatomic default + + /* state */ + this.P1 = 3.0; // atm + this.V1 = 10.0; // L + this._ratio = 0.5; // 0..1, maps end state position along process + + /* process */ + this.process = 'isothermal'; + + /* axis range */ + this.Vmin = 1; this.Vmax = 33; + this.Pmin = 0.2; this.Pmax = 9.5; + + /* margins */ + this.ML = 52; this.MB = 46; this.MT = 20; this.MR = 18; + + this._drag = null; // 'state1' | 'state2' + this.onUpdate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ─────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setProcess(p) { this.process = p; this.draw(); this._emit(); } + setGamma(g) { this.gamma = +g; this.draw(); this._emit(); } + 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)); + this.draw(); this._emit(); + } + setRatio(r) { this._ratio = Math.max(0.01, Math.min(0.99, +r)); this.draw(); this._emit(); } + + /* ── coordinate transforms ─────────────────── */ + + _pw() { return this.W - this.ML - this.MR; } + _ph() { return this.H - this.MT - this.MB; } + + _vx(v) { return this.ML + (v - this.Vmin) / (this.Vmax - this.Vmin) * this._pw(); } + _py(p) { return this.MT + (1 - (p - this.Pmin) / (this.Pmax - this.Pmin)) * this._ph(); } + _xv(x) { return this.Vmin + (x - this.ML) / this._pw() * (this.Vmax - this.Vmin); } + _yp(y) { return this.Pmin + (1 - (y - this.MT) / this._ph()) * (this.Pmax - this.Pmin); } + + /* ── physics ───────────────────────────────── */ + + _T(P, V) { return P * V / (this.n * this.R); } + + _state2() { + const { P1, V1, _ratio, gamma } = this; + /* ratio in [0..1] → multiplier in [0.2..3.5] for V2/V1 or P2/P1 */ + const mult = 0.2 + _ratio * 3.3; + const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v)); + const clampP = p => Math.max(this.Pmin + 0.05, Math.min(this.Pmax - 0.1, p)); + + switch (this.process) { + case 'isothermal': { + const V2 = clampV(V1 * mult); + return { P2: clampP(P1 * V1 / V2), V2 }; + } + case 'isochoric': { + return { P2: clampP(P1 * mult), V2: V1 }; + } + case 'isobaric': { + const V2 = clampV(V1 * mult); + return { P2: P1, V2 }; + } + case 'adiabatic': { + const V2 = clampV(V1 * mult); + return { P2: clampP(P1 * Math.pow(V1 / V2, gamma)), V2 }; + } + } + return { P2: P1, V2: V1 }; + } + + info() { + const { P1, V1, n, R_J, gamma } = this; + const T1 = this._T(P1, V1); + const { P2, V2 } = this._state2(); + const T2 = this._T(P2, V2); + + /* internal energy: ΔU = νCvΔT, Cv = R/(γ-1) */ + const Cv_J = R_J / (gamma - 1); + const dU_J = n * Cv_J * (T2 - T1); + + /* P in Pa = P_atm * 101325, V in m³ = V_L * 0.001 */ + const P1Pa = P1 * 101325, P2Pa = P2 * 101325; + const V1m3 = V1 * 0.001, V2m3 = V2 * 0.001; + + let W_J = 0, Q_J = 0; + switch (this.process) { + case 'isothermal': + W_J = n * R_J * T1 * Math.log(V2 / V1); + Q_J = W_J; break; + case 'isochoric': + W_J = 0; Q_J = dU_J; break; + case 'isobaric': + W_J = P1Pa * (V2m3 - V1m3); + Q_J = dU_J + W_J; break; + case 'adiabatic': + Q_J = 0; + W_J = -dU_J; break; + } + + const fmt = x => (x >= 0 ? '+' : '') + Math.round(x); + return { + P1: P1.toFixed(2), V1: V1.toFixed(1), T1: Math.round(T1), + P2: P2.toFixed(2), V2: V2.toFixed(1), T2: Math.round(T2), + W: fmt(W_J), Q: fmt(Q_J), dU: fmt(Math.round(dU_J)), + W_raw: W_J, Q_raw: Q_J, dU_raw: dU_J, + process: this.process, + }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + /* ── draw ──────────────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + this._drawGrid(ctx); + this._drawBgCurves(ctx); + this._drawActiveCurve(ctx); + this._drawPoints(ctx); + this._drawInfoBox(ctx); + } + + _drawGrid(ctx) { + const { ML, MT, MR, MB } = this; + const pw = this._pw(), ph = this._ph(); + + /* plot background */ + ctx.fillStyle = 'rgba(255,255,255,0.018)'; + ctx.fillRect(ML, MT, pw, ph); + + /* grid */ + ctx.strokeStyle = 'rgba(255,255,255,0.055)'; + ctx.lineWidth = 1; ctx.setLineDash([]); + for (let v = 5; v <= 30; v += 5) { + const x = this._vx(v); + ctx.beginPath(); ctx.moveTo(x, MT); ctx.lineTo(x, MT + ph); ctx.stroke(); + } + for (let p = 1; p <= 9; p++) { + const y = this._py(p); + ctx.beginPath(); ctx.moveTo(ML, y); ctx.lineTo(ML + pw, y); ctx.stroke(); + } + + /* axes */ + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph); + ctx.stroke(); + + /* tick labels */ + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let p = 1; p <= 9; p++) { + const y = this._py(p); + if (y < MT + 2 || y > MT + ph - 2) continue; + ctx.fillText(p, ML - 6, y); + } + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let v = 5; v <= 30; v += 5) { + const x = this._vx(v); + ctx.fillText(v, x, MT + ph + 5); + } + + /* axis titles */ + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = '12px Manrope, system-ui, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('V, л', ML + pw / 2, MT + ph + 32); + ctx.save(); + ctx.translate(13, MT + ph / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText('P, атм', 0, 0); + ctx.restore(); + } + + _COLORS = { + isothermal: '#EF476F', + isochoric: '#06D6E0', + isobaric: '#7BF5A4', + adiabatic: '#FFD166', + }; + + /* draw one process curve through (P1,V1) */ + _curve(ctx, process, alpha, lw, dashed) { + const { P1, V1, gamma, Vmin, Vmax, Pmin, Pmax } = this; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = this._COLORS[process]; + ctx.lineWidth = lw; + ctx.setLineDash(dashed ? [5, 4] : []); + ctx.beginPath(); + + if (process === 'isochoric') { + const x = this._vx(V1); + ctx.moveTo(x, this._py(Pmax)); + ctx.lineTo(x, this._py(Pmin)); + } else { + let started = false; + const steps = 300; + for (let i = 0; i <= steps; i++) { + const v = Vmin + (Vmax - Vmin) * i / steps; + let p; + if (process === 'isothermal') p = P1 * V1 / v; + else if (process === 'isobaric') p = P1; + else p = P1 * Math.pow(V1 / v, gamma); // adiabatic + if (p < Pmin || p > Pmax + 0.1) { started = false; continue; } + const x = this._vx(v), y = this._py(Math.min(p, Pmax)); + if (!started) { ctx.moveTo(x, y); started = true; } + else ctx.lineTo(x, y); + } + } + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + } + + _drawBgCurves(ctx) { + for (const p of ['isothermal', 'isochoric', 'isobaric', 'adiabatic']) { + if (p !== this.process) this._curve(ctx, p, 0.14, 1.2, true); + } + /* legend dots */ + const names = { isothermal: 'Изотерма', isochoric: 'Изохора', isobaric: 'Изобара', adiabatic: 'Адиабата' }; + ctx.font = '10px Manrope, system-ui, sans-serif'; + let lx = this.ML + this._pw() - 8, ly = this.MT + 8; + ctx.textAlign = 'right'; ctx.textBaseline = 'top'; + for (const [proc, label] of Object.entries(names)) { + const col = this._COLORS[proc]; + const isCur = proc === this.process; + ctx.globalAlpha = isCur ? 0.85 : 0.3; + ctx.fillStyle = col; + ctx.beginPath(); ctx.arc(lx + 5, ly + 4, 4, 0, Math.PI * 2); ctx.fill(); + ctx.fillText(label, lx - 3, ly); + ly += 16; + } + ctx.globalAlpha = 1; + } + + _drawActiveCurve(ctx) { + /* full curve dimmed */ + this._curve(ctx, this.process, 0.3, 1.5, false); + + /* highlighted segment state1 → state2 */ + const { P1, V1, gamma } = this; + const { P2, V2 } = this._state2(); + const color = this._COLORS[this.process]; + + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2.8; + ctx.setLineDash([]); + + const steps = 200; + const [Vs, Ve] = V2 >= V1 ? [V1, V2] : [V2, V1]; + + if (this.process === 'isochoric') { + const x = this._vx(V1); + const y1c = this._py(P1), y2c = this._py(P2); + ctx.beginPath(); ctx.moveTo(x, y1c); ctx.lineTo(x, y2c); ctx.stroke(); + this._arrowHead(ctx, x, y1c, x, y2c, color); + } else { + ctx.beginPath(); + let started = false; + for (let i = 0; i <= steps; i++) { + const v = Vs + (Ve - Vs) * i / steps; + let p; + if (this.process === 'isothermal') p = P1 * V1 / v; + else if (this.process === 'isobaric') p = P1; + else p = P1 * Math.pow(V1 / v, gamma); + const x = this._vx(v), y = this._py(p); + if (!started) { ctx.moveTo(x, y); started = true; } + else ctx.lineTo(x, y); + } + ctx.stroke(); + + /* arrow at ~80% of segment */ + const vArr = Vs + (Ve - Vs) * 0.8; + const vArr2 = Vs + (Ve - Vs) * 0.82; + let p1a, p2a; + if (this.process === 'isothermal') { p1a = P1*V1/vArr; p2a = P1*V1/vArr2; } + else if (this.process === 'isobaric') { p1a = P1; p2a = P1; } + else { p1a = P1*Math.pow(V1/vArr,gamma); p2a = P1*Math.pow(V1/vArr2,gamma); } + /* ensure arrow points from 1→2 */ + const dir = V2 > V1 ? 1 : -1; + this._arrowHead(ctx, + this._vx(vArr + dir*0), this._py(p1a + dir*0), + this._vx(vArr2 + dir*0), this._py(p2a + dir*0), color); + } + ctx.restore(); + } + + _arrowHead(ctx, x1, y1, x2, y2, color) { + const angle = Math.atan2(y2 - y1, x2 - x1); + const s = 10; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - s * Math.cos(angle - 0.4), y2 - s * Math.sin(angle - 0.4)); + ctx.lineTo(x2 - s * Math.cos(angle + 0.4), y2 - s * Math.sin(angle + 0.4)); + ctx.closePath(); ctx.fill(); + } + + _drawPoints(ctx) { + const { P2, V2 } = this._state2(); + const color = this._COLORS[this.process]; + + const dot = (x, y, fill, label, textX, textY) => { + ctx.fillStyle = fill; + ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke(); + ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = fill; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(label, textX, textY); + }; + + const x1 = this._vx(this.V1), y1 = this._py(this.P1); + const x2 = this._vx(V2), y2 = this._py(P2); + dot(x1, y1, '#9B5DE5', '1', x1 - 12, y1 - 4); + dot(x2, y2, color, '2', x2 + 12, y2 - 4); + } + + _drawInfoBox(ctx) { + const info = this.info(); + const color = this._COLORS[info.process]; + const names = { isothermal:'Изотермический', isochoric:'Изохорный', isobaric:'Изобарный', adiabatic:'Адиабатический' }; + const formulas = { isothermal:'PV = const', isochoric:'V = const', isobaric:'P = const', adiabatic:'PV^γ = const' }; + + const bx = this.ML + 6, by = this.MT + 6; + const boxW = 205, boxH = 98; + + ctx.fillStyle = 'rgba(13,13,26,0.9)'; + ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); + + ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText(`${names[info.process]} ${formulas[info.process]}`, bx + 10, by + 8); + + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillText(`T₁ = ${info.T1} K → T₂ = ${info.T2} K`, bx + 10, by + 28); + + const wColor = info.W_raw > 0 ? '#7BF5A4' : info.W_raw < 0 ? '#EF476F' : 'rgba(255,255,255,0.4)'; + const qColor = info.Q_raw > 0 ? '#FFD166' : info.Q_raw < 0 ? '#06D6E0' : 'rgba(255,255,255,0.4)'; + + ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('W =', bx + 10, by + 48); + ctx.fillStyle = wColor; ctx.fillText(`${info.W} Дж`, bx + 38, by + 48); + + ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('Q =', bx + 10, by + 65); + ctx.fillStyle = qColor; ctx.fillText(`${info.Q} Дж`, bx + 38, by + 65); + + ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fillText('ΔU =', bx + 10, by + 82); + ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillText(`${info.dU} Дж`, bx + 40, by + 82); + } + + /* ── events ─────────────────────────────────── */ + + _bindEvents() { + const cv = this.canvas; + + const pos = e => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { + px: (t.clientX - r.left) * (this.W / r.width), + py: (t.clientY - r.top) * (this.H / r.height), + }; + }; + + const hit = (px, py) => { + const x1 = this._vx(this.V1), y1 = this._py(this.P1); + if (Math.hypot(px - x1, py - y1) < 18) return 'state1'; + const { P2, V2 } = this._state2(); + const x2 = this._vx(V2), y2 = this._py(P2); + if (Math.hypot(px - x2, py - y2) < 18) return 'state2'; + return null; + }; + + const clampV = v => Math.max(this.Vmin + 0.5, Math.min(this.Vmax - 0.5, v)); + const clampP = p => Math.max(this.Pmin + 0.1, Math.min(this.Pmax - 0.1, p)); + + const onDown = e => { const { px, py } = pos(e); this._drag = hit(px, py); }; + + const onMove = e => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const { px, py } = pos(e); + const v = this._xv(px), p = this._yp(py); + + if (this._drag === 'state1') { + this.V1 = clampV(v); this.P1 = clampP(p); + } else { + /* constrain state2 to current process curve */ + switch (this.process) { + case 'isothermal': case 'isobaric': case 'adiabatic': { + const V2 = clampV(v); + this._ratio = Math.max(0.01, Math.min(0.99, (V2 / this.V1 - 0.2) / 3.3)); + break; + } + case 'isochoric': { + const P2 = clampP(p); + this._ratio = Math.max(0.01, Math.min(0.99, (P2 / this.P1 - 0.2) / 3.3)); + break; + } + } + } + this.draw(); this._emit(); + }; + + const onUp = () => { this._drag = null; }; + + cv.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); + cv.addEventListener('touchmove', e => onMove(e), { passive: false }); + cv.addEventListener('touchend', onUp); + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor = 'grabbing'; return; } + const { px, py } = pos(e); + cv.style.cursor = hit(px, py) ? 'grab' : 'default'; + }); + } +} diff --git a/frontend/js/labs/magnetic.js b/frontend/js/labs/magnetic.js new file mode 100644 index 0000000..b2e3cae --- /dev/null +++ b/frontend/js/labs/magnetic.js @@ -0,0 +1,1055 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════ + MagneticSim — magnetic field of current-carrying wires + • Click canvas to place wire (• out / × in) + • Drag to reposition, double-click / right-click to remove + • Layers: colour map, field lines, vector arrows + • Particle: charged particle with Lorentz force (circular) + B field of wire at (x0,y0) with current I: + Bx = -k·I·(y-y0)/r², By = k·I·(x-x0)/r² + Colour map maps angle of B hue, magnitude brightness +══════════════════════════════════════════════════════════ */ + +class MagneticSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.sources = []; // {id, x, y, I} + this._nextId = 1; + this.curI = 6; // current magnitude (user-adjustable) + this.addMode = 'out'; // 'out' | 'in' + + /* particle */ + this._particle = null; + this.particleOn = false; + this._pRaf = null; + this._pLast = 0; + + /* layers */ + this.layers = { colormap: true, fieldlines: true, vectors: false }; + + /* conductor (проводник в поле) */ + this._cond = { + on: false, + x1: 0, y1: 0, x2: 0, y2: 0, // set in fit() + I: 8, // conductor current + _dragEndpoint: null, // 0 | 1 | 'body' | null + }; + + /* magnetic flux indicator (круг потока) */ + this._flux = { + on: false, + x: 0, y: 0, // set in fit() + r: 55, + _dragging: false, + }; + + /* cursor B reading */ + this._cursorB = null; // {x, y, bx, by, mag} + this._mousePos = null; + + /* interaction */ + this._drag = null; // index into sources[] + this._hovered = null; + + /* offscreen canvas for pixel-level colour map */ + this._oc = null; // created in fit() + this._ocW = 0; + this._ocH = 0; + + this.W = 0; this.H = 0; + this.onUpdate = null; + + this._bindEvents(); + } + + /* ──────────────────────────────── + Sizing + ──────────────────────────────── */ + + fit() { + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = rect.width; + this.H = rect.height; + + const DS = 4; // downsample factor for colour map + this._ocW = Math.ceil(this.W / DS); + this._ocH = Math.ceil(this.H / DS); + this._oc = document.createElement('canvas'); + this._oc.width = this._ocW; + this._oc.height = this._ocH; + + this._initOverlays(); + + /* position conductor + flux relative to canvas */ + if (this.W) { + this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5; + this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5; + this._flux.x = this.W * 0.5; this._flux.y = this.H * 0.35; + } + + this.draw(); + } + + /* init conductor / flux positions on first fit */ + _initOverlays() { + this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5; + this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5; + this._flux.x = this.W * 0.5; this._flux.y = this.H * 0.35; + } + + /* ──────────────────────────────── + Field calculation + ──────────────────────────────── */ + + _K = 8000; // visual scaling constant + + _field(px, py) { + let bx = 0, by = 0; + for (const s of this.sources) { + const dx = px - s.x, dy = py - s.y; + const r2 = dx * dx + dy * dy; + if (r2 < 4) continue; + const k = this._K * s.I / r2; + bx -= k * dy; + by += k * dx; + } + const mag = Math.hypot(bx, by); + return { bx, by, mag }; + } + + _fieldNorm(px, py) { + const { bx, by, mag } = this._field(px, py); + if (mag < 1e-12) return { nx: 0, ny: 0, mag: 0 }; + return { nx: bx / mag, ny: by / mag, mag }; + } + + /* RK4 step for field-line tracing */ + _rk4(x, y, step) { + const f = (xx, yy) => this._fieldNorm(xx, yy); + const k1 = f(x, y); + const k2 = f(x + step * k1.nx * 0.5, y + step * k1.ny * 0.5); + const k3 = f(x + step * k2.nx * 0.5, y + step * k2.ny * 0.5); + const k4 = f(x + step * k3.nx, y + step * k3.ny); + return { + nx: (k1.nx + 2*k2.nx + 2*k3.nx + k4.nx) / 6, + ny: (k1.ny + 2*k2.ny + 2*k3.ny + k4.ny) / 6, + }; + } + + /* ──────────────────────────────── + Source management + ──────────────────────────────── */ + + addSource(x, y, dir) { + this.sources.push({ + id: this._nextId++, + x, y, + I: dir === 'out' ? +this.curI : -this.curI, + }); + this._invalidateCache(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + removeSource(id) { + this.sources = this.sources.filter(s => s.id !== id); + this._invalidateCache(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + clearAll() { + this.sources = []; + this._particle = null; + this._invalidateCache(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + setCurrentAll(I) { + this.curI = I; + this.sources.forEach(s => { s.I = s.I > 0 ? I : -I; }); + this._invalidateCache(); + this.draw(); + } + + /* Invalidate precomputed colour map cache */ + _invalidateCache() { this._cmapDirty = true; } + + /* ── conductor & flux toggles ── */ + + toggleConductor() { + this._cond.on = !this._cond.on; + this.draw(); + } + + setConductorI(I) { + this._cond.I = I; + this.draw(); + } + + toggleFlux() { + this._flux.on = !this._flux.on; + this.draw(); + } + + /* Ampere force on conductor: F = I·(L×B) + L = conductor vector, B from wire sources at midpoint + In 3D with B in xy-plane: F = (0, 0, I*(Lx*By - Ly*Bx)) [force in z] + We display Fz magnitude + direction (⊙ out / ⊗ in) */ + _ampereForce() { + const c = this._cond; + const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1; + const L = Math.hypot(Lx, Ly); + if (L < 1) return { Fz: 0, L, B: 0 }; + const mx = (c.x1 + c.x2) / 2, my = (c.y1 + c.y2) / 2; + const { bx, by, mag } = this._field(mx, my); + // F_z = I*(Lx*By - Ly*Bx) — in "visual units" + const Fz = c.I * (Lx * by - Ly * bx) * 0.0001; + return { Fz, L: L / 100, B: mag, bx, by, mx, my }; + } + + /* Magnetic flux through indicator circle: Φ ≈ |B_avg|·πr² */ + _fluxValue() { + const f = this._flux; + const { mag } = this._field(f.x, f.y); + return mag * Math.PI * f.r * f.r * 0.000001; // visual units + } + + /* Preset arrangements */ + preset(name) { + this.sources = []; + const cx = this.W / 2, cy = this.H / 2, d = 90; + switch (name) { + case 'single': + this.sources.push({ id: this._nextId++, x: cx, y: cy, I: +this.curI }); + break; + case 'parallel': + this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI }); + this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI }); + break; + case 'anti': + this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI }); + this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: -this.curI }); + break; + case 'solenoid': { + const cols = 5, rows = 2, gx = 60, gy = 70; + for (let c = 0; c < cols; c++) { + const x = cx + (c - (cols-1)/2) * gx; + this.sources.push({ id: this._nextId++, x, y: cy - gy/2, I: +this.curI }); + this.sources.push({ id: this._nextId++, x, y: cy + gy/2, I: -this.curI }); + } + break; + } + case 'quadrupole': + this.sources.push({ id: this._nextId++, x: cx - d, y: cy, I: +this.curI }); + this.sources.push({ id: this._nextId++, x: cx + d, y: cy, I: +this.curI }); + this.sources.push({ id: this._nextId++, x: cx, y: cy - d, I: -this.curI }); + this.sources.push({ id: this._nextId++, x: cx, y: cy + d, I: -this.curI }); + break; + case 'ring': { + const n = 8, r = 110; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + const dir = i % 2 === 0 ? +this.curI : -this.curI; + this.sources.push({ id: this._nextId++, + x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r, I: dir }); + } + break; + } + case 'dipole': + this.sources.push({ id: this._nextId++, x: cx - 60, y: cy, I: +this.curI * 1.5 }); + this.sources.push({ id: this._nextId++, x: cx + 60, y: cy, I: -this.curI * 1.5 }); + this.sources.push({ id: this._nextId++, x: cx - 60, y: cy - 50, I: +this.curI * 0.5 }); + this.sources.push({ id: this._nextId++, x: cx + 60, y: cy - 50, I: -this.curI * 0.5 }); + this.sources.push({ id: this._nextId++, x: cx - 60, y: cy + 50, I: +this.curI * 0.5 }); + this.sources.push({ id: this._nextId++, x: cx + 60, y: cy + 50, I: -this.curI * 0.5 }); + break; + } + this._invalidateCache(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ──────────────────────────────── + Particle (Lorentz force) + F = q(v × B) + Treat |B_xy| as Bz (educational approximation for 2D): + Fx = q·vy·Bz, Fy = -q·vx·Bz + ──────────────────────────────── */ + + toggleParticle() { + this.particleOn = !this.particleOn; + if (this.particleOn) { + this._initParticle(); + this._pLast = performance.now(); + this._tickParticle(); + } else { + if (this._pRaf) cancelAnimationFrame(this._pRaf); + this._pRaf = null; + this._particle = null; + this.draw(); + } + if (this.onUpdate) this.onUpdate(this.info()); + } + + _initParticle() { + this._particle = { + x: this.W * 0.18, y: this.H * 0.5, + vx: 2.2, vy: 0, + q: 1, + trail: [], + }; + } + + _tickParticle() { + if (!this.particleOn || !this._particle) return; + const now = performance.now(); + const dt = Math.min((now - this._pLast) * 0.06, 2.5); + this._pLast = now; + + const p = this._particle; + const { mag } = this._field(p.x, p.y); + const Bz = mag * 0.00012 * p.q; + + const spd = Math.hypot(p.vx, p.vy); + + // Lorentz (2D): Fx = q·vy·Bz, Fy = -q·vx·Bz + p.vx += p.q * p.vy * Bz * dt; + p.vy -= p.q * p.vx * Bz * dt; + + // Conserve speed (magnetic force does no work) + const newSpd = Math.hypot(p.vx, p.vy); + if (newSpd > 1e-6) { p.vx = p.vx / newSpd * spd; p.vy = p.vy / newSpd * spd; } + + p.x += p.vx * dt; + p.y += p.vy * dt; + + // Bounce walls + if (p.x < 4) { p.vx = Math.abs(p.vx); p.x = 4; } + if (p.x > this.W - 4) { p.vx = -Math.abs(p.vx); p.x = this.W - 4; } + if (p.y < 4) { p.vy = Math.abs(p.vy); p.y = 4; } + if (p.y > this.H - 4) { p.vy = -Math.abs(p.vy); p.y = this.H - 4; } + + p.trail.push({ x: p.x, y: p.y }); + if (p.trail.length > 350) p.trail.shift(); + + this.draw(); + this._pRaf = requestAnimationFrame(() => this._tickParticle()); + } + + /* ──────────────────────────────── + Info + ──────────────────────────────── */ + + info() { + const out = this.sources.filter(s => s.I > 0).length; + const inn = this.sources.filter(s => s.I < 0).length; + const condOn = this._cond.on; + const fluxOn = this._flux.on; + const ampere = condOn ? this._ampereForce() : null; + const Fz = ampere ? ampere.Fz : 0; + const flux = fluxOn ? this._fluxValue() : 0; + const cursorB = this._cursorB ? this._cursorB.mag : null; + return { total: this.sources.length, out, inn, particleOn: this.particleOn, + condOn, fluxOn, Fz, flux, cursorB }; + } + + /* ──────────────────────────────── + Events + ──────────────────────────────── */ + + _bindEvents() { + const c = this.canvas; + + const pos = e => { + const r = c.getBoundingClientRect(); + const s = e.touches ? e.touches[0] : e; + return { x: s.clientX - r.left, y: s.clientY - r.top }; + }; + + const hitIdx = p => { + for (let i = this.sources.length - 1; i >= 0; i--) { + if (Math.hypot(p.x - this.sources[i].x, p.y - this.sources[i].y) < 22) return i; + } + return -1; + }; + + /* hit test conductor endpoints / body */ + const hitCond = p => { + if (!this._cond.on) return null; + const { x1, y1, x2, y2 } = this._cond; + if (Math.hypot(p.x - x1, p.y - y1) < 16) return 0; + if (Math.hypot(p.x - x2, p.y - y2) < 16) return 1; + // check midpoint drag + const mx = (x1+x2)/2, my = (y1+y2)/2; + if (Math.hypot(p.x - mx, p.y - my) < 14) return 'body'; + return null; + }; + + /* hit test flux circle */ + const hitFlux = p => { + if (!this._flux.on) return false; + return Math.hypot(p.x - this._flux.x, p.y - this._flux.y) < this._flux.r + 12; + }; + + let _mousedownPos = null; + let _condDragOffset = null; + + c.addEventListener('mousedown', e => { + if (e.button !== 0) return; + const p = pos(e); + _mousedownPos = p; + + /* conductor endpoint drag */ + const ch = hitCond(p); + if (ch !== null) { + this._cond._dragEndpoint = ch; + if (ch === 'body') { + _condDragOffset = { dx: p.x - this._cond.x1, dy: p.y - this._cond.y1, + len: Math.hypot(this._cond.x2-this._cond.x1, this._cond.y2-this._cond.y1) }; + } + c.style.cursor = 'grabbing'; + return; + } + + /* flux drag */ + if (hitFlux(p)) { + this._flux._dragging = true; + c.style.cursor = 'grabbing'; + return; + } + + const i = hitIdx(p); + if (i >= 0) { + this._drag = i; + c.style.cursor = 'grabbing'; + } + }); + + c.addEventListener('mousemove', e => { + const p = pos(e); + + /* update cursor B reading */ + if (!e.buttons) { + if (this.sources.length > 0) { + const f = this._field(p.x, p.y); + this._cursorB = { x: p.x, y: p.y, ...f }; + if (this.onUpdate) this.onUpdate(this.info()); + } + this._mousePos = p; + } + + /* conductor drag */ + if (this._cond._dragEndpoint !== null) { + const ep = this._cond._dragEndpoint; + if (ep === 0) { this._cond.x1 = p.x; this._cond.y1 = p.y; } + else if (ep === 1) { this._cond.x2 = p.x; this._cond.y2 = p.y; } + else if (ep === 'body') { + const L = _condDragOffset.len; + const dx = this._cond.x2 - this._cond.x1, dy = this._cond.y2 - this._cond.y1; + const nx = dx / Math.hypot(dx, dy), ny = dy / Math.hypot(dx, dy); + this._cond.x1 = p.x - _condDragOffset.dx; + this._cond.y1 = p.y - _condDragOffset.dy; + this._cond.x2 = this._cond.x1 + nx * L; + this._cond.y2 = this._cond.y1 + ny * L; + } + this.draw(); + return; + } + + /* flux drag */ + if (this._flux._dragging) { + this._flux.x = p.x; this._flux.y = p.y; + this.draw(); return; + } + + if (this._drag !== null) { + this.sources[this._drag].x = p.x; + this.sources[this._drag].y = p.y; + this._invalidateCache(); + this.draw(); + return; + } + const i = hitIdx(p); + const ch = hitCond(p); + const fh = hitFlux(p); + this._hovered = i >= 0 ? i : null; + c.style.cursor = (i >= 0 || ch !== null || fh) ? 'grab' : 'crosshair'; + }); + + c.addEventListener('mouseup', e => { + const p = pos(e); + const moved = _mousedownPos && + Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 5; + + if (this._cond._dragEndpoint !== null) { + this._cond._dragEndpoint = null; c.style.cursor = 'crosshair'; this.draw(); return; + } + if (this._flux._dragging) { + this._flux._dragging = false; c.style.cursor = 'crosshair'; return; + } + + if (this._drag !== null) { + this._invalidateCache(); + this._drag = null; + c.style.cursor = 'crosshair'; + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + return; + } + + // Click (not drag) on empty space add source + if (!moved && e.button === 0 && hitIdx(p) < 0 && + hitCond(p) === null && !hitFlux(p)) { + this.addSource(p.x, p.y, this.addMode); + } + }); + + c.addEventListener('dblclick', e => { + const p = pos(e); + const i = hitIdx(p); + if (i >= 0) this.removeSource(this.sources[i].id); + }); + + c.addEventListener('contextmenu', e => { + e.preventDefault(); + const p = pos(e); + const i = hitIdx(p); + if (i >= 0) this.removeSource(this.sources[i].id); + }); + + c.addEventListener('touchstart', e => { + e.preventDefault(); + _mousedownPos = pos(e); + const i = hitIdx(_mousedownPos); + if (i >= 0) this._drag = i; + }, { passive: false }); + + c.addEventListener('touchmove', e => { + e.preventDefault(); + if (this._drag === null) return; + const p = pos(e); + this.sources[this._drag].x = p.x; + this.sources[this._drag].y = p.y; + this._invalidateCache(); + this.draw(); + }, { passive: false }); + + c.addEventListener('touchend', e => { + const p = e.changedTouches ? pos({ ...e, touches: e.changedTouches }) : null; + const moved = _mousedownPos && p && + Math.hypot(p.x - _mousedownPos.x, p.y - _mousedownPos.y) > 8; + if (this._drag === null && !moved && p) { + this.addSource(p.x, p.y, this.addMode); + } + this._drag = null; + if (this.onUpdate) this.onUpdate(this.info()); + }); + } + + /* ──────────────────────────────── + Drawing + ──────────────────────────────── */ + + draw() { + const ctx = this.ctx; + const W = this.W, H = this.H; + if (!W || !H) return; + + ctx.clearRect(0, 0, W, H); + + // Background + const bg = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.7); + bg.addColorStop(0, '#080818'); + bg.addColorStop(1, '#030308'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + this._drawGrid(ctx, W, H); + + if (this.sources.length > 0) { + if (this.layers.colormap) this._drawColormap(ctx); + if (this.layers.fieldlines) this._drawFieldLines(ctx); + if (this.layers.vectors) this._drawVectors(ctx); + } + + if (this._flux.on) this._drawFlux(ctx); + if (this._cond.on) this._drawConductor(ctx); + if (this._particle) this._drawParticle(ctx); + this._drawSources(ctx); + if (this._cursorB && this.sources.length > 0) this._drawCursorB(ctx); + + if (this.sources.length === 0) this._drawHint(ctx, W, H); + } + + /* ── grid ── */ + _drawGrid(ctx, W, H) { + ctx.save(); + ctx.strokeStyle = 'rgba(155,93,229,0.055)'; ctx.lineWidth = 1; + for (let x = 0; x <= W; x += 50) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); + } + for (let y = 0; y <= H; y += 50) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + ctx.restore(); + } + + /* ── colour map (hue = angle of B, brightness = log|B|) ── */ + _cmapDirty = true; + + _drawColormap(ctx) { + if (!this._oc) return; + const DS = 4; + const oc = this._oc; + const oct = oc.getContext('2d'); + const w = this._ocW, h = this._ocH; + + if (this._cmapDirty) { + const imgData = oct.createImageData(w, h); + const data = imgData.data; + + for (let py = 0; py < h; py++) { + for (let px = 0; px < w; px++) { + const wx = px * DS, wy = py * DS; + const { bx, by, mag } = this._field(wx, wy); + if (mag < 0.5) continue; + + const angle = Math.atan2(by, bx); // -π…π + const hue = ((angle / (2 * Math.PI) + 1) % 1) * 360; + const bright = Math.min(1, Math.log10(1 + mag * 0.005) * 0.55); + const alpha = Math.round(bright * 210); + + const [r, g, b] = this._hsl(hue / 360, 0.90, 0.38 + bright * 0.28); + const idx = (py * w + px) * 4; + data[idx] = r; + data[idx+1] = g; + data[idx+2] = b; + data[idx+3] = alpha; + } + } + oct.putImageData(imgData, 0, 0); + this._cmapDirty = false; + } + + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(oc, 0, 0, w * DS, h * DS); + ctx.restore(); + } + + _hsl(h, s, l) { + let r, g, b; + if (s === 0) { r = g = b = l; } + else { + const q = l < 0.5 ? l*(1+s) : l+s-l*s, p = 2*l - q; + const hue2 = (p, q, t) => { + t = ((t % 1) + 1) % 1; + if (t < 1/6) return p + (q-p)*6*t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q-p)*(2/3-t)*6; + return p; + }; + r = hue2(p, q, h + 1/3); g = hue2(p, q, h); b = hue2(p, q, h - 1/3); + } + return [Math.round(r*255), Math.round(g*255), Math.round(b*255)]; + } + + /* ── field lines ── */ + _drawFieldLines(ctx) { + if (!this.sources.length) return; + const maxSteps = 700; + const step = 5; + const killR = 24; + + ctx.save(); + + for (const src of this.sources) { + const isOut = src.I > 0; + const col = isOut ? '6,214,224' : '241,91,181'; + const nLines = 14; + const seedR = 26; + + for (let li = 0; li < nLines; li++) { + const ang = (li / nLines) * Math.PI * 2; + let x = src.x + Math.cos(ang) * seedR; + let y = src.y + Math.sin(ang) * seedR; + + const pts = [{ x, y }]; + let travelledSq = 0; + + for (let st = 0; st < maxSteps; st++) { + const { nx, ny } = this._rk4(x, y, step); + x += step * nx; + y += step * ny; + travelledSq += step * step; + + if (x < -60 || x > this.W + 60 || y < -60 || y > this.H + 60) break; + + /* stop near another source */ + let nearOther = false; + for (const s2 of this.sources) { + if (s2 === src) continue; + if (Math.hypot(x - s2.x, y - s2.y) < killR) { nearOther = true; break; } + } + if (nearOther) { pts.push({ x, y }); break; } + + /* stop looping back to origin */ + if (st > 20 && Math.hypot(x - src.x, y - src.y) < killR) break; + + pts.push({ x, y }); + } + + if (pts.length < 3) continue; + + /* draw with glow */ + ctx.shadowColor = `rgba(${col},0.5)`; + ctx.shadowBlur = 7; + ctx.strokeStyle = `rgba(${col},0.65)`; + ctx.lineWidth = 1.6; + + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi].x, pts[pi].y); + ctx.stroke(); + + /* arrowheads every ~85 px */ + this._drawArrows(ctx, pts, col); + } + } + ctx.restore(); + } + + _drawArrows(ctx, pts, col) { + let acc = 0, next = 80; + for (let i = 1; i < pts.length; i++) { + const dx = pts[i].x - pts[i-1].x, dy = pts[i].y - pts[i-1].y; + acc += Math.hypot(dx, dy); + if (acc < next) continue; + next += 85; + const ang = Math.atan2(dy, dx); + ctx.save(); + ctx.translate(pts[i].x, pts[i].y); + ctx.rotate(ang); + ctx.shadowColor = `rgba(${col},0.9)`; ctx.shadowBlur = 6; + ctx.fillStyle = `rgba(${col},0.90)`; + ctx.beginPath(); + ctx.moveTo(7, 0); ctx.lineTo(-5, -4); ctx.lineTo(-3, 0); ctx.lineTo(-5, 4); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + } + + /* ── vector field ── */ + _drawVectors(ctx) { + if (!this.sources.length) return; + const step = 42; + ctx.save(); + for (let px = step * 0.5; px < this.W; px += step) { + for (let py = step * 0.5; py < this.H; py += step) { + const { bx, by, mag } = this._field(px, py); + if (mag < 1) continue; + const t = Math.min(1, Math.log10(1 + mag * 0.006) / 1.4); + const len = 8 + t * 14; + const nx = bx / mag, ny = by / mag; + const alp = 0.28 + t * 0.6; + + ctx.save(); + ctx.translate(px, py); + ctx.rotate(Math.atan2(ny, nx)); + ctx.globalAlpha = alp; + ctx.strokeStyle = `rgba(${Math.round(155+t*100)},${Math.round(93+t*121)},229,1)`; + ctx.lineWidth = 1.1 + t * 0.6; + ctx.beginPath(); ctx.moveTo(-len/2, 0); ctx.lineTo(len/2, 0); ctx.stroke(); + ctx.fillStyle = ctx.strokeStyle; + ctx.beginPath(); + ctx.moveTo(len/2, 0); ctx.lineTo(len/2-5, -2.5); ctx.lineTo(len/2-5, 2.5); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + } + ctx.restore(); + } + + /* ── sources ── */ + _drawSources(ctx) { + this.sources.forEach((s, i) => { + const isOut = s.I > 0; + const col = isOut ? '#06D6E0' : '#F15BB5'; + const rgb = isOut ? '6,214,224' : '241,91,181'; + const isHov = this._hovered === i || this._drag === i; + const R = isHov ? 19 : 16; + + ctx.save(); + ctx.shadowColor = col; ctx.shadowBlur = isHov ? 32 : 18; + + /* halo ring */ + ctx.beginPath(); ctx.arc(s.x, s.y, R + 6, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${rgb},0.08)`; ctx.fill(); + + /* body disc */ + ctx.beginPath(); ctx.arc(s.x, s.y, R, 0, Math.PI * 2); + ctx.fillStyle = isHov ? `rgba(${rgb},0.25)` : 'rgba(5,5,20,0.9)'; + ctx.fill(); + ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.stroke(); + + /* symbol */ + if (isOut) { + /* dot = current toward viewer */ + ctx.beginPath(); ctx.arc(s.x, s.y, 5, 0, Math.PI * 2); + ctx.fillStyle = col; ctx.shadowBlur = 8; ctx.fill(); + } else { + /* × = current away from viewer */ + const d = 5.5; + ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.shadowBlur = 6; + ctx.beginPath(); + ctx.moveTo(s.x - d, s.y - d); ctx.lineTo(s.x + d, s.y + d); + ctx.moveTo(s.x + d, s.y - d); ctx.lineTo(s.x - d, s.y + d); + ctx.stroke(); + } + + /* current label below */ + ctx.shadowBlur = 0; + ctx.font = '10px Manrope, sans-serif'; + ctx.fillStyle = `rgba(${rgb},0.75)`; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText((isOut ? '↑' : '↓') + ' ' + Math.abs(s.I).toFixed(0) + ' А', s.x, s.y + R + 5); + + ctx.restore(); + }); + } + + /* ── particle ── */ + _drawParticle(ctx) { + const p = this._particle; + if (!p) return; + + /* trail */ + if (p.trail.length > 1) { + ctx.save(); + for (let i = 1; i < p.trail.length; i++) { + const t = i / p.trail.length; + ctx.beginPath(); + ctx.moveTo(p.trail[i-1].x, p.trail[i-1].y); + ctx.lineTo(p.trail[i].x, p.trail[i].y); + ctx.strokeStyle = `rgba(255,255,80,${t * 0.55})`; + ctx.lineWidth = t * 2.5; + ctx.stroke(); + } + ctx.restore(); + } + + /* glow aura */ + const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 16); + grad.addColorStop(0, 'rgba(255,255,80,0.35)'); + grad.addColorStop(1, 'rgba(255,255,80,0)'); + ctx.save(); ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(p.x, p.y, 16, 0, Math.PI*2); ctx.fill(); ctx.restore(); + + /* body */ + ctx.save(); + ctx.shadowColor = '#ffff50'; ctx.shadowBlur = 18; + ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, Math.PI*2); + ctx.fillStyle = '#ffff50'; ctx.fill(); + ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.8; ctx.stroke(); + ctx.restore(); + + /* velocity arrow */ + const spd = Math.hypot(p.vx, p.vy); + if (spd > 0.01) { + const s = 22; + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,80,0.7)'; ctx.lineWidth = 1.8; + ctx.shadowColor = '#ffff50'; ctx.shadowBlur = 8; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x + p.vx / spd * s, p.y + p.vy / spd * s); + ctx.stroke(); + ctx.restore(); + } + } + + /* ── empty hint ── */ + /* ── conductor (проводник в поле — Сила Ампера) ── */ + _drawConductor(ctx) { + const c = this._cond; + const Lx = c.x2 - c.x1, Ly = c.y2 - c.y1; + const L = Math.hypot(Lx, Ly); + if (L < 2) return; + + const { Fz, B, bx, by, mx, my } = this._ampereForce(); + const Fabs = Math.abs(Fz); + const fOut = Fz > 0; // force out of screen (⊙) vs into screen (⊗) + + ctx.save(); + + /* glow under conductor */ + ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 14; + ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 5; + ctx.globalAlpha = 0.35; + ctx.beginPath(); ctx.moveTo(c.x1, c.y1); ctx.lineTo(c.x2, c.y2); ctx.stroke(); + + /* main conductor line */ + ctx.globalAlpha = 1; ctx.shadowBlur = 6; + ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(c.x1, c.y1); ctx.lineTo(c.x2, c.y2); ctx.stroke(); + + /* current direction arrows along conductor */ + const steps = Math.floor(L / 55); + ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 5; + for (let s = 0; s <= steps; s++) { + const t = (s + 0.5) / (steps + 1); + const ax = c.x1 + Lx * t, ay = c.y1 + Ly * t; + const ang = c.I > 0 ? Math.atan2(Ly, Lx) : Math.atan2(-Ly, -Lx); + ctx.save(); ctx.translate(ax, ay); ctx.rotate(ang); + ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(-5,-4); ctx.lineTo(-5,4); ctx.closePath(); + ctx.fill(); ctx.restore(); + } + + /* endpoints handle dots */ + [[c.x1, c.y1], [c.x2, c.y2]].forEach(([ex, ey]) => { + ctx.beginPath(); ctx.arc(ex, ey, 8, 0, Math.PI*2); + ctx.fillStyle = '#F15BB5'; ctx.shadowBlur = 10; ctx.fill(); + ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); + }); + + /* B vector at midpoint */ + if (B > 0.5 && this.sources.length) { + const bScale = Math.min(40, Math.log10(1 + B * 0.02) * 50); + const bNorm = Math.hypot(bx, by); + const bnx = bx/bNorm, bny = by/bNorm; + ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.5; ctx.shadowColor = '#22d55e'; + ctx.beginPath(); ctx.moveTo(mx, my); + ctx.lineTo(mx + bnx*bScale, my + bny*bScale); ctx.stroke(); + ctx.fillStyle = '#22d55e'; + ctx.font = '10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText('B⃗', mx + bnx*(bScale+10), my + bny*(bScale+10)); + } + + /* Ampere force symbols along conductor */ + if (Fabs > 1e-6) { + const sym = fOut ? '⊙' : '⊗'; + const symCol = fOut ? '#06D6E0' : '#ff6060'; + const symSize = Math.min(22, 8 + Fabs * 200); + const perpX = -Ly / L, perpY = Lx / L; // perpendicular to conductor + const offset = fOut ? -35 : 35; // visual direction hint + + ctx.font = `${symSize}px Manrope`; + ctx.fillStyle = symCol; ctx.shadowColor = symCol; ctx.shadowBlur = 10; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const symCount = Math.max(1, Math.min(5, Math.floor(L / 80))); + for (let s = 0; s < symCount; s++) { + const t = (s + 0.5) / symCount; + ctx.fillText(sym, + c.x1 + Lx*t + perpX * 22, + c.y1 + Ly*t + perpY * 22); + } + + /* force magnitude label */ + ctx.font = 'bold 11px Manrope'; ctx.shadowBlur = 5; + ctx.fillStyle = symCol; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('F = ' + Fabs.toFixed(3) + ' (ед)', c.x2 + 12, c.y2); + } + + /* current label */ + ctx.shadowBlur = 0; + ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(241,91,181,0.8)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + const ang2 = Math.atan2(Ly, Lx); + ctx.fillText('I = ' + c.I + ' А', mx - Math.sin(ang2)*20, my + Math.cos(ang2)*(-20)); + + ctx.restore(); + } + + /* ── flux circle (магнитный поток) ── */ + _drawFlux(ctx) { + const f = this._flux; + const Phi = this._fluxValue(); + const { mag } = this._field(f.x, f.y); + const brightness = Math.min(1, Math.log10(1 + mag * 0.003) * 0.7); + + ctx.save(); + + /* filled circle — colour by field strength */ + const grad = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r); + grad.addColorStop(0, `rgba(255,220,50,${brightness * 0.4})`); + grad.addColorStop(0.6, `rgba(155,93,229,${brightness * 0.15})`); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI*2); ctx.fill(); + + /* dashed border */ + ctx.strokeStyle = 'rgba(255,220,50,0.7)'; ctx.lineWidth = 1.8; + ctx.setLineDash([6, 4]); ctx.shadowColor = '#ffdc32'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI*2); ctx.stroke(); + ctx.setLineDash([]); + + /* centre dot */ + ctx.beginPath(); ctx.arc(f.x, f.y, 4, 0, Math.PI*2); + ctx.fillStyle = '#ffdc32'; ctx.fill(); + + /* flux label */ + ctx.font = 'bold 11px Manrope'; ctx.fillStyle = '#ffdc32'; + ctx.shadowColor = '#ffdc32'; ctx.shadowBlur = 6; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('Φ = ' + Phi.toFixed(4) + ' Вб', f.x, f.y + f.r + 6); + ctx.fillText('|B| = ' + mag.toFixed(1) + ' (ед)', f.x, f.y + f.r + 20); + + ctx.restore(); + } + + /* ── B value at cursor ── */ + _drawCursorB(ctx) { + const b = this._cursorB; + if (!b || !this._mousePos) return; + const { x, y, mag, bx, by } = b; + if (mag < 0.5) return; + + ctx.save(); + /* small circle */ + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; + ctx.setLineDash([3,3]); ctx.beginPath(); ctx.arc(x, y, 14, 0, Math.PI*2); ctx.stroke(); + ctx.setLineDash([]); + + /* B direction arrow */ + const bNorm = Math.hypot(bx, by); + const len = Math.min(28, Math.log10(1 + mag * 0.01) * 35); + const bnx = bx/bNorm, bny = by/bNorm; + const col = 'rgba(255,255,255,0.6)'; + ctx.strokeStyle = col; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + bnx*len, y + bny*len); ctx.stroke(); + ctx.fillStyle = col; + const a = Math.atan2(bny, bnx); + const tx = x + bnx*len, ty = y + bny*len; + ctx.beginPath(); ctx.moveTo(tx,ty); + ctx.lineTo(tx - 6*Math.cos(a-0.4), ty - 6*Math.sin(a-0.4)); + ctx.lineTo(tx - 6*Math.cos(a+0.4), ty - 6*Math.sin(a+0.4)); + ctx.closePath(); ctx.fill(); + + /* label */ + ctx.font = '9px Manrope'; ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('|B|=' + mag.toFixed(0), x + 18, y - 8); + + ctx.restore(); + } + + _drawHint(ctx, W, H) { + ctx.save(); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.font = '16px Manrope, sans-serif'; + ctx.fillStyle = 'rgba(155,93,229,0.45)'; + ctx.fillText('Нажми на канвас — добавь провод с током', W/2, H/2 - 18); + ctx.font = '13px Manrope, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.fillText('• Ток на нас × Ток от нас ПКМ / двойной клик — удалить', W/2, H/2 + 14); + ctx.restore(); + } +} diff --git a/frontend/js/labs/mirror.js b/frontend/js/labs/mirror.js new file mode 100644 index 0000000..d16dafe --- /dev/null +++ b/frontend/js/labs/mirror.js @@ -0,0 +1,1004 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + MirrorSim v3 + Flat / Concave / Convex · 1/f = 1/d + 1/d' · M = -d'/d + Features: fan rays, normals, angle arcs, ray labels ①②③, + center C, zones, grid, photon animation, step mode, + speed control, point mode, drag image, hover tooltips, + mirror transition, unified infobox, legend, export PNG + ══════════════════════════════════════════════════════════════ */ + +class MirrorSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + // physics + this.type = 'concave'; + this.f = 120; + this.d = 240; + this.h = 60; + + // object animation + this._playing = false; + this._animT = 1.4; + this._animSpeed = 1; + this._raf = null; + + // step mode (-1 = all, 0..3 = progressive) + this._step = -1; + + // display toggles + this._showGrid = false; + this._showZones = true; + this._showNormals = true; + this._showDims = true; + this._showAngles = true; + this._showPhotons = true; + this._pointMode = false; + + // photon system + this._photons = []; + this._photonRaf = null; + this._photonTimer = 0; + this._lastPhoTime = 0; + this._photonPaths = []; + + // mirror transition + this._prevType = 'concave'; + this._transT = 1.0; + this._transRaf = null; + + // drag & hover + this._drag = null; + this._hoverX = -999; + this._hoverY = -999; + + // callbacks + this.onUpdate = null; + this.onAnimate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── Public API ──────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setType(type) { + if (type === this.type) return; + this._prevType = this.type; + this.type = type; + if (this._playing) this._stopAnim(); + this._startTransition(); + this.draw(); this._emit(); + } + + 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)); + if (h !== undefined) this.h = Math.max(20, Math.min(80, +h)); + this.draw(); this._emit(); + } + + setAnimSpeed(s) { this._animSpeed = +s || 1; } + togglePlay() { this._playing ? this._stopAnim() : this._startAnim(); } + stepNext() { this._step = Math.min(3, this._step + 1); this.draw(); } + stepReset() { this._step = -1; this.draw(); } + setPointMode(on) { this._pointMode = !!on; this.draw(); this._emit(); } + + setToggle(name, val) { + const map = { + grid:'_showGrid', zones:'_showZones', normals:'_showNormals', + dims:'_showDims', angles:'_showAngles', photons:'_showPhotons', + }; + if (map[name]) this[map[name]] = !!val; + if (name === 'photons') { val ? this._startPhotons() : this._stopPhotons(); } + this.draw(); + } + + exportPng() { + const a = document.createElement('a'); + a.href = this.canvas.toDataURL('image/png'); + a.download = `mirror_${this.type}_d${Math.round(this.d)}.png`; + a.click(); + } + + /* ── Physics ─────────────────────────────────── */ + + _fSigned() { + if (this.type === 'flat') return Infinity; + return this.type === 'convex' ? -this.f : this.f; + } + + info() { + const { type, d, h } = this; + const f = this._fSigned(); + let dPrime, M; + if (type === 'flat') { + dPrime = -d; M = 1; + } else { + const den = d - f; + if (Math.abs(den) < 0.5) { dPrime = Infinity; M = Infinity; } + else { dPrime = f * d / den; M = -dPrime / d; } + } + const hPrime = M === Infinity ? Infinity : M * h; + const isReal = dPrime > 0 && dPrime !== Infinity; + const imageType = dPrime === Infinity ? '∞' : isReal ? 'действительное' : 'мнимое'; + const orient = (M === Infinity || M === 1) ? 'прямое' : M < 0 ? 'перевёрнутое' : 'прямое'; + const sizeStr = M === Infinity ? '' : Math.abs(M) > 1.05 ? 'увеличенное' : Math.abs(M) < 0.95 ? 'уменьшенное' : 'равное'; + return { + f: type === 'flat' ? '∞' : (type === 'convex' ? -this.f : +this.f).toFixed(0), + d: +d.toFixed(1), + dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1), + M: M === Infinity ? Infinity : +M.toFixed(3), + imageType, orient, sizeStr, + hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1), + isReal, + }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + /* ── Mirror transition ───────────────────────── */ + + _getBulge(type) { + if (type === 'flat') return 0; + if (type === 'concave') return -Math.min(30, this.f * 0.18); + return Math.min(24, this.f * 0.16); + } + + _startTransition() { + this._transT = 0; + if (this._transRaf) cancelAnimationFrame(this._transRaf); + const step = () => { + this._transT = Math.min(1, this._transT + 0.07); + this.draw(); + if (this._transT < 1) this._transRaf = requestAnimationFrame(step); + else this._transRaf = null; + }; + this._transRaf = requestAnimationFrame(step); + } + + /* ── Object animation ────────────────────────── */ + + _startAnim() { this._playing = true; this._animLoop(); } + + _stopAnim() { + this._playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + _animLoop() { + if (!this._playing) return; + this._animT += 0.013 * this._animSpeed; + const t = 0.5 - 0.5 * Math.cos(this._animT); + if (this.type === 'concave') this.d = Math.max(30, Math.min(490, this.f * (0.35 + 2.75 * t))); + else this.d = 40 + 400 * t; + if (this.onAnimate) this.onAnimate(this.d); + this.draw(); this._emit(); + this._raf = requestAnimationFrame(() => this._animLoop()); + } + + /* ── Photon system ───────────────────────────── */ + + _getRayPaths(mx, ay, f, dPrime, hPrime) { + const { d, h, type } = this; + const hasImage = dPrime !== null && isFinite(dPrime); + const isReal = hasImage && dPrime > 0; + const imgX = hasImage ? mx - dPrime : null; + const imgY = hasImage ? ay - (this._pointMode ? 0 : hPrime) : null; + const objX = mx - d; + const objY = ay - (this._pointMode ? 0 : h); + const COLORS = ['#06D6E0', '#7BF5A4', '#FFD166']; + + if (type === 'flat') { + return [objY, ay, ay - h * 0.5].map((hy, i) => ({ + pts: [[objX, objY], [mx, hy], ...(hasImage ? [[imgX, imgY]] : [])], + color: COLORS[i], + })); + } + + const hit1Y = ay - (this._pointMode ? 0 : h); + const hit2Y = ay; + const denom3 = d - f; + const hit3Y = Math.abs(denom3) < 0.5 ? null : ay + (this._pointMode ? 0 : h) * f / denom3; + + const rays = []; + const add = (hitY, color) => { + if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2 * this.H) return; + const pts = [[objX, objY], [mx, hitY]]; + if (hasImage) { + if (isReal) { + pts.push([imgX, imgY]); + const dx = imgX - mx, dy = imgY - hitY, l = Math.hypot(dx, dy); + if (l > 1) pts.push([imgX + dx / l * 60, imgY + dy / l * 60]); + } else { + const dx = imgX - mx, dy = imgY - hitY; + if (Math.abs(dx) > 1) { + const tL = (mx - 5) / dx; + let endX = 5, endY = hitY - dy * tL; + if (endY < 5 || endY > this.H - 5) { + endY = endY < 5 ? 5 : this.H - 5; + const tE = (hitY - endY) / dy; + endX = Math.max(5, mx - dx * tE); + } + pts.push([endX, endY]); + } + } + } + rays.push({ pts, color }); + }; + add(hit1Y, COLORS[0]); add(hit2Y, COLORS[1]); add(hit3Y, COLORS[2]); + return rays; + } + + _startPhotons() { + if (this._photonRaf) return; + this._lastPhoTime = performance.now(); + this._photonLoop(); + } + + _stopPhotons() { + if (this._photonRaf) { cancelAnimationFrame(this._photonRaf); this._photonRaf = null; } + this._photons = []; + this.draw(); + } + + _photonLoop() { + const now = performance.now(); + const dt = Math.min((now - this._lastPhoTime) / 1000, 0.1); + this._lastPhoTime = now; + + const spd = 200; + for (const p of this._photons) p.t = Math.min(1, p.t + dt * spd / p.len); + this._photons = this._photons.filter(p => p.t < 1); + + this._photonTimer += dt; + if (this._photonTimer > 0.75 && this._photonPaths.length) { + this._photonTimer = 0; + for (const path of this._photonPaths) { + if (path.pts.length < 2) continue; + let len = 0; + for (let i = 1; i < path.pts.length; i++) + len += Math.hypot(path.pts[i][0]-path.pts[i-1][0], path.pts[i][1]-path.pts[i-1][1]); + if (len > 20) this._photons.push({ pts: path.pts, color: path.color, t: 0, len }); + } + } + + if (!this._playing) this.draw(); // animation loop handles draw when playing + this._photonRaf = requestAnimationFrame(() => this._photonLoop()); + } + + /* ── Main draw ───────────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + + const f = this._fSigned(); + const mx = Math.round(W * 0.62); + const ay = H / 2; + + let dPrime = null, hPrime = null; + if (this.type === 'flat') { + dPrime = -this.d; hPrime = this._pointMode ? 0 : this.h; + } else { + const den = this.d - f; + if (Math.abs(den) >= 0.5) { + dPrime = f * this.d / den; + hPrime = this._pointMode ? 0 : (-dPrime / this.d) * this.h; + } + } + + const step = this._step; + const showRay = i => step === -1 || i <= step; + const showFill = step === -1 || step >= 3; + + this._photonPaths = this._getRayPaths(mx, ay, f, dPrime, hPrime); + + /* bg */ + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + if (this._showGrid) this._drawGrid(ctx); + if (this._showZones) this._drawZones(ctx, mx); + + /* axis */ + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke(); + ctx.setLineDash([]); + + /* fan rays */ + this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill); + + /* mirror */ + this._drawMirror(ctx, mx, ay); + + /* focal pts + C */ + if (this.type !== 'flat') { + this._drawFocalPoints(ctx, mx, ay, f); + this._drawCenterC(ctx, mx, ay, f); + } + + /* normals */ + if (this._showNormals && this.type !== 'flat' && (step === -1 || step >= 3)) + this._drawNormals(ctx, mx, ay, f); + + /* angle arcs (only in full view) */ + if (this._showAngles && this.type !== 'flat' && step === -1) + this._drawAngleArcs(ctx, mx, ay, f); + + /* ray labels */ + if (step === -1 || step >= 1) + this._drawRayLabels(ctx, mx, ay, f, step); + + /* object */ + const objX = mx - this.d; + if (this._pointMode) { + ctx.save(); ctx.shadowColor='#9B5DE5'; ctx.shadowBlur=10; + ctx.fillStyle = '#9B5DE5'; + ctx.beginPath(); ctx.arc(objX, ay, 5, 0, Math.PI*2); ctx.fill(); + ctx.restore(); + } else { + this._drawArrow(ctx, objX, ay, objX, ay - this.h, '#9B5DE5', false); + } + + /* image */ + if (dPrime !== null && isFinite(dPrime)) { + const imgX = mx - dPrime; + const imgY = ay - (this._pointMode ? 0 : hPrime); + if (this._pointMode) { + ctx.save(); + ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; + if (dPrime < 0) { ctx.globalAlpha = 0.55; ctx.setLineDash([4,3]); } + ctx.beginPath(); ctx.arc(imgX, ay, 5, 0, Math.PI*2); + dPrime > 0 ? ctx.fill() : (() => { ctx.stroke(); })(); + ctx.restore(); + } else { + this._drawArrow(ctx, imgX, ay, imgX, imgY, + dPrime > 0 ? '#EF476F' : '#FFD166', dPrime <= 0); + } + } + + /* dims */ + if (this._showDims && (step === -1 || step >= 3)) + this._drawDimensions(ctx, mx, ay, f, dPrime, hPrime); + + /* infobox */ + this._drawInfoBox(ctx, f, dPrime); + + /* badge */ + if ((step === -1 || step >= 3) && dPrime !== null) + this._drawImageBadge(ctx, dPrime, hPrime); + + /* critical marker */ + this._drawCriticalMarker(ctx, f); + + /* legend */ + if (this._showDims) this._drawLegend(ctx); + + /* photons */ + if (this._showPhotons && this._photons.length) + this._drawPhotons(ctx); + + /* tooltip */ + this._drawTooltip(ctx, mx, ay, f, dPrime, hPrime); + + /* step overlay */ + if (step >= 0) this._drawStepOverlay(ctx, step); + } + + /* ── Grid & Zones ────────────────────────────── */ + + _drawGrid(ctx) { + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < this.W; x += 40) { ctx.moveTo(x,0); ctx.lineTo(x,this.H); } + for (let y = 0; y < this.H; y += 40) { ctx.moveTo(0,y); ctx.lineTo(this.W,y); } + ctx.stroke(); + } + + _drawZones(ctx, mx) { + const g1 = ctx.createLinearGradient(0,0,mx,0); + g1.addColorStop(0, 'rgba(6,214,224,0.0)'); + g1.addColorStop(1, 'rgba(6,214,224,0.03)'); + ctx.fillStyle = g1; ctx.fillRect(0, 0, mx, this.H); + + const g2 = ctx.createLinearGradient(mx,0,this.W,0); + g2.addColorStop(0, 'rgba(239,71,111,0.04)'); + g2.addColorStop(1, 'rgba(239,71,111,0.0)'); + ctx.fillStyle = g2; ctx.fillRect(mx, 0, this.W-mx, this.H); + } + + /* ── Mirror surface ──────────────────────────── */ + + _drawMirror(ctx, mx, ay) { + const mH = Math.min(this.H * 0.4, 150); + ctx.save(); + + const ease = t => t < 0.5 ? 2*t*t : -1+(4-2*t)*t; + const bulge = this._getBulge(this._prevType) + + (this._getBulge(this.type) - this._getBulge(this._prevType)) * ease(this._transT); + + ctx.strokeStyle = 'rgba(6,214,224,0.92)'; + ctx.lineWidth = 3; + ctx.shadowColor = 'rgba(6,214,224,0.45)'; + ctx.shadowBlur = 8; + + ctx.beginPath(); + ctx.moveTo(mx, ay - mH); + ctx.quadraticCurveTo(mx + bulge, ay, mx, ay + mH); + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.strokeStyle = 'rgba(6,214,224,0.15)'; ctx.lineWidth = 1.5; + for (let i = 0; i <= 10; i++) { + const y = ay - mH + i * mH * 2 / 10; + ctx.beginPath(); ctx.moveTo(mx, y); ctx.lineTo(mx+14, y+10); ctx.stroke(); + } + ctx.restore(); + } + + /* ── Focal points ────────────────────────────── */ + + _drawFocalPoints(ctx, mx, ay, f) { + const behind = f < 0; + const pts = [{ px: mx-f, lbl:'F', r:5 }, { px: mx-2*f, lbl:'2F', r:3.5 }]; + ctx.font = '11px Manrope, system-ui, sans-serif'; + for (const p of pts) { + if (p.px < 4 || p.px > this.W-4) continue; + const col = behind ? 'rgba(255,209,102,0.7)' : '#06D6E0'; + ctx.fillStyle = col; + ctx.beginPath(); ctx.arc(p.px, ay, p.r, 0, Math.PI*2); ctx.fill(); + ctx.fillStyle = col; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(p.lbl, p.px, ay+9); + } + } + + /* ── Center of curvature C ───────────────────── */ + + _drawCenterC(ctx, mx, ay, f) { + if (!isFinite(f)) return; + const cx = mx - 2*f; + if (cx < 4 || cx > this.W-4) return; + const pulse = Math.abs(this.d - 2*Math.abs(f)) < Math.abs(f)*0.06; + ctx.save(); + if (pulse) { ctx.shadowColor='rgba(255,152,0,0.9)'; ctx.shadowBlur=14; } + ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.5)'; + ctx.beginPath(); ctx.arc(cx, ay, pulse ? 5 : 3.5, 0, Math.PI*2); ctx.fill(); + ctx.shadowBlur = 0; + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = pulse ? '#FF9800' : 'rgba(255,152,0,0.6)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('C', cx, ay+9); + ctx.restore(); + } + + /* ── Fan rays ────────────────────────────────── */ + + _drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill) { + const { d, h, type } = this; + const hasImg = dPrime !== null && isFinite(dPrime); + const isReal = hasImg && dPrime > 0; + const imgX = hasImg ? mx - dPrime : null; + const imgY = hasImg ? ay - (this._pointMode ? 0 : hPrime) : null; + const objX = mx - d; + const objY = ay - (this._pointMode ? 0 : h); + const COLS = ['#06D6E0','#7BF5A4','#FFD166']; + const FAN = 'rgba(255,255,255,0.18)'; + + if (type === 'flat') { + const hits = [objY, ay, ay - (this._pointMode ? 0 : h)*0.5]; + hits.forEach((hy, i) => { + if (!showRay(i)) return; + this._flatRay(ctx, mx, ay, d, h, objX, objY, hy, COLS[i], imgX, imgY, hasImg); + }); + return; + } + + const hit1 = ay - (this._pointMode ? 0 : h); + const hit2 = ay; + const den3 = d - f; + const hit3 = Math.abs(den3) < 0.5 ? null : ay + (this._pointMode ? 0 : h)*f/den3; + + if (showFill) { + const fills = [(hit1+hit2)/2]; + if (hit3 !== null && isFinite(hit3)) fills.push((hit2+hit3)/2); + for (const hy of fills) + this._oneRay(ctx, mx, objX, objY, hy, FAN, 0.6, hasImg, isReal, imgX, imgY); + } + + if (showRay(0)) this._oneRay(ctx, mx, objX, objY, hit1, COLS[0], 1.0, hasImg, isReal, imgX, imgY); + if (showRay(1)) this._oneRay(ctx, mx, objX, objY, hit2, COLS[1], 1.0, hasImg, isReal, imgX, imgY); + if (showRay(2)) this._oneRay(ctx, mx, objX, objY, hit3, COLS[2], 1.0, hasImg, isReal, imgX, imgY); + } + + _oneRay(ctx, mx, ox, oy, hitY, color, alpha, hasImg, isReal, imgX, imgY) { + if (hitY === null || !isFinite(hitY) || hitY < -this.H || hitY > 2*this.H) return; + ctx.save(); ctx.globalAlpha = alpha; + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); + if (!hasImg) { ctx.restore(); return; } + + if (isReal) { + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); + const dx = imgX-mx, dy = imgY-hitY, l = Math.hypot(dx,dy); + if (l > 1) { + ctx.globalAlpha = alpha * 0.22; + ctx.beginPath(); ctx.moveTo(imgX,imgY); ctx.lineTo(imgX+dx/l*60, imgY+dy/l*60); ctx.stroke(); + } + } else { + const dx = imgX-mx, dy = imgY-hitY; + if (Math.abs(dx) < 1) { ctx.restore(); return; } + const tL = (mx-5)/dx; + let ex = 5, ey = hitY - dy*tL; + if (ey < 5 || ey > this.H-5) { + ey = ey < 5 ? 5 : this.H-5; + ex = Math.max(5, mx - dx*(hitY-ey)/dy); + } + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(ex, ey); ctx.stroke(); + ctx.globalAlpha = alpha * 0.4; + ctx.setLineDash([4,4]); + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); + ctx.setLineDash([]); + } + ctx.restore(); + } + + _flatRay(ctx, mx, ay, d, h, ox, oy, hitY, color, imgX, imgY, hasImg) { + ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.setLineDash([]); + ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, hitY); ctx.stroke(); + const slope = (hitY-oy)/(mx-ox); + const farX = Math.max(5, ox-50); + const farY = hitY - slope*(mx-farX); + ctx.globalAlpha = 0.3; + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(farX, Math.max(5, Math.min(this.H-5, farY))); ctx.stroke(); + ctx.globalAlpha = 1; + if (hasImg) { + ctx.setLineDash([4,4]); + ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(imgX, imgY); ctx.stroke(); + ctx.setLineDash([]); + } + ctx.restore(); + } + + /* ── Normals ─────────────────────────────────── */ + + _drawNormals(ctx, mx, ay, f) { + if (!isFinite(f)) return; + const { d, h } = this; + const cX = mx - 2*f; + const hits = [ay-h, ay]; + const d3 = d-f; + if (Math.abs(d3) >= 0.5) { const y3 = ay + h*f/d3; if (isFinite(y3)) hits.push(y3); } + + ctx.save(); ctx.strokeStyle='rgba(255,255,255,0.14)'; ctx.lineWidth=1; ctx.setLineDash([4,4]); + for (const hy of hits) { + if (hy < -this.H || hy > 2*this.H) continue; + const nx=cX-mx, ny=ay-hy, nl=Math.hypot(nx,ny); + if (nl < 1) continue; + const ux=nx/nl*28, uy=ny/nl*28; + ctx.beginPath(); ctx.moveTo(mx-ux,hy-uy); ctx.lineTo(mx+ux,hy+uy); ctx.stroke(); + } + ctx.setLineDash([]); ctx.restore(); + } + + /* ── Angle arcs ──────────────────────────────── */ + + _drawAngleArcs(ctx, mx, ay, f) { + if (!isFinite(f)) return; + const { d, h } = this; + const hitY = ay - h; // use ray 1 hit point + if (hitY < 5 || hitY > this.H-5) return; + + const cX = mx - 2*f; + const nx = cX-mx, ny = ay-hitY, nl = Math.hypot(nx, ny); + if (nl < 1) return; + + const normInward = Math.atan2(ny, nx); // toward C + const normOuter = normInward + Math.PI; // outward normal + const incDir = Math.atan2(hitY-(ay-h), mx-(mx-d)); // incident FROM object + const incFrom = incDir + Math.PI; // direction FROM mirror to object + + const r = 14; + ctx.save(); + ctx.lineWidth = 1; + + // arc on incident side + ctx.strokeStyle = 'rgba(6,214,224,0.45)'; + ctx.beginPath(); + let a1 = normOuter, a2 = incFrom; + // normalize so arc goes the short way + ctx.arc(mx, hitY, r, a1, a2, false); + ctx.stroke(); + + // θ label + ctx.fillStyle = 'rgba(6,214,224,0.7)'; + ctx.font = '9px Manrope, system-ui, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const mid = (a1+a2)/2; + ctx.fillText('θ', mx+Math.cos(mid)*(r+9), hitY+Math.sin(mid)*(r+9)); + + ctx.restore(); + } + + /* ── Ray labels ①②③ ──────────────────────────── */ + + _drawRayLabels(ctx, mx, ay, f, step) { + if (this.type === 'flat' || !isFinite(f)) return; + const { d, h } = this; + const hits = [ay-h, ay, null]; + const den3 = d-f; + if (Math.abs(den3) >= 0.5) { const y3 = ay+h*f/den3; if (isFinite(y3)) hits[2] = y3; } + const COLS = ['#06D6E0','#7BF5A4','#FFD166']; + const LBLS = ['①','②','③']; + + ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; + ctx.textAlign = 'left'; + hits.forEach((hy, i) => { + if (hy === null || !isFinite(hy) || hy < -50 || hy > this.H+50) return; + if (step !== -1 && i > step) return; + ctx.fillStyle = COLS[i]; ctx.textBaseline = 'middle'; + ctx.fillText(LBLS[i], mx+8, hy); + }); + } + + /* ── Arrow ───────────────────────────────────── */ + + _drawArrow(ctx, x1, y1, x2, y2, color, dashed) { + ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 2.5; + if (dashed) ctx.setLineDash([6,4]); + ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); + if (dashed) ctx.setLineDash([]); + const a = Math.atan2(y2-y1, x2-x1), s=10; + ctx.beginPath(); + ctx.moveTo(x2,y2); + ctx.lineTo(x2-s*Math.cos(a-0.35), y2-s*Math.sin(a-0.35)); + ctx.lineTo(x2-s*Math.cos(a+0.35), y2-s*Math.sin(a+0.35)); + ctx.closePath(); ctx.fill(); + } + + /* ── Dimension annotations ───────────────────── */ + + _drawDimensions(ctx, mx, ay, f, dPrime, hPrime) { + const { d, h } = this; + const objX = mx - d; + const yBase = ay + Math.min(this.H*0.22, 60); + ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.lineWidth = 1; + + const bracket = (x1, x2, y, lbl, col) => { + if (x1 === x2 || x1 < 4 || x2 > this.W-4) return; + ctx.strokeStyle = col; ctx.fillStyle = col; ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(x1, y-5); ctx.lineTo(x1, y+5); + ctx.moveTo(x1, y); ctx.lineTo(x2, y); + ctx.moveTo(x2, y-5); ctx.lineTo(x2, y+5); + ctx.stroke(); + ctx.textAlign='center'; ctx.textBaseline='top'; + ctx.fillText(lbl, (x1+x2)/2, y+3); + }; + + bracket(objX, mx, yBase, `d=${d.toFixed(0)}`, 'rgba(155,93,229,0.65)'); + if (isFinite(f) && Math.abs(f) > 5) { + const fX = mx-f; + if (fX > 4 && fX < this.W-4) + bracket(Math.min(fX,mx), Math.max(fX,mx), yBase+20, + `f=${Math.abs(f).toFixed(0)}`, 'rgba(6,214,224,0.55)'); + } + if (dPrime !== null && isFinite(dPrime)) { + const ix = mx-dPrime; + if (ix > 4 && ix < this.W-4) + bracket(Math.min(ix,mx), Math.max(ix,mx), yBase, + `d'=${Math.abs(dPrime).toFixed(0)}`, + dPrime > 0 ? 'rgba(239,71,111,0.65)' : 'rgba(255,209,102,0.65)'); + } + + const xl = objX-18; + if (xl > 4 && h > 6 && !this._pointMode) { + ctx.strokeStyle='rgba(155,93,229,0.4)'; + ctx.beginPath(); ctx.moveTo(xl,ay); ctx.lineTo(xl,ay-h); ctx.stroke(); + ctx.fillStyle='rgba(155,93,229,0.7)'; ctx.textAlign='right'; ctx.textBaseline='middle'; + ctx.fillText(`h=${h.toFixed(0)}`, xl-3, ay-h/2); + } + + if (dPrime !== null && isFinite(dPrime) && !this._pointMode && Math.abs(hPrime) > 6) { + const ix = mx-dPrime; + const xil = ix + (dPrime > 0 ? -18 : 18); + if (xil > 4 && xil < this.W-4) { + const col = dPrime > 0 ? 'rgba(239,71,111,' : 'rgba(255,209,102,'; + ctx.strokeStyle = col+'0.4)'; + ctx.beginPath(); ctx.moveTo(ix,ay); ctx.lineTo(ix,ay-hPrime); ctx.stroke(); + ctx.fillStyle = col+'0.7)'; + ctx.textAlign = dPrime > 0 ? 'right' : 'left'; ctx.textBaseline='middle'; + ctx.fillText(`h'=${Math.abs(hPrime).toFixed(0)}`, ix+(dPrime>0?-3:3), ay-hPrime/2); + } + } + } + + /* ── Unified info box ────────────────────────── */ + + _drawInfoBox(ctx, f, dPrime) { + const info = this.info(); + const bx=12, by=12, bw=230, bh=76; + ctx.fillStyle='rgba(13,13,26,0.9)'; + ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); + ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); + + ctx.font='11px Manrope, system-ui, sans-serif'; + ctx.textAlign='left'; ctx.textBaseline='top'; + ctx.fillStyle='rgba(255,255,255,0.42)'; + ctx.fillText("1/f = 1/d + 1/d'", bx+10, by+8); + + if (isFinite(f) && dPrime !== null && isFinite(dPrime)) { + ctx.fillStyle='rgba(6,214,224,0.88)'; ctx.fillText(`1/${Math.abs(+info.f)}`, bx+10, by+28); + ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('=',bx+60,by+28); + ctx.fillStyle='rgba(155,93,229,0.88)'; ctx.fillText(`1/${info.d}`, bx+78, by+28); + ctx.fillStyle='rgba(255,255,255,0.28)';ctx.fillText('+',bx+120,by+28); + ctx.fillStyle= dPrime>0 ? 'rgba(239,71,111,0.88)' : 'rgba(255,209,102,0.88)'; + ctx.fillText(`${dPrime>0?'':'−'}1/${Math.abs(+info.dPrime).toFixed(0)}`, bx+136, by+28); + } else { + ctx.fillStyle='rgba(255,209,102,0.75)'; + ctx.fillText('d = f → изображение на ∞', bx+10, by+28); + } + + if (info.M !== Infinity) { + ctx.fillStyle='rgba(255,255,255,0.28)'; ctx.fillText(`M = ${info.M}`, bx+10, by+48); + if (isFinite(dPrime)) { + ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; + ctx.textAlign = 'right'; + ctx.fillText(info.imageType + ' ' + info.orient, bx+bw-10, by+48); + ctx.textAlign = 'left'; + } + } + } + + /* ── Image badge ─────────────────────────────── */ + + _drawImageBadge(ctx, dPrime, hPrime) { + const info = this.info(); + const bw=160, bh=58, bx=this.W-bw-12, by=12; + ctx.fillStyle='rgba(13,13,26,0.88)'; + ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.fill(); + ctx.strokeStyle='rgba(255,255,255,0.06)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(bx,by,bw,bh,8); ctx.stroke(); + + const isInf = !isFinite(dPrime); + ctx.font='10px Manrope, system-ui, sans-serif'; + ctx.textAlign='left'; ctx.textBaseline='top'; + const tc = isInf ? '#FFD166' : dPrime>0 ? '#EF476F' : '#FFD166'; + ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Тип:', bx+10, by+8); + ctx.fillStyle=tc; ctx.fillText(isInf?'∞':info.imageType, bx+44, by+8); + if (!isInf) { + ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Ориент.:', bx+10, by+26); + ctx.fillStyle = info.M<0 ? 'rgba(239,71,111,0.9)' : 'rgba(123,245,164,0.9)'; + ctx.fillText(info.orient, bx+62, by+26); + if (info.sizeStr) { + const sc = Math.abs(+info.M)>1.05 ? '#9B5DE5' : Math.abs(+info.M)<0.95 ? '#06D6E0' : 'rgba(255,255,255,0.6)'; + ctx.fillStyle='rgba(255,255,255,0.3)'; ctx.fillText('Размер:', bx+10, by+42); + ctx.fillStyle=sc; ctx.fillText(`${info.sizeStr} ×${Math.abs(+info.M).toFixed(2)}`, bx+57, by+42); + } + } + } + + /* ── Critical marker ─────────────────────────── */ + + _drawCriticalMarker(ctx, f) { + if (!isFinite(f) || f <= 0) return; + const eps = f*0.06; + let text = null; + if (Math.abs(this.d-f) < eps) text = 'd = f : лучи параллельны, изображения нет'; + else if (Math.abs(this.d-2*f) 0 — предмет перед зеркалом' }, + { c:'rgba(239,71,111,0.8)', t:"d' > 0 — действительное" }, + { c:'rgba(255,209,102,0.8)',t:"d' < 0 — мнимое" }, + ]; + const bx=12, lh=14, by=this.H - items.length*lh - 16; + ctx.save(); ctx.font='9px Manrope, system-ui, sans-serif'; ctx.textBaseline='top'; + items.forEach(({ c, t }, i) => { + const y = by+i*lh; + ctx.fillStyle=c; ctx.fillRect(bx, y+3, 8, 8); + ctx.fillStyle='rgba(255,255,255,0.32)'; ctx.textAlign='left'; ctx.fillText(t, bx+13, y); + }); + ctx.restore(); + } + + /* ── Photon drawing ──────────────────────────── */ + + _drawPhotons(ctx) { + for (const p of this._photons) { + const pos = this._photonPos(p.pts, p.t); + if (!pos) continue; + ctx.save(); + ctx.shadowColor = p.color; ctx.shadowBlur = 8; + ctx.fillStyle = p.color; + ctx.beginPath(); ctx.arc(pos[0], pos[1], 3, 0, Math.PI*2); ctx.fill(); + ctx.restore(); + } + } + + _photonPos(pts, t) { + if (pts.length < 2) return null; + let total = 0; + const lens = []; + for (let i=1; i { + if (!tip && Math.hypot(hx-px, hy-py) < 15) tip = { lbl, sub }; + }; + if (isFinite(f)) { + chk(mx-f, ay, 'Главный фокус F', `f = ${Math.abs(f).toFixed(0)}`); + chk(mx-2*f, ay, 'Центр кривизны C', `R = 2f = ${(2*Math.abs(f)).toFixed(0)}`); + } + chk(mx-this.d, ay-(this._pointMode?0:this.h), 'Предмет', `d = ${this.d.toFixed(0)}, h = ${this.h.toFixed(0)}`); + if (dPrime !== null && isFinite(dPrime)) { + const ix=mx-dPrime, iy=ay-(this._pointMode?0:hPrime); + chk(ix, iy, 'Изображение', `d' = ${Math.abs(dPrime).toFixed(0)}, M = ${this.info().M}`); + } + if (!tip) return; + + ctx.save(); + ctx.font = 'bold 11px Manrope, system-ui, sans-serif'; + const tw = Math.max(ctx.measureText(tip.lbl).width, ctx.measureText(tip.sub).width); + const bw=tw+20, bh=34; + let tx=hx+14, ty=hy-bh-6; + if (tx+bw > this.W-4) tx = hx-bw-14; + if (ty < 4) ty = hy+10; + ctx.fillStyle='rgba(13,13,26,0.95)'; ctx.strokeStyle='rgba(6,214,224,0.45)'; ctx.lineWidth=1; + ctx.beginPath(); ctx.roundRect(tx,ty,bw,bh,6); ctx.fill(); ctx.stroke(); + ctx.fillStyle='#fff'; ctx.textAlign='left'; ctx.textBaseline='top'; + ctx.fillText(tip.lbl, tx+10, ty+6); + ctx.font='10px Manrope, system-ui, sans-serif'; + ctx.fillStyle='rgba(255,255,255,0.5)'; ctx.fillText(tip.sub, tx+10, ty+20); + ctx.restore(); + } + + /* ── Step overlay ────────────────────────────── */ + + _drawStepOverlay(ctx, step) { + const lbls = [ + '① Луч параллельно оси → отражается через F', + '② Луч через вершину → отражается симметрично', + '③ Луч через F → отражается параллельно', + ' Изображение — пересечение всех отражённых лучей', + ]; + const text = lbls[Math.min(step, lbls.length-1)]; + ctx.save(); + ctx.font = '11px Manrope, system-ui, sans-serif'; + const tw = ctx.measureText(text).width; + const bx = this.W/2-tw/2-12, by = this.H-34; + ctx.fillStyle='rgba(13,13,26,0.9)'; + ctx.beginPath(); ctx.roundRect(bx,by,tw+24,24,6); ctx.fill(); + ctx.fillStyle='#7BF5A4'; ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText(text, this.W/2, by+12); + ctx.restore(); + } + + /* ── Events ──────────────────────────────────── */ + + _bindEvents() { + const cv = this.canvas; + const getPos = e => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { + px: (t.clientX-r.left)*(this.W/r.width), + py: (t.clientY-r.top) *(this.H/r.height), + }; + }; + const mX = () => Math.round(this.W*0.62); + const aY = () => this.H/2; + + const hitTest = (px, py) => { + if (this._playing) return null; + const mx=mX(), ay=aY(), f=this._fSigned(); + if (Math.hypot(px-(mx-this.d), py-(ay-(this._pointMode?0:this.h))) < 20) return 'object'; + if (this.type !== 'flat' && isFinite(f) && Math.hypot(px-(mx-f), py-ay) < 16) return 'focus'; + const info = this.info(); + if (info.dPrime !== Infinity && isFinite(info.dPrime)) { + const ix=mx-info.dPrime, iy=ay-(this._pointMode?0:(info.hPrime||0)); + if (Math.hypot(px-ix, py-iy) < 18) return 'image'; + } + return null; + }; + + cv.addEventListener('mousedown', e => { const {px,py}=getPos(e); this._drag=hitTest(px,py); }); + + window.addEventListener('mousemove', e => { + const {px,py} = getPos(e); + this._hoverX = px; this._hoverY = py; + if (this._drag) { + if (e.cancelable) e.preventDefault(); + const mx=mX(), f=this._fSigned(); + if (this._drag === 'object') { + this.d = Math.max(30, Math.min(490, mx-px)); + } else if (this._drag === 'focus') { + this.f = Math.max(30, Math.min(300, Math.abs(mx-px))); + } else if (this._drag === 'image' && isFinite(f) && this.type !== 'flat') { + const dp = mx-px; + if (Math.abs(dp-f) > 5) this.d = Math.max(30, Math.min(490, f*dp/(dp-f))); + } + if (this.onAnimate) this.onAnimate(this.d); + this.draw(); this._emit(); + } else if (!this._photonRaf && !this._playing) { + this.draw(); // redraw for tooltip + } + }); + + window.addEventListener('mouseup', () => { this._drag = null; }); + + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor='grabbing'; return; } + const {px,py}=getPos(e); + cv.style.cursor = (hitTest(px,py) && !this._playing) ? 'grab' : 'default'; + }); + + cv.addEventListener('touchstart', e => { + if (e.touches.length===1) { const {px,py}=getPos(e); this._drag=hitTest(px,py); } + }, { passive: true }); + + cv.addEventListener('touchmove', e => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const {px}=getPos(e), mx=mX(), f=this._fSigned(); + if (this._drag==='object') this.d=Math.max(30,Math.min(490,mx-px)); + else if (this._drag==='focus') this.f=Math.max(30,Math.min(300,Math.abs(mx-px))); + else if (this._drag==='image' && isFinite(f) && this.type!=='flat') { + const dp=mx-px; if (Math.abs(dp-f)>5) this.d=Math.max(30,Math.min(490,f*dp/(dp-f))); + } + if (this.onAnimate) this.onAnimate(this.d); + this.draw(); this._emit(); + }, { passive: false }); + + cv.addEventListener('touchend', () => { this._drag=null; }); + } +} diff --git a/frontend/js/labs/newton.js b/frontend/js/labs/newton.js new file mode 100644 index 0000000..d82c7cf --- /dev/null +++ b/frontend/js/labs/newton.js @@ -0,0 +1,1204 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + NewtonSim — три закона Ньютона + Закон I : A — скользящий блок, B — шар на нити + Закон II : A — один блок F=ma, B — сравнение масс + Закон III: A — пушка + откат, B — столкновение шаров, C — ракета + ════════════════════════════════════════════════════════════════ */ + +class NewtonSim { + + static SCALE = 58; // px per metre (visual) + static G = 9.81; // m/s² + + /* ── Конструктор ─────────────────────────────────────────── */ + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + /* Пользовательские параметры */ + this.law = 1; + this.scene = 'A'; + this.mu = 0.20; + this.mass1 = 5; // кг — основной блок / шар / ядро + this.mass2 = 12; // кг — сравниваемый блок / пушка + this.force = 30; // Н — приложенная сила (закон II) + + /* Состояние сцен */ + this._1A = {}; + this._1B = {}; + this._2 = {}; + this._3A = {}; + this._3B = {}; + this._3C = {}; + + /* Петля */ + this._raf = null; + this._last = 0; + this._paused = false; + + /* Геометрия */ + this.W = 0; this.H = 0; + this._g = {}; + + this.onUpdate = null; + this.onModeChange = null; + + this.fit(); + this._bindEvents(); + } + + /* ── Геометрия ───────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 700; + const H = this.canvas.offsetHeight || 440; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._g = { + gY: H * 0.73, + cx: W * 0.50, + cy: H * 0.48, + orbitR: Math.min(W, H) * 0.255, + }; + this._resetAll(); + } + + _resetAll() { + this._reset1A(); this._reset1B(); + this._reset2(); + this._reset3A(); this._reset3B(); this._reset3C(); + } + + /* ── Сброс каждой сцены ──────────────────────────────────── */ + + _reset1A() { + const { W, _g: g } = this; + this._1A = { + bx: W * 0.15, by: g.gY, bvx: 0, bvy: 0, + BW: 56, BH: 46, + trail: [], inAir: false, + }; + } + + _reset1B() { + const { _g: g } = this; + const omega = 1.35; + this._1B = { + angle: 0, omega, + cut: false, cutTimer: 0, + bx: g.cx + g.orbitR, by: g.cy, + bvx: 0, bvy: -g.orbitR * omega, + }; + } + + _reset2() { + const { W, _g: g } = this; + this._2 = { + b1x: W * 0.12, b1vx: 0, + b2x: W * 0.12, b2vx: 0, + history: [], t: 0, running: false, + flash: '', // 'fin' message + }; + } + + _reset3A() { + const { W, _g: g } = this; + this._3A = { + cx: W * 0.38, cvx: 0, + ball: null, fired: false, + sparks: [], forceFlash: 0, + }; + } + + _reset3B() { + const { W, _g: g } = this; + const r1 = 16 + this.mass1 * 1.1; + const r2 = 16 + this.mass2 * 1.1; + this._3B = { + b1: { x: W * 0.18, vx: 160, mass: this.mass1, r: r1, color: '#EF476F' }, + b2: { x: W * 0.82, vx: -100, mass: this.mass2, r: r2, color: '#4CC9F0' }, + colFlash: 0, done: false, + }; + } + + _reset3C() { + const { W, H } = this; + this._3C = { + ry: H * 0.78, rvy: 0, + rmass: 10, fuel: 1, + particles: [], running: false, + stopped: false, + }; + } + + /* ── Запуск / остановка ──────────────────────────────────── */ + + start() { + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + /* ── Публичный API ───────────────────────────────────────── */ + + setLaw(n) { this.law = n; this.scene = 'A'; this._resetAll(); if (this.onModeChange) this.onModeChange(); } + setScene(s) { this.scene = s; this._resetAll(); } + setMu(v) { this.mu = v; } + setMass1(v) { this.mass1 = v; this._reset3B(); } + setMass2(v) { this.mass2 = v; this._reset3B(); } + setForce(v) { this.force = v; } + + cutString() { + this._1B.cut = true; + this._1B.bvx = -Math.sin(this._1B.angle) * this._g.orbitR * this._1B.omega; + this._1B.bvy = Math.cos(this._1B.angle) * this._g.orbitR * this._1B.omega; + } + + startL2() { this._2.running = true; } + resetL2() { this._reset2(); } + + fireCannon() { + if (this._3A.fired) { this._reset3A(); return; } + const { _g: g } = this; + const S = NewtonSim.SCALE; + const vBall = 360; // px/s + const vCannon = -(this.mass1 / this.mass2) * vBall; + this._3A.ball = { x: this._3A.cx + 68, y: g.gY - 22, vx: vBall, vy: -160 }; + this._3A.cvx = vCannon; + this._3A.fired = true; + this._3A.forceFlash = 0.55; + for (let i = 0; i < 24; i++) { + const a = (Math.random() - 0.5) * 1.1 - 0.05; + this._3A.sparks.push({ + x: this._3A.cx + 68, y: g.gY - 22, + vx: Math.cos(a) * (180 + Math.random() * 220), + vy: Math.sin(a) * 140 - 80 - Math.random() * 120, + life: 1, + }); + } + } + + toggleRocket() { + if (this._3C.fuel <= 0) { this._reset3C(); return; } + this._3C.running = !this._3C.running; + if (this.onModeChange) this.onModeChange(); + } + + togglePause() { this._paused = !this._paused; } + + preset(name) { + switch (name) { + case 'space': this.mu = 0; this._reset1A(); break; + case 'ice': this.mu = 0.04; this._reset1A(); break; + case 'asphalt': this.mu = 0.38; this._reset1A(); break; + case 'rubber': this.mu = 0.72; this._reset1A(); break; + case 'light': this.mass1 = 2; this.force = 20; this._reset2(); break; + case 'heavy': this.mass1 = 18; this.force = 20; this._reset2(); break; + case 'compare': this.mass1 = 2; this.mass2 = 16; this.scene = 'B'; this._reset2(); break; + case 'big_cannon': this.mass2 = 50; this.mass1 = 1; this._reset3A(); break; + case 'small_cannon': this.mass2 = 5; this.mass1 = 4; this._reset3A(); break; + case 'equal_balls': this.mass1 = 8; this.mass2 = 8; this._reset3B(); break; + } + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ── Тик ──────────────────────────────────────────────────── */ + + _tick(now) { + const dt = Math.min((now - this._last) / 1000, 0.05); + this._last = now; + if (!this._paused) { + if (this.law === 1 && this.scene === 'A') this._step1A(dt); + else if (this.law === 1) this._step1B(dt); + else if (this.law === 2) this._step2(dt); + else if (this.scene === 'A') this._step3A(dt); + else if (this.scene === 'B') this._step3B(dt); + else this._step3C(dt); + } + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ── Физика I-A : блок с трением ────────────────────────── */ + + _step1A(dt) { + const b = this._1A; + const { W, _g: g } = this; + const S = NewtonSim.SCALE; + const GV = NewtonSim.G * S; + + /* Гравитация */ + if (b.by < g.gY || b.bvy < 0) { + b.bvy += GV * dt; + b.inAir = true; + } + + /* Интеграция */ + b.bx += b.bvx * dt; + b.by += b.bvy * dt; + + /* Приземление */ + if (b.by >= g.gY) { + b.by = g.gY; + b.bvy = Math.abs(b.bvy) > 60 ? -b.bvy * 0.42 : 0; + b.inAir = false; + } + + /* Трение (только на земле) */ + if (!b.inAir) { + const speed = Math.abs(b.bvx); + if (speed > 1) { + const dec = this.mu * GV * dt; + if (dec >= speed) b.bvx = 0; + else b.bvx -= Math.sign(b.bvx) * dec; + } + } + + /* Стены (упругий отскок) */ + const hw = b.BW / 2; + if (b.bx < hw) { b.bx = hw; b.bvx = Math.abs(b.bvx) * 0.65; } + if (b.bx > W - hw) { b.bx = W - hw; b.bvx = -Math.abs(b.bvx) * 0.65; } + + /* След */ + const speed = Math.hypot(b.bvx, b.bvy); + if (speed > 15) { + b.trail.push({ x: b.bx, y: Math.min(b.by, g.gY) }); + if (b.trail.length > 90) b.trail.shift(); + } else if (b.trail.length > 0) { + b.trail.shift(); + } + } + + /* ── Физика I-B : орбита прямолинейное движение ────────── */ + + _step1B(dt) { + const s = this._1B; + const { _g: g } = this; + + if (!s.cut) { + s.angle += s.omega * dt; + s.bx = g.cx + Math.cos(s.angle) * g.orbitR; + s.by = g.cy + Math.sin(s.angle) * g.orbitR; + s.bvx = -Math.sin(s.angle) * g.orbitR * s.omega; + s.bvy = Math.cos(s.angle) * g.orbitR * s.omega; + } else { + s.bx += s.bvx * dt; + s.by += s.bvy * dt; + s.cutTimer += dt; + if (s.cutTimer > 4.5) this._reset1B(); + } + } + + /* ── Физика II : F = m·a ──────────────────────────────────── */ + + _step2(dt) { + if (!this._2.running) return; + const { W } = this; + const S = NewtonSim.SCALE; + const a1 = (this.force / this.mass1) * S; + const a2 = (this.force / this.mass2) * S; + + this._2.b1vx += a1 * dt; this._2.b1x += this._2.b1vx * dt; + this._2.b2vx += a2 * dt; this._2.b2x += this._2.b2vx * dt; + this._2.t += dt; + + /* История для графика (примерно 20 точек/с) */ + if (this._2.t % 0.05 < dt) { + this._2.history.push({ v1: this._2.b1vx / S, v2: this._2.b2vx / S }); + if (this._2.history.length > 130) this._2.history.shift(); + } + + /* Сброс при достижении правого края */ + if (this._2.b1x > W * 0.89 || this._2.b2x > W * 0.89) { + this._reset2(); this._2.running = true; + } + } + + /* ── Физика III-A : пушка ─────────────────────────────────── */ + + _step3A(dt) { + const s = this._3A; + const { W, _g: g } = this; + const S = NewtonSim.SCALE; + const GV = NewtonSim.G * S; + + if (!s.fired) return; + + if (this._3A.forceFlash > 0) this._3A.forceFlash -= dt; + + /* Пушка тормозит */ + if (Math.abs(s.cvx) > 2) { + const dec = this.mu * GV * dt; + if (dec >= Math.abs(s.cvx)) s.cvx = 0; + else s.cvx -= Math.sign(s.cvx) * dec; + } else { s.cvx = 0; } + s.cx = Math.max(60, Math.min(W - 80, s.cx + s.cvx * dt)); + + /* Ядро (баллистика) */ + if (s.ball) { + s.ball.vy += GV * dt; + s.ball.x += s.ball.vx * dt; + s.ball.y += s.ball.vy * dt; + if (s.ball.y > g.gY + 40 || s.ball.x > W + 120 || s.ball.x < -120) s.ball = null; + } + + /* Искры */ + for (const sp of s.sparks) { + sp.x += sp.vx * dt; sp.y += sp.vy * dt; + sp.vy += GV * 0.6 * dt; sp.life -= dt * 2; + } + s.sparks = s.sparks.filter(sp => sp.life > 0); + } + + /* ── Физика III-B : столкновение ─────────────────────────── */ + + _step3B(dt) { + const { b1, b2 } = this._3B; + const { W, _g: g } = this; + const S = NewtonSim.SCALE; + + b1.x += b1.vx * dt; + b2.x += b2.vx * dt; + + if (this._3B.colFlash > 0) this._3B.colFlash -= dt; + + /* Упругое столкновение */ + if (!this._3B.done) { + const dist = b2.x - b1.x; + if (dist < b1.r + b2.r) { + const m1 = b1.mass, m2 = b2.mass; + const v1 = b1.vx, v2 = b2.vx; + b1.vx = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2); + b2.vx = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2); + const overlap = b1.r + b2.r - dist; + b1.x -= overlap * 0.5; b2.x += overlap * 0.5; + this._3B.colFlash = 0.55; + this._3B.done = true; + } + } + + /* Минимальное трение поверхности (сохраняет видимость закона импульса) */ + if (this._3B.done) { + [b1, b2].forEach(b => { b.vx *= (1 - dt * 0.04); }); + } + + /* Авто-сброс */ + if (b1.x < -120 || b2.x > W + 120 || (this._3B.done && Math.abs(b1.vx) < 2 && Math.abs(b2.vx) < 2)) { + setTimeout(() => this._reset3B(), 1800); + this._3B.done = true; // prevent double reset + } + } + + /* ── Физика III-C : ракета ────────────────────────────────── */ + + _step3C(dt) { + const s = this._3C; + const { W, H } = this; + const g_vis = NewtonSim.G * 0.42; // visual gravity (px/s²) + + /* Gravity always acts — even after fuel is out */ + if (!s.running && s.fuel <= 0 && !s.stopped) { + s.rvy += g_vis * dt; + s.ry += s.rvy * dt; + if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; s.stopped = true; } + /* exhaust smoke fading */ + for (const p of s.particles) { p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6; } + s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20); + return; + } + + if (!s.running || s.fuel <= 0) { + if (s.fuel <= 0 && !s.stopped) { s.running = false; } + return; + } + + const dmdt = 0.025; // кг/с — сгорание топлива + const F_thr = 220; // Н — тяга (визуальная) + s.fuel = Math.max(0, s.fuel - dmdt * dt); + s.rmass = Math.max(2, 10 * s.fuel + 2); + + /* F_net = F_thrust - m·g */ + const a_thrust = F_thr / s.rmass; + const a_net = a_thrust - g_vis; + s.rvy -= a_net * dt; + s.ry += s.rvy * dt; + if (s.ry < H * 0.08) { s.ry = H * 0.08; s.rvy = 0; } + if (s.ry >= H * 0.78) { s.ry = H * 0.78; s.rvy = 0; } + + /* Частицы выхлопа */ + if (Math.random() < 0.55) { + s.particles.push({ + x: W * 0.5 + (Math.random() - 0.5) * 16, + y: s.ry + 48, + vx: (Math.random() - 0.5) * 38, + vy: 130 + Math.random() * 170, + r: 2 + Math.random() * 4, + life: 1, + }); + } + for (const p of s.particles) { + p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt * 1.6; + } + s.particles = s.particles.filter(p => p.life > 0 && p.y < H + 20); + } + + /* ── Мышь ─────────────────────────────────────────────────── */ + + _bindEvents() { + this.canvas.addEventListener('click', e => { + if (this.law !== 1 || this.scene !== 'A') return; + const r = this.canvas.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + const b = this._1A; + const dx = x - b.bx, dy = y - b.by; + const d = Math.hypot(dx, dy); + if (d < 4) return; + const spd = 340; + b.bvx = (dx / d) * spd; + b.bvy = (dy / d) * spd; + }); + } + + /* ═══════════════════════════════════════════════════════════ + РЕНДЕРИНГ + ═══════════════════════════════════════════════════════════ */ + + draw() { + const ctx = this.ctx; + const { W, H } = this; + ctx.clearRect(0, 0, W, H); + + /* Фон */ + const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82); + bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + /* Водяной знак — номер закона */ + ctx.save(); + ctx.font = 'bold 78px sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.023)'; + ctx.textAlign = 'center'; + ctx.fillText(['', 'ЗАКОН I', 'ЗАКОН II', 'ЗАКОН III'][this.law], W / 2, H * 0.60); + ctx.textAlign = 'left'; + ctx.restore(); + + if (this.law === 1 && this.scene === 'A') this._drawL1A(ctx); + else if (this.law === 1) this._drawL1B(ctx); + else if (this.law === 2) this._drawL2(ctx); + else if (this.scene === 'A') this._drawL3A(ctx); + else if (this.scene === 'B') this._drawL3B(ctx); + else this._drawL3C(ctx); + } + + /* ── Закон I — Сцена A ───────────────────────────────────── */ + + _drawL1A(ctx) { + const { W, H, _g: g } = this; + const b = this._1A; + const S = NewtonSim.SCALE; + const spd = Math.hypot(b.bvx, b.bvy); + + /* Звёздный фон при μ≈0 */ + if (this.mu < 0.02) { + this._stars(ctx); + ctx.font = 'bold 12px sans-serif'; + ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; + ctx.fillText('Трения нет — тело движется вечно!', W / 2, H * 0.10); + ctx.textAlign = 'left'; + } else { + this._ground(ctx, g.gY, W); + } + + /* След */ + if (b.trail.length > 2) { + for (let i = 2; i < b.trail.length; i++) { + const a = (i / b.trail.length) * 0.55; + ctx.strokeStyle = `rgba(255,209,102,${a})`; + ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(b.trail[i-1].x, b.trail[i-1].y - 2); + ctx.lineTo(b.trail[i].x, b.trail[i].y - 2); + ctx.stroke(); + } + } + + /* Блок */ + const by = b.by - b.BH / 2; + this._block(ctx, b.bx, by, b.BW, b.BH, '#9B5DE5', `${this.mass1} кг`); + + /* Вектор скорости */ + if (spd > 8) { + const scale = 0.28; + this._arrow(ctx, + b.bx, by, + b.bx + b.bvx * scale, by + b.bvy * scale, + '#FFD166', 'v = ' + (spd / S).toFixed(1) + ' м/с', 2.5); + } + + /* Сила трения (горизонтальная, только на земле) */ + if (!b.inAir && Math.abs(b.bvx) > 8 && this.mu > 0.01) { + const fFr = this.mu * this.mass1 * NewtonSim.G; + this._arrow(ctx, + b.bx, by - 32, + b.bx - Math.sign(b.bvx) * 55, by - 32, + '#EF476F', `F тр = ${fFr.toFixed(1)} Н`, 2); + } + + /* Подсказка */ + if (spd < 4) { + ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)'; + ctx.textAlign = 'center'; + ctx.fillText('Кликни куда угодно — придай импульс блоку', W / 2, g.gY + 28); + ctx.textAlign = 'left'; + } + + /* μ и формула */ + ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.75)'; + ctx.fillText(`μ = ${this.mu.toFixed(2)} F тр = μ · m · g`, 18, 26); + + this._caption(ctx, 'Тело продолжает движение\nпока не подействует сила', W, H); + } + + /* ── Закон I — Сцена B ───────────────────────────────────── */ + + _drawL1B(ctx) { + const { W, H, _g: g } = this; + const s = this._1B; + + this._stars(ctx); + + /* Орбита */ + ctx.save(); + ctx.setLineDash([5, 9]); + ctx.strokeStyle = 'rgba(100,165,255,0.20)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(g.cx, g.cy, g.orbitR, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); ctx.restore(); + + /* Центральное тело */ + ctx.save(); + ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 22; + ctx.beginPath(); ctx.arc(g.cx, g.cy, 20, 0, Math.PI * 2); + const cg = ctx.createRadialGradient(g.cx - 5, g.cy - 5, 0, g.cx, g.cy, 20); + cg.addColorStop(0, '#FFEA70'); cg.addColorStop(1, '#FF9500'); + ctx.fillStyle = cg; ctx.fill(); + ctx.restore(); + + /* Нить */ + if (!s.cut) { + ctx.strokeStyle = 'rgba(210,225,255,0.55)'; ctx.lineWidth = 1.8; + ctx.beginPath(); ctx.moveTo(g.cx, g.cy); ctx.lineTo(s.bx, s.by); ctx.stroke(); + + /* Стрелка натяжения (центростремительная) */ + const len = g.orbitR; + const tx = (g.cx - s.bx) / len * 36; + const ty = (g.cy - s.by) / len * 36; + const F_c = (this.mass1 * g.orbitR * s.omega * s.omega * NewtonSim.SCALE / NewtonSim.SCALE).toFixed(1); + this._arrow(ctx, s.bx, s.by, s.bx + tx, s.by + ty, '#4CC9F0', `T = F ц`, 1.8); + } else { + ctx.font = 'bold 14px sans-serif'; ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; + ctx.fillText('✂ Нить разрезана — тело летит прямолинейно!', W / 2, H * 0.10); + ctx.textAlign = 'left'; + + /* Вектор скорости по касательной */ + this._arrow(ctx, s.bx, s.by, + s.bx + s.bvx * 0.22, s.by + s.bvy * 0.22, + '#FFD166', 'v = const', 2.8); + } + + /* Шар */ + ctx.save(); + ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 14; + ctx.beginPath(); ctx.arc(s.bx, s.by, 16, 0, Math.PI * 2); + ctx.fillStyle = '#4CC9F0'; ctx.fill(); + ctx.restore(); + + /* Вектор скорости (во время орбиты) */ + if (!s.cut) { + this._arrow(ctx, s.bx, s.by, + s.bx + s.bvx * 0.18, s.by + s.bvy * 0.18, + '#FFD166', '', 2); + } + + this._caption(ctx, 'Без силы тело движется прямолинейно\nравномерно (1-й закон Ньютона)', W, H); + } + + /* ── Закон II ─────────────────────────────────────────────── */ + + _drawL2(ctx) { + const { W, H, _g: g } = this; + const S = NewtonSim.SCALE; + const a1 = this.force / this.mass1; + const a2 = this.force / this.mass2; + + this._ground(ctx, g.gY, W); + + /* Линия финиша */ + ctx.strokeStyle = 'rgba(255,255,255,0.13)'; ctx.lineWidth = 1; + ctx.setLineDash([6, 7]); + ctx.beginPath(); ctx.moveTo(W * 0.89, 0); ctx.lineTo(W * 0.89, g.gY + 8); ctx.stroke(); + ctx.setLineDash([]); + + const BW = 58, BH = 48; + + if (this.scene === 'A') { + /* ── Один блок ── */ + const { b1x: bx, b1vx: bvx } = this._2; + const by = g.gY - BH / 2; + + this._block(ctx, bx, by, BW, BH, '#EF476F', `${this.mass1} кг`); + + /* Сила F */ + this._arrow(ctx, bx + BW / 2, by, + bx + BW / 2 + 48 + this.force * 0.9, by, + '#EF476F', `F = ${this.force} Н`, 2.5); + + /* Ускорение a */ + const aLen = 32 + a1 * 5; + this._arrow(ctx, bx + BW / 2, by - 32, + bx + BW / 2 + aLen, by - 32, + '#7BF5A4', `a = ${a1.toFixed(1)} м/с²`, 2.5); + + /* Скорость v */ + if (bvx > 8) { + const v = bvx / S; + this._arrow(ctx, bx + BW / 2, by + 32, + bx + BW / 2 + bvx * 0.28, by + 32, + '#FFD166', `v = ${v.toFixed(1)} м/с`, 2); + } + + /* Уравнение F = m·a */ + this._fma(ctx, this.force, this.mass1, a1, W / 2, H * 0.12); + + /* Мини-график v(t) */ + if (this._2.history.length > 3) { + this._graph(ctx, this._2.history.map(h => h.v1), + W * 0.72, 14, 145, 62, '#FFD166', 'v(t) м/с'); + } + + if (!this._2.running) { + ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)'; + ctx.textAlign = 'center'; + ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28); + ctx.textAlign = 'left'; + } + + } else { + /* ── Сравнение двух масс ── */ + const y1 = g.gY - BH - 6, y2 = g.gY + 4; + + /* Разделитель дорожек */ + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 7]); + ctx.beginPath(); ctx.moveTo(0, g.gY - BH / 2 - 4); ctx.lineTo(W, g.gY - BH / 2 - 4); ctx.stroke(); + ctx.setLineDash([]); + + const bx1 = this._2.b1x, bx2 = this._2.b2x; + + this._block(ctx, bx1, y1, BW, BH, '#EF476F', `${this.mass1} кг`); + this._block(ctx, bx2, y2, BW, BH, '#4CC9F0', `${this.mass2} кг`); + + /* Силы (одинаковые) */ + const fLen = 40 + this.force * 0.9; + this._arrow(ctx, bx1 + BW / 2, y1 + BH / 2, bx1 + BW / 2 + fLen, y1 + BH / 2, '#EF476F', `F=${this.force}Н`, 2); + this._arrow(ctx, bx2 + BW / 2, y2 + BH / 2, bx2 + BW / 2 + fLen, y2 + BH / 2, '#4CC9F0', `F=${this.force}Н`, 2); + + /* Ускорения */ + ctx.font = 'bold 11px monospace'; + ctx.fillStyle = '#7BF5A4'; ctx.fillText(`a₁=${a1.toFixed(1)} м/с²`, bx1 + 2, y1 - 8); + ctx.fillStyle = '#06D6E0'; ctx.fillText(`a₂=${a2.toFixed(1)} м/с²`, bx2 + 2, y2 - 8); + + /* Вывод */ + if (this._2.running && bx1 > bx2 + 20) { + ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; + ctx.fillText('Меньше масса → больше ускорение!', W / 2, g.gY + 26); + ctx.textAlign = 'left'; + } + + /* Графики */ + if (this._2.history.length > 3) { + this._graph(ctx, this._2.history.map(h => h.v1), W * 0.68, 14, 130, 58, '#EF476F', 'v₁ м/с'); + this._graph(ctx, this._2.history.map(h => h.v2), W * 0.68, 80, 130, 58, '#4CC9F0', 'v₂ м/с'); + } + + if (!this._2.running) { + ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)'; + ctx.textAlign = 'center'; + ctx.fillText('Нажмите «Старт» чтобы запустить', W / 2, g.gY + 28); + ctx.textAlign = 'left'; + } + } + + this._caption(ctx, 'F = m · a', W, H); + } + + /* ── Закон III — Сцена A : пушка ────────────────────────── */ + + _drawL3A(ctx) { + const { W, H, _g: g } = this; + const s = this._3A; + const S = NewtonSim.SCALE; + const CW = 124, CH = 42; + + this._ground(ctx, g.gY, W); + + /* Корпус пушки */ + ctx.save(); + ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 14; + _nwt_rrect(ctx, s.cx - CW / 2, g.gY - CH - 4, CW, CH, 8); + const cg = ctx.createLinearGradient(0, g.gY - CH - 4, 0, g.gY - 4); + cg.addColorStop(0, '#5a3a7a'); cg.addColorStop(1, '#3a2260'); + ctx.fillStyle = cg; ctx.fill(); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.8; ctx.stroke(); + ctx.restore(); + + /* Ствол */ + ctx.save(); + ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 7; + _nwt_rrect(ctx, s.cx + CW / 2 - 8, g.gY - CH / 2 - 8 - 4, 58, 16, 4); + ctx.fillStyle = '#7340a0'; ctx.fill(); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke(); + ctx.restore(); + + /* Колёса */ + [s.cx - CW / 2 + 18, s.cx + CW / 2 - 18].forEach(wx => { + ctx.save(); ctx.shadowColor = '#9B5DE5'; ctx.shadowBlur = 6; + ctx.beginPath(); ctx.arc(wx, g.gY, 10, 0, Math.PI * 2); + ctx.fillStyle = '#4a2870'; ctx.fill(); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.stroke(); + ctx.restore(); + }); + + /* Масса пушки */ + ctx.font = 'bold 11px monospace'; ctx.fillStyle = 'rgba(200,180,255,0.9)'; + ctx.textAlign = 'center'; + ctx.fillText(`M = ${this.mass2} кг`, s.cx, g.gY - CH - 16); + ctx.textAlign = 'left'; + + /* Ядро */ + if (s.ball) { + ctx.save(); + ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc(s.ball.x, s.ball.y, 12, 0, Math.PI * 2); + ctx.fillStyle = '#FFD166'; ctx.fill(); + ctx.restore(); + ctx.font = '10px monospace'; ctx.fillStyle = 'rgba(255,209,102,0.82)'; + ctx.textAlign = 'center'; + ctx.fillText(`m = ${this.mass1} кг`, s.ball.x, s.ball.y + 26); + ctx.textAlign = 'left'; + } + + /* Стрелки сил (сразу после выстрела) */ + if (s.forceFlash > 0) { + const alpha = Math.min(1, s.forceFlash * 2.5); + const fScale = 72 * alpha; + const ny = g.gY - CH - 32; + /* Сила на ядро вправо */ + this._arrow(ctx, s.cx + CW / 2 + 20, ny, s.cx + CW / 2 + 20 + fScale, ny, '#EF476F', 'Fядро', 2.5); + /* Реакция на пушку влево */ + this._arrow(ctx, s.cx - CW / 2 - 20, ny, s.cx - CW / 2 - 20 - fScale, ny, '#4CC9F0', 'Fпушка', 2.5); + + ctx.save(); ctx.globalAlpha = alpha; + ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; + ctx.fillText('|F→ядро| = |F→пушка|', s.cx, ny - 22); + ctx.restore(); + } + + /* Скорости (после выстрела, когда искры погасли) */ + if (s.fired && s.sparks.length === 0 && s.forceFlash <= 0) { + if (s.cvx !== 0) { + this._arrow(ctx, s.cx, g.gY - CH / 2 - 4, s.cx + s.cvx * 0.35, g.gY - CH / 2 - 4, + '#4CC9F0', `V₂=${(s.cvx/S).toFixed(1)}м/с`, 2); + } + if (s.ball) { + this._arrow(ctx, s.ball.x, s.ball.y, s.ball.x + s.ball.vx * 0.12, s.ball.y + s.ball.vy * 0.12, + '#EF476F', `V₁=${(s.ball.vx/S).toFixed(1)}м/с`, 2); + } + /* Сохранение импульса */ + const p1 = (this.mass1 * 360 / S).toFixed(1); + const p2 = Math.abs(this.mass2 * (this.mass1 / this.mass2) * 360 / S).toFixed(1); + ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)'; + ctx.textAlign = 'center'; + ctx.fillText(`p₁ = ${p1} кг·м/с p₂ = ${p2} кг·м/с Δp_total = 0`, W / 2, H * 0.11); + ctx.textAlign = 'left'; + } + + /* Искры */ + for (const sp of s.sparks) { + ctx.save(); ctx.globalAlpha = sp.life; + ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(sp.x, sp.y, 3 * sp.life, 0, Math.PI * 2); + ctx.fillStyle = sp.life > 0.5 ? '#FFD166' : '#EF476F'; ctx.fill(); + ctx.restore(); + } + + if (!s.fired) { + ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)'; + ctx.textAlign = 'center'; + ctx.fillText('Нажмите «Выстрел!» чтобы запустить ядро', W / 2, g.gY + 28); + ctx.textAlign = 'left'; + } + + this._caption(ctx, 'Действие = Противодействие\nF₁ = −F₂', W, H); + } + + /* ── Закон III — Сцена B : столкновение ─────────────────── */ + + _drawL3B(ctx) { + const { W, H, _g: g } = this; + const { b1, b2, colFlash } = this._3B; + const S = NewtonSim.SCALE; + const cy = g.cy + 15; + + /* Дорожка */ + ctx.fillStyle = 'rgba(60,90,160,0.07)'; + ctx.fillRect(0, cy - 50, W, 100); + + /* Тени-следы */ + [b1, b2].forEach(b => { + if (Math.abs(b.vx) < 4) return; + ctx.save(); ctx.globalAlpha = 0.14; + for (let s2 = 1; s2 <= 3; s2++) { + ctx.beginPath(); + ctx.arc(b.x - b.vx * 0.06 * s2, cy, b.r * (1 - s2 * 0.1), 0, Math.PI * 2); + ctx.fillStyle = b.color; ctx.fill(); + } + ctx.restore(); + }); + + /* Шары */ + [b1, b2].forEach(b => { + ctx.save(); + ctx.shadowColor = b.color; ctx.shadowBlur = 16; + ctx.beginPath(); ctx.arc(b.x, cy, b.r, 0, Math.PI * 2); + const bg2 = ctx.createRadialGradient(b.x - b.r * 0.3, cy - b.r * 0.3, 0, b.x, cy, b.r); + bg2.addColorStop(0, _nwt_lighten(b.color, 65)); + bg2.addColorStop(1, b.color); + ctx.fillStyle = bg2; ctx.fill(); + ctx.restore(); + /* Масса и скорость */ + ctx.font = 'bold 12px monospace'; ctx.fillStyle = 'rgba(225,235,255,0.9)'; + ctx.textAlign = 'center'; + ctx.fillText(`${b.mass} кг`, b.x, cy + b.r + 19); + if (Math.abs(b.vx) > 6) { + ctx.fillStyle = '#FFD166'; + ctx.fillText(`v=${( b.vx / S ).toFixed(1)}`, b.x, cy - b.r - 8); + } + ctx.textAlign = 'left'; + }); + + /* Вспышка сил при ударе */ + if (colFlash > 0.06) { + const mx = (b1.x + b2.x) / 2; + const fY = cy - b1.r - 28; + const a = Math.min(1, colFlash * 2.5); + const len = 65 * a; + this._arrow(ctx, mx, fY, mx - len, fY, '#EF476F', 'F₁₂', 2.5); + this._arrow(ctx, mx, fY + 8, mx + len, fY + 8, '#4CC9F0', 'F₂₁', 2.5); + ctx.save(); ctx.globalAlpha = a; + ctx.font = 'bold 12px sans-serif'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; ctx.fillText('|F₁₂| = |F₂₁|', mx, fY - 18); ctx.restore(); + } + + /* Импульс */ + const p1 = (b1.mass * b1.vx / S).toFixed(2); + const p2 = (b2.mass * b2.vx / S).toFixed(2); + const pt = ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2); + ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)'; + ctx.textAlign = 'center'; + ctx.fillText(`p₁ = ${p1} p₂ = ${p2} p(сумм) = ${pt} кг·м/с`, W / 2, H * 0.12); + ctx.textAlign = 'left'; + + this._caption(ctx, 'Δp₁ = −Δp₂ (импульс сохраняется)', W, H); + } + + /* ── Закон III — Сцена C : ракета ───────────────────────── */ + + _drawL3C(ctx) { + const { W, H } = this; + const s = this._3C; + const S = NewtonSim.SCALE; + const rx = W / 2; + + this._stars(ctx); + + /* Частицы выхлопа */ + for (const p of s.particles) { + ctx.save(); + ctx.globalAlpha = p.life * 0.85; + const col = p.life > 0.6 ? '#FFD166' : p.life > 0.3 ? '#FF6B35' : '#EF476F'; + ctx.shadowColor = col; ctx.shadowBlur = 7; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * p.life, 0, Math.PI * 2); + ctx.fillStyle = col; ctx.fill(); + ctx.restore(); + } + + /* Ракета */ + const ry = s.ry; + ctx.save(); ctx.shadowColor = '#4CC9F0'; ctx.shadowBlur = 18; + + /* Фюзеляж */ + _nwt_rrect(ctx, rx - 17, ry - 50, 34, 62, 7); + const rg = ctx.createLinearGradient(rx - 17, 0, rx + 17, 0); + rg.addColorStop(0, '#1a3a5a'); rg.addColorStop(0.5, '#4CC9F0'); rg.addColorStop(1, '#1a3a5a'); + ctx.fillStyle = rg; ctx.fill(); + ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1.5; ctx.stroke(); + + /* Нос */ + ctx.beginPath(); + ctx.moveTo(rx, ry - 72); ctx.lineTo(rx - 15, ry - 50); ctx.lineTo(rx + 15, ry - 50); + ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill(); + + /* Плавники */ + [[-1], [1]].forEach(([dx]) => { + ctx.beginPath(); + ctx.moveTo(rx + dx * 17, ry + 12); + ctx.lineTo(rx + dx * 32, ry + 32); + ctx.lineTo(rx + dx * 17, ry + 22); + ctx.closePath(); ctx.fillStyle = '#06D6E0'; ctx.fill(); + }); + /* Иллюминатор */ + ctx.beginPath(); ctx.arc(rx, ry - 20, 7, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(200,240,255,0.25)'; ctx.fill(); + ctx.strokeStyle = '#4CC9F0'; ctx.lineWidth = 1; ctx.stroke(); + ctx.restore(); + + /* Стрелки сил */ + if (s.running) { + const g_vis = NewtonSim.G * 0.42; + const a_thrust = 220 / s.rmass; + const a_net = (a_thrust - g_vis).toFixed(1); + this._arrow(ctx, rx, ry - 55, rx, ry - 55 - 52, '#7BF5A4', `F тяга`, 2.5); + this._arrow(ctx, rx - 38, ry, rx - 38, ry + 36, '#FFD166', 'mg', 1.8); + this._arrow(ctx, rx, ry + 25, rx, ry + 80, '#EF476F', 'F газ', 2.5); + ctx.font = '12px monospace'; ctx.fillStyle = '#7BF5A4'; + ctx.textAlign = 'center'; + ctx.fillText(`a = F/m − g = ${a_net} м/с²`, rx, ry - 110); + ctx.textAlign = 'left'; + } + /* Falling after fuel out — show gravity arrow */ + if (s.fuel <= 0 && !s.stopped) { + this._arrow(ctx, rx, ry + 25, rx, ry + 65, '#EF476F', 'mg', 2.5); + ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#EF476F'; + ctx.textAlign = 'center'; ctx.fillText('Топливо кончилось — ракета падает!', W / 2, H * 0.15); ctx.textAlign = 'left'; + } + + /* Инфо */ + ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.82)'; + ctx.textAlign = 'center'; + ctx.fillText(`Масса: ${s.rmass.toFixed(1)} кг Топливо: ${(s.fuel * 100).toFixed(0)}%`, W / 2, H * 0.94); + ctx.textAlign = 'left'; + + if (s.fuel <= 0 && s.stopped) { + ctx.font = 'bold 13px sans-serif'; ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'center'; ctx.fillText('Ракета приземлилась. Нажмите «Запуск» для сброса.', W / 2, H * 0.15); ctx.textAlign = 'left'; + } else if (!s.running && s.fuel > 0) { + ctx.font = '13px sans-serif'; ctx.fillStyle = 'rgba(185,210,255,0.38)'; + ctx.textAlign = 'center'; ctx.fillText('Нажмите «Запуск» для включения двигателя', W / 2, H * 0.50); ctx.textAlign = 'left'; + } + + this._caption(ctx, 'Газ вниз ракета вверх\n(3-й закон Ньютона)', W, H); + } + + /* ── Вспомогательные рисовалки ──────────────────────────── */ + + _ground(ctx, gY, W) { + const mu = this.mu; + /* Поверхность */ + const gg = ctx.createLinearGradient(0, gY, 0, gY + 42); + gg.addColorStop(0, mu < 0.1 ? '#182535' : mu < 0.45 ? '#1c1f2d' : '#201420'); + gg.addColorStop(1, '#0c101a'); + ctx.fillStyle = gg; ctx.fillRect(0, gY, W, 55); + /* Линия */ + ctx.strokeStyle = mu < 0.1 ? 'rgba(76,201,240,0.42)' : mu < 0.45 ? 'rgba(155,93,229,0.42)' : 'rgba(239,71,111,0.42)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, gY); ctx.lineTo(W, gY); ctx.stroke(); + /* Штриховка */ + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; + for (let x = 0; x < W; x += 22) { + ctx.beginPath(); ctx.moveTo(x, gY); ctx.lineTo(x + 12, gY + 12); ctx.stroke(); + } + /* Шероховатость (при высоком трении) */ + if (mu > 0.06) { + ctx.fillStyle = `rgba(255,255,255,${mu * 0.055})`; + for (let x = 9; x < W; x += 20) { + ctx.beginPath(); ctx.arc(x, gY + 5, 2.5, 0, Math.PI * 2); ctx.fill(); + } + } + } + + _stars(ctx) { + const { W, H } = this; + for (let i = 0; i < 65; i++) { + const x = ((i * 139.5 + 7) % W); + const y = ((i * 97.3 + 5) % (H * 0.88)); + const r = i % 8 === 0 ? 1.6 : 0.9; + ctx.fillStyle = `rgba(255,255,255,${0.35 + (i % 3) * 0.18})`; + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); + } + } + + _block(ctx, cx, cy, w, h, color, label) { + ctx.save(); + ctx.shadowColor = color; ctx.shadowBlur = 10; + _nwt_rrect(ctx, cx - w / 2, cy - h / 2, w, h, 7); + const bg = ctx.createLinearGradient(cx - w/2, cy - h/2, cx + w/2, cy + h/2); + bg.addColorStop(0, _nwt_lighten(color, 45)); + bg.addColorStop(1, color); + ctx.fillStyle = bg; ctx.fill(); + ctx.strokeStyle = _nwt_lighten(color, 60); ctx.lineWidth = 1.5; ctx.stroke(); + ctx.shadowBlur = 0; + ctx.font = 'bold 11px monospace'; ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(label, cx, cy); + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + ctx.restore(); + } + + _arrow(ctx, x1, y1, x2, y2, color, label, lw = 2) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy); + if (len < 5) return; + const ux = dx / len, uy = dy / len; + const hw = 7, hl = 13; + ctx.save(); + ctx.strokeStyle = color; ctx.lineWidth = lw; + ctx.shadowColor = color; ctx.shadowBlur = 6; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2 - ux * hl, y2 - uy * hl); + ctx.stroke(); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ux * hl - uy * hw, y2 - uy * hl + ux * hw); + ctx.lineTo(x2 - ux * hl + uy * hw, y2 - uy * hl - ux * hw); + ctx.closePath(); ctx.fill(); + if (label) { + ctx.shadowBlur = 0; ctx.font = '11px monospace'; ctx.fillStyle = color; + const lx = (x1 + x2) / 2 - uy * 16; + const ly = (y1 + y2) / 2 + ux * 16; + ctx.textAlign = 'center'; ctx.fillText(label, lx, ly); ctx.textAlign = 'left'; + } + ctx.restore(); + } + + _fma(ctx, F, m, a, cx, y) { + ctx.save(); + ctx.font = 'bold 15px monospace'; + ctx.textAlign = 'center'; + ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 14; + ctx.fillStyle = 'rgba(123,245,164,0.88)'; + ctx.fillText(`F = m·a ${F} Н = ${m} кг × ${a.toFixed(1)} м/с²`, cx, y); + ctx.restore(); + } + + _graph(ctx, data, x, y, w, h, color, label) { + if (data.length < 2) return; + const max = Math.max(...data, 0.01); + _nwt_rrect(ctx, x, y, w, h, 4); + ctx.fillStyle = 'rgba(0,0,0,0.38)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.strokeStyle = color; ctx.lineWidth = 1.6; + ctx.beginPath(); + data.forEach((v, i) => { + const px = x + (i / (data.length - 1)) * w; + const py = y + h - 3 - (v / max) * (h - 7); + if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + }); + ctx.stroke(); + ctx.font = '9px monospace'; ctx.fillStyle = color; + ctx.fillText(label, x + 3, y + 11); + ctx.fillText(max.toFixed(1), x + 3, y + h - 3); + } + + _caption(ctx, text, W, H) { + ctx.save(); + ctx.font = 'italic 12px sans-serif'; + ctx.fillStyle = 'rgba(185,210,255,0.35)'; + ctx.textAlign = 'right'; + text.split('\n').forEach((line, i) => ctx.fillText(line, W - 16, H * 0.90 + i * 18)); + ctx.textAlign = 'left'; + ctx.restore(); + } + + /* ── Info ──────────────────────────────────────────────────── */ + + info() { + const S = NewtonSim.SCALE; + const base = { law: this.law, scene: this.scene }; + + if (this.law === 1 && this.scene === 'A') { + const b = this._1A; + const spd = Math.hypot(b.bvx, b.bvy) / S; + const fFr = this.mu * this.mass1 * NewtonSim.G; + return { ...base, v: spd.toFixed(2), fFr: fFr.toFixed(2), mu: this.mu.toFixed(2), m: this.mass1 }; + } + if (this.law === 1) { + const s = this._1B; + const spd = Math.hypot(s.bvx, s.bvy) / S; + return { ...base, v: spd.toFixed(2), cut: s.cut }; + } + if (this.law === 2) { + const a = this.force / this.mass1; + const v = this._2.b1vx / S; + return { ...base, F: this.force, m: this.mass1, a: a.toFixed(2), v: v.toFixed(2) }; + } + if (this.scene === 'A') { + const vBall = this._3A.ball ? (this._3A.ball.vx / S).toFixed(1) : '—'; + const vCannon = (this._3A.cvx / S).toFixed(2); + return { ...base, vBall, vCannon, m1: this.mass1, m2: this.mass2 }; + } + if (this.scene === 'B') { + const { b1, b2 } = this._3B; + return { ...base, + p1: (b1.mass * b1.vx / S).toFixed(2), + p2: (b2.mass * b2.vx / S).toFixed(2), + pt: ((b1.mass * b1.vx + b2.mass * b2.vx) / S).toFixed(2), + }; + } + /* III-C rocket */ + const s = this._3C; + const g_vis = NewtonSim.G * 0.42; + const a_net = s.running ? (220 / s.rmass - g_vis) : (s.stopped ? 0 : -g_vis); + return { ...base, + a: a_net.toFixed(1), + v: Math.abs(s.rvy / S).toFixed(2), + fuel: (s.fuel * 100).toFixed(0), + m: s.rmass.toFixed(1), + }; + } +} + +/* ── Утилиты ─────────────────────────────────────────────────── */ + +function _nwt_rrect(ctx, x, y, w, h, r) { + if (w <= 0 || h <= 0) return; + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function _nwt_lighten(hex, d) { + const n = parseInt(hex.slice(1), 16); + const c = v => Math.max(0, Math.min(255, v)); + return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`; +} diff --git a/frontend/js/labs/normaldist.js b/frontend/js/labs/normaldist.js new file mode 100644 index 0000000..e75bff6 --- /dev/null +++ b/frontend/js/labs/normaldist.js @@ -0,0 +1,393 @@ +'use strict'; +/** + * NormalDistSim v2 — интерактивное нормальное распределение + * μ, σ · правило 68-95-99.7 · Z-score · закрашивание области + * Чистый рерайт: без SVG-строк в info(), лучшая визуализация. + */ +class NormalDistSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.mu = 0; + this.sigma = 1; + this.shade = '1s'; // 'none' | '1s' | '2s' | '3s' | 'custom' + this.zLow = -1; + this.zHigh = 1; + this.hx = null; + + this.onUpdate = null; + this._bind(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + // ── public API ──────────────────────────────────────────────── + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setParams({ mu, sigma, shade, zLow, zHigh } = {}) { + if (mu !== undefined) this.mu = +mu; + if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma); + if (shade !== undefined) this.shade = shade; + if (zLow !== undefined) this.zLow = +zLow; + if (zHigh !== undefined) this.zHigh = +zHigh; + this.draw(); this._emit(); + } + + info() { + const { mu, sigma, shade } = this; + let areaLabel = '\u2014', areaPct = 0; + if (shade === '1s') { areaPct = 68.27; areaLabel = '\u03bc \u00b1 1\u03c3 \u2192 68.27%'; } + else if (shade === '2s') { areaPct = 95.45; areaLabel = '\u03bc \u00b1 2\u03c3 \u2192 95.45%'; } + else if (shade === '3s') { areaPct = 99.73; areaLabel = '\u03bc \u00b1 3\u03c3 \u2192 99.73%'; } + else if (shade === 'custom') { + areaPct = (this._phi(this.zHigh) - this._phi(this.zLow)) * 100; + areaLabel = `Z \u2208 [${this.zLow.toFixed(1)}, ${this.zHigh.toFixed(1)}] \u2192 ${areaPct.toFixed(2)}%`; + } + return { + mu: mu.toFixed(1), + sigma: sigma.toFixed(2), + peak: (1 / (sigma * Math.sqrt(2 * Math.PI))).toFixed(4), + area: areaLabel, + areaPct: areaPct.toFixed(2), + }; + } + + // ── math ───────────────────────────────────────────────────── + + _pdf(x) { + const z = (x - this.mu) / this.sigma; + return Math.exp(-0.5 * z * z) / (this.sigma * Math.sqrt(2 * Math.PI)); + } + + _phi(z) { + const a1=0.254829592, a2=-0.284496736, a3=1.421413741, a4=-1.453152027, a5=1.061405429, p=0.3275911; + const sign = z < 0 ? -1 : 1; + const t = 1 / (1 + p * Math.abs(z) / Math.SQRT2); + const y = 1 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t * Math.exp(-z*z/2); + return 0.5 * (1 + sign * y); + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + // ── coordinate transforms ───────────────────────────────────── + + _pad() { return { PL: 52, PR: 22, PT: 38, PB: 50 }; } + + _xToP(x, xMin, xMax, PL, pw) { return PL + (x - xMin) / (xMax - xMin) * pw; } + _yToP(y, yMax, PT, ph) { return PT + ph - (y / yMax) * ph; } + _pToX(px, xMin, xMax, PL, pw){ return xMin + (px - PL) / pw * (xMax - xMin); } + + // ── draw ───────────────────────────────────────────────────── + + draw() { + const { ctx, W, H, mu, sigma } = this; + if (!W || !H) return; + const { PL, PR, PT, PB } = this._pad(); + const pw = W - PL - PR, ph = H - PT - PB; + const xMin = mu - 4.5 * sigma, xMax = mu + 4.5 * sigma; + const yMax = this._pdf(mu) * 1.18; + + ctx.fillStyle = '#0D0D1A'; ctx.fillRect(0, 0, W, H); + + this._drawGrid (PL, PT, pw, ph, xMin, xMax, yMax); + this._drawShade (PL, PT, pw, ph, xMin, xMax, yMax); + this._drawCurve (PL, PT, pw, ph, xMin, xMax, yMax); + this._drawLabels (PL, PT, pw, ph, xMin, xMax, yMax); + this._drawBadge (PL, PT, pw, ph); + if (this.hx !== null) this._drawHover(PL, PT, pw, ph, xMin, xMax, yMax); + } + + _drawGrid(PL, PT, pw, ph, xMin, xMax, yMax) { + const { ctx, mu, sigma } = this; + const bottom = PT + ph; + const FN = 'Manrope, sans-serif'; + + // Horizontal grid + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + const py = PT + ph * (1 - i / 4); + ctx.beginPath(); ctx.moveTo(PL, py); ctx.lineTo(PL + pw, py); ctx.stroke(); + } + + // Vertical sigma grid lines + for (let s = -4; s <= 4; s++) { + const x = mu + s * sigma; + if (x < xMin || x > xMax) continue; + const px = this._xToP(x, xMin, xMax, PL, pw); + ctx.strokeStyle = s === 0 + ? 'rgba(6,214,224,0.22)' + : `rgba(255,255,255,${0.04 + (Math.abs(s) <= 2 ? 0.03 : 0)})`; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke(); + } + + // Axes + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(PL, bottom); ctx.lineTo(PL + pw, bottom); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, bottom); ctx.stroke(); + + // X-axis labels (sigma notation) + ctx.font = `11px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + for (let s = -4; s <= 4; s++) { + const x = mu + s * sigma; + if (x < xMin || x > xMax) continue; + const px = this._xToP(x, xMin, xMax, PL, pw); + const lbl = s === 0 ? '\u03bc' : (s > 0 ? `+${s}\u03c3` : `${s}\u03c3`); + ctx.fillText(lbl, px, bottom + 6); + } + + // Actual x values below + ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(255,255,255,0.18)'; + for (let s = -3; s <= 3; s++) { + const x = mu + s * sigma; + if (x < xMin || x > xMax) continue; + const px = this._xToP(x, xMin, xMax, PL, pw); + const dec = sigma < 1 ? 1 : 0; + ctx.fillText(x.toFixed(dec), px, bottom + 20); + } + + // Y-axis labels + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.font = `10px ${FN}`; + for (let i = 0; i <= 4; i++) { + const v = (yMax / 4) * i; + const py = PT + ph - (v / yMax) * ph; + ctx.fillText(v.toFixed(2), PL - 6, py); + } + + // Axis names + ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.font = `10px ${FN}`; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('x', PL + pw / 2, PT + ph + 36); + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('f(x)', PL + 6, PT); + } + + _drawShade(PL, PT, pw, ph, xMin, xMax, yMax) { + const { ctx, mu, sigma, shade } = this; + let lo, hi; + if (shade === '1s') { lo = mu - sigma; hi = mu + sigma; } + else if (shade === '2s') { lo = mu - 2 * sigma; hi = mu + 2 * sigma; } + else if (shade === '3s') { lo = mu - 3 * sigma; hi = mu + 3 * sigma; } + else if (shade === 'custom') { lo = mu + this.zLow * sigma; hi = mu + this.zHigh * sigma; } + else return; + + const bottom = PT + ph; + const steps = 240; + const dx = (hi - lo) / steps; + const xp = x => this._xToP(x, xMin, xMax, PL, pw); + const yp = y => this._yToP(y, yMax, PT, ph); + + // Filled area with gradient + const grd = ctx.createLinearGradient(xp(lo), 0, xp(hi), 0); + grd.addColorStop(0, 'rgba(155,93,229,0.10)'); + grd.addColorStop(0.5, 'rgba(155,93,229,0.30)'); + grd.addColorStop(1, 'rgba(155,93,229,0.10)'); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.moveTo(xp(lo), bottom); + for (let i = 0; i <= steps; i++) { + const x = lo + i * dx; + ctx.lineTo(xp(x), yp(this._pdf(x))); + } + ctx.lineTo(xp(hi), bottom); + ctx.closePath(); ctx.fill(); + + // Border dashes + ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); + for (const bx of [lo, hi]) { + const px = xp(bx); + ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke(); + } + ctx.setLineDash([]); + } + + _drawCurve(PL, PT, pw, ph, xMin, xMax, yMax) { + const { ctx } = this; + const steps = Math.min(pw * 2, 500); + const dx = (xMax - xMin) / steps; + const xp = x => this._xToP(x, xMin, xMax, PL, pw); + const yp = y => this._yToP(y, yMax, PT, ph); + + // Glow layer + ctx.strokeStyle = 'rgba(155,93,229,0.1)'; ctx.lineWidth = 10; ctx.lineJoin = 'round'; + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const x = xMin + i * dx; + i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x))); + } + ctx.stroke(); + + // Main curve + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; + ctx.beginPath(); + for (let i = 0; i <= steps; i++) { + const x = xMin + i * dx; + i === 0 ? ctx.moveTo(xp(x), yp(this._pdf(x))) : ctx.lineTo(xp(x), yp(this._pdf(x))); + } + ctx.stroke(); + + // μ marker + const muPx = xp(this.mu); + const bottom = PT + ph; + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(muPx, PT); ctx.lineTo(muPx, bottom); ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = '#06D6E0'; + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(`\u03bc = ${this.mu.toFixed(1)}`, muPx, PT - 4); + + // Peak label + const peakPx = xp(this.mu); + const peakPy = yp(this._pdf(this.mu)); + ctx.fillStyle = 'rgba(155,93,229,0.5)'; + ctx.font = '9px Manrope, sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; + const peakVal = (1 / (this.sigma * Math.sqrt(2 * Math.PI))).toFixed(3); + ctx.fillText('f(μ) = ' + peakVal, peakPx + 6, peakPy - 2); + } + + _drawLabels(PL, PT, pw, ph, xMin, xMax, yMax) { + // sigma annotation brackets + const { ctx, mu, sigma, shade } = this; + if (shade === 'none') return; + const nSig = shade === '1s' ? 1 : shade === '2s' ? 2 : shade === '3s' ? 3 : null; + if (!nSig) return; + + const bottom = PT + ph; + const FN = 'Manrope, sans-serif'; + const xp = x => this._xToP(x, xMin, xMax, PL, pw); + const yp = y => this._yToP(y, yMax, PT, ph); + + // Annotate ±nσ points with small bracket + const lo = mu - nSig * sigma, hi = mu + nSig * sigma; + const loPx = xp(lo), hiPx = xp(hi); + const midY = bottom + 32; + + ctx.save(); + ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(loPx, bottom + 4); ctx.lineTo(loPx, midY); + ctx.lineTo(hiPx, midY); ctx.lineTo(hiPx, bottom + 4); + ctx.stroke(); + + ctx.fillStyle = 'rgba(155,93,229,0.55)'; + ctx.font = `10px ${FN}`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('\u00b1' + nSig + '\u03c3', (loPx + hiPx) / 2, midY + 2); + ctx.restore(); + } + + _drawBadge(PL, PT, pw, ph) { + const { ctx, shade } = this; + if (shade === 'none') return; + const info = this.info(); + const pct = parseFloat(info.areaPct); + if (!pct) return; + + const FN = 'Manrope, sans-serif'; + ctx.save(); + ctx.font = `bold 15px ${FN}`; + const text = pct.toFixed(2) + '%'; + const tw = ctx.measureText(text).width; + const bw = tw + 24, bh = 28; + const bx = PL + pw - bw - 4, by = PT + 4; + + ctx.fillStyle = 'rgba(155,93,229,0.16)'; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill(); + ctx.strokeStyle = 'rgba(155,93,229,0.38)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.stroke(); + ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, bx + bw / 2, by + bh / 2); + + const shadeNames = { '1s': '\u03bc \u00b1 1\u03c3', '2s': '\u03bc \u00b1 2\u03c3', '3s': '\u03bc \u00b1 3\u03c3', custom: 'произвольный Z' }; + ctx.font = `9px ${FN}`; ctx.fillStyle = 'rgba(155,93,229,0.55)'; + ctx.fillText(shadeNames[shade] || '', bx + bw / 2, by + bh + 10); + ctx.restore(); + } + + _drawHover(PL, PT, pw, ph, xMin, xMax, yMax) { + const { ctx, W } = this; + const x = this.hx; + if (x < xMin || x > xMax) return; + const px = this._xToP(x, xMin, xMax, PL, pw); + const y = this._pdf(x); + const py = this._yToP(y, yMax, PT, ph); + const bottom = PT + ph; + const FN = 'Manrope, sans-serif'; + + // Vertical crosshair + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); + ctx.beginPath(); ctx.moveTo(px, PT); ctx.lineTo(px, bottom); ctx.stroke(); + ctx.setLineDash([]); + + // Point on curve + ctx.fillStyle = '#FFD166'; ctx.shadowColor = '#FFD166'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.stroke(); + + // Tooltip + const z = (x - this.mu) / this.sigma; + const rows = [ + ['x', x.toFixed(3)], + ['z', z.toFixed(3)], + ['f(x)', y.toFixed(5)], + ['\u03a6(z)', (this._phi(z) * 100).toFixed(2) + '%'], + ]; + ctx.font = `11px ${FN}`; + const maxKW = Math.max(...rows.map(([k]) => ctx.measureText(k).width)); + const maxVW = Math.max(...rows.map(([, v]) => ctx.measureText(v).width)); + const tw = maxKW + maxVW + 26, th = rows.length * 18 + 14; + let tx = px + 14, ty = py - th / 2; + if (tx + tw > W - 8) tx = px - tw - 14; + if (ty < PT + 4) ty = PT + 4; + if (ty + th > bottom) ty = bottom - th; + + ctx.fillStyle = 'rgba(10,10,28,0.95)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke(); + + ctx.textBaseline = 'middle'; + rows.forEach(([k, v], i) => { + const ry = ty + 7 + i * 18 + 9; + ctx.fillStyle = 'rgba(255,255,255,0.38)'; ctx.textAlign = 'left'; ctx.fillText(k, tx + 10, ry); + ctx.fillStyle = '#FFD166'; ctx.textAlign = 'right'; ctx.fillText(v, tx + tw - 10, ry); + }); + } + + // ── events ──────────────────────────────────────────────────── + + _bind() { + const cv = this.canvas; + const getHx = e => { + const r = cv.getBoundingClientRect(); + const { PL, PR } = this._pad(); + const pw = this.W - PL - PR; + const xMin = this.mu - 4.5 * this.sigma; + const xMax = this.mu + 4.5 * this.sigma; + return this._pToX(e.clientX - r.left, xMin, xMax, PL, pw); + }; + cv.addEventListener('mousemove', e => { this.hx = getHx(e); this.draw(); }); + cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); }); + cv.addEventListener('touchmove', e => { + e.preventDefault(); + if (e.touches.length === 1) { this.hx = getHx(e.touches[0]); this.draw(); } + }, { passive: false }); + cv.addEventListener('touchend', () => { this.hx = null; this.draw(); }); + } +} diff --git a/frontend/js/labs/orbitals.js b/frontend/js/labs/orbitals.js new file mode 100644 index 0000000..1d7d2ab --- /dev/null +++ b/frontend/js/labs/orbitals.js @@ -0,0 +1,342 @@ +'use strict'; + +/* ═══════════════════════════════════════════════ + OrbitalsSim — 3D molecular orbitals (Three.js) + s, p, d orbitals + H₂ / H₂O molecular bonding + ═══════════════════════════════════════════════ */ + +class OrbitalsSim { + constructor(container) { + this.container = container; + this._running = false; + + /* Three.js */ + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x0D0D1A, 1); + container.appendChild(this.renderer.domElement); + + /* lighting */ + this.scene.add(new THREE.AmbientLight(0xffffff, 0.45)); + const dir = new THREE.DirectionalLight(0xffffff, 0.7); + dir.position.set(5, 8, 6); + this.scene.add(dir); + const pt = new THREE.PointLight(0x9B5DE5, 0.3, 50); + pt.position.set(-4, 3, 5); + this.scene.add(pt); + + this.camera.position.set(6, 4, 6); + this.camera.lookAt(0, 0, 0); + + /* orbit controls (manual) */ + this._drag = false; + this._prevX = 0; + this._prevY = 0; + this._rotY = 0.6; + this._rotX = 0.3; + this._dist = 8; + this._autoSpin = true; + + const el = this.renderer.domElement; + el.style.cursor = 'grab'; + el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; }); + window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; }); + window.addEventListener('pointermove', e => { + if (!this._drag) return; + this._rotY += (e.clientX - this._prevX) * 0.008; + this._rotX += (e.clientY - this._prevY) * 0.008; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._prevX = e.clientX; this._prevY = e.clientY; + }); + el.addEventListener('wheel', e => { + e.preventDefault(); + this._dist = Math.max(3, Math.min(20, this._dist + e.deltaY * 0.02)); + }, { passive: false }); + + /* touch */ + el.addEventListener('touchstart', e => { + if (e.touches.length === 1) { + this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false; + } + }, { passive: true }); + el.addEventListener('touchmove', e => { + if (!this._drag || e.touches.length !== 1) return; + const t = e.touches[0]; + this._rotY += (t.clientX - this._prevX) * 0.008; + this._rotX += (t.clientY - this._prevY) * 0.008; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._prevX = t.clientX; this._prevY = t.clientY; + }, { passive: true }); + el.addEventListener('touchend', () => { this._drag = false; }); + + /* resize */ + this._ro = new ResizeObserver(() => this.fit()); + this._ro.observe(container); + + /* state */ + this._mode = 's'; + this._group = new THREE.Group(); + this.scene.add(this._group); + + this._buildOrbital('s'); + this.fit(); + this.play(); + } + + /* ── public ── */ + setMode(mode) { + this._mode = mode; + this._buildOrbital(mode); + } + + fit() { + const w = this.container.clientWidth || 600; + const h = this.container.clientHeight || 400; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(w, h); + } + + play() { if (!this._running) { this._running = true; this._loop(); } } + stop() { this._running = false; } + pause() { this._running = false; } + + /* ── clear scene group ── */ + _clear() { + while (this._group.children.length) { + const c = this._group.children[0]; + if (c.geometry) c.geometry.dispose(); + if (c.material) { + if (Array.isArray(c.material)) c.material.forEach(m => m.dispose()); + else c.material.dispose(); + } + this._group.remove(c); + } + } + + /* ── orbital builders ── */ + _buildOrbital(mode) { + this._clear(); + const b = { + s: () => this._buildS(), + p: () => this._buildP(), + d: () => this._buildD(), + h2: () => this._buildH2(), + h2o: () => this._buildH2O(), + }; + (b[mode] || b.s)(); + } + + /* nucleus dot */ + _nucleus(pos, color = 0xffffff) { + const geo = new THREE.SphereGeometry(0.12, 16, 16); + const mat = new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5 }); + const m = new THREE.Mesh(geo, mat); + m.position.copy(pos); + this._group.add(m); + return m; + } + + /* cloud lobe (ellipsoid) */ + _lobe(pos, scale, color, opacity = 0.3) { + const geo = new THREE.SphereGeometry(1, 32, 32); + const mat = new THREE.MeshPhysicalMaterial({ + color, transparent: true, opacity, + metalness: 0, roughness: 0.6, + clearcoat: 0.3, side: THREE.DoubleSide, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.copy(pos); + mesh.scale.set(scale.x, scale.y, scale.z); + this._group.add(mesh); + return mesh; + } + + /* particle cloud (points) */ + _cloud(center, radius, count, color) { + const positions = new Float32Array(count * 3); + for (let i = 0; i < count; i++) { + // gaussian distribution + const r = radius * Math.pow(Math.random(), 0.33); + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + positions[i * 3] = center.x + r * Math.sin(phi) * Math.cos(theta); + positions[i * 3 + 1] = center.y + r * Math.sin(phi) * Math.sin(theta); + positions[i * 3 + 2] = center.z + r * Math.cos(phi); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const mat = new THREE.PointsMaterial({ color, size: 0.04, transparent: true, opacity: 0.6, depthWrite: false }); + const pts = new THREE.Points(geo, mat); + this._group.add(pts); + return pts; + } + + /* ── s orbital: spherical ── */ + _buildS() { + this._nucleus(new THREE.Vector3(0, 0, 0)); + this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 1.5, 1.5), 0x9B5DE5, 0.18); + this._cloud(new THREE.Vector3(0, 0, 0), 1.5, 2000, 0x9B5DE5); + } + + /* ── p orbitals: 3 dumbbell shapes ── */ + _buildP() { + this._nucleus(new THREE.Vector3(0, 0, 0)); + + // px (red) + this._lobe(new THREE.Vector3(1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25); + this._lobe(new THREE.Vector3(-1.2, 0, 0), new THREE.Vector3(0.7, 0.5, 0.5), 0xF15BB5, 0.25); + + // py (green) + this._lobe(new THREE.Vector3(0, 1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25); + this._lobe(new THREE.Vector3(0, -1.2, 0), new THREE.Vector3(0.5, 0.7, 0.5), 0x34d399, 0.25); + + // pz (blue) + this._lobe(new THREE.Vector3(0, 0, 1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25); + this._lobe(new THREE.Vector3(0, 0, -1.2), new THREE.Vector3(0.5, 0.5, 0.7), 0x60a5fa, 0.25); + + // axis labels + this._addLabel('px', 2, 0, 0, 0xF15BB5); + this._addLabel('py', 0, 2, 0, 0x34d399); + this._addLabel('pz', 0, 0, 2, 0x60a5fa); + } + + /* ── d orbital: dxy cloverleaf ── */ + _buildD() { + this._nucleus(new THREE.Vector3(0, 0, 0)); + + // four lobes in xy plane + const r = 1.1, s = 0.45; + const angles = [Math.PI / 4, 3 * Math.PI / 4, 5 * Math.PI / 4, 7 * Math.PI / 4]; + const colors = [0xF59E0B, 0x9B5DE5, 0xF59E0B, 0x9B5DE5]; // alternating sign + + for (let i = 0; i < 4; i++) { + const x = r * Math.cos(angles[i]); + const y = r * Math.sin(angles[i]); + this._lobe(new THREE.Vector3(x, y, 0), new THREE.Vector3(s, s, s * 0.6), colors[i], 0.28); + } + + // dz² torus + lobes + this._lobe(new THREE.Vector3(0, 0, 1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2); + this._lobe(new THREE.Vector3(0, 0, -1.3), new THREE.Vector3(0.35, 0.35, 0.6), 0x06D6E0, 0.2); + // torus ring + const tGeo = new THREE.TorusGeometry(0.7, 0.18, 16, 32); + const tMat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.15, side: THREE.DoubleSide, depthWrite: false }); + const torus = new THREE.Mesh(tGeo, tMat); + this._group.add(torus); + + this._addLabel('d_{xy}', 1.8, 1.8, 0, 0xF59E0B); + this._addLabel('d_{z²}', 0, 0, 2.2, 0x06D6E0); + } + + /* ── H₂ sigma bond ── */ + _buildH2() { + const sep = 1.5; + this._nucleus(new THREE.Vector3(-sep / 2, 0, 0), 0xffffff); + this._nucleus(new THREE.Vector3(sep / 2, 0, 0), 0xffffff); + + // individual 1s orbitals (faded) + this._lobe(new THREE.Vector3(-sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1); + this._lobe(new THREE.Vector3(sep / 2, 0, 0), new THREE.Vector3(0.8, 0.8, 0.8), 0x9B5DE5, 0.1); + + // bonding σ (overlap region — elongated ellipsoid) + this._lobe(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.4, 0.6, 0.6), 0x06D6E0, 0.22); + this._cloud(new THREE.Vector3(0, 0, 0), 1.0, 3000, 0x06D6E0); + + // bond line + const bGeo = new THREE.CylinderGeometry(0.03, 0.03, sep, 8); + const bMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.3 }); + const bond = new THREE.Mesh(bGeo, bMat); + bond.rotation.z = Math.PI / 2; + this._group.add(bond); + + this._addLabel('H', -sep / 2 - 0.5, 0.6, 0, 0xffffff); + this._addLabel('H', sep / 2 + 0.3, 0.6, 0, 0xffffff); + this._addLabel('σ', 0, -0.9, 0, 0x06D6E0); + } + + /* ── H₂O bent molecule ── */ + _buildH2O() { + const angle = 104.5 * Math.PI / 180; + const bondLen = 1.6; + + const oPos = new THREE.Vector3(0, 0, 0); + const h1 = new THREE.Vector3(-bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0); + const h2 = new THREE.Vector3(bondLen * Math.sin(angle / 2), -bondLen * Math.cos(angle / 2), 0); + + // nuclei + const oNuc = this._nucleus(oPos, 0xF15BB5); + oNuc.scale.set(1.5, 1.5, 1.5); + this._nucleus(h1, 0xffffff); + this._nucleus(h2, 0xffffff); + + // O lone pairs (above) + this._lobe(new THREE.Vector3(-0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2); + this._lobe(new THREE.Vector3(0.5, 0.8, 0), new THREE.Vector3(0.4, 0.5, 0.35), 0xF59E0B, 0.2); + + // O-H σ bonds (electron density) + const mid1 = new THREE.Vector3().addVectors(oPos, h1).multiplyScalar(0.5); + const mid2 = new THREE.Vector3().addVectors(oPos, h2).multiplyScalar(0.5); + this._lobe(mid1, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2); + this._lobe(mid2, new THREE.Vector3(0.4, 0.7, 0.35), 0x06D6E0, 0.2); + + // bond lines + for (const hPos of [h1, h2]) { + const d = new THREE.Vector3().subVectors(hPos, oPos); + const len = d.length(); + const bGeo = new THREE.CylinderGeometry(0.04, 0.04, len, 8); + const bMat = new THREE.MeshStandardMaterial({ color: 0xaaaaaa }); + const bond = new THREE.Mesh(bGeo, bMat); + bond.position.copy(oPos).add(d.clone().multiplyScalar(0.5)); + bond.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d.normalize()); + this._group.add(bond); + } + + // electron cloud + this._cloud(oPos, 1.2, 1500, 0xF15BB5); + + this._addLabel('O', 0.3, 0.3, 0, 0xF15BB5); + this._addLabel('H', h1.x - 0.4, h1.y - 0.3, 0, 0xffffff); + this._addLabel('H', h2.x + 0.2, h2.y - 0.3, 0, 0xffffff); + this._addLabel('104.5°', 0, -0.5, 0, 0x888888); + } + + /* ── text label (using sprite) ── */ + _addLabel(text, x, y, z, color) { + const canvas = document.createElement('canvas'); + canvas.width = 128; canvas.height = 48; + const ctx = canvas.getContext('2d'); + ctx.font = 'bold 28px Manrope, sans-serif'; + ctx.fillStyle = '#' + color.toString(16).padStart(6, '0'); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 64, 24); + + const tex = new THREE.CanvasTexture(canvas); + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }); + const sprite = new THREE.Sprite(mat); + sprite.position.set(x, y, z); + sprite.scale.set(1, 0.4, 1); + this._group.add(sprite); + } + + /* ── animation ── */ + _loop() { + if (!this._running) return; + requestAnimationFrame(() => this._loop()); + + if (this._autoSpin) this._rotY += 0.004; + + this.camera.position.set( + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), + this._dist * Math.sin(this._rotX), + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) + ); + this.camera.lookAt(0, 0, 0); + + this.renderer.render(this.scene, this.camera); + } +} diff --git a/frontend/js/labs/pendulum.js b/frontend/js/labs/pendulum.js new file mode 100644 index 0000000..a2a2976 --- /dev/null +++ b/frontend/js/labs/pendulum.js @@ -0,0 +1,401 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + PendulumSim — simple pendulum simulation + θ'' = -(g/L)sin(θ) − γ·θ' + RK4 integration · energy bar · trail · phase portrait + ══════════════════════════════════════════════════════════════ */ + +class PendulumSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* physics */ + this.L = 200; // px length + this.g = 9.81; + this.theta = Math.PI / 4; // angle (rad) + this.omega = 0; // angular velocity + this.damping = 0; // damping coefficient γ + + /* animation */ + this.playing = false; + this._raf = null; + this._lastTs = null; + this.speed = 1; + + /* trail */ + this._trail = []; // [{x, y, age}] + this._maxTrail = 200; + + /* energy chart (bottom) */ + this._eHistory = []; // [{t, ke, pe}] + this._tSim = 0; + + this.onUpdate = null; + + this._drag = null; + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ─────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setParams({ L, g, theta, damping } = {}) { + if (L !== undefined) this.L = +L; + if (g !== undefined) this.g = +g; + if (theta !== undefined) { this.theta = +theta * Math.PI / 180; this.omega = 0; this._clearTrail(); } + if (damping !== undefined) this.damping = +damping; + this.draw(); + this._emit(); + } + + play() { + if (this.playing) return; + this.playing = true; + this._lastTs = null; + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + reset() { + this.pause(); + this.theta = Math.PI / 4; + this.omega = 0; + this._tSim = 0; + this._clearTrail(); + this._eHistory = []; + this.draw(); + this._emit(); + } + + start() { this.play(); } + stop() { this.pause(); } + + info() { + const T = 2 * Math.PI * Math.sqrt(this.L / (this.g * 100)); // L in px approx + const KE = 0.5 * this.omega * this.omega * this.L * this.L; + const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); + const total = KE + PE; + return { + angle: (this.theta * 180 / Math.PI).toFixed(1) + '°', + omega: this.omega.toFixed(3) + ' рад/с', + period: T.toFixed(2) + ' с', + energy: total > 0 ? Math.round(KE / total * 100) + '% KE' : '—', + }; + } + + /* ── internals ─────────────────────────────── */ + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + _clearTrail() { this._trail = []; } + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (this._lastTs === null) this._lastTs = ts; + const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05); + this._lastTs = ts; + const dt = rawDt * this.speed; + + this._step(dt); + this._tSim += dt; + + // trail + const { bx, by } = this._bobPos(); + this._trail.push({ x: bx, y: by }); + if (this._trail.length > this._maxTrail) this._trail.shift(); + + // energy history + const KE = 0.5 * this.omega * this.omega * this.L * this.L; + const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); + this._eHistory.push({ t: this._tSim, ke: KE, pe: PE }); + if (this._eHistory.length > 300) this._eHistory.shift(); + + this.draw(); + this._emit(); + this._tick(); + }); + } + + /* RK4 step for θ'' = -(g/L)sinθ - γ·ω */ + _step(dt) { + const gL = this.g * 100 / this.L; // scale g for px units + const c = this.damping; + + const deriv = (th, om) => ({ + dth: om, + dom: -gL * Math.sin(th) - c * om, + }); + + const k1 = deriv(this.theta, this.omega); + const k2 = deriv(this.theta + k1.dth * dt / 2, this.omega + k1.dom * dt / 2); + const k3 = deriv(this.theta + k2.dth * dt / 2, this.omega + k2.dom * dt / 2); + const k4 = deriv(this.theta + k3.dth * dt, this.omega + k3.dom * dt); + + this.theta += dt / 6 * (k1.dth + 2 * k2.dth + 2 * k3.dth + k4.dth); + this.omega += dt / 6 * (k1.dom + 2 * k2.dom + 2 * k3.dom + k4.dom); + } + + _bobPos() { + const cx = this.W / 2; + const cy = Math.min(this.H * 0.18, 80); + return { + px: cx, + py: cy, + bx: cx + this.L * Math.sin(this.theta), + by: cy + this.L * Math.cos(this.theta), + }; + } + + /* ── draw ──────────────────────────────────── */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + const { px, py, bx, by } = this._bobPos(); + + // trail + this._drawTrail(ctx); + + // support + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.fillRect(W / 2 - 30, py - 4, 60, 4); + + // string + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(bx, by); ctx.stroke(); + + // pivot + ctx.fillStyle = '#666'; + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + + // bob + const bobR = 18; + ctx.fillStyle = '#9B5DE5'; + ctx.beginPath(); ctx.arc(bx, by, bobR, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 2; ctx.stroke(); + + // glow + const grad = ctx.createRadialGradient(bx, by, 0, bx, by, bobR * 2); + grad.addColorStop(0, 'rgba(155,93,229,0.25)'); + grad.addColorStop(1, 'rgba(155,93,229,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(bx, by, bobR * 2, 0, Math.PI * 2); ctx.fill(); + + // angle arc + if (Math.abs(this.theta) > 0.02) { + ctx.strokeStyle = 'rgba(6,214,224,0.5)'; + ctx.lineWidth = 1.5; + const arcR = 40; + const startAngle = Math.PI / 2; + const endAngle = Math.PI / 2 + this.theta; + ctx.beginPath(); + ctx.arc(px, py, arcR, Math.min(startAngle, endAngle), Math.max(startAngle, endAngle)); + ctx.stroke(); + + ctx.fillStyle = '#06D6E0'; + ctx.font = '12px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const labelAngle = startAngle + this.theta / 2; + ctx.fillText( + (this.theta * 180 / Math.PI).toFixed(1) + '°', + px + (arcR + 16) * Math.cos(labelAngle), + py + (arcR + 16) * Math.sin(labelAngle) + ); + } + + // energy bar + this._drawEnergyBar(ctx, W, H); + + // energy chart + this._drawEnergyChart(ctx, W, H); + } + + _drawTrail(ctx) { + const n = this._trail.length; + if (n < 2) return; + for (let i = 1; i < n; i++) { + const a = i / n * 0.6; + ctx.strokeStyle = `rgba(155,93,229,${a})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(this._trail[i - 1].x, this._trail[i - 1].y); + ctx.lineTo(this._trail[i].x, this._trail[i].y); + ctx.stroke(); + } + } + + _drawEnergyBar(ctx, W, H) { + const KE = 0.5 * this.omega * this.omega * this.L * this.L; + const PE = this.g * 100 * this.L * (1 - Math.cos(this.theta)); + const total = KE + PE || 1; + const bw = 160, bh = 14; + const x = W - bw - 20, y = 20; + + ctx.fillStyle = 'rgba(22,22,38,0.85)'; + ctx.beginPath(); ctx.roundRect(x - 8, y - 6, bw + 16, bh + 32, 8); ctx.fill(); + + // KE bar + const kw = (KE / total) * bw; + ctx.fillStyle = '#EF476F'; + ctx.beginPath(); ctx.roundRect(x, y, Math.max(2, kw), bh, 4); ctx.fill(); + + // PE bar + ctx.fillStyle = '#06D6E0'; + ctx.beginPath(); ctx.roundRect(x + kw, y, Math.max(2, bw - kw), bh, 4); ctx.fill(); + + ctx.font = '10px Manrope, sans-serif'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; + ctx.fillText('KE ' + Math.round(KE / total * 100) + '%', x, y + bh + 4); + ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'right'; + ctx.fillText('PE ' + Math.round(PE / total * 100) + '%', x + bw, y + bh + 4); + } + + _drawEnergyChart(ctx, W, H) { + const data = this._eHistory; + if (data.length < 2) return; + + const cw = Math.min(300, W * 0.4); + const ch = 80; + const cx = W - cw - 20; + const cy = H - ch - 20; + + ctx.fillStyle = 'rgba(22,22,38,0.7)'; + ctx.beginPath(); ctx.roundRect(cx - 8, cy - 8, cw + 16, ch + 16, 8); ctx.fill(); + + let maxE = 0; + for (const d of data) maxE = Math.max(maxE, d.ke + d.pe); + if (maxE < 0.01) return; + + // PE filled area + ctx.fillStyle = 'rgba(6,214,224,0.2)'; + ctx.beginPath(); + ctx.moveTo(cx, cy + ch); + for (let i = 0; i < data.length; i++) { + const x = cx + (i / (data.length - 1)) * cw; + const y = cy + ch - (data[i].pe / maxE) * ch; + ctx.lineTo(x, y); + } + ctx.lineTo(cx + cw, cy + ch); + ctx.closePath(); ctx.fill(); + + // KE line + ctx.strokeStyle = '#EF476F'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + for (let i = 0; i < data.length; i++) { + const x = cx + (i / (data.length - 1)) * cw; + const y = cy + ch - (data[i].ke / maxE) * ch; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + + // total line + ctx.strokeStyle = 'rgba(255,255,255,0.25)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + for (let i = 0; i < data.length; i++) { + const x = cx + (i / (data.length - 1)) * cw; + const y = cy + ch - ((data[i].ke + data[i].pe) / maxE) * ch; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.setLineDash([]); + + // labels + ctx.font = '10px Manrope, sans-serif'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = '#EF476F'; ctx.textAlign = 'left'; ctx.fillText('KE', cx + 2, cy); + ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.fillText('PE', cx + 30, cy); + ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.textAlign = 'right'; ctx.fillText('Total', cx + cw, cy); + } + + /* ── events ─────────────────────────────────── */ + + _bindEvents() { + const cv = this.canvas; + + cv.addEventListener('mousedown', e => { + const { bx, by } = this._bobPos(); + const r = cv.getBoundingClientRect(); + const mx = (e.clientX - r.left) * (this.W / r.width); + const my = (e.clientY - r.top) * (this.H / r.height); + if (Math.hypot(mx - bx, my - by) < 30) { + this._drag = true; + this.pause(); + } + }); + + window.addEventListener('mousemove', e => { + if (!this._drag) return; + const r = cv.getBoundingClientRect(); + const mx = (e.clientX - r.left) * (this.W / r.width); + const my = (e.clientY - r.top) * (this.H / r.height); + const { px, py } = this._bobPos(); + this.theta = Math.atan2(mx - px, my - py); + this.omega = 0; + this._clearTrail(); + this.draw(); + this._emit(); + }); + + window.addEventListener('mouseup', () => { + if (this._drag) { + this._drag = false; + this.play(); + } + }); + + // touch + cv.addEventListener('touchstart', e => { + if (e.touches.length !== 1) return; + const { bx, by } = this._bobPos(); + const r = cv.getBoundingClientRect(); + const mx = (e.touches[0].clientX - r.left) * (this.W / r.width); + const my = (e.touches[0].clientY - r.top) * (this.H / r.height); + if (Math.hypot(mx - bx, my - by) < 40) { + this._drag = true; + this.pause(); + } + }, { passive: true }); + cv.addEventListener('touchmove', e => { + if (!this._drag) return; + e.preventDefault(); + const r = cv.getBoundingClientRect(); + const mx = (e.touches[0].clientX - r.left) * (this.W / r.width); + const my = (e.touches[0].clientY - r.top) * (this.H / r.height); + const { px, py } = this._bobPos(); + this.theta = Math.atan2(mx - px, my - py); + this.omega = 0; + this._clearTrail(); + this.draw(); + this._emit(); + }, { passive: false }); + cv.addEventListener('touchend', () => { + if (this._drag) { this._drag = false; this.play(); } + }); + } +} diff --git a/frontend/js/labs/photosynthesis.js b/frontend/js/labs/photosynthesis.js new file mode 100644 index 0000000..70352a0 --- /dev/null +++ b/frontend/js/labs/photosynthesis.js @@ -0,0 +1,811 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════ + PhotosynthesisSim — Фотосинтез и клеточное дыхание + Световые реакции · цикл Кальвина · митохондриальное дыхание + Молекулярная анимация · частицы · статистика + ════════════════════════════════════════════════════════════════ */ + +class PhotosynthesisSim { + + static C = { + bg: '#0a0e14', + // хлоропласт + chlorBg: 'rgba(34,211,153,0.07)', + chlorStroke: 'rgba(34,211,153,0.5)', + thylBg: 'rgba(34,211,153,0.18)', + thylStroke: 'rgba(34,211,153,0.6)', + stroma: 'rgba(34,211,153,0.04)', + // митохондрия + mitoBg: 'rgba(239,71,111,0.08)', + mitoStroke: 'rgba(239,71,111,0.5)', + cristaeBg: 'rgba(239,71,111,0.18)', + cristaeStroke:'rgba(239,71,111,0.55)', + matrix: 'rgba(239,71,111,0.04)', + // молекулы + photon: '#FFD166', + water: '#06D6E0', + co2: '#EF476F', + o2: '#4CC9F0', + atp: '#9B5DE5', + nadph: '#7BF5A4', + g3p: '#22d399', + glucose: '#FFD166', + pyruvate: '#FF6B35', + electron: '#4CC9F0', + // text + label: 'rgba(255,255,255,0.35)', + labelBright: 'rgba(255,255,255,0.8)', + }; + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.mode = 'photo'; // 'photo' | 'resp' + this._light = 70; // 0..100 + this._co2 = 50; // 0..100 + + this._particles = []; + this._time = 0; + + this._stats = { atp: 0, atpRate: 0, o2: 0, co2Out: 0, efficiency: 0 }; + this._atpAccum = 0; + this._atpSmoothR = 0; + + // spawn timers + this._photonTimer = 0; + this._waterTimer = 0; + this._co2Timer = 0; + this._glucoseTimer = 0; + this._atpTimer = 0; + this._pyrTimer = 0; + this._krebsAngle = 0; + this._etcOffset = 0; + + // layout (computed in fit) + this._layout = {}; + + this._raf = null; + this._last = 0; + this.W = 0; this.H = 0; + + this.onUpdate = null; + + this.fit(); + } + + /* ── Lifecycle ────────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 700; + const H = this.canvas.offsetHeight || 440; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._calcLayout(); + if (!this._raf) this._draw(); + } + + start() { + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + reset() { + this._particles = []; + this._time = 0; + this._stats = { atp: 0, atpRate: 0, o2: 0, co2Out: 0, efficiency: 0 }; + this._atpAccum = 0; + this._atpSmoothR = 0; + if (!this._raf) this._draw(); + this._emitUpdate(); + } + + setMode(mode) { + this.mode = mode; + this.reset(); + } + + setLightIntensity(v) { this._light = v; } + setCO2(v) { this._co2 = v; } + + /* ── Layout ───────────────────────────────────────────────── */ + + _calcLayout() { + const { W, H } = this; + const cx = W / 2, cy = H / 2; + const ow = Math.min(W * 0.82, 560), oh = Math.min(H * 0.72, 300); + + if (this.mode === 'photo' || this.mode !== 'resp') { + // chloroplast outer + this._layout = { + cx, cy, + outerRx: ow / 2, outerRy: oh / 2, + // thylakoid band (horizontal, middle third) + thylY: cy - oh * 0.06, + thylH: oh * 0.28, + thylX1: cx - ow * 0.4, + thylX2: cx + ow * 0.4, + // stroma top / bottom + stromaTopY: cy - oh / 2, + stromaBotY: cy + oh / 2, + // label positions + thylLabelY: cy + oh * 0.08, + stromaLabelY: cy - oh * 0.34, + }; + } else { + // mitochondria + this._layout = { + cx, cy, + outerRx: ow / 2, outerRy: oh / 2, + // inner membrane (cristae zone: inner 60% of organelle) + innerRx: ow * 0.3, + innerRy: oh * 0.3, + // zones + matrixCx: cx + ow * 0.12, + cytoCx: cx - ow * 0.32, + etcY: cy, + }; + } + } + + /* ── Tick ─────────────────────────────────────────────────── */ + + _tick(t) { + const dt = Math.min(t - this._last, 80); + this._last = t; + this._time += dt; + + if (this.mode === 'photo') { + this._updatePhoto(dt); + } else { + this._updateResp(dt); + } + + this._updateParticles(dt); + this._draw(); + this._emitUpdate(); + } + + /* ── Photosynthesis update ────────────────────────────────── */ + + _updatePhoto(dt) { + const L = this._light / 100; + const CO = this._co2 / 100; + const rate = L * 0.8 + 0.2; // min rate even at low light + + // фотоны (rain from top) + this._photonTimer += dt; + const photonInterval = 300 / (L * 3 + 0.5); + while (this._photonTimer > photonInterval) { + this._photonTimer -= photonInterval; + this._spawnPhoton(); + } + + // H2O splitting (thylakoid) + this._waterTimer += dt; + if (this._waterTimer > 600 / (rate + 0.2)) { + this._waterTimer = 0; + if (L > 0.1) this._spawnWaterSplit(); + } + + // CO2 into stroma + this._co2Timer += dt; + if (this._co2Timer > 500 / (CO * 2 + 0.3)) { + this._co2Timer = 0; + if (CO > 0.05) this._spawnCO2(); + } + + // ATP from thylakoid stroma + this._atpTimer += dt; + if (this._atpTimer > 400 / (rate + 0.1)) { + this._atpTimer = 0; + if (L > 0.05) this._spawnATP(); + } + + // G3P output + this._glucoseTimer += dt; + if (this._glucoseTimer > 800 / (rate * CO + 0.1)) { + this._glucoseTimer = 0; + if (L > 0.1 && CO > 0.05) this._spawnG3P(); + } + + // Calvin cycle rotation + this._krebsAngle += dt * 0.0004 * rate; + + // stats + const atpR = L * CO * 18; + this._atpSmoothR += (atpR - this._atpSmoothR) * 0.05; + this._atpAccum += atpR * dt / 1000; + this._stats.atpRate = this._atpSmoothR; + this._stats.atp = this._atpAccum; + this._stats.o2 += L * 0.4 * dt / 1000; + this._stats.co2Out = CO; + this._stats.efficiency = Math.round(L * CO * 100 * 0.38); + } + + /* ── Respiration update ───────────────────────────────────── */ + + _updateResp(dt) { + const rate = 0.8; + + // glucose pyruvate (glycolysis) + this._glucoseTimer += dt; + if (this._glucoseTimer > 900) { + this._glucoseTimer = 0; + this._spawnGlucose(); + } + + // pyruvate krebs cycle + this._pyrTimer += dt; + if (this._pyrTimer > 600) { + this._pyrTimer = 0; + this._spawnPyruvate(); + } + + // ATP bursts (ETC) + this._atpTimer += dt; + if (this._atpTimer > 350) { + this._atpTimer = 0; + this._spawnATPResp(); + } + + // CO2 from krebs + this._co2Timer += dt; + if (this._co2Timer > 500) { + this._co2Timer = 0; + this._spawnCO2Resp(); + } + + // electron flow along ETC + this._etcOffset = (this._etcOffset + dt * 0.0008) % 1; + this._krebsAngle += dt * 0.0005; + + // stats + const atpR = 38 * 0.5; + this._atpSmoothR += (atpR - this._atpSmoothR) * 0.04; + this._atpAccum += atpR * dt / 1000; + this._stats.atpRate = this._atpSmoothR; + this._stats.atp = this._atpAccum; + this._stats.o2 += 0.3 * dt / 1000; + this._stats.co2Out += 0.5 * dt / 1000; + this._stats.efficiency = 38; + } + + /* ── Particle spawners ────────────────────────────────────── */ + + _spawnPhoton() { + const L = this._layout; + const x = L.thylX1 + Math.random() * (L.thylX2 - L.thylX1); + this._particles.push({ + type: 'photon', x, y: this.H * 0.02, + vx: (Math.random() - 0.5) * 15, + vy: 60 + Math.random() * 30, + life: 1, maxLife: 1, + targetY: L.thylY - L.thylH / 2, + }); + } + + _spawnWaterSplit() { + const L = this._layout; + const x = L.cx - L.outerRx * 0.35 + Math.random() * L.outerRx * 0.15; + const y = L.thylY; + // O2 bubbles rise + for (let i = 0; i < 2; i++) { + this._particles.push({ + type: 'o2', x: x + i * 12, y, + vx: (Math.random() - 0.5) * 20, + vy: -(40 + Math.random() * 30), + life: 1, maxLife: 1, + }); + } + } + + _spawnCO2() { + const L = this._layout; + const x = L.cx + L.outerRx * 0.3; + const y = L.stromaTopY + Math.random() * (L.cy - L.stromaTopY); + this._particles.push({ + type: 'co2', x, y, + vx: -(30 + Math.random() * 20), + vy: (Math.random() - 0.5) * 15, + life: 1, maxLife: 1, + }); + } + + _spawnATP() { + const L = this._layout; + const x = L.thylX1 + Math.random() * (L.thylX2 - L.thylX1); + const y = L.thylY - L.thylH * 0.1; + this._particles.push({ + type: 'atp', x, y, + vx: (Math.random() - 0.5) * 25, + vy: -(35 + Math.random() * 25), + life: 1, maxLife: 1, + }); + } + + _spawnG3P() { + const L = this._layout; + const angle = Math.random() * Math.PI * 2; + const r = 30 + Math.random() * 20; + this._particles.push({ + type: 'g3p', + x: L.cx + Math.cos(angle) * r, + y: L.cy - L.thylH * 0.8 + Math.sin(angle) * r * 0.5, + vx: (Math.random() - 0.5) * 20, + vy: -(20 + Math.random() * 15), + life: 1, maxLife: 1, + }); + } + + _spawnGlucose() { + const L = this._layout; + this._particles.push({ + type: 'glucose', + x: L.cx - L.outerRx * 0.6, + y: L.cy + (Math.random() - 0.5) * 40, + vx: 35, vy: (Math.random() - 0.5) * 10, + life: 1, maxLife: 1, + }); + } + + _spawnPyruvate() { + const L = this._layout; + this._particles.push({ + type: 'pyruvate', + x: L.cx - 20, + y: L.cy + (Math.random() - 0.5) * 30, + vx: 20, vy: (Math.random() - 0.5) * 15, + life: 1, maxLife: 1, + }); + } + + _spawnATPResp() { + const L = this._layout; + const angle = this._krebsAngle + Math.random() * 0.5; + const r = L.innerRx * 0.7; + this._particles.push({ + type: 'atp', + x: L.cx + Math.cos(angle) * r, + y: L.cy + Math.sin(angle) * r * 0.75, + vx: (Math.random() - 0.5) * 30, + vy: -(25 + Math.random() * 20), + life: 1, maxLife: 1, + }); + } + + _spawnCO2Resp() { + const L = this._layout; + const angle = this._krebsAngle + Math.PI * (0.5 + Math.random() * 0.5); + const r = L.innerRx * 0.5; + this._particles.push({ + type: 'co2', + x: L.cx + Math.cos(angle) * r, + y: L.cy + Math.sin(angle) * r * 0.75, + vx: (Math.random() - 0.5) * 20, + vy: -(30 + Math.random() * 20), + life: 1, maxLife: 1, + }); + } + + /* ── Particle update ──────────────────────────────────────── */ + + _updateParticles(dt) { + const s = dt / 1000; + for (const p of this._particles) { + if (p.targetY !== undefined && p.y > p.targetY) { + p.y += p.vy * s; + p.x += p.vx * s; + } else { + p.x += p.vx * s; + p.y += p.vy * s; + p.life -= s * (0.5 + Math.random() * 0.3); + } + if (p.type === 'photon' && p.y >= (p.targetY || 0)) { + p.targetY = undefined; + p.vy = -15; + p.vx = (Math.random() - 0.5) * 30; + p.life -= 0.4; + } + } + // cap at 120 particles + this._particles = this._particles.filter(p => p.life > 0).slice(-120); + } + + /* ── Draw ─────────────────────────────────────────────────── */ + + _draw() { + const { ctx, W, H } = this; + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = PhotosynthesisSim.C.bg; + ctx.fillRect(0, 0, W, H); + + if (this.mode === 'photo') { + this._drawChloroplast(); + } else { + this._drawMitochondria(); + } + + this._drawParticles(); + this._drawEquation(); + } + + /* ── Chloroplast ──────────────────────────────────────────── */ + + _drawChloroplast() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + if (!L.outerRx) return; + + // outer envelope + ctx.beginPath(); + ctx.ellipse(L.cx, L.cy, L.outerRx, L.outerRy, 0, 0, Math.PI * 2); + ctx.fillStyle = C.chlorBg; + ctx.fill(); + ctx.strokeStyle = C.chlorStroke; + ctx.lineWidth = 2.5; + ctx.stroke(); + + // stroma label + ctx.save(); + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(34,211,153,0.45)'; + ctx.textAlign = 'center'; + ctx.fillText('Строма (цикл Кальвина)', L.cx, L.stromaTopY + 22); + ctx.restore(); + + // thylakoid membrane band + const tY = L.thylY, tH = L.thylH; + const tX1 = L.thylX1, tX2 = L.thylX2; + const tW = tX2 - tX1; + + ctx.beginPath(); + _psRRect(ctx, tX1, tY - tH / 2, tW, tH, tH / 2); + ctx.fillStyle = C.thylBg; + ctx.fill(); + ctx.strokeStyle = C.thylStroke; + ctx.lineWidth = 2; + ctx.stroke(); + + // thylakoid label + ctx.save(); + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(34,211,153,0.6)'; + ctx.textAlign = 'center'; + ctx.fillText('Тилакоид (световые реакции)', L.cx, L.thylY + tH / 2 + 16); + ctx.restore(); + + // Calvin cycle rotating wheel in stroma + this._drawCalvinCycle(); + + // light arrows + this._drawLightArrows(); + } + + _drawCalvinCycle() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + const cx = L.cx, cy = L.cy - L.thylH * 0.85; + const r = Math.min(L.outerRy * 0.28, 42); + const a = this._krebsAngle; + + ctx.save(); + ctx.globalAlpha = 0.6; + + // circle arrow (rotating) + ctx.beginPath(); + ctx.arc(cx, cy, r, a, a + Math.PI * 1.7); + ctx.strokeStyle = C.g3p; + ctx.lineWidth = 2.5; + ctx.stroke(); + + // arrowhead + const ex = cx + Math.cos(a + Math.PI * 1.7) * r; + const ey = cy + Math.sin(a + Math.PI * 1.7) * r; + const da = 0.4; + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - Math.cos(a + Math.PI * 1.7 - da) * 8, + ey - Math.sin(a + Math.PI * 1.7 - da) * 8); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - Math.cos(a + Math.PI * 1.7 + da) * 8, + ey - Math.sin(a + Math.PI * 1.7 + da) * 8); + ctx.strokeStyle = C.g3p; + ctx.lineWidth = 2; + ctx.stroke(); + + // center label + ctx.globalAlpha = 0.55; + ctx.font = 'bold 10px Manrope,sans-serif'; + ctx.fillStyle = C.g3p; + ctx.textAlign = 'center'; + ctx.fillText('цикл', cx, cy - 2); + ctx.fillText('Кальвина', cx, cy + 11); + ctx.restore(); + } + + _drawLightArrows() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + const tX1 = L.thylX1, tX2 = L.thylX2; + const topY = L.stromaTopY + 8; + const botY = L.thylY - L.thylH / 2; + const L_norm = this._light / 100; + + ctx.save(); + ctx.globalAlpha = 0.25 + L_norm * 0.55; + const n = 5; + for (let i = 0; i < n; i++) { + const x = tX1 + (i + 0.5) / n * (tX2 - tX1); + ctx.beginPath(); + ctx.moveTo(x, topY); + ctx.lineTo(x, botY - 4); + ctx.strokeStyle = C.photon; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 4]); + ctx.stroke(); + ctx.setLineDash([]); + // arrowhead + ctx.beginPath(); + ctx.moveTo(x, botY - 4); + ctx.lineTo(x - 5, botY - 14); + ctx.moveTo(x, botY - 4); + ctx.lineTo(x + 5, botY - 14); + ctx.strokeStyle = C.photon; + ctx.lineWidth = 1.5; + ctx.stroke(); + // sun dot + ctx.beginPath(); + ctx.arc(x, topY - 5, 4, 0, Math.PI * 2); + ctx.fillStyle = C.photon; + ctx.fill(); + } + ctx.restore(); + } + + /* ── Mitochondria ─────────────────────────────────────────── */ + + _drawMitochondria() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + if (!L.outerRx) return; + + // outer membrane + ctx.beginPath(); + ctx.ellipse(L.cx, L.cy, L.outerRx, L.outerRy, 0, 0, Math.PI * 2); + ctx.fillStyle = C.mitoBg; + ctx.fill(); + ctx.strokeStyle = C.mitoStroke; + ctx.lineWidth = 2.5; + ctx.stroke(); + + // inner membrane / cristae (zigzag folds) + this._drawCristae(); + + // zone labels + ctx.save(); + ctx.font = '11px Manrope,sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(239,71,111,0.5)'; + ctx.fillText('Матрикс (цикл Кребса)', L.cx + L.innerRx * 0.1, L.cy + 12); + ctx.fillStyle = 'rgba(239,71,111,0.35)'; + ctx.fillText('Цитоплазма (гликолиз)', L.cx - L.outerRx * 0.62, L.cy); + ctx.restore(); + + // Krebs cycle arrow + this._drawKrebsWheel(); + + // ETC along inner membrane + this._drawETC(); + } + + _drawCristae() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + const iRx = L.innerRx || 110, iRy = L.innerRy || 80; + + ctx.beginPath(); + ctx.ellipse(L.cx, L.cy, iRx, iRy, 0, 0, Math.PI * 2); + ctx.fillStyle = C.cristaeBg; + ctx.fill(); + ctx.strokeStyle = C.cristaeStroke; + ctx.lineWidth = 1.8; + ctx.stroke(); + + // cristae folds (vertical zigzag lines inside) + ctx.save(); + ctx.globalAlpha = 0.45; + ctx.strokeStyle = C.cristaeStroke; + ctx.lineWidth = 1.5; + for (let i = -2; i <= 2; i++) { + const x = L.cx + i * iRx * 0.32; + const h = Math.sqrt(Math.max(0, 1 - (i * 0.32) ** 2)) * iRy * 0.7; + ctx.beginPath(); + ctx.moveTo(x, L.cy - h); + ctx.bezierCurveTo(x - 12, L.cy - h * 0.3, x + 12, L.cy + h * 0.3, x, L.cy + h); + ctx.stroke(); + } + ctx.restore(); + } + + _drawKrebsWheel() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + const cx = L.cx, cy = L.cy; + const r = (L.innerRx || 110) * 0.42; + const a = this._krebsAngle; + + ctx.save(); + ctx.globalAlpha = 0.55; + ctx.beginPath(); + ctx.arc(cx, cy, r, a, a + Math.PI * 1.65); + ctx.strokeStyle = C.pyruvate; + ctx.lineWidth = 2.5; + ctx.stroke(); + // arrowhead + const ex = cx + Math.cos(a + Math.PI * 1.65) * r; + const ey = cy + Math.sin(a + Math.PI * 1.65) * r; + const da = 0.45; + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - Math.cos(a + Math.PI * 1.65 - da) * 8, + ey - Math.sin(a + Math.PI * 1.65 - da) * 8); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - Math.cos(a + Math.PI * 1.65 + da) * 8, + ey - Math.sin(a + Math.PI * 1.65 + da) * 8); + ctx.strokeStyle = C.pyruvate; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.globalAlpha = 0.45; + ctx.font = 'bold 10px Manrope,sans-serif'; + ctx.fillStyle = C.pyruvate; + ctx.textAlign = 'center'; + ctx.fillText('цикл', cx, cy - 3); + ctx.fillText('Кребса', cx, cy + 11); + ctx.restore(); + } + + _drawETC() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + const L = this._layout; + const iRx = L.innerRx || 110, iRy = L.innerRy || 80; + const n = 8; + + ctx.save(); + ctx.globalAlpha = 0.7; + for (let i = 0; i < n; i++) { + const frac = ((i / n) + this._etcOffset) % 1; + const a = frac * Math.PI * 2 - Math.PI / 2; + const x = L.cx + Math.cos(a) * iRx; + const y = L.cy + Math.sin(a) * iRy; + const size = 4 + 2 * Math.sin(frac * Math.PI * 4); + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fillStyle = C.electron; + ctx.shadowColor = C.electron; + ctx.shadowBlur = 6; + ctx.fill(); + ctx.shadowBlur = 0; + } + ctx.restore(); + } + + /* ── Particle rendering ───────────────────────────────────── */ + + _drawParticles() { + const { ctx } = this; + const C = PhotosynthesisSim.C; + + const colorMap = { + photon: C.photon, + o2: C.o2, + co2: C.co2, + atp: C.atp, + nadph: C.nadph, + g3p: C.g3p, + glucose: C.glucose, + pyruvate: C.pyruvate, + electron: C.electron, + }; + const labelMap = { + photon: '*', + o2: 'O₂', + co2: 'CO₂', + atp: 'ATP', + nadph: 'NADPH', + g3p: 'G3P', + glucose: 'Глк', + pyruvate: 'Пир', + electron: 'e⁻', + }; + + for (const p of this._particles) { + const alpha = Math.min(1, p.life * 2) * 0.9; + if (alpha <= 0) continue; + ctx.save(); + ctx.globalAlpha = alpha; + const col = colorMap[p.type] || '#fff'; + const lbl = labelMap[p.type] || ''; + + // glow + ctx.beginPath(); + ctx.arc(p.x, p.y, 9, 0, Math.PI * 2); + ctx.fillStyle = col + '28'; + ctx.fill(); + + // circle + ctx.beginPath(); + ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); + ctx.fillStyle = col; + ctx.fill(); + + // label + ctx.font = 'bold 8px Manrope,sans-serif'; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.fillText(lbl, p.x, p.y + 18); + ctx.restore(); + } + } + + /* ── Equation footer ──────────────────────────────────────── */ + + _drawEquation() { + const { ctx, W, H } = this; + const eq = this.mode === 'photo' + ? '6CO₂ + 6H₂O + свет → C₆H₁₂O₆ + 6O₂' + : 'C₆H₁₂O₆ + 6O₂ → 6CO₂ + 6H₂O + 38 ATP'; + + ctx.save(); + ctx.font = '12px Manrope,sans-serif'; + const tw = ctx.measureText(eq).width; + const px = W / 2 - tw / 2 - 12, py = H - 28; + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + _psRRect(ctx, px, py - 2, tw + 24, 20, 6); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.textAlign = 'center'; + ctx.fillText(eq, W / 2, py + 13); + ctx.restore(); + } + + /* ── Stats emit ───────────────────────────────────────────── */ + + _emitUpdate() { + if (!this.onUpdate) return; + this.onUpdate({ + mode: this.mode, + atpRate: this._stats.atpRate.toFixed(1), + o2: Math.floor(this._stats.o2), + co2: Math.floor(this._stats.co2Out), + efficiency: this._stats.efficiency.toFixed ? this._stats.efficiency.toFixed(0) : this._stats.efficiency, + light: this._light, + co2Level: this._co2, + }); + } +} + +/* helper */ +function _psRRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} diff --git a/frontend/js/labs/probability.js b/frontend/js/labs/probability.js new file mode 100644 index 0000000..d6e3122 --- /dev/null +++ b/frontend/js/labs/probability.js @@ -0,0 +1,570 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + ProbabilitySim — probability & law of large numbers + coin flip · single die · two-dice sum + histogram + convergence chart + animated visuals + ══════════════════════════════════════════════════════════════ */ + +class ProbabilitySim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* parameters */ + this.mode = 'coin'; // 'coin' | 'dice' | 'dice2' + this.trials = 100; // target total + this.speed = 5; // trials per frame + + /* state */ + this.results = []; // outcome per trial + this.distribution = {}; // outcome count + this._convHist = []; // running freq for convergence chart + this._trackKey = null; // key tracked for convergence + + /* animation */ + this.playing = false; + this._raf = null; + this._animT = 0; // animation phase for coin/dice visual + this._lastOutcome = null; + this._shakeT = 0; + + this.onUpdate = null; + + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── presets ──────────────────────────────────── */ + + static PRESETS = { + coin_100: { mode: 'coin', trials: 100, speed: 2 }, + coin_1000: { mode: 'coin', trials: 1000, speed: 10 }, + dice_100: { mode: 'dice', trials: 100, speed: 2 }, + dice2_500: { mode: 'dice2', trials: 500, speed: 5 }, + }; + + preset(name) { + const p = ProbabilitySim.PRESETS[name]; + if (p) { this.setParams(p); this.reset(); } + } + + /* ── public API ──────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setParams({ mode, trials, speed } = {}) { + if (mode !== undefined) this.mode = mode; + if (trials !== undefined) this.trials = Math.max(1, +trials); + if (speed !== undefined) this.speed = Math.max(1, Math.min(50, +speed)); + this._setupMode(); + this.draw(); + this._emit(); + } + + reset() { + this.pause(); + this.results = []; + this.distribution = {}; + this._convHist = []; + this._animT = 0; + this._lastOutcome = null; + this._shakeT = 0; + this._setupMode(); + this.draw(); + this._emit(); + } + + play() { + if (this.playing) return; + this.playing = true; + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + start() { this.play(); } + stop() { this.pause(); } + + info() { + const n = this.results.length; + const dist = { ...this.distribution }; + const theo = this._theoretical(); + let chiSq = 0, maxDev = 0; + for (const k of Object.keys(theo)) { + const obs = (dist[k] || 0) / (n || 1); + const exp = theo[k]; + const dev = Math.abs(obs - exp); + if (dev > maxDev) maxDev = dev; + if (n > 0) chiSq += ((dist[k] || 0) - n * exp) ** 2 / (n * exp || 1); + } + return { + mode: this.mode, + totalTrials: n, + distribution: dist, + chiSquare: +chiSq.toFixed(4), + maxDeviation: +maxDev.toFixed(6), + }; + } + + /* ── internals ───────────────────────────────── */ + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + _setupMode() { + const keys = this._outcomeKeys(); + for (const k of keys) { + if (!(k in this.distribution)) this.distribution[k] = 0; + } + this._trackKey = keys[0]; // convergence tracks first outcome + } + + _outcomeKeys() { + if (this.mode === 'coin') return ['О', 'Р']; + if (this.mode === 'dice') return ['1','2','3','4','5','6']; + // dice2: sums 2..12 + const keys = []; + for (let i = 2; i <= 12; i++) keys.push(String(i)); + return keys; + } + + _theoretical() { + const t = {}; + if (this.mode === 'coin') { + t['О'] = 0.5; t['Р'] = 0.5; + } else if (this.mode === 'dice') { + for (let i = 1; i <= 6; i++) t[String(i)] = 1 / 6; + } else { + // dice2: two dice sum probabilities + const ways = [0,0,1,2,3,4,5,6,5,4,3,2,1]; // index 0-12, sum 2-12 + for (let s = 2; s <= 12; s++) t[String(s)] = ways[s] / 36; + } + return t; + } + + _rollOnce() { + if (this.mode === 'coin') return Math.random() < 0.5 ? 'О' : 'Р'; + if (this.mode === 'dice') return String(Math.floor(Math.random() * 6) + 1); + const d1 = Math.floor(Math.random() * 6) + 1; + const d2 = Math.floor(Math.random() * 6) + 1; + return String(d1 + d2); + } + + _addTrial() { + if (this.results.length >= this.trials) return false; + const outcome = this._rollOnce(); + this.results.push(outcome); + this.distribution[outcome] = (this.distribution[outcome] || 0) + 1; + this._lastOutcome = outcome; + + // convergence: running frequency of tracked key + const n = this.results.length; + const freq = (this.distribution[this._trackKey] || 0) / n; + this._convHist.push(freq); + if (this._convHist.length > 500) this._convHist.shift(); + return true; + } + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(() => { + let added = 0; + for (let i = 0; i < this.speed; i++) { + if (!this._addTrial()) break; + added++; + } + this._animT += 0.15; + if (added > 0) this._shakeT = 1; + else this._shakeT *= 0.9; + + this.draw(); + this._emit(); + + if (this.results.length >= this.trials) { + this.pause(); + return; + } + this._tick(); + }); + } + + /* ── drawing ─────────────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + const vizH = H * 0.28; + const histH = H * 0.48; + const convH = H * 0.24; + + this._drawVisual(ctx, 0, 0, W, vizH); + this._drawHistogram(ctx, 0, vizH, W, histH); + this._drawConvergence(ctx, 0, vizH + histH, W, convH); + this._drawStats(ctx, W, H); + } + + /* ── top visual: coin or dice ──────────────── */ + + _drawVisual(ctx, x0, y0, w, h) { + const cx = x0 + w / 2, cy = y0 + h / 2; + + if (this.mode === 'coin') { + this._drawCoin(ctx, cx, cy, Math.min(w, h) * 0.32); + } else if (this.mode === 'dice') { + this._drawDie(ctx, cx, cy, Math.min(w, h) * 0.34, this._lastOutcome ? +this._lastOutcome : 1); + } else { + // dice2: two dice side by side + const sz = Math.min(w, h) * 0.28; + const gap = sz * 0.3; + const last = this._lastOutcome ? +this._lastOutcome : 7; + const d1 = Math.min(6, Math.max(1, Math.ceil(last / 2))); + const d2 = last - d1; + this._drawDie(ctx, cx - sz / 2 - gap, cy, sz, Math.max(1, Math.min(6, d1))); + this._drawDie(ctx, cx + sz / 2 + gap, cy, sz, Math.max(1, Math.min(6, d2))); + } + + // trial counter + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.font = "bold 13px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(`Испытание ${this.results.length} / ${this.trials}`, x0 + w / 2, y0 + h - 6); + } + + _drawCoin(ctx, cx, cy, r) { + const phase = this._animT % (Math.PI * 2); + const squeeze = Math.abs(Math.cos(phase)); + const showHeads = Math.cos(phase) >= 0; + + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(Math.max(0.05, squeeze), 1); + + // shadow + ctx.fillStyle = 'rgba(155,93,229,0.15)'; + ctx.beginPath(); ctx.ellipse(0, r * 0.15, r * 1.1, r * 0.18, 0, 0, Math.PI * 2); ctx.fill(); + + // coin body + const grad = ctx.createRadialGradient(-r * 0.2, -r * 0.2, 0, 0, 0, r); + if (showHeads) { + grad.addColorStop(0, '#FFD166'); + grad.addColorStop(1, '#D4950A'); + } else { + grad.addColorStop(0, '#9B5DE5'); + grad.addColorStop(1, '#6B2FA0'); + } + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill(); + + // rim + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // label + ctx.fillStyle = showHeads ? '#5A3000' : '#E0D0FF'; + ctx.font = `bold ${Math.round(r * 0.6)}px 'Manrope', system-ui, sans-serif`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(showHeads ? 'О' : 'Р', 0, 2); + + ctx.restore(); + } + + _drawDie(ctx, cx, cy, size, value) { + const half = size / 2; + const shake = this._shakeT * 2; + const sx = shake * (Math.random() - 0.5); + const sy = shake * (Math.random() - 0.5); + + ctx.save(); + ctx.translate(cx + sx, cy + sy); + + // shadow + ctx.fillStyle = 'rgba(6,214,224,0.08)'; + ctx.beginPath(); ctx.roundRect(-half + 4, -half + 6, size, size, size * 0.18); ctx.fill(); + + // body + const grad = ctx.createLinearGradient(-half, -half, half, half); + grad.addColorStop(0, '#1E1E3A'); + grad.addColorStop(1, '#12122A'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.roundRect(-half, -half, size, size, size * 0.18); ctx.fill(); + + // border + ctx.strokeStyle = 'rgba(155,93,229,0.4)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // dots + const dotR = size * 0.08; + const off = size * 0.26; + const dots = { + 1: [[0, 0]], + 2: [[-off, -off], [off, off]], + 3: [[-off, -off], [0, 0], [off, off]], + 4: [[-off, -off], [off, -off], [-off, off], [off, off]], + 5: [[-off, -off], [off, -off], [0, 0], [-off, off], [off, off]], + 6: [[-off, -off], [off, -off], [-off, 0], [off, 0], [-off, off], [off, off]], + }; + + const positions = dots[Math.max(1, Math.min(6, value))] || dots[1]; + for (const [dx, dy] of positions) { + const dg = ctx.createRadialGradient(dx, dy, 0, dx, dy, dotR); + dg.addColorStop(0, '#FFFFFF'); + dg.addColorStop(1, '#C0C0E0'); + ctx.fillStyle = dg; + ctx.beginPath(); ctx.arc(dx, dy, dotR, 0, Math.PI * 2); ctx.fill(); + } + + ctx.restore(); + } + + /* ── histogram ─────────────────────────────── */ + + _drawHistogram(ctx, x0, y0, w, h) { + const keys = this._outcomeKeys(); + const theo = this._theoretical(); + const n = this.results.length || 1; + const pad = { l: 48, r: 16, t: 20, b: 34 }; + const pw = w - pad.l - pad.r; + const ph = h - pad.t - pad.b; + const px = x0 + pad.l, py = y0 + pad.t; + + // panel bg + ctx.fillStyle = 'rgba(5,5,20,0.5)'; + ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 4, w - 16, h - 8, 8); ctx.fill(); + + // y-axis: relative frequency + let maxFreq = 0; + for (const k of keys) { + const f = (this.distribution[k] || 0) / n; + if (f > maxFreq) maxFreq = f; + } + for (const k of keys) { + const t = theo[k]; + if (t > maxFreq) maxFreq = t; + } + maxFreq = Math.max(maxFreq * 1.15, 0.05); + + // grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const gy = py + ph * (1 - i / 4); + ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke(); + } + + // y labels + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let i = 0; i <= 4; i++) { + const v = (maxFreq * i / 4 * 100).toFixed(0) + '%'; + ctx.fillText(v, px - 6, py + ph * (1 - i / 4)); + } + + // bars + const barCount = keys.length; + const gap = Math.max(2, pw * 0.03); + const barW = (pw - gap * (barCount + 1)) / barCount; + const colors = ['#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166', + '#EF476F','#9B5DE5','#06D6E0','#7BF5A4','#FFD166','#EF476F']; + + for (let i = 0; i < barCount; i++) { + const k = keys[i]; + const freq = (this.distribution[k] || 0) / n; + const bh = (freq / maxFreq) * ph; + const bx = px + gap + i * (barW + gap); + const by = py + ph - bh; + + // bar gradient + const bg = ctx.createLinearGradient(bx, by, bx, py + ph); + bg.addColorStop(0, colors[i % colors.length]); + bg.addColorStop(1, colors[i % colors.length] + '66'); + ctx.fillStyle = bg; + ctx.beginPath(); ctx.roundRect(bx, by, barW, bh, [4, 4, 0, 0]); ctx.fill(); + + // glow at top + if (bh > 4) { + ctx.fillStyle = colors[i % colors.length] + '33'; + ctx.beginPath(); ctx.roundRect(bx - 2, by - 2, barW + 4, 6, 3); ctx.fill(); + } + + // count + percentage label above bar + const count = this.distribution[k] || 0; + const pct = (freq * 100).toFixed(1); + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.font = "bold 9px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + if (bh > 16) { + ctx.fillText(count, bx + barW / 2, by - 2); + } else { + ctx.fillText(count, bx + barW / 2, py + ph - bh - 2); + } + // percentage inside bar + if (bh > 28) { + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.font = "8px 'Manrope', system-ui, sans-serif"; + ctx.textBaseline = 'top'; + ctx.fillText(pct + '%', bx + barW / 2, by + 4); + } + + // x label + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(k, bx + barW / 2, py + ph + 6); + } + + // theoretical probability dashed lines + ctx.setLineDash([5, 4]); + ctx.lineWidth = 1.5; + for (let i = 0; i < barCount; i++) { + const k = keys[i]; + const tp = theo[k]; + const ly = py + ph - (tp / maxFreq) * ph; + const bx = px + gap + i * (barW + gap); + ctx.strokeStyle = colors[i % colors.length] + '88'; + ctx.beginPath(); + ctx.moveTo(bx - 2, ly); + ctx.lineTo(bx + barW + 2, ly); + ctx.stroke(); + } + ctx.setLineDash([]); + + // legend + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; + ctx.setLineDash([5, 4]); + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(px, y0 + h - 8); ctx.lineTo(px + 18, y0 + h - 8); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillText('— теор. вероятность', px + 22, y0 + h - 4); + } + + /* ── convergence chart ─────────────────────── */ + + _drawConvergence(ctx, x0, y0, w, h) { + const pad = { l: 48, r: 16, t: 14, b: 20 }; + const pw = w - pad.l - pad.r; + const ph = h - pad.t - pad.b; + const px = x0 + pad.l, py = y0 + pad.t; + + // bg + ctx.fillStyle = 'rgba(5,5,20,0.5)'; + ctx.beginPath(); ctx.roundRect(x0 + 8, y0 + 2, w - 16, h - 4, 8); ctx.fill(); + + // title + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = "9px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + const trackLabel = this._trackKey; + ctx.fillText(`Сходимость частоты «${trackLabel}»`, px, y0 + 3); + + // theoretical value + const theo = this._theoretical(); + const tp = theo[this._trackKey] || 0; + + // y range + const yMin = Math.max(0, tp - 0.35); + const yMax = Math.min(1, tp + 0.35); + const yRange = yMax - yMin || 0.01; + + // grid + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 3; i++) { + const gy = py + ph * (i / 3); + ctx.beginPath(); ctx.moveTo(px, gy); ctx.lineTo(px + pw, gy); ctx.stroke(); + } + + // y labels + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.font = "8px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let i = 0; i <= 3; i++) { + const v = yMax - (i / 3) * yRange; + ctx.fillText(v.toFixed(2), px - 5, py + ph * (i / 3)); + } + + // theoretical dashed line + const theoY = py + ph * (1 - (tp - yMin) / yRange); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = '#FFD166'; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(px, theoY); ctx.lineTo(px + pw, theoY); ctx.stroke(); + ctx.setLineDash([]); + + // label for theoretical + ctx.fillStyle = '#FFD166'; + ctx.font = "8px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; + ctx.fillText('p=' + tp.toFixed(4), px + pw, theoY - 3); + + // convergence line + const data = this._convHist; + if (data.length < 2) return; + + ctx.beginPath(); + ctx.strokeStyle = '#06D6E0'; + ctx.lineWidth = 1.5; + for (let i = 0; i < data.length; i++) { + const lx = px + (i / (data.length - 1)) * pw; + const ly = py + ph * (1 - (data[i] - yMin) / yRange); + const cly = Math.max(py, Math.min(py + ph, ly)); + i === 0 ? ctx.moveTo(lx, cly) : ctx.lineTo(lx, cly); + } + ctx.stroke(); + + // x label + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.font = "8px 'Manrope', system-ui, sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText('номер испытания →', px + pw / 2, y0 + h - 14); + } + + /* ── stats overlay ─────────────────────────── */ + + _drawStats(ctx, W) { + const info = this.info(); + const px = 12, py = 10, pw = 170, ph = 72; + + ctx.fillStyle = 'rgba(5,5,20,0.82)'; + ctx.beginPath(); ctx.roundRect(px, py, pw, ph, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.font = "10px 'Manrope', system-ui, sans-serif"; + const lh = 15; + + const modeLabel = { coin: 'Монета', dice: 'Кубик', dice2: '2 кубика' }[this.mode] || this.mode; + ctx.fillStyle = '#9B5DE5'; + ctx.fillText(`Режим: ${modeLabel}`, px + 10, py + 8); + + ctx.fillStyle = '#06D6E0'; + ctx.fillText(`N = ${info.totalTrials}`, px + 10, py + 8 + lh); + + ctx.fillStyle = '#7BF5A4'; + ctx.fillText(`χ² = ${info.chiSquare}`, px + 10, py + 8 + lh * 2); + + ctx.fillStyle = '#FFD166'; + ctx.fillText(`max Δ = ${info.maxDeviation.toFixed(4)}`, px + 10, py + 8 + lh * 3); + } +} + +if (typeof module !== 'undefined') module.exports = ProbabilitySim; diff --git a/frontend/js/labs/projectile.js b/frontend/js/labs/projectile.js new file mode 100644 index 0000000..d43ec9e --- /dev/null +++ b/frontend/js/labs/projectile.js @@ -0,0 +1,1058 @@ +'use strict'; + +/* ═══════════════════════════════════════════════════════════════════ + ProjectileSim v2 — physics simulation + Features: air drag (RK4) · wind · bounce · speed multiplier + ghost trail comparison · velocity vector labels + range arrow · landing angle · canvas click play/pause + ═══════════════════════════════════════════════════════════════════ */ + +class ProjectileSim { + constructor(canvas) { + this.c = canvas; + this.ctx = canvas.getContext('2d'); + + /* ── physics params ── */ + this.v0 = 20; + this.angle = 45; + this.h0 = 2; + this.g = 9.81; + + /* air resistance */ + this.drag = false; + this.Cd = 0.3; + this.mass = 1; // kg + + /* wind (m/s, positive = tailwind / right) */ + this.wind = 0; + + /* bounce */ + this.bounce = false; + this.restitution = 0.7; + + /* animation speed multiplier */ + this.speed = 1; + + /* computed trajectory (null = use analytical) */ + this._path = null; // [{x, y, vx, vy, t}] + this._pathTf = 0; + + /* animation state */ + this.t = 0; + this.playing = false; + this._raf = null; + this._lastTs = null; + + /* visual effects */ + this._trail = []; + this._sparks = []; + this._impactTs = -999; + this._launchFlash = 0; + this._stars = this._genStars(90); + + /* ghost trails for comparison */ + this._ghosts = []; + this._ghostIdx = 0; + this._GHOST_COLORS = [ + 'rgba(255,214,102,.45)', + 'rgba(6,214,224,.45)', + 'rgba(123,245,164,.45)', + 'rgba(255,140,66,.45)', + ]; + + this.onUpdate = null; + this.onPlayPause = null; // called by canvas click + + /* hover inspector */ + this._hover = null; // { t, s } | null + this._viewParams = null; // coordinate transform params (set in draw) + + canvas.addEventListener('click', () => { + if (this.onPlayPause) this.onPlayPause(); + }); + canvas.addEventListener('mousemove', e => this._onMouseMove(e)); + canvas.addEventListener('mouseleave', () => this._onMouseLeave()); + + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const r = this.c.parentElement.getBoundingClientRect(); + const w = r.width || 600, h = r.height || 400; + this.c.width = w * dpr; + this.c.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this._cw = w; this._ch = h; + } + + setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) { + if (v0 !== undefined) this.v0 = +v0; + if (angle !== undefined) this.angle = +angle; + if (h0 !== undefined) this.h0 = +h0; + if (g !== undefined) this.g = +g; + if (drag !== undefined) this.drag = !!drag; + if (Cd !== undefined) this.Cd = +Cd; + if (mass !== undefined) this.mass = Math.max(0.1, +mass); + if (wind !== undefined) this.wind = +wind; + if (bounce !== undefined) this.bounce = !!bounce; + if (restitution !== undefined) this.restitution = Math.max(0, Math.min(1, +restitution)); + this._computePath(); + this._resetFX(); + this.draw(); + this._emit(); + } + + setSpeed(s) { this.speed = +s; } + + play() { + if (this.playing) return; + if (this._pathTf > 0 && this.t >= this._pathTf) this._resetFX(); + this._launchFlash = 1; + this.playing = true; + this._lastTs = null; + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + reset() { + this.pause(); + this._resetFX(); + this.draw(); + this._emit(); + } + + /* ghost trails */ + saveGhost() { + if (this._pathTf <= 0) return; + const points = []; + if (this._path) { + for (const p of this._path) points.push({ x: p.x, y: p.y }); + } else { + const tf = this._pathTf; + for (let i = 0; i <= 200; i++) { + const s = this._stateAnalytical((i / 200) * tf); + points.push({ x: s.x, y: s.y }); + } + } + const st = this.stats(); + const windStr = this.wind !== 0 ? ` ${this.wind > 0 ? '+' : ''}${this.wind}` : ''; + const label = `${this.angle}° ${this.v0}м/с${windStr}${this.drag ? ' +drag' : ''}${this.bounce ? ' ' : ''}`; + const color = this._GHOST_COLORS[this._ghostIdx % this._GHOST_COLORS.length]; + this._ghostIdx++; + this._ghosts.push({ points, color, label, range: st.range, hMax: st.hMax }); + if (this._ghosts.length > 4) this._ghosts.shift(); + this.draw(); + } + + clearGhosts() { + this._ghosts = []; + this._ghostIdx = 0; + this.draw(); + } + + /* ── physics ── */ + + /* pure analytical solution (no drag/wind/bounce) */ + _stateAnalytical(t) { + const rad = this.angle * Math.PI / 180; + const vx = this.v0 * Math.cos(rad); + const vy0 = this.v0 * Math.sin(rad); + return { + x: vx * t, + y: this.h0 + vy0 * t - 0.5 * this.g * t * t, + vx, + vy: vy0 - this.g * t, + }; + } + + /* analytical flight time (for reference / no-effect comparison) */ + _tFlightAnalytical() { + const rad = this.angle * Math.PI / 180; + const vy0 = this.v0 * Math.sin(rad); + const disc = vy0 * vy0 + 2 * this.g * this.h0; + if (disc < 0) return 0; + return Math.max(0, (vy0 + Math.sqrt(disc)) / this.g); + } + + _needsNumerical() { + return this.drag || this.wind !== 0 || this.bounce; + } + + /* RK4 integration — handles drag, wind, bounce */ + _computePath() { + if (!this._needsNumerical()) { + this._path = null; + this._pathTf = this._tFlightAnalytical(); + return; + } + + const rho = 1.225, A = 0.00785; // air density, ball cross-section + const k = this.drag ? 0.5 * this.Cd * rho * A / Math.max(0.1, this.mass) : 0; + const g = this.g; + const W = this.wind; + const e = this.restitution; + const maxBounces = this.bounce ? 7 : 0; + + const rad = this.angle * Math.PI / 180; + let x = 0, y = this.h0; + let vx = this.v0 * Math.cos(rad); + let vy = this.v0 * Math.sin(rad); + const dt = 0.005; + const path = [{ x, y, vx, vy, t: 0 }]; + let bounceCount = 0; + + const deriv = (sx, sy, svx, svy) => { + const rvx = svx - W; // velocity relative to wind + const rvy = svy; + const speed = Math.sqrt(rvx * rvx + rvy * rvy); + const dragF = speed > 0 ? k * speed : 0; + // wind-only pseudo-force when drag is off (simplified model) + const windAcc = (!this.drag && W !== 0) ? W * 0.05 : 0; + return { + dx: svx, + dy: svy, + dvx: -dragF * rvx + windAcc, + dvy: -g - dragF * rvy, + }; + }; + + for (let step = 0; step < 200000; step++) { + const k1 = deriv(x, y, vx, vy); + const k2 = deriv(x + k1.dx * dt / 2, y + k1.dy * dt / 2, vx + k1.dvx * dt / 2, vy + k1.dvy * dt / 2); + const k3 = deriv(x + k2.dx * dt / 2, y + k2.dy * dt / 2, vx + k2.dvx * dt / 2, vy + k2.dvy * dt / 2); + const k4 = deriv(x + k3.dx * dt, y + k3.dy * dt, vx + k3.dvx * dt, vy + k3.dvy * dt); + + x += (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) * dt / 6; + y += (k1.dy + 2 * k2.dy + 2 * k3.dy + k4.dy) * dt / 6; + vx += (k1.dvx + 2 * k2.dvx + 2 * k3.dvx + k4.dvx) * dt / 6; + vy += (k1.dvy + 2 * k2.dvy + 2 * k3.dvy + k4.dvy) * dt / 6; + const t = (step + 1) * dt; + + if (y <= 0) { + const prev = path[path.length - 1]; + if (prev && prev.y > 0) { + const frac = prev.y / (prev.y - y); + const lx = prev.x + (x - prev.x) * frac; + const lvx = prev.vx + (vx - prev.vx) * frac; + const lvy = prev.vy + (vy - prev.vy) * frac; + const lt = prev.t + dt * frac; + path.push({ x: lx, y: 0, vx: lvx, vy: lvy, t: lt }); + + if (this.bounce && bounceCount < maxBounces && Math.abs(lvy) > 0.4) { + vy = -e * lvy; + vx = lvx * (1 - 0.04); // small horizontal friction + y = 0.001; + x = lx; + bounceCount++; + continue; + } + } + break; + } + + path.push({ x, y, vx, vy, t }); + } + + this._path = path; + this._pathTf = path[path.length - 1].t; + } + + _pathStateAt(t) { + const path = this._path; + if (!path || path.length < 2) return { x: 0, y: this.h0, vx: 0, vy: 0 }; + if (t <= 0) return path[0]; + if (t >= this._pathTf) return path[path.length - 1]; + + let lo = 0, hi = path.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (path[mid].t <= t) lo = mid; else hi = mid; + } + const a = path[lo], b = path[hi]; + const frac = (t - a.t) / (b.t - a.t); + return { + x: a.x + (b.x - a.x) * frac, + y: a.y + (b.y - a.y) * frac, + vx: a.vx + (b.vx - a.vx) * frac, + vy: a.vy + (b.vy - a.vy) * frac, + }; + } + + _curState(t) { + return this._path ? this._pathStateAt(t) : this._stateAnalytical(t); + } + + _curTFlight() { return this._pathTf; } + + stats() { + const tf = this._pathTf; + const end = this._curState(tf); + + let hMax = this.h0; + if (this._path) { + for (const p of this._path) if (p.y > hMax) hMax = p.y; + } else { + const rad = this.angle * Math.PI / 180; + const vy0 = this.v0 * Math.sin(rad); + const tMax = Math.max(0, vy0 / this.g); + hMax = Math.max(this.h0, this.h0 + vy0 * tMax - 0.5 * this.g * tMax * tMax); + } + + const range = Math.max(0, end.x); + const vLand = Math.sqrt(end.vx ** 2 + end.vy ** 2); + const landAngle = vLand > 0.01 + ? Math.abs(Math.atan2(Math.abs(end.vy), Math.abs(end.vx)) * 180 / Math.PI) + : 0; + + // range compared to pure analytical (no drag/wind/bounce) + let rangeLoss = 0; + if (this._needsNumerical()) { + const tfND = this._tFlightAnalytical(); + const endND = this._stateAnalytical(tfND); + const rangeND = Math.max(0, endND.x); + if (rangeND > 0.1) rangeLoss = Math.round((range / rangeND - 1) * 100); + } + + return { + tf, hMax, range, vLand, rangeLoss, landAngle, + t: this.t, + progress: tf > 0 ? Math.min(1, this.t / tf) : 0, + hasMod: this._needsNumerical(), + }; + } + + /* ── animation loop ── */ + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (!this.playing) return; + if (this._lastTs === null) this._lastTs = ts; + const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05); + this._lastTs = ts; + + this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5); + + const cur = this._curState(this.t); + this._trail.push({ mx: cur.x, my: cur.y }); + if (this._trail.length > 80) this._trail.shift(); + + this.t += rawDt * this.speed; + const tf = this._curTFlight(); + if (this.t >= tf) { + this.t = tf; + this.playing = false; + this._triggerImpact(); + } + this.draw(); + this._emit(); + if (this.playing) this._tick(); + }); + } + + _triggerImpact() { + const end = this._curState(this._curTFlight()); + this._impactTs = performance.now(); + this._sparks = Array.from({ length: 18 }, (_, i) => { + const ang = (i / 18) * Math.PI * 2 + Math.random() * 0.3; + const spd = 40 + Math.random() * 80; + return { ang, spd, mx: end.x }; + }); + this._tickFX(); + } + + _tickFX() { + const elapsed = (performance.now() - this._impactTs) / 1000; + if (elapsed < 1.8) { + this.draw(); this._emit(); + requestAnimationFrame(() => this._tickFX()); + } else { + this._sparks = []; + this.draw(); this._emit(); + } + } + + _resetFX() { + this.t = 0; + this._trail = []; + this._sparks = []; + this._impactTs = -999; + this._launchFlash = 0; + this._computePath(); + } + + _emit() { if (this.onUpdate) this.onUpdate(this.stats()); } + + /* ── stars ── */ + + _genStars(n) { + return Array.from({ length: n }, () => ({ + rx: Math.random(), ry: Math.random(), + r: Math.random() * 1.1 + 0.2, + a: Math.random() * 0.55 + 0.15, + })); + } + + /* ── main render ── */ + + draw() { + const W = this._cw || this.c.width, H = this._ch || this.c.height; + if (!W || !H) return; + const ctx = this.ctx; + const tf = this._curTFlight(); + const st = this.stats(); + + const PL = 54, PR = 20, PT = 26, PB = 44; + const pw = W - PL - PR, ph = H - PT - PB; + + let maxRange = Math.max(st.range, 1); + let maxH = Math.max(st.hMax, 1); + for (const gh of this._ghosts) { + if (gh.range > maxRange) maxRange = gh.range; + if (gh.hMax > maxH) maxH = gh.hMax; + } + + const xMax = maxRange * 1.15; + const yMax = maxH * 1.35; + const scX = pw / xMax, scY = ph / yMax; + const tpx = mx => PL + mx * scX; + const tpy = my => H - PB - my * scY; + const gy = tpy(0); + + /* store for hover inspector */ + this._viewParams = { xMax, yMax, PL, PR, PT, PB, W, H }; + + /* ── 1. Sky ── */ + const sky = ctx.createLinearGradient(0, 0, 0, gy); + sky.addColorStop(0, '#05050f'); + sky.addColorStop(0.6, '#0d0d2a'); + sky.addColorStop(1, '#141430'); + ctx.fillStyle = sky; + ctx.fillRect(0, 0, W, gy); + + /* ── 2. Stars ── */ + for (const s of this._stars) { + const sx = PL + s.rx * pw, sy = PT + s.ry * (gy - PT - 10); + ctx.fillStyle = `rgba(255,255,255,${s.a})`; + ctx.beginPath(); ctx.arc(sx, sy, s.r, 0, Math.PI * 2); ctx.fill(); + } + + /* ── 2.5. Wind streaks ── */ + if (this.wind !== 0) { + this._drawWind(ctx, PL, PT, pw, gy - PT); + } + + /* ── 3. Ground ── */ + const gnd = ctx.createLinearGradient(0, gy, 0, H - PB); + gnd.addColorStop(0, 'rgba(22,101,52,.35)'); + gnd.addColorStop(1, 'rgba(15,23,42,.9)'); + ctx.fillStyle = gnd; + ctx.fillRect(PL, gy, pw, H - PB - gy); + + const gl = ctx.createLinearGradient(PL, 0, PL + pw, 0); + gl.addColorStop(0, 'rgba(34,197,94,.2)'); + gl.addColorStop(0.15, 'rgba(74,222,128,.7)'); + gl.addColorStop(1, 'rgba(34,197,94,.3)'); + ctx.strokeStyle = gl; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(PL + pw, gy); ctx.stroke(); + + /* ── 4. Margin fills ── */ + ctx.fillStyle = '#0A0A14'; + ctx.fillRect(0, H - PB, W, PB); + ctx.fillRect(0, 0, PL, H); + ctx.fillRect(W - PR, 0, PR, H); + + /* ── 5. Grid ── */ + const stX = _projNiceStep(xMax, 6), stY = _projNiceStep(yMax, 5); + ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; + for (let x = stX; x < xMax; x += stX) { + ctx.beginPath(); ctx.moveTo(tpx(x), PT); ctx.lineTo(tpx(x), H - PB); ctx.stroke(); + } + for (let y = stY; y < yMax; y += stY) { + ctx.beginPath(); ctx.moveTo(PL, tpy(y)); ctx.lineTo(W - PR, tpy(y)); ctx.stroke(); + } + + /* ── 6. Axes + labels ── */ + ctx.strokeStyle = 'rgba(255,255,255,.2)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(PL, PT); ctx.lineTo(PL, H - PB); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(W - PR, gy); ctx.stroke(); + + ctx.font = '10px Manrope, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,.28)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let x = stX; x < xMax * 0.97; x += stX) + ctx.fillText(_projFmt(x) + ' м', tpx(x), gy + 7); + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let y = stY; y < yMax * 0.97; y += stY) + ctx.fillText(_projFmt(y) + ' м', PL - 6, tpy(y)); + + /* ── 6.5. Ghost trails ── */ + for (const gh of this._ghosts) { + ctx.strokeStyle = gh.color; ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + for (let i = 0; i < gh.points.length; i++) { + const p = gh.points[i]; + i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y)); + } + ctx.stroke(); ctx.setLineDash([]); + + const last = gh.points[gh.points.length - 1]; + const lx = tpx(last.x), ly = tpy(0); + ctx.strokeStyle = gh.color; ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(lx - 5, ly - 5); ctx.lineTo(lx + 5, ly + 5); + ctx.moveTo(lx + 5, ly - 5); ctx.lineTo(lx - 5, ly + 5); + ctx.stroke(); + ctx.fillStyle = gh.color; + ctx.font = '9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(gh.label, lx, ly + 10); + } + + /* ── 7. Launch platform ── */ + if (this.h0 > 0.2) { + const px0 = tpx(0), py0 = tpy(0), pyH = tpy(this.h0); + ctx.strokeStyle = 'rgba(255,200,60,.35)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(px0, py0); ctx.lineTo(px0, pyH); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255,200,60,.25)'; + ctx.fillRect(px0 - 12, pyH, 28, 4); + ctx.fillStyle = 'rgba(255,200,60,.5)'; + ctx.font = '9px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillText(_projFmt(this.h0) + ' м', px0 - 14, pyH); + } + + /* ── 8. Reference / full trajectories ── */ + if (tf > 0) { + // analytical reference (always shown as faint dashed) + const noDragTf = this._tFlightAnalytical(); + ctx.strokeStyle = 'rgba(155,93,229,.22)'; + ctx.lineWidth = 1.5; ctx.setLineDash([7, 5]); + ctx.beginPath(); + for (let i = 0; i <= 300; i++) { + const s = this._stateAnalytical((i / 300) * noDragTf); + i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y)); + } + ctx.stroke(); ctx.setLineDash([]); + + // numerical path preview (if active) + if (this._path && this._path.length > 2) { + ctx.strokeStyle = this.drag ? 'rgba(239,71,111,.3)' : 'rgba(255,200,60,.35)'; + ctx.lineWidth = 1.5; ctx.setLineDash([5, 4]); + ctx.beginPath(); + const step = Math.max(1, Math.floor(this._path.length / 300)); + for (let i = 0; i < this._path.length; i += step) { + const p = this._path[i]; + i === 0 ? ctx.moveTo(tpx(p.x), tpy(p.y)) : ctx.lineTo(tpx(p.x), tpy(p.y)); + } + const last = this._path[this._path.length - 1]; + ctx.lineTo(tpx(last.x), tpy(last.y)); + ctx.stroke(); ctx.setLineDash([]); + } + } + + /* ── 9. Flown path ── */ + if (this.t > 0 && tf > 0) { + const s0 = this._curState(0), s1 = this._curState(Math.min(this.t, tf)); + const grad = ctx.createLinearGradient(tpx(s0.x), tpy(s0.y), tpx(s1.x), tpy(s1.y)); + grad.addColorStop(0, 'rgba(155,93,229,.4)'); + grad.addColorStop(0.5, '#9B5DE5'); + grad.addColorStop(1, '#F15BB5'); + ctx.strokeStyle = grad; ctx.lineWidth = 3; + ctx.beginPath(); + + if (this._path) { + let first = true; + for (const p of this._path) { + if (p.t > this.t) break; + first ? (ctx.moveTo(tpx(p.x), tpy(p.y)), first = false) + : ctx.lineTo(tpx(p.x), tpy(p.y)); + } + const cur = this._pathStateAt(this.t); + ctx.lineTo(tpx(cur.x), tpy(Math.max(0, cur.y))); + } else { + const steps = Math.max(2, Math.ceil(st.progress * 300)); + for (let i = 0; i <= steps; i++) { + const s = this._stateAnalytical((i / 300) * tf); + i === 0 ? ctx.moveTo(tpx(s.x), tpy(s.y)) : ctx.lineTo(tpx(s.x), tpy(s.y)); + } + } + ctx.stroke(); + } + + /* ── 10. Trail dots ── */ + for (let i = 0; i < this._trail.length; i++) { + const frac = i / this._trail.length; + const tr = this._trail[i]; + ctx.fillStyle = `rgba(241,91,181,${frac * 0.55})`; + ctx.beginPath(); ctx.arc(tpx(tr.mx), tpy(tr.my), frac * 5, 0, Math.PI * 2); ctx.fill(); + } + + /* ── 11. Max height marker ── */ + if (st.hMax > this.h0 + 0.2 && tf > 0) { + let mpx, mpy; + if (this._path) { + let best = this._path[0]; + for (const p of this._path) if (p.y > best.y) best = p; + mpx = tpx(best.x); mpy = tpy(best.y); + } else { + const rad = this.angle * Math.PI / 180; + const vy0 = this.v0 * Math.sin(rad); + const tPk = vy0 / this.g; + const pk = this._stateAnalytical(Math.max(0, tPk)); + mpx = tpx(pk.x); mpy = tpy(pk.y); + } + ctx.strokeStyle = 'rgba(255,200,60,.3)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(PL, mpy); ctx.lineTo(mpx, mpy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(mpx, mpy); ctx.lineTo(mpx, gy); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255,200,60,.7)'; + ctx.beginPath(); ctx.arc(mpx, mpy, 4, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = 'rgba(255,200,60,.55)'; + ctx.font = '10px Manrope'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillText('↑ ' + _projFmt(st.hMax) + ' м', PL - 6, mpy); + } + + /* ── 12. Landing marker + range arrow ── */ + if (tf > 0) { + const lx = tpx(st.range), ly = tpy(0); + const elapsed = (performance.now() - this._impactTs) / 1000; + const pulse = (elapsed >= 0 && elapsed < 10) ? 0.7 + 0.3 * Math.sin(elapsed * 8) : 0.6; + + // X mark + ctx.strokeStyle = `rgba(6,214,224,${pulse})`; ctx.lineWidth = 2; + const ms = 7; + ctx.beginPath(); + ctx.moveTo(lx - ms, ly - ms); ctx.lineTo(lx + ms, ly + ms); + ctx.moveTo(lx + ms, ly - ms); ctx.lineTo(lx - ms, ly + ms); + ctx.stroke(); + ctx.fillStyle = `rgba(6,214,224,${pulse * 0.8})`; + ctx.font = 'bold 10px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(_projFmt(st.range) + ' м', lx, ly + 9); + + // range arrow + if (st.range > 0.5 && lx > PL + 30) { + const ay = gy + 20; + ctx.strokeStyle = 'rgba(6,214,224,.3)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(lx - 3, ay); ctx.stroke(); + ctx.fillStyle = 'rgba(6,214,224,.3)'; + ctx.beginPath(); ctx.moveTo(PL + 3, ay); ctx.lineTo(PL + 9, ay - 3); ctx.lineTo(PL + 9, ay + 3); ctx.closePath(); ctx.fill(); + ctx.beginPath(); ctx.moveTo(lx - 3, ay); ctx.lineTo(lx - 9, ay - 3); ctx.lineTo(lx - 9, ay + 3); ctx.closePath(); ctx.fill(); + } + + if (st.hasMod && st.rangeLoss !== 0) { + const sign = st.rangeLoss > 0 ? '+' : ''; + ctx.fillStyle = st.rangeLoss < 0 ? 'rgba(239,71,111,.7)' : 'rgba(123,245,164,.7)'; + ctx.font = 'bold 9px Manrope'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(sign + st.rangeLoss + '% от идеала', lx, ly + 22); + } + } + + /* ── 13. Impact effects ── */ + const impactElapsed = (performance.now() - this._impactTs) / 1000; + if (impactElapsed >= 0 && impactElapsed < 1.5 && tf > 0) { + const end = this._curState(tf); + const ix = tpx(end.x), iy = tpy(0); + const p = impactElapsed / 1.5; + + for (let r = 0; r < 3; r++) { + const rp = Math.max(0, impactElapsed - r * 0.12); + if (rp <= 0) continue; + const rr = rp * 55 * (1 + r * 0.3); + const ra = Math.max(0, (0.5 - rp * 0.5) * (1 - r * 0.2)); + ctx.strokeStyle = `rgba(6,214,224,${ra})`; ctx.lineWidth = 2 - r * 0.4; + ctx.beginPath(); ctx.ellipse(ix, iy, rr, rr * 0.28, 0, 0, Math.PI * 2); ctx.stroke(); + } + if (impactElapsed < 0.6) { + const ca = (0.6 - impactElapsed) / 0.6; + const cg = ctx.createRadialGradient(ix, iy, 0, ix, iy, 30 + impactElapsed * 40); + cg.addColorStop(0, `rgba(255,230,100,${ca * 0.7})`); + cg.addColorStop(0.4, `rgba(241,91,181,${ca * 0.4})`); + cg.addColorStop(1, 'transparent'); + ctx.fillStyle = cg; + ctx.beginPath(); ctx.arc(ix, iy, 60, 0, Math.PI * 2); ctx.fill(); + } + for (const sp of this._sparks) { + if (impactElapsed > 1.0) continue; + const sa = Math.max(0, 1 - impactElapsed * 1.4); + const spd = sp.spd * impactElapsed; + const ex = ix + Math.cos(sp.ang) * spd; + const ey = iy + Math.sin(sp.ang) * spd * 0.4 - impactElapsed * impactElapsed * 120; + ctx.strokeStyle = `rgba(255,220,80,${sa})`; ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(ix + Math.cos(sp.ang) * spd * 0.6, iy + Math.sin(sp.ang) * spd * 0.6 * 0.4); + ctx.lineTo(ex, ey); + ctx.stroke(); + } + const swR = impactElapsed * 120; + const swa = Math.max(0, 0.35 - p * 0.35); + ctx.strokeStyle = `rgba(255,255,255,${swa})`; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(ix - swR, iy); ctx.lineTo(ix + swR, iy); ctx.stroke(); + } + + /* ── 14. Ball ── */ + const cur = this._curState(Math.min(this.t, tf)); + const bx = tpx(cur.x), by = tpy(Math.max(0, cur.y)); + const speed = Math.sqrt(cur.vx ** 2 + cur.vy ** 2); + + // shadow + const shadowX = tpx(cur.x); + const shadowA = Math.max(0, 0.25 - (by - gy) / (ph * 2)); + if (shadowA > 0) { + const sh = ctx.createRadialGradient(shadowX, gy + 2, 0, shadowX, gy + 2, 18); + sh.addColorStop(0, `rgba(0,0,0,${shadowA})`); + sh.addColorStop(1, 'transparent'); + ctx.fillStyle = sh; + ctx.beginPath(); ctx.ellipse(shadowX, gy + 3, 18, 5, 0, 0, Math.PI * 2); ctx.fill(); + } + + // glow + const glo = ctx.createRadialGradient(bx, by, 2, bx, by, 30); + glo.addColorStop(0, 'rgba(241,91,181,.5)'); + glo.addColorStop(0.4, 'rgba(155,93,229,.25)'); + glo.addColorStop(1, 'transparent'); + ctx.fillStyle = glo; + ctx.beginPath(); ctx.arc(bx, by, 30, 0, Math.PI * 2); ctx.fill(); + + // ball body + const ballGrad = ctx.createRadialGradient(bx - 3, by - 3, 1, bx, by, 10); + ballGrad.addColorStop(0, '#ffffff'); + ballGrad.addColorStop(0.25, '#F15BB5'); + ballGrad.addColorStop(1, '#7c3aed'); + ctx.fillStyle = ballGrad; + ctx.beginPath(); ctx.arc(bx, by, 10, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,.6)'; ctx.lineWidth = 1.5; ctx.stroke(); + + /* ── 15. Velocity arrows + labels ── */ + if (speed > 0.3 && this.t < tf) { + const VX_LEN = Math.min(55, 50 * Math.abs(cur.vx) / Math.max(1, this.v0)); + const VY_LEN = Math.min(55, 50 * Math.abs(cur.vy) / Math.max(1, this.v0)); + + if (Math.abs(cur.vx) > 0.2) { + _projArrow(ctx, bx, by, bx + VX_LEN, by, '#06D6E0', 2); + ctx.fillStyle = '#06D6E0'; ctx.font = 'bold 9px Manrope'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(_projFmt(Math.abs(cur.vx)) + ' м/с', bx + VX_LEN / 2, by + 7); + } + + if (Math.abs(cur.vy) > 0.2) { + const vyDir = cur.vy > 0 ? -1 : 1; + const vyCol = cur.vy > 0 ? '#9B5DE5' : '#F15BB5'; + _projArrow(ctx, bx, by, bx, by + vyDir * VY_LEN, vyCol, 2); + ctx.fillStyle = vyCol; ctx.font = 'bold 9px Manrope'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(_projFmt(Math.abs(cur.vy)) + ' м/с', bx + 6, by + vyDir * VY_LEN / 2); + } + + // total velocity arrow + const vLen = 48 * (speed / Math.max(1, this.v0)); + _projArrow(ctx, bx, by, + bx + (cur.vx / speed) * vLen, + by - (cur.vy / speed) * vLen, + '#ffffff', 2.5); + } + + /* ── 16. Launch flash ── */ + if (this._launchFlash > 0) { + const f = this._launchFlash; + const rad = this.angle * Math.PI / 180; + for (let i = 0; i < 10; i++) { + const a = rad + (i / 10) * Math.PI * 2; + const len = f * (20 + i % 3 * 15); + ctx.strokeStyle = `rgba(255,230,100,${f * 0.8})`; ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(bx + Math.cos(a) * 12, by - Math.sin(a) * 12); + ctx.lineTo(bx + Math.cos(a) * len, by - Math.sin(a) * len); + ctx.stroke(); + } + const halo = ctx.createRadialGradient(bx, by, 0, bx, by, f * 40); + halo.addColorStop(0, `rgba(255,230,100,${f * 0.5})`); + halo.addColorStop(0.5, `rgba(241,91,181,${f * 0.2})`); + halo.addColorStop(1, 'transparent'); + ctx.fillStyle = halo; + ctx.beginPath(); ctx.arc(bx, by, f * 40, 0, Math.PI * 2); ctx.fill(); + } + + /* ── 17. Launch angle arc (idle) ── */ + if (this.t < 0.04 && this.angle > 2 && !this.playing) { + const rad = this.angle * Math.PI / 180; + ctx.strokeStyle = 'rgba(255,200,60,.45)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(bx, by, 34, -rad, 0); ctx.stroke(); + const ivLen = Math.min(70, 30 + this.v0 * 0.8); + ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.lineWidth = 1.5; + ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.moveTo(bx, by); + ctx.lineTo(bx + Math.cos(rad) * ivLen, by - Math.sin(rad) * ivLen); + ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255,200,60,.75)'; + ctx.font = 'bold 11px Manrope'; ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; + ctx.fillText(this.angle + '°', bx + 38, by - 2); + } + + /* ── 18. Info badges (top-right) ── */ + let bRight = W - PR - 8; + if (this.drag) { + this._drawBadge(ctx, bRight, PT + 6, 'Cd=' + this.Cd.toFixed(2) + ' m=' + this.mass + 'кг', 'rgba(239,71,111,.15)', 'rgba(239,71,111,.75)'); + bRight -= 130; + } + if (this.wind !== 0) { + const dir = this.wind > 0 ? '' : ''; + this._drawBadge(ctx, bRight, PT + 6, dir + ' ветер ' + Math.abs(this.wind) + 'м/с', 'rgba(6,214,224,.12)', 'rgba(6,214,224,.8)'); + bRight -= 130; + } + if (this.bounce) { + this._drawBadge(ctx, bRight, PT + 6, ' e=' + this.restitution.toFixed(2), 'rgba(123,245,164,.1)', 'rgba(123,245,164,.75)'); + } + + /* speed badge bottom-right */ + if (this.speed !== 1) { + this._drawBadge(ctx, W - PR - 8, H - PB - 28, '×' + this.speed, 'rgba(255,214,102,.12)', 'rgba(255,214,102,.8)'); + } + + /* ── 19. Hover inspector ── */ + if (!this.playing && this._hover) { + this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT); + } + } + + /* ── hover inspector ── */ + + _onMouseMove(e) { + if (this.playing) { this._hover = null; return; } + const tf = this._curTFlight(); + if (tf <= 0 || !this._viewParams) { this._hover = null; return; } + + const r = this.c.getBoundingClientRect(); + const cw = this._cw || this.c.width, ch = this._ch || this.c.height; + const mx = (e.clientX - r.left) * (cw / r.width); + const my = (e.clientY - r.top) * (ch / r.height); + + const { xMax, yMax, PL, PR, PT, PB, W, H } = this._viewParams; + const pw = W - PL - PR, ph = H - PT - PB; + const scX = pw / xMax, scY = ph / yMax; + const tpx = wx => PL + wx * scX; + const tpy = wy => H - PB - wy * scY; + + let bestT = null, bestDist = Infinity; + const N = 400; + + if (this._path) { + const step = Math.max(1, Math.floor(this._path.length / N)); + for (let i = 0; i < this._path.length; i += step) { + const p = this._path[i]; + const d = Math.hypot(tpx(p.x) - mx, tpy(Math.max(0, p.y)) - my); + if (d < bestDist) { bestDist = d; bestT = p.t; } + } + /* also check last point */ + const last = this._path[this._path.length - 1]; + const d = Math.hypot(tpx(last.x) - mx, tpy(Math.max(0, last.y)) - my); + if (d < bestDist) { bestDist = d; bestT = last.t; } + } else { + for (let i = 0; i <= N; i++) { + const t = (i / N) * tf; + const s = this._stateAnalytical(t); + const d = Math.hypot(tpx(s.x) - mx, tpy(Math.max(0, s.y)) - my); + if (d < bestDist) { bestDist = d; bestT = t; } + } + } + + if (bestDist < 32 && bestT !== null) { + const s = this._curState(bestT); + this._hover = { t: bestT, s }; + } else { + this._hover = null; + } + this.draw(); + } + + _onMouseLeave() { + this._hover = null; + this.draw(); + } + + _drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT) { + const { t, s } = this._hover; + const bx = tpx(s.x); + const by = tpy(Math.max(0, s.y)); + const speed = Math.sqrt(s.vx ** 2 + s.vy ** 2); + const velAng = Math.atan2(s.vy, s.vx) * 180 / Math.PI; + + /* ── crosshair lines ── */ + ctx.save(); + ctx.strokeStyle = 'rgba(255,214,102,.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx, gy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(PL, by); ctx.lineTo(bx, by); ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + /* ── axis labels ── */ + ctx.font = 'bold 9px Manrope'; + ctx.fillStyle = 'rgba(255,214,102,.7)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(_projFmt(Math.max(0, s.x)) + ' м', bx, gy + 6); + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ctx.fillText(_projFmt(Math.max(0, s.y)) + ' м', PL - 4, by); + + /* ── dot on trajectory ── */ + const glow = ctx.createRadialGradient(bx, by, 0, bx, by, 14); + glow.addColorStop(0, 'rgba(255,214,102,.5)'); + glow.addColorStop(1, 'transparent'); + ctx.fillStyle = glow; + ctx.beginPath(); ctx.arc(bx, by, 14, 0, Math.PI * 2); ctx.fill(); + + ctx.fillStyle = '#FFD166'; + ctx.strokeStyle = 'rgba(255,255,255,.9)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(bx, by, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + + /* ── tooltip ── */ + const rows = [ + { label: 't', val: t.toFixed(3) + ' с', color: '#FFD166' }, + { label: 'x', val: _projFmt(Math.max(0, s.x)) + ' м', color: '#06D6E0' }, + { label: 'y', val: _projFmt(Math.max(0, s.y)) + ' м', color: '#7BF5A4' }, + { label: '|v|', val: _projFmt(speed) + ' м/с', color: '#ffffff' }, + { label: 'vx', val: _projFmt(s.vx) + ' м/с', color: '#06D6E0' }, + { label: 'vy', val: _projFmt(s.vy) + ' м/с', color: '#9B5DE5' }, + { label: 'угол', val: velAng.toFixed(1) + '°', color: '#F15BB5' }, + ]; + + const padX = 10, padY = 8, lineH = 17; + const tw = 138, th = padY * 2 + rows.length * lineH; + + /* position — avoid canvas edges */ + let tx = bx + 16, ty = by - th / 2; + if (tx + tw > W - 22) tx = bx - tw - 16; + if (ty < PT + 4) ty = PT + 4; + if (ty + th > H - PB - 4) ty = H - PB - th - 4; + + /* shadow */ + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,.6)'; + ctx.shadowBlur = 12; + ctx.fillStyle = 'rgba(8,8,18,.92)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.fill(); + ctx.restore(); + + /* border */ + ctx.strokeStyle = 'rgba(255,214,102,.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 9); ctx.stroke(); + + /* top accent line */ + ctx.strokeStyle = 'rgba(255,214,102,.6)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(tx + 9, ty + 1); + ctx.lineTo(tx + tw - 9, ty + 1); + ctx.stroke(); + + /* rows */ + ctx.font = '10px Manrope, sans-serif'; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const ry = ty + padY + i * lineH + lineH / 2; + + /* separator */ + if (i > 0) { + ctx.strokeStyle = 'rgba(255,255,255,.04)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(tx + 8, ry - lineH / 2); ctx.lineTo(tx + tw - 8, ry - lineH / 2); ctx.stroke(); + } + + ctx.fillStyle = 'rgba(255,255,255,.35)'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(row.label, tx + padX, ry); + + ctx.fillStyle = row.color; + ctx.textAlign = 'right'; + ctx.fillText(row.val, tx + tw - padX, ry); + } + + /* connector dot */ + ctx.fillStyle = '#FFD166'; + ctx.strokeStyle = 'rgba(8,8,18,.9)'; + ctx.lineWidth = 1.5; + const cx = tx < bx ? tx + tw : tx; + const cy = ty + th / 2; + ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + + /* ── draw helpers ── */ + + _drawBadge(ctx, rightX, y, text, bg, fg) { + const bh = 20; + ctx.font = 'bold 9px Manrope'; + const tw = ctx.measureText(text).width; + const bw = tw + 16; + const bx = rightX - bw; + ctx.fillStyle = bg; + ctx.beginPath(); ctx.roundRect(bx, y, bw, bh, 6); ctx.fill(); + ctx.fillStyle = fg; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, bx + bw / 2, y + bh / 2); + } + + _drawWind(ctx, x, y, w, h) { + const now = performance.now() / 1000; + const dir = this.wind > 0 ? 1 : -1; + const strength = Math.min(1, Math.abs(this.wind) / 20); + const count = Math.floor(4 + strength * 7); + const len = (18 + strength * 45) * dir; + + ctx.save(); + ctx.strokeStyle = '#06D6E0'; + for (let i = 0; i < count; i++) { + const phase = ((i / count) + now * strength * 0.25) % 1; + const streak_x = dir > 0 ? x + phase * w : x + (1 - phase) * w; + const streak_y = y + (0.1 + (i / count) * 0.8) * h; + const alpha = 0.08 + strength * 0.15; + ctx.globalAlpha = alpha; + ctx.lineWidth = 0.8 + strength * 0.6; + ctx.beginPath(); ctx.moveTo(streak_x, streak_y); ctx.lineTo(streak_x + len, streak_y); ctx.stroke(); + } + ctx.restore(); + } +} + +/* ── module helpers ── */ + +function _projNiceStep(range, n) { + const raw = range / n; + const p = Math.pow(10, Math.floor(Math.log10(raw))); + for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; + return p; +} + +function _projFmt(n) { + if (n >= 1000) return (n / 1000).toFixed(1) + 'k'; + if (n >= 100) return Math.round(n).toString(); + if (n >= 10) return n.toFixed(1); + return n.toFixed(2); +} + +function _projArrow(ctx, x1, y1, x2, y2, color, lw) { + const ang = Math.atan2(y2 - y1, x2 - x1); + ctx.save(); + ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw; + ctx.shadowColor = color; ctx.shadowBlur = 6; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - 9 * Math.cos(ang - 0.4), y2 - 9 * Math.sin(ang - 0.4)); + ctx.lineTo(x2 - 9 * Math.cos(ang + 0.4), y2 - 9 * Math.sin(ang + 0.4)); + ctx.closePath(); ctx.fill(); + ctx.restore(); +} diff --git a/frontend/js/labs/quadratic.js b/frontend/js/labs/quadratic.js new file mode 100644 index 0000000..0d84b5c --- /dev/null +++ b/frontend/js/labs/quadratic.js @@ -0,0 +1,433 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + QuadraticSim — interactive quadratic equation explorer + y = ax² + bx + c · discriminant, roots, vertex + ══════════════════════════════════════════════════════════════ */ + +class QuadraticSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* coefficients */ + this.a = 1; + this.b = 0; + this.c = -1; + + /* view */ + this.ox = 0; + this.oy = 0; + this.scl = 40; // px per unit + + /* interaction */ + this._drag = null; + this.hx = null; // hovered math x + + /* callback */ + this.onUpdate = null; + + this._bind(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ───────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + setParams({ a, b, c } = {}) { + if (a !== undefined) this.a = +a; + if (b !== undefined) this.b = +b; + if (c !== undefined) this.c = +c; + this.draw(); + this._emit(); + } + + resetView() { this.ox = 0; this.oy = 0; this.scl = 40; this.draw(); } + zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); } + zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); } + + info() { + const { a, b, c } = this; + const D = b * b - 4 * a * c; + let roots = '—'; + let rootCount = 0; + if (a === 0) { + roots = b !== 0 ? `x = ${this._fmt(-c / b)}` : '—'; + rootCount = b !== 0 ? 1 : 0; + } else if (D > 0.0001) { + const sqD = Math.sqrt(D); + const x1 = (-b - sqD) / (2 * a); + const x2 = (-b + sqD) / (2 * a); + roots = `x₁=${this._fmt(x1)}, x₂=${this._fmt(x2)}`; + rootCount = 2; + } else if (Math.abs(D) <= 0.0001) { + roots = `x = ${this._fmt(-b / (2 * a))}`; + rootCount = 1; + } + + const vx = a !== 0 ? -b / (2 * a) : 0; + const vy = a !== 0 ? c - b * b / (4 * a) : 0; + + return { + D: this._fmt(D), + rootCount, + roots, + vertex: a !== 0 ? `(${this._fmt(vx)}; ${this._fmt(vy)})` : '—', + equation: `y = ${a !== 1 ? (a === -1 ? '−' : this._fmt(a)) : ''}x² ${b >= 0 ? '+' : '−'} ${this._fmt(Math.abs(b))}x ${c >= 0 ? '+' : '−'} ${this._fmt(Math.abs(c))}`, + }; + } + + /* ── internals ────────────────────────────────────── */ + + _fmt(n) { + if (Number.isInteger(n)) return String(n); + return Math.abs(n) < 0.005 ? '0' : n.toFixed(2).replace(/\.?0+$/, ''); + } + + _f(x) { + return this.a * x * x + this.b * x + this.c; + } + + _emit() { + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ── coordinate transforms ─────────────────────────── */ + + _toPx(mx, my) { + return [ + this.W / 2 + (mx - this.ox) * this.scl, + this.H / 2 - (my - this.oy) * this.scl, + ]; + } + + _toMath(px, py) { + return [ + (px - this.W / 2) / this.scl + this.ox, + -(py - this.H / 2) / this.scl + this.oy, + ]; + } + + /* ── draw ─────────────────────────────────────────── */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + this._drawGrid(ctx, W, H); + this._drawAxes(ctx, W, H); + this._drawParabola(ctx, W, H); + this._drawFeatures(ctx, W, H); + if (this.hx !== null) this._drawHover(ctx, W, H); + } + + /* ── grid & axes ──────────────────────────────────── */ + + _niceStep() { + const raw = this.W / this.scl / 8; + const p = Math.pow(10, Math.floor(Math.log10(raw))); + for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p; + return p; + } + + _drawGrid(ctx, W, H) { + const step = this._niceStep(); + const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0); + const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0); + const gx = Math.floor(x0 / step) * step; + const gy = Math.floor(y0 / step) * step; + + ctx.strokeStyle = 'rgba(255,255,255,0.065)'; + ctx.lineWidth = 1; + for (let x = gx; x <= x1 + step; x += step) { + const [px] = this._toPx(x, 0); + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + } + for (let y = gy; y <= y1 + step; y += step) { + const [, py] = this._toPx(0, y); + ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); + } + + // labels + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + const [axX, axY] = this._toPx(0, 0); + const lblY = Math.max(4, Math.min(H - 18, axY + 5)); + const lblX = Math.max(28, Math.min(W - 6, axX - 5)); + + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let x = gx; x <= x1; x += step) { + if (Math.abs(x) < step * 0.01) continue; + const [px] = this._toPx(x, 0); + if (px < 18 || px > W - 18) continue; + ctx.fillText(this._fmtLabel(x, step), px, lblY); + } + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let y = gy; y <= y1; y += step) { + if (Math.abs(y) < step * 0.01) continue; + const [, py] = this._toPx(0, y); + if (py < 12 || py > H - 12) continue; + ctx.fillText(this._fmtLabel(y, step), lblX, py); + } + } + + _fmtLabel(n, step) { + if (n === 0) return '0'; + if (step >= 1 && Number.isInteger(n)) return String(n); + if (step < 0.001) return n.toExponential(1); + const dec = Math.max(0, -Math.floor(Math.log10(step))); + return n.toFixed(dec); + } + + _drawAxes(ctx, W, H) { + const [ax, ay] = this._toPx(0, 0); + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke(); + + // arrowheads + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + this._arrowHead(ctx, W - 8, ay, 0); + this._arrowHead(ctx, ax, 6, -Math.PI / 2); + + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = 'bold 12px Manrope, sans-serif'; + ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; + ctx.fillText('x', W - 10, ay - 13); + ctx.textBaseline = 'top'; ctx.textAlign = 'left'; + ctx.fillText('y', ax + 7, 4); + } + + _arrowHead(ctx, x, y, angle) { + const s = 5; + ctx.save(); ctx.translate(x, y); ctx.rotate(angle); + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); + ctx.closePath(); ctx.fill(); + ctx.restore(); + } + + /* ── parabola curve ───────────────────────────────── */ + + _drawParabola(ctx, W, H) { + const steps = Math.min(W * 2, 2000); + const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0); + const dx = (x1 - x0) / steps; + + // glow + ctx.strokeStyle = 'rgba(155,93,229,0.15)'; + ctx.lineWidth = 8; + ctx.lineJoin = 'round'; + ctx.beginPath(); + let pen = false; + for (let i = 0; i <= steps; i++) { + const mx = x0 + i * dx; + const my = this._f(mx); + if (!isFinite(my)) { pen = false; continue; } + const [px, py] = this._toPx(mx, my); + if (py < -200 || py > H + 200) { pen = false; continue; } + pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py); + pen = true; + } + ctx.stroke(); + + // main curve + ctx.strokeStyle = '#9B5DE5'; + ctx.lineWidth = 2.5; + ctx.beginPath(); + pen = false; + for (let i = 0; i <= steps; i++) { + const mx = x0 + i * dx; + const my = this._f(mx); + if (!isFinite(my)) { pen = false; continue; } + const [px, py] = this._toPx(mx, my); + if (py < -200 || py > H + 200) { pen = false; continue; } + pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py); + pen = true; + } + ctx.stroke(); + } + + /* ── vertex, roots, axis of symmetry ──────────────── */ + + _drawFeatures(ctx, W, H) { + const { a, b, c } = this; + if (a === 0) return; // linear — no features + + const vx = -b / (2 * a); + const vy = this._f(vx); + const D = b * b - 4 * a * c; + + // axis of symmetry + const [symPx] = this._toPx(vx, 0); + ctx.strokeStyle = 'rgba(6,214,224,0.25)'; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(symPx, 0); ctx.lineTo(symPx, H); ctx.stroke(); + ctx.setLineDash([]); + + // vertex point + const [vpx, vpy] = this._toPx(vx, vy); + if (vpy > -20 && vpy < H + 20) { + ctx.fillStyle = '#06D6E0'; + ctx.beginPath(); ctx.arc(vpx, vpy, 6, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); + + // label + ctx.fillStyle = '#06D6E0'; + ctx.font = 'bold 12px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText(`(${this._fmt(vx)}; ${this._fmt(vy)})`, vpx, vpy - 12); + } + + // roots + if (D >= -0.0001) { + ctx.fillStyle = '#EF476F'; + const roots = []; + if (D > 0.0001) { + const sqD = Math.sqrt(D); + roots.push((-b - sqD) / (2 * a)); + roots.push((-b + sqD) / (2 * a)); + } else { + roots.push(-b / (2 * a)); + } + + for (const rx of roots) { + const [rpx, rpy] = this._toPx(rx, 0); + if (rpx < -20 || rpx > W + 20) continue; + + // root dot + ctx.fillStyle = '#EF476F'; + ctx.beginPath(); ctx.arc(rpx, rpy, 5.5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); + + // label + ctx.fillStyle = '#EF476F'; + ctx.font = '11px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(this._fmt(rx), rpx, rpy + 10); + } + } + + // discriminant badge + const badgeColor = D > 0.0001 ? '#7BF5A4' : (D < -0.0001 ? '#EF476F' : '#FFD166'); + const badgeText = D > 0.0001 ? `D = ${this._fmt(D)} > 0 (2 корня)` : + D < -0.0001 ? `D = ${this._fmt(D)} < 0 (нет корней)` : + `D = 0 (1 корень)`; + ctx.font = 'bold 12px Manrope, sans-serif'; + const tw = ctx.measureText(badgeText).width; + const bx = W - tw - 28, by = 16; + ctx.fillStyle = 'rgba(22,22,38,0.85)'; + ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.fill(); + ctx.strokeStyle = badgeColor; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(bx, by, tw + 20, 28, 8); ctx.stroke(); + ctx.fillStyle = badgeColor; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(badgeText, bx + 10, by + 14); + } + + /* ── hover crosshair ──────────────────────────────── */ + + _drawHover(ctx, W, H) { + const [px] = this._toPx(this.hx, 0); + const my = this._f(this.hx); + if (!isFinite(my)) return; + const [, py] = this._toPx(this.hx, my); + + // vertical line + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); + ctx.setLineDash([]); + + if (py < -20 || py > H + 20) return; + + // point + ctx.fillStyle = '#FFD166'; + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); + + // tooltip + ctx.fillStyle = 'rgba(22,22,38,0.9)'; + const text = `(${this._fmt(this.hx)}, ${this._fmt(my)})`; + ctx.font = '12px Manrope, sans-serif'; + const tw2 = ctx.measureText(text).width; + const tx = px + 14, ty = py - 14; + ctx.beginPath(); ctx.roundRect(tx, ty - 10, tw2 + 16, 22, 6); ctx.fill(); + ctx.fillStyle = '#FFD166'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(text, tx + 8, ty + 1); + } + + /* ── events ───────────────────────────────────────── */ + + _bind() { + const cv = this.canvas; + + cv.addEventListener('wheel', e => { + e.preventDefault(); + const [mx, my] = this._toMath(e.offsetX, e.offsetY); + this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15))); + const [nx, ny] = this._toMath(e.offsetX, e.offsetY); + this.ox -= nx - mx; this.oy -= ny - my; + this.draw(); + }, { passive: false }); + + cv.addEventListener('mousedown', e => { + this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy }; + cv.style.cursor = 'grabbing'; + }); + window.addEventListener('mousemove', e => { + if (this._drag) { + this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl; + this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl; + this.draw(); + } else { + const r = cv.getBoundingClientRect(); + const lx = e.clientX - r.left, ly = e.clientY - r.top; + if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) { + this.hx = this._toMath(lx, ly)[0]; + this.draw(); + } + } + }); + window.addEventListener('mouseup', () => { + this._drag = null; + cv.style.cursor = 'crosshair'; + }); + cv.addEventListener('mouseleave', () => { + this.hx = null; this.draw(); + }); + cv.style.cursor = 'crosshair'; + + // touch + let t0 = null; + cv.addEventListener('touchstart', e => { + if (e.touches.length === 1) + t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy }; + }, { passive: true }); + cv.addEventListener('touchmove', e => { + e.preventDefault(); + if (e.touches.length === 1 && t0) { + this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl; + this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl; + this.draw(); + } + }, { passive: false }); + cv.addEventListener('touchend', () => { t0 = null; }); + } +} diff --git a/frontend/js/labs/reactions.js b/frontend/js/labs/reactions.js new file mode 100644 index 0000000..0945c40 --- /dev/null +++ b/frontend/js/labs/reactions.js @@ -0,0 +1,618 @@ +'use strict'; + +/** + * ReactionSim — Chemical reaction kinetics simulation. + * Particle-based A + B C (and variants) with Arrhenius kinetics. + * Renders: glowing molecules, flash effects on reaction, + * live concentration graph, energy profile diagram. + */ +class ReactionSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + this.particles = []; + this.flashes = []; // [{x, y, t, maxT, color}] + this._history = []; // [{step, nA, nB, nC}] + this._nextId = 0; + + // Parameters + this.N = 28; // initial molecules per reactive species + this.T = 1.2; // temperature 0.2–4.0 + this.Ea = 2.0; // activation energy 0.5–5.0 + this.mode = 'forward'; // 'forward' | 'reversible' | 'chain' + this.reactionOn = true; + + // Runtime stats + this._steps = 0; + this._totalReactions = 0; + this._recentReactions = 0; + this._rate = 0; // reactions per step (ema) + + this._raf = null; + this._dpr = 1; + this.onUpdate = null; + + // Spatial grid + this._grid = new Map(); + this._GRID_C = 22; // cell size (> max particle diameter) + } + + /* ────────────────────────── Lifecycle ────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + this._dpr = dpr; + const w = this.canvas.offsetWidth; + const h = this.canvas.offsetHeight; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.W = w; this.H = h; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.reset(); + } + + reset() { + const { W, H } = this; + if (!W || !H) return; + this.particles = []; + this.flashes = []; + this._history = []; + this._steps = 0; + this._totalReactions = 0; + this._recentReactions = 0; + this._rate = 0; + this._nextId = 0; + + // Spawn N of A and N of B + this._spawnType('A', this.N); + this._spawnType('B', this.N); + this._recordHistory(); + } + + _spawnType(type, count) { + const { W, H } = this; + const r = this._radius(type); + const margin = 12; + let placed = 0, attempts = 0; + while (placed < count && attempts < count * 60) { + attempts++; + const x = margin + r + Math.random() * (W - 2 * r - margin * 2); + const y = margin + r + Math.random() * (H - 2 * r - margin * 2); + let overlap = false; + for (const p of this.particles) { + const dx = p.x - x, dy = p.y - y; + if (dx * dx + dy * dy < (p.r + r + 1) ** 2) { overlap = true; break; } + } + if (overlap) continue; + const ang = Math.random() * Math.PI * 2; + const spd = this._baseSpeed(type) * (0.6 + Math.random() * 0.8); + this.particles.push({ x, y, vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd, r, type, id: this._nextId++ }); + placed++; + } + } + + start() { + if (this._raf) return; + const loop = () => { + this._raf = requestAnimationFrame(loop); + for (let i = 0; i < 3; i++) this._step(); + this.draw(); + }; + this._raf = requestAnimationFrame(loop); + } + + stop() { + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + /* ────────────────────────── Parameters ────────────────────────── */ + + setN(n) { + this.N = Math.max(5, Math.min(80, n)); + this.reset(); + } + + setT(t) { + const ratio = Math.max(0.1, t) / Math.max(0.1, this.T); + this.T = Math.max(0.2, Math.min(4.0, t)); + const scale = Math.sqrt(ratio); + for (const p of this.particles) { p.vx *= scale; p.vy *= scale; } + } + + setEa(ea) { + this.Ea = Math.max(0.5, Math.min(5.0, ea)); + } + + setMode(mode) { this.mode = mode; } + + toggleReaction() { this.reactionOn = !this.reactionOn; } + + preset(name) { + this.reactionOn = true; + const presets = { + simple: { N: 28, T: 1.2, Ea: 1.8, mode: 'forward' }, + reversible: { N: 22, T: 1.5, Ea: 1.5, mode: 'reversible' }, + hot: { N: 25, T: 2.8, Ea: 2.0, mode: 'forward' }, + cold: { N: 25, T: 0.4, Ea: 1.5, mode: 'forward' }, + chain: { N: 18, T: 1.8, Ea: 0.9, mode: 'chain' }, + }; + Object.assign(this, presets[name] || {}); + this.reset(); + } + + info() { + let nA = 0, nB = 0, nC = 0; + for (const p of this.particles) { + if (p.type === 'A') nA++; + else if (p.type === 'B') nB++; + else nC++; + } + return { nA, nB, nC, total: this.particles.length, reactions: this._totalReactions, rate: this._rate }; + } + + /* ────────────────────────── Helpers ────────────────────────── */ + + _radius(type) { return type === 'C' ? 7 : 5; } + _baseSpeed(type) { return (type === 'C' ? 0.55 : 1.0) * this.T * 3.2; } + _color(type) { return { A: '#06D6E0', B: '#EF476F', C: '#FFD166' }[type] || '#aaa'; } + + /* ────────────────────────── Physics ────────────────────────── */ + + _buildGrid() { + this._grid.clear(); + const cs = this._GRID_C; + for (const p of this.particles) { + const key = `${Math.floor(p.x / cs)},${Math.floor(p.y / cs)}`; + if (!this._grid.has(key)) this._grid.set(key, []); + this._grid.get(key).push(p); + } + } + + _neighbors(p) { + const cs = this._GRID_C; + const gx = Math.floor(p.x / cs), gy = Math.floor(p.y / cs); + const out = []; + for (let dx = -1; dx <= 1; dx++) + for (let dy = -1; dy <= 1; dy++) { + const cell = this._grid.get(`${gx + dx},${gy + dy}`); + if (cell) for (const q of cell) if (q !== p) out.push(q); + } + return out; + } + + _step() { + const { W, H } = this; + const dt = 0.55; + + // Move + wall bounce + for (const p of this.particles) { + p.x += p.vx * dt; + p.y += p.vy * dt; + if (p.x < p.r) { p.x = p.r; p.vx = Math.abs(p.vx); } + if (p.x > W - p.r) { p.x = W - p.r; p.vx = -Math.abs(p.vx); } + if (p.y < p.r) { p.y = p.r; p.vy = Math.abs(p.vy); } + if (p.y > H - p.r) { p.y = H - p.r; p.vy = -Math.abs(p.vy); } + } + + this._buildGrid(); + + const toRemove = new Set(); + const toAdd = []; + + // Pairwise: collision detection, reaction check, elastic bounce + for (const p of this.particles) { + if (toRemove.has(p.id)) continue; + for (const q of this._neighbors(p)) { + if (q.id <= p.id || toRemove.has(q.id)) continue; + + const dx = q.x - p.x, dy = q.y - p.y; + const dist2 = dx * dx + dy * dy; + const minD = p.r + q.r; + if (dist2 >= minD * minD) continue; + + const dist = Math.sqrt(dist2); + + // Try chemical reaction + if (this.reactionOn && this._tryReact(p, q, dx, dy, dist, toRemove, toAdd)) continue; + + // Elastic collision + const nx = dx / dist, ny = dy / dist; + const dvx = p.vx - q.vx, dvy = p.vy - q.vy; + const dot = dvx * nx + dvy * ny; + if (dot >= 0) { + // Just separate overlapping particles that are already moving apart + const ov = (minD - dist) * 0.5; + p.x -= nx * ov; p.y -= ny * ov; + q.x += nx * ov; q.y += ny * ov; + continue; + } + const m1 = p.r * p.r, m2 = q.r * q.r; + const imp = (2 * dot) / (m1 + m2); + p.vx -= imp * m2 * nx; p.vy -= imp * m2 * ny; + q.vx += imp * m1 * nx; q.vy += imp * m1 * ny; + const ov = (minD - dist) * 0.5; + p.x -= nx * ov; p.y -= ny * ov; + q.x += nx * ov; q.y += ny * ov; + } + } + + // Spontaneous decomposition C A + B (reversible mode) + if (this.mode === 'reversible') { + const prob = 0.00022 * this.T * Math.exp(-this.Ea * 0.38 / this.T); + for (const p of this.particles) { + if (p.type !== 'C' || toRemove.has(p.id)) continue; + if (Math.random() < prob) { + toRemove.add(p.id); + const ang = Math.random() * Math.PI * 2; + const spd = this._baseSpeed('A'); + const mk = id => ({ x: p.x + Math.cos(ang + id * Math.PI) * 5, + y: p.y + Math.sin(ang + id * Math.PI) * 5, + vx: Math.cos(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6), + vy: Math.sin(ang + id * Math.PI) * spd * (0.7 + Math.random() * 0.6), + r: 5, type: id === 0 ? 'A' : 'B', id: this._nextId++ }); + toAdd.push(mk(0), mk(1)); + this.flashes.push({ x: p.x, y: p.y, t: 0, maxT: 14, color: '100,160,255' }); + } + } + } + + // Apply changes + if (toRemove.size) this.particles = this.particles.filter(p => !toRemove.has(p.id)); + for (const p of toAdd) this.particles.push(p); + + // Age flashes + this.flashes = this.flashes.filter(f => ++f.t < f.maxT); + + this._steps++; + if (this._steps % 30 === 0) { + this._rate = this._recentReactions / 30; + this._recentReactions = 0; + } + if (this._steps % 20 === 0) { + this._recordHistory(); + if (this.onUpdate) this.onUpdate(this.info()); + } + } + + _tryReact(p, q, dx, dy, dist, toRemove, toAdd) { + const isAB = (p.type === 'A' && q.type === 'B') || (p.type === 'B' && q.type === 'A'); + if (!isAB) return false; + + // Arrhenius factor: k ∝ exp(-Ea / T) + if (Math.random() > Math.exp(-this.Ea / this.T) * 0.38) return false; + + const m1 = p.r * p.r, m2 = q.r * q.r, mt = m1 + m2; + const cx = (p.x * m1 + q.x * m2) / mt; + const cy = (p.y * m1 + q.y * m2) / mt; + const pvx = (p.vx * m1 + q.vx * m2) / mt; + const pvy = (p.vy * m1 + q.vy * m2) / mt; + + toRemove.add(p.id); + toRemove.add(q.id); + + if (this.mode === 'chain') { + // Chain: A + B 2 C (two fast products — cascade reaction) + const spd = Math.sqrt(pvx * pvx + pvy * pvy) * 1.35 + this._baseSpeed('C') * 0.7; + const ang = Math.atan2(pvy || 0.001, pvx || 0.001); + for (let s = 0; s < 2; s++) { + const sign = s === 0 ? 1 : -1; + toAdd.push({ + x: cx + Math.cos(ang) * sign * 5, + y: cy + Math.sin(ang) * sign * 5, + vx: Math.cos(ang) * sign * spd, + vy: Math.sin(ang) * sign * spd, + r: 6, type: 'C', id: this._nextId++ + }); + } + this.flashes.push({ x: cx, y: cy, t: 0, maxT: 28, color: '255,140,30' }); + } else { + // Forward / reversible: A + B 1 C + const cSpd = Math.sqrt(pvx * pvx + pvy * pvy) * 0.62 + this._baseSpeed('C') * 0.28; + const ang = Math.atan2(pvy || 0.001, pvx || 0.001); + toAdd.push({ x: cx, y: cy, vx: Math.cos(ang) * cSpd, vy: Math.sin(ang) * cSpd, r: 7, type: 'C', id: this._nextId++ }); + this.flashes.push({ x: cx, y: cy, t: 0, maxT: 22, color: '255,200,50' }); + } + + this._totalReactions++; + this._recentReactions++; + return true; + } + + _recordHistory() { + let nA = 0, nB = 0, nC = 0; + for (const p of this.particles) { + if (p.type === 'A') nA++; + else if (p.type === 'B') nB++; + else nC++; + } + this._history.push({ step: this._steps, nA, nB, nC }); + if (this._history.length > 260) this._history.shift(); + } + + /* ────────────────────────── Rendering ────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + + // ── Background ── + ctx.fillStyle = '#080818'; + ctx.fillRect(0, 0, W, H); + + // ── Subtle dot grid ── + ctx.fillStyle = 'rgba(255,255,255,0.033)'; + for (let x = 35; x < W; x += 35) + for (let y = 35; y < H; y += 35) { + ctx.beginPath(); + ctx.arc(x, y, 1, 0, Math.PI * 2); + ctx.fill(); + } + + // ── Reaction flashes ── + for (const f of this.flashes) { + const prog = f.t / f.maxT; + const radius = prog * 48 + 4; + const alpha = (1 - prog) * 0.55; + const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, radius); + g.addColorStop(0, `rgba(${f.color},${alpha * 1.6})`); + g.addColorStop(0.4, `rgba(${f.color},${alpha * 0.5})`); + g.addColorStop(1, `rgba(${f.color},0)`); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(f.x, f.y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + // ── Particles ── + for (const p of this.particles) this._drawParticle(ctx, p); + + // ── Overlays ── + this._drawLegend(ctx); + this._drawConcentrationGraph(ctx); + this._drawEnergyDiagram(ctx); + + // ── Empty state ── + if (this.particles.length === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Все молекулы прореагировали — нажмите Сброс', W / 2, H / 2); + } + } + + _drawParticle(ctx, p) { + const col = this._color(p.type); + const { x, y, r } = p; + + // Outer glow + const glow = ctx.createRadialGradient(x, y, 0, x, y, r * 3); + glow.addColorStop(0, col + '50'); + glow.addColorStop(1, col + '00'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(x, y, r * 3, 0, Math.PI * 2); + ctx.fill(); + + // Body (radial gradient for depth) + const body = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r); + body.addColorStop(0, col + 'ff'); + body.addColorStop(0.65, col + 'cc'); + body.addColorStop(1, col + '88'); + ctx.fillStyle = body; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + + // Specular highlight + ctx.fillStyle = 'rgba(255,255,255,0.42)'; + ctx.beginPath(); + ctx.arc(x - r * 0.27, y - r * 0.27, r * 0.3, 0, Math.PI * 2); + ctx.fill(); + + // Type label + ctx.fillStyle = 'rgba(0,0,0,0.72)'; + ctx.font = `bold ${Math.round(r * 1.15)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(p.type, x, y + 0.5); + ctx.textBaseline = 'alphabetic'; + } + + _drawConcentrationGraph(ctx) { + if (this._history.length < 2) return; + const { W, H } = this; + const gW = 198, gH = 118; + const gX = W - gW - 10, gY = H - gH - 10; + + // Panel + ctx.fillStyle = 'rgba(5,5,20,0.88)'; + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.lineWidth = 1; + this._rrect(ctx, gX, gY, gW, gH, 7); + ctx.fill(); ctx.stroke(); + + // Title + ctx.fillStyle = 'rgba(255,255,255,0.42)'; + ctx.font = '9px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Концентрация молекул', gX + 7, gY + 12); + + const pad = { l: 8, r: 6, t: 18, b: 24 }; + const px = gX + pad.l, py = gY + pad.t; + const pw = gW - pad.l - pad.r, ph = gH - pad.t - pad.b; + const maxN = this.N * 2.3; + const n = this._history.length; + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const yl = py + ph * (1 - i / 4); + ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke(); + } + + // Data lines + const lines = [ + { key: 'nA', color: '#06D6E0', label: 'A — реагент' }, + { key: 'nB', color: '#EF476F', label: 'B — реагент' }, + { key: 'nC', color: '#FFD166', label: 'C — продукт' }, + ]; + for (const { key, color } of lines) { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1.6; + for (let i = 0; i < n; i++) { + const lx = px + (i / Math.max(n - 1, 1)) * pw; + const ly = py + ph - Math.min(this._history[i][key] / maxN, 1) * ph; + i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly); + } + ctx.stroke(); + } + + // Legend + current values + const last = this._history[this._history.length - 1]; + lines.forEach(({ color, label }, i) => { + const lx = gX + 8 + i * 58; + ctx.fillStyle = color; + ctx.fillRect(lx, gY + gH - 16, 11, 2.5); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = '8px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(label.split(' ')[0], lx + 13, gY + gH - 12); + }); + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '8px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(`A:${last.nA} B:${last.nB} C:${last.nC}`, gX + gW - 6, gY + gH - 12); + } + + _drawEnergyDiagram(ctx) { + const { W } = this; + const dW = 158, dH = 100; + const dX = W - dW - 10, dY = 10; + + ctx.fillStyle = 'rgba(5,5,20,0.88)'; + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.lineWidth = 1; + this._rrect(ctx, dX, dY, dW, dH, 7); + ctx.fill(); ctx.stroke(); + + ctx.fillStyle = 'rgba(255,255,255,0.42)'; + ctx.font = '9px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Профиль энергии', dX + 7, dY + 12); + + const pad = { l: 22, r: 10, t: 18, b: 20 }; + const ex = dX + pad.l, ey_bot = dY + dH - pad.b; + const ew = dW - pad.l - pad.r, eh = dH - pad.t - pad.b; + + const rE = 0.15; + const tE = 0.85; + const pE = Math.max(0.04, rE + this._diagDeltaH()); + const toY = e => ey_bot - e * eh; + + // Smooth reaction path + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255,200,60,0.78)'; + ctx.lineWidth = 2; + ctx.moveTo(ex, toY(rE)); + ctx.lineTo(ex + ew * 0.15, toY(rE)); + ctx.bezierCurveTo( + ex + ew * 0.32, toY(rE), + ex + ew * 0.40, toY(tE), + ex + ew * 0.50, toY(tE) + ); + ctx.bezierCurveTo( + ex + ew * 0.60, toY(tE), + ex + ew * 0.68, toY(pE), + ex + ew * 0.85, toY(pE) + ); + ctx.lineTo(ex + ew, toY(pE)); + ctx.stroke(); + + // Horizontal dashes at levels + ctx.setLineDash([2, 3]); + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = 0.75; + [rE, pE].forEach(e => { + ctx.beginPath(); ctx.moveTo(ex, toY(e)); ctx.lineTo(ex + ew, toY(e)); ctx.stroke(); + }); + ctx.setLineDash([]); + + // Ea bracket (left side) + ctx.strokeStyle = 'rgba(255,255,255,0.28)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ex - 3, toY(rE)); ctx.lineTo(ex - 8, toY(rE)); + ctx.moveTo(ex - 3, toY(tE)); ctx.lineTo(ex - 8, toY(tE)); + ctx.moveTo(ex - 7, toY(rE)); ctx.lineTo(ex - 7, toY(tE)); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.38)'; + ctx.font = '8px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText('Ea', ex - 9, toY((rE + tE) / 2) + 3); + + // Labels + ctx.fillStyle = '#06D6E0cc'; + ctx.font = '8px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('A+B', ex, toY(rE) - 4); + ctx.fillStyle = '#FFD166cc'; + ctx.textAlign = 'right'; + ctx.fillText('C', ex + ew, toY(pE) - 4); + + // Mode label at bottom + const modeTxt = { forward: ' A + B C', reversible: '⇌ A + B ⇌ C', chain: 'цепная реакция' }[this.mode] || ''; + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.font = '8px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(modeTxt, dX + dW / 2, dY + dH - 6); + } + + _diagDeltaH() { + // Visual ΔH for energy diagram: exothermic by default + return -(0.10 + this.Ea * 0.045); + } + + _drawLegend(ctx) { + const items = [ + { color: '#06D6E0', label: 'A — реагент' }, + { color: '#EF476F', label: 'B — реагент' }, + { color: '#FFD166', label: 'C — продукт' }, + ]; + const lX = 10, lY = 10, lW = 120, lH = 14 * items.length + 14; + ctx.fillStyle = 'rgba(5,5,20,0.78)'; + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + ctx.lineWidth = 1; + this._rrect(ctx, lX, lY, lW, lH, 6); + ctx.fill(); ctx.stroke(); + + items.forEach(({ color, label }, i) => { + const iy = lY + 14 + i * 14; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(lX + 12, iy, 4.5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.52)'; + ctx.font = '9px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(label, lX + 22, iy + 3.5); + }); + } + + _rrect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } +} diff --git a/frontend/js/labs/redox.js b/frontend/js/labs/redox.js new file mode 100644 index 0000000..962046a --- /dev/null +++ b/frontend/js/labs/redox.js @@ -0,0 +1,503 @@ +'use strict'; +/* ==================================================================== + RedoxSim — Окислительно-восстановительные реакции + ==================================================================== */ + +class RedoxSim { + + /* ── Данные реакций ──────────────────────────────────────────────── */ + + static RXN = { + fe_cu: { + name: 'Fe + CuSO₄', + reducer: { f: 'Fe', name: 'Железо', color: '#A0856A', ox: 0 }, + oxidizer: { f: 'Cu²⁺', name: 'Ион меди', color: '#29B6F6', ox: 2 }, + prod_r: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 }, + prod_o: { f: 'Cu', color: '#C87840', ox: 0, solid: true }, + e: 2, + half_r: 'Fe⁰ – 2e⁻ Fe²⁺ окисление', + half_o: 'Cu²⁺ + 2e⁻ Cu⁰ восстановление', + eq_ion: 'Fe + Cu²⁺ Fe²⁺ + Cu', + eq_mol: 'Fe + CuSO₄ FeSO₄ + Cu', + sol_a: '#1565C040', sol_b: '#2E7D3230', + precip: true, pcolor: '#C87840', pname: 'медь Cu', + }, + zn_hcl: { + name: 'Zn + HCl', + reducer: { f: 'Zn', name: 'Цинк', color: '#90A4AE', ox: 0 }, + oxidizer: { f: 'H⁺', name: 'Ион H⁺', color: '#EF5350', ox: 1 }, + prod_r: { f: 'Zn²⁺', color: '#80CBC4', ox: 2 }, + prod_o: { f: 'H₂', color: '#EEEEEE', ox: 0, gas: true }, + e: 2, + half_r: 'Zn⁰ – 2e⁻ Zn²⁺ окисление', + half_o: '2H⁺ + 2e⁻ H₂ восстановление', + eq_ion: 'Zn + 2H⁺ Zn²⁺ + H₂', + eq_mol: 'Zn + 2HCl ZnCl₂ + H₂', + sol_a: '#EF525228', sol_b: '#E0F2F118', + gas: true, gcolor: '#CFD8DC', gname: 'водород H₂', + }, + cl2_ki: { + name: 'Cl₂ + KI', + reducer: { f: 'I⁻', name: 'Иодид-ион', color: '#CE93D8', ox: -1 }, + oxidizer: { f: 'Cl₂', name: 'Хлор', color: '#D4E157', ox: 0 }, + prod_r: { f: 'I₂', color: '#6A1B9A', ox: 0, solid: true }, + prod_o: { f: 'Cl⁻', color: '#AED581', ox: -1 }, + e: 1, + half_r: '2I⁻ – 2e⁻ I₂ окисление', + half_o: 'Cl₂ + 2e⁻ 2Cl⁻ восстановление', + eq_ion: 'Cl₂ + 2I⁻ I₂ + 2Cl⁻', + eq_mol: 'Cl₂ + 2KI I₂ + 2KCl', + sol_a: '#7B1FA230', sol_b: '#F9A82520', + precip: true, pcolor: '#6A1B9A', pname: 'йод I₂', + }, + kmno4: { + name: 'KMnO₄ + FeSO₄', + reducer: { f: 'Fe²⁺', name: 'Ион Fe²⁺', color: '#66BB6A', ox: 2 }, + oxidizer: { f: 'MnO₄⁻', name: 'Перманганат', color: '#AB47BC', ox: 7 }, + prod_r: { f: 'Fe³⁺', color: '#FFA726', ox: 3 }, + prod_o: { f: 'Mn²⁺', color: '#FFF9C4', ox: 2 }, + e: 5, + half_r: 'Fe²⁺ – e⁻ Fe³⁺ (×5) окисление', + half_o: 'MnO₄⁻+8H⁺+5e⁻Mn²⁺+4H₂O восстановление', + eq_ion: 'MnO₄⁻ + 5Fe²⁺ + 8H⁺ Mn²⁺ + 5Fe³⁺ + 4H₂O', + eq_mol: '2KMnO₄ + 10FeSO₄ + 8H₂SO₄ 2MnSO₄ + 5Fe₂(SO₄)₃ + K₂SO₄ + 8H₂O', + sol_a: '#7B1FA250', sol_b: '#F9A82515', + colorChange: true, newSolColor: '#FFF9C428', newName: 'бесцветный MnSO₄', + }, + cu_fecl3: { + name: 'Cu + FeCl₃', + reducer: { f: 'Cu', name: 'Медь', color: '#C87840', ox: 0 }, + oxidizer: { f: 'Fe³⁺', name: 'Ион Fe³⁺', color: '#FFA726', ox: 3 }, + prod_r: { f: 'Cu²⁺', color: '#29B6F6', ox: 2 }, + prod_o: { f: 'Fe²⁺', color: '#66BB6A', ox: 2 }, + e: 1, + half_r: 'Cu⁰ – 2e⁻ Cu²⁺ окисление', + half_o: '2Fe³⁺ + 2e⁻ 2Fe²⁺ восстановление', + eq_ion: 'Cu + 2Fe³⁺ Cu²⁺ + 2Fe²⁺', + eq_mol: 'Cu + 2FeCl₃ CuCl₂ + 2FeCl₂', + sol_a: '#E6510018', sol_b: '#1565C018', + colorChange: true, newSolColor: '#1565C030', newName: 'синий CuCl₂', + }, + }; + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.rxnId = 'fe_cu'; + this._raf = null; + this._last = 0; + this._t = 0; + this._phase = 'idle'; // idle | mixing | reacting | done + this._prog = 0; + this._colorT = 0; + this._stepIdx = 0; + this._stepTimer = 0; + this._eParts = []; + this._rParts = []; + this._oParts = []; + this._precip = []; + this._gas = []; + this.W = 0; this.H = 0; + this.onUpdate = null; + this.fit(); + this._initParts(); + } + + fit() { + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.offsetWidth || 600; + const H = this.canvas.offsetHeight || 400; + this.canvas.width = Math.round(W * dpr); + this.canvas.height = Math.round(H * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = W; this.H = H; + this._initParts(); + } + + setReaction(id) { + if (!RedoxSim.RXN[id]) return; + this.rxnId = id; + this.reset(); + } + + reset() { + this._phase = 'idle'; this._prog = 0; this._colorT = 0; + this._stepIdx = 0; this._stepTimer = 0; + this._eParts = []; this._precip = []; this._gas = []; + this._initParts(); + this.draw(); + } + + _initParts() { + const { W, H } = this; + const N = 16; + this._rParts = Array.from({ length: N }, () => ({ + x: W * 0.22 + (Math.random() - 0.5) * W * 0.22, + y: H * 0.42 + (Math.random() - 0.5) * H * 0.34, + vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6, + r: 11 + Math.random() * 4, + phase: Math.random() * Math.PI * 2, + trans: false, flashT: 0, + })); + this._oParts = Array.from({ length: N }, () => ({ + x: W * 0.78 + (Math.random() - 0.5) * W * 0.22, + y: H * 0.42 + (Math.random() - 0.5) * H * 0.34, + vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6, + r: 11 + Math.random() * 4, + phase: Math.random() * Math.PI * 2, + trans: false, flashT: 0, + })); + } + + start() { + if (this._phase !== 'idle') this.reset(); + this._phase = 'mixing'; this._prog = 0; + if (this._raf) return; + this._last = performance.now(); + const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); }; + this._raf = requestAnimationFrame(loop); + } + + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + /* ── Физика ─────────────────────────────────────────────────────── */ + + _tick(t) { + const dt = Math.min((t - this._last) / 1000, 0.05); + this._last = t; this._t += dt; + const { W, H } = this; + const rxn = RedoxSim.RXN[this.rxnId]; + + if (this._phase === 'mixing') { + this._prog = Math.min(1, this._prog + dt * 0.38); + const all = [...this._rParts, ...this._oParts]; + all.forEach(p => { + const tx = W * 0.5 + (Math.random() - 0.5) * W * 0.52; + const ty = H * 0.44 + (Math.random() - 0.5) * H * 0.34; + p.vx += (tx - p.x) * 0.003 * this._prog; + p.vy += (ty - p.y) * 0.003 * this._prog; + p.vx += (Math.random() - 0.5) * 0.5; + p.vy += (Math.random() - 0.5) * 0.5; + p.vx *= 0.90; p.vy *= 0.90; + p.x += p.vx; p.y += p.vy; + p.phase += dt * 1.5; + this._clamp(p); + }); + if (this._prog >= 1) { this._phase = 'reacting'; this._prog = 0; } + } + + if (this._phase === 'reacting') { + this._prog = Math.min(1, this._prog + dt * 0.14); + this._colorT = this._prog; + this._stepTimer += dt; + if (this._stepTimer > 1.6 && this._stepIdx < 3) { this._stepIdx++; this._stepTimer = 0; } + + const all = [...this._rParts, ...this._oParts]; + all.forEach(p => { + p.vx += (Math.random() - 0.5) * 0.9; + p.vy += (Math.random() - 0.5) * 0.9; + p.vx *= 0.87; p.vy *= 0.87; + p.x += p.vx; p.y += p.vy; + p.phase += dt * 2; + p.flashT = Math.max(0, p.flashT - dt * 3); + this._clamp(p); + }); + + /* Transform particles proportional to progress */ + const rT = Math.floor(this._prog * this._rParts.length); + const oT = Math.floor(this._prog * this._oParts.length); + this._rParts.forEach((p, i) => { + if (i < rT && !p.trans) { p.trans = true; p.flashT = 1; } + }); + this._oParts.forEach((p, i) => { + if (i < oT && !p.trans) { + p.trans = true; p.flashT = 1; + if (rxn.precip) this._precip.push({ x: p.x, y: p.y, vy: 0, r: 3 + Math.random() * 3, settled: false }); + if (rxn.gas) this._gas.push({ x: p.x, y: p.y, vy: -(1.5 + Math.random()), vx: (Math.random() - 0.5) * 0.5, r: 2 + Math.random() * 3, alpha: 1 }); + } + }); + + if (Math.random() < 0.22 && this._prog > 0.05) this._spawnE(); + if (this._prog >= 1) { this._phase = 'done'; this._stepIdx = 3; } + } + + if (this._phase === 'done') { + const all = [...this._rParts, ...this._oParts]; + all.forEach(p => { + p.vx += (Math.random() - 0.5) * 0.45; + p.vy += (Math.random() - 0.5) * 0.45; + p.vx *= 0.92; p.vy *= 0.92; + p.x += p.vx; p.y += p.vy; + p.phase += dt; + this._clamp(p); + }); + } + + /* Electrons — quadratic bezier arc */ + this._eParts = this._eParts.filter(e => e.t < 1); + this._eParts.forEach(e => { + e.t = Math.min(1, e.t + dt * e.spd); + const u = e.t; + e.x = (1-u)*(1-u)*e.x0 + 2*(1-u)*u*e.mx + u*u*e.x1; + e.y = (1-u)*(1-u)*e.y0 + 2*(1-u)*u*e.my + u*u*e.y1; + e.alpha = u < 0.1 ? u * 10 : u > 0.85 ? (1 - u) / 0.15 : 1; + }); + + /* Precipitate */ + this._precip.forEach(p => { + if (!p.settled) { + p.vy = Math.min(p.vy + 0.15, 5); + p.y += p.vy; + if (p.y >= H * 0.78) { p.y = H * 0.78; p.vy = 0; p.settled = true; } + } + }); + + /* Gas */ + this._gas.forEach(b => { b.y += b.vy; b.x += b.vx; b.vy -= 0.01; b.alpha -= 0.005; }); + this._gas = this._gas.filter(b => b.alpha > 0 && b.y > 10); + + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + } + + _clamp(p) { + const { W, H } = this; + const bot = H * 0.78; + if (p.x < p.r + 6) { p.x = p.r + 6; p.vx *= -0.5; } + if (p.x > W - p.r - 6) { p.x = W - p.r - 6; p.vx *= -0.5; } + if (p.y < p.r + 6) { p.y = p.r + 6; p.vy *= -0.5; } + if (p.y > bot - p.r) { p.y = bot - p.r; p.vy *= -0.5; } + } + + _spawnE() { + const freeR = this._rParts.filter(p => !p.trans); + const freeO = this._oParts.filter(p => !p.trans); + if (!freeR.length || !freeO.length) return; + const rp = freeR[Math.floor(Math.random() * freeR.length)]; + const op = freeO[Math.floor(Math.random() * freeO.length)]; + const mx = (rp.x + op.x) / 2; + const my = Math.min(rp.y, op.y) - 45 - Math.random() * 40; + this._eParts.push({ + x0: rp.x, y0: rp.y, x1: op.x, y1: op.y, + mx, my, x: rp.x, y: rp.y, + t: 0, spd: 0.65 + Math.random() * 0.45, alpha: 0, + }); + } + + /* ── Рендеринг ──────────────────────────────────────────────────── */ + + draw() { + const { ctx, W, H } = this; + const rxn = RedoxSim.RXN[this.rxnId]; + + /* Background */ + ctx.fillStyle = '#07071A'; + ctx.fillRect(0, 0, W, H); + + /* Dot grid */ + ctx.fillStyle = 'rgba(255,255,255,0.07)'; + for (let x = 0; x < W; x += 28) { + for (let y = 0; y < H; y += 28) { + ctx.beginPath(); ctx.arc(x, y, 0.8, 0, Math.PI * 2); ctx.fill(); + } + } + + /* Solution tint */ + const bx = W * 0.04, bw = W * 0.92, bTop = H * 0.08, bBot = H * 0.80; + if (this._phase === 'idle') { + if (rxn.sol_a) { ctx.save(); ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw / 2, bBot - bTop); ctx.restore(); } + if (rxn.sol_b) { ctx.save(); ctx.fillStyle = rxn.sol_b; ctx.fillRect(bx + bw / 2, bTop, bw / 2, bBot - bTop); ctx.restore(); } + /* Dashed divider */ + ctx.save(); + ctx.setLineDash([6, 5]); ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(W / 2, bTop + 4); ctx.lineTo(W / 2, bBot - 4); ctx.stroke(); + ctx.setLineDash([]); ctx.restore(); + /* Zone labels */ + ctx.save(); + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif'; + ctx.fillText(rxn.reducer.name, W * 0.22, bTop + 8); + ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif'; + ctx.fillText('восстановитель', W * 0.22, bTop + 26); + ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font = 'bold 12px sans-serif'; + ctx.fillText(rxn.oxidizer.name, W * 0.78, bTop + 8); + ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.font = '10px sans-serif'; + ctx.fillText('окислитель', W * 0.78, bTop + 26); + ctx.restore(); + } else if (this._colorT > 0) { + if (rxn.sol_a) { + ctx.save(); ctx.globalAlpha = 1 - this._colorT * 0.7; + ctx.fillStyle = rxn.sol_a; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore(); + } + if (rxn.colorChange && rxn.newSolColor) { + ctx.save(); ctx.globalAlpha = this._colorT * 0.55; + ctx.fillStyle = rxn.newSolColor; ctx.fillRect(bx, bTop, bw, bBot - bTop); ctx.restore(); + } + } + + this._drawBeaker(ctx, W, H); + this._drawParticles(ctx, rxn); + this._drawElectrons(ctx); + if (rxn.precip) this._drawPrecip(ctx, rxn); + if (rxn.gas) this._drawGas(ctx, rxn); + this._drawPanel(ctx, W, H, rxn); + } + + _drawBeaker(ctx, W, H) { + const bx = W * 0.04, by = H * 0.08, bw = W * 0.92, bh = H * 0.73; + ctx.save(); + ctx.strokeStyle = 'rgba(120,185,255,0.60)'; ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(bx, by); ctx.lineTo(bx, by + bh); + ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw, by); + ctx.stroke(); + ctx.beginPath(); ctx.moveTo(bx - 5, by); ctx.lineTo(bx + bw + 5, by); ctx.stroke(); + /* Left highlight */ + const hlg = ctx.createLinearGradient(bx, by, bx + 18, by + bh); + hlg.addColorStop(0, 'rgba(200,230,255,0.18)'); + hlg.addColorStop(1, 'rgba(200,230,255,0.02)'); + ctx.strokeStyle = hlg; ctx.lineWidth = 6; + ctx.beginPath(); ctx.moveTo(bx + 8, by + 8); ctx.lineTo(bx + 8, by + bh - 8); ctx.stroke(); + ctx.restore(); + } + + _drawParticles(ctx, rxn) { + const draw1 = (p, spec, prod) => { + const s = p.trans ? prod : spec; + ctx.save(); + ctx.shadowColor = p.flashT > 0 ? '#FFFFFF' : s.color; + ctx.shadowBlur = p.flashT > 0 ? 28 * p.flashT : 8 + Math.sin(p.phase) * 3; + ctx.globalAlpha = 0.88; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = p.flashT > 0 ? `rgba(255,255,255,${p.flashT * 0.9})` : s.color; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.shadowBlur = 0; ctx.globalAlpha = 1; + /* Oxidation state */ + const ox = p.trans ? prod.ox : spec.ox; + const oxStr = ox > 0 ? `+${ox}` : ox < 0 ? `${ox}` : '0'; + ctx.fillStyle = p.trans ? '#FFD166' : 'rgba(255,255,255,0.88)'; + ctx.font = `bold ${Math.round(p.r * 0.78)}px monospace`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(oxStr, p.x, p.y); + ctx.restore(); + }; + this._rParts.forEach(p => draw1(p, rxn.reducer, rxn.prod_r)); + this._oParts.forEach(p => draw1(p, rxn.oxidizer, rxn.prod_o)); + } + + _drawElectrons(ctx) { + this._eParts.forEach(e => { + ctx.save(); + ctx.globalAlpha = e.alpha; + ctx.shadowColor = '#4FC3F7'; ctx.shadowBlur = 16; + ctx.beginPath(); ctx.arc(e.x, e.y, 5.5, 0, Math.PI * 2); + ctx.fillStyle = '#4FC3F7'; ctx.fill(); + ctx.shadowBlur = 0; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.font = 'bold 7px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('e⁻', e.x, e.y); + ctx.restore(); + }); + } + + _drawPrecip(ctx, rxn) { + if (!this._precip.length) return; + ctx.save(); + this._precip.forEach(p => { + ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 4; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = rxn.pcolor; ctx.fill(); + }); + ctx.restore(); + /* Label when settled */ + const settled = this._precip.filter(p => p.settled); + if (settled.length > 3) { + ctx.save(); + ctx.fillStyle = rxn.pcolor; ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.shadowColor = rxn.pcolor; ctx.shadowBlur = 6; + ctx.fillText(`↓ ${rxn.pname}`, this.W / 2, this.H * 0.80 - 4); + ctx.restore(); + } + } + + _drawGas(ctx, rxn) { + this._gas.forEach(b => { + ctx.save(); ctx.globalAlpha = b.alpha * 0.75; + ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 5; + ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); + ctx.strokeStyle = rxn.gcolor; ctx.lineWidth = 1; ctx.stroke(); + ctx.restore(); + }); + const count = this._gas.length; + if (count > 2) { + ctx.save(); + ctx.fillStyle = rxn.gcolor; ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; ctx.shadowColor = rxn.gcolor; ctx.shadowBlur = 6; + ctx.fillText(`↑ ${rxn.gname}`, this.W / 2, this.H * 0.12); + ctx.restore(); + } + } + + _drawPanel(ctx, W, H, rxn) { + const py = H * 0.82; + ctx.fillStyle = 'rgba(7,7,26,0.94)'; + ctx.fillRect(0, py, W, H - py); + ctx.strokeStyle = 'rgba(100,165,255,0.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); + + if (this._phase === 'idle') { + ctx.fillStyle = '#37474F'; ctx.font = '11px monospace'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('← Нажми «Начать» для запуска реакции →', W / 2, py + (H - py) / 2); + return; + } + + const steps = [ + { lbl: 'Молекулярное:', txt: rxn.eq_mol, col: '#B0BEC5' }, + { lbl: 'Окисление:', txt: rxn.half_r, col: '#EF476F' }, + { lbl: 'Восстановление:', txt: rxn.half_o, col: '#4CC9F0' }, + { lbl: 'Ионное:', txt: rxn.eq_ion, col: '#FFD166' }, + ]; + + const panH = H - py; + const n = Math.min(this._stepIdx + 1, steps.length); + for (let i = 0; i < n; i++) { + const s = steps[i]; + const y = py + 11 + i * (panH * 0.22); + ctx.save(); + if (i === this._stepIdx && this._phase !== 'done') { + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + ctx.fillRect(8, y - 9, W - 16, 20); + } + ctx.fillStyle = s.col; ctx.font = 'bold 9.5px monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(s.lbl, 14, y); + ctx.fillStyle = (i === this._stepIdx && this._phase !== 'done') ? '#FFF' : 'rgba(255,255,255,0.62)'; + ctx.font = '9.5px monospace'; + ctx.fillText(s.txt, 14 + ctx.measureText(s.lbl).width + 8, y); + ctx.restore(); + } + + if (this._phase === 'done') { + ctx.save(); + ctx.fillStyle = '#7BF5A4'; ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'right'; ctx.textBaseline = 'top'; + ctx.shadowColor = '#7BF5A4'; ctx.shadowBlur = 8; + ctx.fillText('✓ Реакция завершена', W - 14, py + 3); + ctx.restore(); + } + } + + info() { + const rxn = RedoxSim.RXN[this.rxnId]; + return { + rxn: rxn.name, + phase: this._phase, + prog: Math.round((this._phase === 'reacting' ? this._prog : this._phase === 'done' ? 1 : 0) * 100), + e: rxn.e, + }; + } +} diff --git a/frontend/js/labs/refraction.js b/frontend/js/labs/refraction.js new file mode 100644 index 0000000..5470eb0 --- /dev/null +++ b/frontend/js/labs/refraction.js @@ -0,0 +1,497 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + RefractionSim — light refraction simulation (Snell's law) + n₁·sin(θ₁) = n₂·sin(θ₂) + Total internal reflection · Fresnel coefficients · Dispersion + Interactive incident ray drag · Presets + ══════════════════════════════════════════════════════════════ */ + +class RefractionSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* physics */ + this.n1 = 1.0; // refractive index of top medium + this.n2 = 1.5; // refractive index of bottom medium + this.angle = 30; // incidence angle in degrees + + /* dispersion mode */ + this.dispersion = false; + + /* drag state */ + this._drag = false; + + /* callback */ + this.onUpdate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ─────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + 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)); + if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle)); + if (dispersion !== undefined) this.dispersion = !!dispersion; + this.draw(); + this._emit(); + } + + reset() { + this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; + this.dispersion = false; + this.draw(); + this._emit(); + } + + info() { + const { n1, n2, angle } = this; + const theta1Rad = angle * Math.PI / 180; + const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad); + const isTIR = Math.abs(sinTheta2) > 1; + const criticalAngle = n1 > n2 + ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) + : null; + + let angle2; + if (isTIR) { + angle2 = 'ПВО'; + } else { + angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1); + } + + return { + n1: +n1.toFixed(2), + n2: +n2.toFixed(2), + angle1: +angle.toFixed(1), + angle2, + criticalAngle, + isTIR, + }; + } + + /* ── presets ────────────────────────────────── */ + + static PRESETS = { + air_glass: { n1: 1.0, n2: 1.5, angle: 30 }, + glass_air: { n1: 1.5, n2: 1.0, angle: 30 }, + water_glass: { n1: 1.33, n2: 1.5, angle: 30 }, + diamond: { n1: 1.0, n2: 2.42, angle: 45 }, + }; + + /* ── internals ─────────────────────────────── */ + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + /* ── draw ──────────────────────────────────── */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + const midY = H / 2; + const hitX = W / 2; + const hitY = midY; + + /* --- background: two media --- */ + // top medium (lighter) + const gradTop = ctx.createLinearGradient(0, 0, 0, midY); + gradTop.addColorStop(0, '#131328'); + gradTop.addColorStop(1, '#1a1a3a'); + ctx.fillStyle = gradTop; + ctx.fillRect(0, 0, W, midY); + + // bottom medium (darker, denser feel) + const gradBot = ctx.createLinearGradient(0, midY, 0, H); + gradBot.addColorStop(0, '#0e1a2e'); + gradBot.addColorStop(1, '#0D0D1A'); + ctx.fillStyle = gradBot; + ctx.fillRect(0, midY, W, H - midY); + + /* --- interface line with glow --- */ + ctx.save(); + ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; + ctx.shadowBlur = 12; + ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke(); + ctx.restore(); + + /* --- normal line (dashed vertical) --- */ + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke(); + ctx.setLineDash([]); + + /* --- physics --- */ + const theta1Rad = this.angle * Math.PI / 180; + const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad); + const isTIR = Math.abs(sinTheta2) > 1; + + /* Fresnel reflectance (simplified) */ + let R = 1; + if (!isTIR) { + const theta2Rad = Math.asin(sinTheta2); + const cosT1 = Math.cos(theta1Rad); + const cosT2 = Math.cos(theta2Rad); + const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2); + R = rs * rs; + } + + /* ray length (from edge to hit point) */ + const rayLen = Math.max(W, H) * 0.6; + + /* --- critical angle indicator --- */ + if (this.n1 > this.n2) { + const critRad = Math.asin(this.n2 / this.n1); + const critDx = Math.sin(critRad); + const critDy = Math.cos(critRad); + ctx.strokeStyle = 'rgba(255,209,102,0.25)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + // critical angle ray in top medium + ctx.beginPath(); + ctx.moveTo(hitX, hitY); + ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); + ctx.stroke(); + ctx.setLineDash([]); + // label + ctx.font = '10px Manrope, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255,209,102,0.5)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + const lblX = hitX - critDx * rayLen * 0.35 + 6; + const lblY = hitY - critDy * rayLen * 0.35; + ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', lblX, lblY); + } + + if (this.dispersion && !isTIR) { + this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen); + } else { + this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen); + } + + /* --- angle arcs --- */ + this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR); + + /* --- medium labels --- */ + this._drawMediumLabels(ctx, W, H, midY); + + /* --- info box --- */ + this._drawInfoBox(ctx, isTIR, R); + + /* --- drag handle indicator (incident ray endpoint) --- */ + const incDx = Math.sin(theta1Rad); + const incDy = Math.cos(theta1Rad); + const handleX = hitX - incDx * rayLen * 0.55; + const handleY = hitY - incDy * rayLen * 0.55; + const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10); + grad.addColorStop(0, 'rgba(155,93,229,0.4)'); + grad.addColorStop(1, 'rgba(155,93,229,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill(); + } + + _drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) { + const incDx = Math.sin(theta1Rad); + const incDy = Math.cos(theta1Rad); + + /* incident ray */ + const incStartX = hitX - incDx * rayLen; + const incStartY = hitY - incDy * rayLen; + this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#9B5DE5', 2.5); + this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#9B5DE5'); + + /* reflected ray */ + const refDx = incDx; // same x component + const refDy = -incDy; // flipped y + const refEndX = hitX + refDx * rayLen; + const refEndY = hitY + refDy * rayLen; // goes up (refDy is negative of incDy) + const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R)); + ctx.globalAlpha = refAlpha; + this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5); + this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F'); + ctx.globalAlpha = 1; + + /* refracted ray */ + if (!isTIR) { + const theta2Rad = Math.asin(sinTheta2); + const refracDx = Math.sin(theta2Rad); + const refracDy = Math.cos(theta2Rad); + const refracEndX = hitX + refracDx * rayLen; + const refracEndY = hitY + refracDy * rayLen; + const T = 1 - R; + ctx.globalAlpha = Math.max(0.3, Math.sqrt(T)); + this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, '#06D6E0', 2.5); + this._drawArrowhead(ctx, refracEndX, refracEndY, + Math.atan2(refracEndY - hitY, refracEndX - hitX), '#06D6E0'); + ctx.globalAlpha = 1; + } + } + + _drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) { + /* Cauchy dispersion: n(λ) = A + B/λ² */ + const spectral = [ + { name: 'red', color: '#FF0000', wave: 656 }, + { name: 'orange', color: '#FF7F00', wave: 589 }, + { name: 'yellow', color: '#FFFF00', wave: 550 }, + { name: 'green', color: '#00FF00', wave: 510 }, + { name: 'cyan', color: '#00FFFF', wave: 475 }, + { name: 'blue', color: '#0000FF', wave: 450 }, + { name: 'violet', color: '#8B00FF', wave: 400 }, + ]; + + /* incident white ray */ + const incDx = Math.sin(theta1Rad); + const incDy = Math.cos(theta1Rad); + const incStartX = hitX - incDx * rayLen; + const incStartY = hitY - incDy * rayLen; + this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5); + this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF'); + + /* Cauchy coefficients derived from base n2 */ + const A = this.n2 - 4500 / (550 * 550); + const B = 4500; + + for (const s of spectral) { + const n2w = A + B / (s.wave * s.wave); + const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad); + if (Math.abs(sinT2) > 1) continue; + const t2 = Math.asin(sinT2); + const dx = Math.sin(t2); + const dy = Math.cos(t2); + ctx.globalAlpha = 0.85; + this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5); + ctx.globalAlpha = 1; + } + + /* reflected (white, partial) */ + const refDx = incDx; + const refDy = -incDy; + ctx.globalAlpha = 0.35; + this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5); + ctx.globalAlpha = 1; + } + + _drawRay(ctx, x1, y1, x2, y2, color, width) { + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + + /* subtle glow */ + ctx.save(); + ctx.shadowColor = color; + ctx.shadowBlur = 8; + ctx.globalAlpha = 0.3; + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + ctx.restore(); + } + + _drawArrowhead(ctx, x, y, angle, color) { + const aLen = 10; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3)); + ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3)); + ctx.closePath(); ctx.fill(); + } + + _drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) { + const arcR = 50; + const font = '12px Manrope, system-ui, sans-serif'; + + /* θ₁ arc (incidence angle, measured from normal = vertical up) */ + if (this.angle > 1) { + ctx.strokeStyle = 'rgba(155,93,229,0.6)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + // normal points up from hit: angle = -π/2 in canvas coords + // incident ray comes from upper-left + // Arc from normal (straight up = -π/2) to incident ray direction + const normAngle = -Math.PI / 2; + const incAngle = -Math.PI / 2 - theta1Rad; + ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); + ctx.stroke(); + + // label + ctx.font = font; + ctx.fillStyle = '#9B5DE5'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const midA = normAngle - theta1Rad / 2; + ctx.fillText( + 'θ₁=' + this.angle.toFixed(1) + '°', + hitX + (arcR + 20) * Math.cos(midA), + hitY + (arcR + 20) * Math.sin(midA) + ); + } + + /* θ₂ arc (refraction angle, measured from normal = vertical down) */ + if (!isTIR && Math.abs(sinTheta2) <= 1) { + const theta2Rad = Math.asin(sinTheta2); + if (theta2Rad > 0.02) { + ctx.strokeStyle = 'rgba(6,214,224,0.6)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const normDown = Math.PI / 2; + const refAngle = Math.PI / 2 + theta2Rad; + ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); + ctx.stroke(); + + // label + const angle2Deg = theta2Rad * 180 / Math.PI; + ctx.font = font; + ctx.fillStyle = '#06D6E0'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const midA2 = normDown + theta2Rad / 2; + ctx.fillText( + 'θ₂=' + angle2Deg.toFixed(1) + '°', + hitX + (arcR * 0.8 + 20) * Math.cos(midA2), + hitY + (arcR * 0.8 + 20) * Math.sin(midA2) + ); + } + } + } + + _drawMediumLabels(ctx, W, H, midY) { + ctx.font = '13px Manrope, system-ui, sans-serif'; + ctx.textBaseline = 'middle'; + + /* top medium */ + ctx.fillStyle = 'rgba(155,93,229,0.6)'; + ctx.textAlign = 'left'; + ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30); + + /* bottom medium */ + ctx.fillStyle = 'rgba(6,214,224,0.6)'; + ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30); + + /* TIR badge */ + const theta1Rad = this.angle * Math.PI / 180; + const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad); + if (Math.abs(sinT2) > 1) { + ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; + ctx.fillStyle = '#EF476F'; + ctx.textAlign = 'center'; + ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60); + } + } + + _drawInfoBox(ctx, isTIR, R) { + const boxW = 220, boxH = 72; + const bx = this.W - boxW - 12, by = 12; + + ctx.fillStyle = 'rgba(22,22,38,0.85)'; + ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); + + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10); + + const info = this.info(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + const a2str = info.isTIR ? 'ПВО' : info.angle2 + '°'; + ctx.fillText(`θ₁ = ${info.angle1}° θ₂ = ${a2str}`, bx + 10, by + 28); + + const rPct = (R * 100).toFixed(1); + const tPct = ((1 - R) * 100).toFixed(1); + ctx.fillStyle = '#EF476F'; + ctx.fillText(`R = ${rPct}%`, bx + 10, by + 46); + ctx.fillStyle = '#06D6E0'; + ctx.fillText(`T = ${isTIR ? '0' : tPct}%`, bx + 90, by + 46); + + if (info.criticalAngle !== null) { + ctx.fillStyle = '#FFD166'; + ctx.fillText(`θc = ${info.criticalAngle}°`, bx + 160, by + 46); + } + } + + /* ── events ─────────────────────────────────── */ + + _bindEvents() { + const cv = this.canvas; + + const getPos = (e) => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { + mx: (t.clientX - r.left) * (this.W / r.width), + my: (t.clientY - r.top) * (this.H / r.height), + }; + }; + + const hitTest = (mx, my) => { + /* Check if near the incident ray line (top half only) */ + const hitX = this.W / 2; + const hitY = this.H / 2; + if (my >= hitY) return false; + /* distance from mouse to the hit point — if within top half, allow drag */ + const dx = mx - hitX; + const dy = my - hitY; + const dist = Math.hypot(dx, dy); + return dist > 20 && dist < Math.max(this.W, this.H) * 0.6; + }; + + const angleFromMouse = (mx, my) => { + const hitX = this.W / 2; + const hitY = this.H / 2; + const dx = mx - hitX; + const dy = hitY - my; // flip: canvas y goes down, angle measured from vertical up + // angle from vertical = atan2(|dx|, dy) + const a = Math.atan2(Math.abs(dx), dy) * 180 / Math.PI; + return Math.max(0, Math.min(89, a)); + }; + + const onDown = (e) => { + const { mx, my } = getPos(e); + if (hitTest(mx, my)) this._drag = true; + }; + + const onMove = (e) => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const { mx, my } = getPos(e); + this.angle = angleFromMouse(mx, my); + this.draw(); + this._emit(); + }; + + const onUp = () => { this._drag = false; }; + + /* mouse */ + cv.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + + /* touch */ + cv.addEventListener('touchstart', e => { + if (e.touches.length === 1) onDown(e); + }, { passive: true }); + cv.addEventListener('touchmove', e => onMove(e), { passive: false }); + cv.addEventListener('touchend', onUp); + + /* cursor style */ + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor = 'grabbing'; return; } + const { mx, my } = getPos(e); + cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; + }); + } +} diff --git a/frontend/js/labs/states.js b/frontend/js/labs/states.js new file mode 100644 index 0000000..7b505f3 --- /dev/null +++ b/frontend/js/labs/states.js @@ -0,0 +1,620 @@ +/** + * StatesSim v4 — Aggregate States of Matter (Lennard-Jones MD) + * Clean rewrite: stable physics, proper layout, canvas clipping, no boundary artifacts. + */ +class StatesSim { + // ── layout / physics constants ─────────────────────────────────────────── + static PAD_B = 112; // px reserved at bottom for charts + static PAD_L = 38; // px reserved on left for temperature bar + static SIG = 14; // Lennard-Jones σ (px) + static EPS = 1.0; // Lennard-Jones ε + static DT = 0.16; // time step + static CUTOFF = 3.5; // force cutoff in σ units + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + this.N = 64; + this.T = 0.15; + this.particles = []; + + this._raf = null; + this._stepCount = 0; + this._loop = this._loop.bind(this); + this._wallImpulse = 0; + this._pressureSmooth = 0; + this._energyHistory = []; + this._rdfData = null; + this._rdfMaxG = 3; + this._rdfTick = 0; + this._phaseFlash = 0; + this._flashColor = '#4CC9F0'; + this._prevPhase = ''; + this._phasePulse = 0; + this._hover = null; + this._showVectors = false; + this.onUpdate = null; + + canvas.addEventListener('mousemove', e => this._onMouse(e)); + canvas.addEventListener('mouseleave', () => { this._hover = null; }); + } + + // ── public API ──────────────────────────────────────────────────────────── + fit() { + this.W = this.canvas.offsetWidth || 400; + this.H = this.canvas.offsetHeight || 400; + this.canvas.width = this.W * devicePixelRatio; + this.canvas.height = this.H * devicePixelRatio; + this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + this.reset(); + } + + reset() { + this.particles = []; + const { N, T } = this; + const { SIG, PAD_L, PAD_B } = StatesSim; + const spacing = SIG * 1.15; + const simW = this.W - PAD_L; + const simH = this.H - PAD_B; + const cols = Math.ceil(Math.sqrt(N)); + const rows = Math.ceil(N / cols); + const gridW = (cols - 1) * spacing; + const gridH = (rows - 1) * spacing * Math.sqrt(3) / 2; + const ox = PAD_L + (simW - gridW) / 2; + const oy = (simH - gridH) / 2; + + let n = 0; + for (let r = 0; r < rows && n < N; r++) { + const xOff = (r % 2) * spacing * 0.5; + for (let c = 0; c < cols && n < N; c++) { + this.particles.push({ + x: ox + xOff + c * spacing, + y: oy + r * spacing * Math.sqrt(3) / 2, + vx: (Math.random() - 0.5) * T * 3, + vy: (Math.random() - 0.5) * T * 3, + ax: 0, ay: 0, + }); + n++; + } + } + + this._stepCount = 0; + this._wallImpulse = 0; + this._pressureSmooth = 0; + this._energyHistory = []; + this._rdfData = null; + this._rdfMaxG = 3; + this._rdfTick = 0; + this._phaseFlash = 0; + this._prevPhase = ''; + this._hover = null; + } + + setT(t) { + const old = this.T; + this.T = Math.max(0.01, t); + if (old > 0) { + const f = Math.min(4, Math.sqrt(this.T / old)); + for (const p of this.particles) { p.vx *= f; p.vy *= f; } + } + } + + setN(n) { + this.N = Math.max(16, Math.min(120, n)); + this.reset(); + } + + toggleVectors() { this._showVectors = !this._showVectors; } + start() { if (!this._raf) this._raf = requestAnimationFrame(this._loop); } + stop() { cancelAnimationFrame(this._raf); this._raf = null; } + + // ── simulation ──────────────────────────────────────────────────────────── + _loop() { + for (let i = 0; i < 5; i++) this._stepPhysics(); + this.draw(); + this._raf = requestAnimationFrame(this._loop); + } + + _stepPhysics() { + const { particles } = this; + const { SIG, EPS, DT, CUTOFF, PAD_L, PAD_B } = StatesSim; + const dt = DT; + const pr = SIG * 0.48; + const cut2 = (CUTOFF * SIG) ** 2; + const xMin = PAD_L + pr, xMax = this.W - pr; + const yMin = pr, yMax = this.H - PAD_B - pr; + + // Velocity Verlet — step 1 + for (const p of particles) { + p.vx += 0.5 * p.ax * dt; p.vy += 0.5 * p.ay * dt; + p.x += p.vx * dt; p.y += p.vy * dt; + if (p.x < xMin) { p.x = xMin; p.vx = Math.abs(p.vx); this._wallImpulse += Math.abs(p.vx); } + else if (p.x > xMax) { p.x = xMax; p.vx = -Math.abs(p.vx); this._wallImpulse += Math.abs(p.vx); } + if (p.y < yMin) { p.y = yMin; p.vy = Math.abs(p.vy); this._wallImpulse += Math.abs(p.vy); } + else if (p.y > yMax) { p.y = yMax; p.vy = -Math.abs(p.vy); this._wallImpulse += Math.abs(p.vy); } + } + + // Lennard-Jones forces + for (const p of particles) { p.ax = 0; p.ay = 0; } + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const pi = particles[i], pj = particles[j]; + const dx = pj.x - pi.x, dy = pj.y - pi.y; + const r2 = dx * dx + dy * dy; + if (r2 >= cut2 || r2 < 0.25) continue; + const sr2 = (SIG * SIG) / r2, sr6 = sr2 * sr2 * sr2; + const f = Math.max(-40, Math.min(40, 24 * EPS * (2 * sr6 * sr6 - sr6) / r2)); + pi.ax += f * dx; pi.ay += f * dy; + pj.ax -= f * dx; pj.ay -= f * dy; + } + } + + // Velocity Verlet — step 2 + for (const p of particles) { + p.vx += 0.5 * p.ax * dt; p.vy += 0.5 * p.ay * dt; + } + + // Berendsen thermostat + this._stepCount++; + let ke2 = 0; + for (const p of particles) ke2 += p.vx * p.vx + p.vy * p.vy; + const ke = ke2 / (2 * particles.length); + if (ke > 1e-8) { + const lam = Math.max(0.92, Math.min(1.08, Math.sqrt(1 + (dt / 60) * (this.T / ke - 1)))); + for (const p of particles) { p.vx *= lam; p.vy *= lam; } + } + + // smooth pressure + this._pressureSmooth = this._pressureSmooth * 0.95 + this._wallImpulse * 0.05; + this._wallImpulse = 0; + + // energy + phase history (every 8 steps) + if (this._stepCount % 8 === 0) { + const info = this.info(); + this._energyHistory.push({ ke: +info.avgKE, pe: +info.avgPE, te: +info.avgKE + +info.avgPE }); + if (this._energyHistory.length > 300) this._energyHistory.shift(); + const ph = info.phase; + if (this._prevPhase && ph !== this._prevPhase) { + const fc = { solid: '#4CC9F0', liquid: '#7BF5A4', gas: '#FFB347' }; + this._phaseFlash = 1; this._flashColor = fc[ph] || '#ffffff'; + } + this._prevPhase = ph; + } + + // RDF every 25 steps + if (++this._rdfTick % 25 === 0) this._computeRDF(); + if (this._stepCount % 25 === 0 && this.onUpdate) this.onUpdate(this.info()); + } + + // ── RDF g(r) ────────────────────────────────────────────────────────────── + _computeRDF() { + const { particles } = this; + const N = particles.length; + if (N < 4) return; + const { SIG, PAD_L, PAD_B } = StatesSim; + const nBins = 32, maxR = 3.8 * SIG, dr = maxR / nBins; + const hist = new Float32Array(nBins); + for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) { + const r = Math.hypot(particles[j].x - particles[i].x, particles[j].y - particles[i].y); + if (r < maxR) hist[Math.floor(r / dr)]++; + } + const area = (this.W - PAD_L) * (this.H - PAD_B); + const g = new Float32Array(nBins); + for (let i = 0; i < nBins; i++) { + const rc = (i + 0.5) * dr; + const ideal = N * (N - 1) * Math.PI * rc * dr / area; + g[i] = ideal > 1e-10 ? hist[i] / ideal : 0; + } + if (!this._rdfData) { this._rdfData = g; } + else { for (let i = 0; i < nBins; i++) this._rdfData[i] = this._rdfData[i] * 0.65 + g[i] * 0.35; } + this._rdfMaxG = Math.max(1.5, ...Array.from(this._rdfData.slice(1))); + } + + // ── info / phase ────────────────────────────────────────────────────────── + _phase() { + return this.T < 0.2 ? 'solid' : this.T < 0.5 ? 'liquid' : 'gas'; + } + + info() { + const { particles, T } = this; + const { SIG, EPS, CUTOFF, PAD_L, PAD_B } = StatesSim; + let ke2 = 0; + for (const p of particles) ke2 += p.vx * p.vx + p.vy * p.vy; + const avgKE = particles.length ? 0.5 * ke2 / particles.length : 0; + const cut2 = (CUTOFF * SIG) ** 2; + let peTot = 0; + for (let i = 0; i < particles.length; i++) for (let j = i + 1; j < particles.length; j++) { + const dx = particles[j].x - particles[i].x, dy = particles[j].y - particles[i].y; + const r2 = dx * dx + dy * dy; + if (r2 < cut2 && r2 > 0.1) { + const sr2 = SIG * SIG / r2, sr6 = sr2 * sr2 * sr2; + peTot += 4 * EPS * (sr6 * sr6 - sr6); + } + } + const avgPE = particles.length ? peTot / particles.length : 0; + const perim = 2 * ((this.W - PAD_L) + (this.H - PAD_B)); + const P = this._pressureSmooth / perim * 80; + return { + phase: this._phase(), + T, + avgKE: avgKE.toFixed(3), + avgPE: avgPE.toFixed(3), + P: P.toFixed(1), + }; + } + + // ── mouse ───────────────────────────────────────────────────────────────── + _onMouse(e) { + const r = this.canvas.getBoundingClientRect(); + const x = (e.clientX - r.left) * (this.W / r.width); + const y = (e.clientY - r.top) * (this.H / r.height); + let best = null, bd = 20; + for (const p of this.particles) { + const d = Math.hypot(p.x - x, p.y - y); + if (d < bd) { bd = d; best = p; } + } + this._hover = best; + } + + // ── draw ────────────────────────────────────────────────────────────────── + draw() { + const { ctx, W, H, T } = this; + const { SIG, PAD_B, PAD_L } = StatesSim; + const simH = H - PAD_B; + const phase = this._phase(); + + // full background + ctx.fillStyle = '#08091a'; ctx.fillRect(0, 0, W, H); + + // ── clip everything to simulation area ───────────────────────────────── + ctx.save(); + ctx.beginPath(); ctx.rect(0, 0, W, simH); ctx.clip(); + + // phase flash + if (this._phaseFlash > 0) { + this._phaseFlash = Math.max(0, this._phaseFlash - 0.02); + const [fr, fg, fb] = this._hex3(this._flashColor); + ctx.fillStyle = `rgba(${fr},${fg},${fb},${this._phaseFlash * 0.16})`; + ctx.fillRect(0, 0, W, simH); + } + + // pressure wall glow (walls at simulation boundaries) + const P = parseFloat(this.info().P); + const wi = Math.min(1, P / 25); + if (wi > 0.04) { + const a = wi * 0.28, gd = 30; + const walls = [ + { x: PAD_L, y: 0, w: gd, h: simH, d: 'r' }, + { x: W - gd, y: 0, w: gd, h: simH, d: 'l' }, + { x: 0, y: 0, w: W, h: gd, d: 'd' }, + { x: 0, y: simH-gd, w: W, h: gd, d: 'u' }, + ]; + for (const { x, y, w, h, d } of walls) { + let gr; + if (d==='r') gr = ctx.createLinearGradient(x, 0, x+w, 0); + else if (d==='l') gr = ctx.createLinearGradient(x+w, 0, x, 0); + else if (d==='d') gr = ctx.createLinearGradient(0, y, 0, y+h); + else gr = ctx.createLinearGradient(0, y+h, 0, y); + gr.addColorStop(0, `rgba(139,92,246,${a})`); + gr.addColorStop(1, 'rgba(139,92,246,0)'); + ctx.fillStyle = gr; ctx.fillRect(x, y, w, h); + } + } + + // per-particle speeds + const speeds = this.particles.map(p => Math.hypot(p.vx, p.vy)); + const maxSpd = Math.max(...speeds, 1e-6); + + // bonds (solid / liquid) + const bondCut = SIG * 1.85; + if (phase !== 'gas') { + ctx.save(); + ctx.strokeStyle = phase === 'solid' + ? 'rgba(96,210,250,0.45)' + : 'rgba(120,130,255,0.22)'; + ctx.lineWidth = phase === 'solid' ? 1.2 : 0.8; + ctx.beginPath(); + for (let i = 0; i < this.particles.length; i++) { + for (let j = i + 1; j < this.particles.length; j++) { + const pi = this.particles[i], pj = this.particles[j]; + if (Math.hypot(pj.x - pi.x, pj.y - pi.y) < bondCut) { + ctx.moveTo(pi.x, pi.y); ctx.lineTo(pj.x, pj.y); + } + } + } + ctx.stroke(); ctx.restore(); + } + + // velocity vectors (optional) + if (this._showVectors) { + ctx.save(); + const vScale = SIG * 2 / maxSpd; + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + const len = speeds[i] * vScale; + if (len < 1.5) continue; + const ang = Math.atan2(p.vy, p.vx); + const ex = p.x + Math.cos(ang) * len, ey = p.y + Math.sin(ang) * len; + const hue = 240 - (speeds[i] / maxSpd) * 200; + ctx.strokeStyle = `hsla(${hue},85%,65%,0.55)`; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(ex, ey); ctx.stroke(); + const hl = Math.min(7, len * 0.38); + ctx.fillStyle = `hsla(${hue},85%,65%,0.55)`; + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - hl * Math.cos(ang - 0.45), ey - hl * Math.sin(ang - 0.45)); + ctx.lineTo(ex - hl * Math.cos(ang + 0.45), ey - hl * Math.sin(ang + 0.45)); + ctx.closePath(); ctx.fill(); + } + ctx.restore(); + } + + // particles + ctx.save(); + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + const t = speeds[i] / maxSpd; + const hue = 240 - t * 220; // blue (cold) → green → yellow → red (hot) + const col = `hsl(${hue},85%,62%)`; + const isH = this._hover === p; + const rad = isH ? SIG * 0.62 : SIG * 0.5; + ctx.shadowBlur = isH ? 22 : 5 + t * 12; + ctx.shadowColor = col; + ctx.fillStyle = col; + ctx.beginPath(); ctx.arc(p.x, p.y, rad, 0, Math.PI * 2); ctx.fill(); + if (isH) { + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(p.x, p.y, rad + 4, 0, Math.PI * 2); ctx.stroke(); + } + } + ctx.restore(); + + // phase badge + this._phasePulse += 0.04; + this._drawPhaseBadge(ctx, W, phase); + + // temperature bar + this._drawTempBar(ctx, simH, T); + + ctx.restore(); // end simulation clip + + // chart separator + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, simH); ctx.lineTo(W, simH); ctx.stroke(); + + // charts (outside clip) + this._drawEnergyChart(ctx, W, H, PAD_B); + this._drawRDFChart(ctx, W, H, PAD_B); + + // hover inspector (may extend into chart area) + if (this._hover) this._drawInspector(ctx, this._hover, speeds, maxSpd, W, H); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + _hex3(hex) { + const h = hex.replace('#', ''); + return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16)]; + } + + // ── sub-drawing ─────────────────────────────────────────────────────────── + _drawPhaseBadge(ctx, W, phase) { + const cfg = { + solid: { icon: '❄', label: 'Твёрдое', color: '#4CC9F0', bg: 'rgba(76,201,240,0.12)' }, + liquid: { icon: '~', label: 'Жидкость', color: '#7BF5A4', bg: 'rgba(123,245,164,0.12)' }, + gas: { icon: '·', label: 'Газ', color: '#FFB347', bg: 'rgba(255,179,71,0.12)' }, + }[phase]; + const sc = 1 + 0.028 * Math.sin(this._phasePulse); + ctx.save(); + ctx.font = 'bold 13px sans-serif'; + const text = `${cfg.icon} ${cfg.label}`; + const tw = ctx.measureText(text).width; + const bw = tw + 24, bh = 27, bx = W / 2 - bw / 2, by = 10; + ctx.translate(W/2, by+bh/2); ctx.scale(sc,sc); ctx.translate(-W/2, -(by+bh/2)); + ctx.fillStyle = cfg.bg; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill(); + ctx.strokeStyle = cfg.color + '50'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.stroke(); + ctx.fillStyle = cfg.color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, W/2, by+bh/2); + ctx.restore(); + } + + _drawTempBar(ctx, simH, T) { + const bx = 10, by = 50, bw = 9; + const bh = Math.max(50, Math.min(simH - 72, 260)); + ctx.save(); + + // gradient track + const grad = ctx.createLinearGradient(0, by, 0, by + bh); + grad.addColorStop(0, '#EF476F'); + grad.addColorStop(0.4, '#FFD166'); + grad.addColorStop(1, '#4CC9F0'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 4); ctx.fill(); + + // phase transition markers + ctx.strokeStyle = 'rgba(255,255,255,0.28)'; ctx.lineWidth = 1; ctx.setLineDash([2,3]); + ctx.font = '7px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + for (const [tv, lbl] of [[0.2,'Жидк.'],[0.5,'Газ']]) { + const y = by + bh - (tv / 0.7) * bh; + if (y > by + 4 && y < by + bh - 4) { + ctx.fillStyle = 'rgba(255,255,255,0)'; + ctx.beginPath(); ctx.moveTo(bx-3, y); ctx.lineTo(bx+bw+3, y); ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.fillText(lbl, bx+bw+5, y); + } + } + ctx.setLineDash([]); + + // indicator + const tNorm = Math.min(1, T / 0.7); + const iy = by + bh - tNorm * bh; + ctx.fillStyle = '#fff'; ctx.shadowBlur = 8; ctx.shadowColor = '#fff'; + ctx.beginPath(); ctx.arc(bx + bw/2, iy, 5, 0, Math.PI*2); ctx.fill(); + ctx.shadowBlur = 0; + + // T value + const labelY = iy < by + 18 ? iy + 14 : iy - 14; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = "bold 9px 'Manrope',monospace"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(T.toFixed(2), bx + bw/2, labelY); + + ctx.fillStyle = 'rgba(255,255,255,0.32)'; ctx.font = '9px sans-serif'; + ctx.fillText('T', bx + bw/2, by - 8); + ctx.restore(); + } + + _drawEnergyChart(ctx, W, H, padB) { + const hist = this._energyHistory; + const cw = Math.min(196, Math.floor((W - 16) * 0.46)); + const ch = padB - 18; + const cx = 8, cy = H - ch - 8; + + ctx.save(); + ctx.fillStyle = 'rgba(4,6,20,0.82)'; + ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke(); + + ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = "9px 'Manrope',sans-serif"; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('Энергия / частицу', cx + 8, cy + 5); + + if (hist.length > 3) { + const pL=8, pR=6, pT=17, pB=13; + const pw = cw-pL-pR, ph = ch-pT-pB; + const allV = hist.flatMap(h => [h.ke, h.pe, h.te]); + const minV = Math.min(...allV, 0), maxV = Math.max(...allV, 0.001); + const rng = maxV - minV || 0.001; + const px = i => cx + pL + (i / (hist.length-1)) * pw; + const py = v => cy + pT + ph - ((v - minV) / rng) * ph; + + // zero line + const zy = py(0); + if (zy > cy + pT && zy < cy + pT + ph) { + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.setLineDash([3,3]); + ctx.beginPath(); ctx.moveTo(cx+pL, zy); ctx.lineTo(cx+pL+pw, zy); ctx.stroke(); + ctx.setLineDash([]); + } + + for (const [key, color, lw, dash] of [ + ['pe','#9B5DE5',1.2,false], + ['ke','#FFD166',1.2,false], + ['te','rgba(255,255,255,0.38)',1,true], + ]) { + ctx.strokeStyle = color; ctx.lineWidth = lw; + if (dash) ctx.setLineDash([3,4]); + ctx.beginPath(); + hist.forEach((h,i) => { + const x = px(i), y = py(h[key]); + i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y); + }); + ctx.stroke(); ctx.setLineDash([]); + } + + // legend + [['#FFD166','КЕ'],['#9B5DE5','ПЭ'],['rgba(255,255,255,0.4)','Е']].forEach(([c,l],li) => { + const lx = cx + 8 + li * 34; + ctx.fillStyle = c; ctx.beginPath(); ctx.arc(lx, cy+ch-7, 3, 0, Math.PI*2); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = '8px sans-serif'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText(l, lx+5, cy+ch-7); + }); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = "9px 'Manrope',sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('накапливается…', cx+cw/2, cy+ch/2); + } + ctx.restore(); + } + + _drawRDFChart(ctx, W, H, padB) { + const g = this._rdfData; + const cw = Math.min(196, Math.floor((W - 16) * 0.46)); + const ch = padB - 18; + const cx = W - cw - 8, cy = H - ch - 8; + + ctx.save(); + ctx.fillStyle = 'rgba(4,6,20,0.82)'; + ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, 6); ctx.stroke(); + + ctx.fillStyle = 'rgba(255,255,255,0.48)'; ctx.font = "9px 'Manrope',sans-serif"; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText('g(r) — радиальная функция', cx+8, cy+5); + + if (g) { + const pL=8, pR=6, pT=17, pB=14; + const pw = cw-pL-pR, ph = ch-pT-pB; + const nBins = g.length, barW = pw/nBins, maxG = this._rdfMaxG; + + // g=1 reference + const refY = cy+pT+ph - (1/maxG)*ph; + ctx.strokeStyle = 'rgba(255,209,102,0.38)'; ctx.lineWidth=1; ctx.setLineDash([4,3]); + ctx.beginPath(); ctx.moveTo(cx+pL,refY); ctx.lineTo(cx+pL+pw,refY); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(255,209,102,0.35)'; ctx.font='7px sans-serif'; + ctx.textAlign='right'; ctx.textBaseline='middle'; + ctx.fillText('1', cx+pL-2, refY); + + for (let i = 0; i < nBins; i++) { + const v = Math.min(g[i], maxG), frac = v / maxG; + const bh = frac * ph; + const bx = cx+pL+i*barW, by = cy+pT+ph-bh; + const hue = 220 - frac * 180; + ctx.fillStyle = `hsla(${hue},70%,55%,0.82)`; + ctx.beginPath(); ctx.roundRect(bx+0.5, by, barW-1, bh, 1); ctx.fill(); + } + + ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.font='7px sans-serif'; ctx.textAlign='center'; + for (let v=0; v<=3; v++) { + ctx.fillText(v, cx+pL+(v/3.8)*pw, cy+pT+ph+8); + } + ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.textBaseline='bottom'; + ctx.fillText('r / σ', cx+pL+pw/2, cy+ch); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.22)'; ctx.font = "9px 'Manrope',sans-serif"; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('накапливается…', cx+cw/2, cy+ch/2); + } + ctx.restore(); + } + + _drawInspector(ctx, p, speeds, maxSpd, W, H) { + const { SIG } = StatesSim; + const spd = Math.hypot(p.vx, p.vy); + const ke = 0.5 * spd * spd; + const ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI; + let coord = 0; + for (const q of this.particles) { + if (q !== p && Math.hypot(q.x-p.x, q.y-p.y) < SIG*1.5) coord++; + } + const t = spd / maxSpd, hue = 240 - t * 220; + const clr = `hsl(${hue},85%,62%)`; + const rows = [ + ['|v|', spd.toFixed(3)], ['vx', p.vx.toFixed(2)], ['vy', p.vy.toFixed(2)], + ['KE', ke.toFixed(3)], ['угол', ang.toFixed(1)+'°'], ['z', coord+' сос.'], + ]; + const tw=136, th=rows.length*17+20; + let tx = p.x+14, ty = p.y-th/2; + if (tx+tw > W-8) tx = p.x-tw-14; + ty = Math.max(8, Math.min(H-th-8, ty)); + ctx.save(); + ctx.fillStyle = 'rgba(5,7,22,0.95)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.fill(); + ctx.fillStyle = clr; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, 3, [8,8,0,0]); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 8); ctx.stroke(); + ctx.font = "11px 'Manrope',monospace"; ctx.textBaseline = 'middle'; + rows.forEach(([k,v],i) => { + const ry = ty+15+i*17; + ctx.fillStyle='rgba(255,255,255,0.38)'; ctx.textAlign='left'; ctx.fillText(k, tx+10, ry); + ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.textAlign='right'; ctx.fillText(v, tx+tw-10, ry); + }); + ctx.restore(); + } +} diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js new file mode 100644 index 0000000..09192cb --- /dev/null +++ b/frontend/js/labs/stereo.js @@ -0,0 +1,2421 @@ +'use strict'; + +/* ═══════════════════════════════════════════════════════════ + StereoSim — 3D Stereometry (Three.js) + Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder, + Cone, Truncated Cone, Sphere, Prism + sections, unfold + ═══════════════════════════════════════════════════════════ */ + +class StereoSim { + constructor(container) { + this.container = container; + this._running = false; + + /* Three.js core */ + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.setClearColor(0x0D0D1A, 1); + container.appendChild(this.renderer.domElement); + + /* lighting */ + this.scene.add(new THREE.AmbientLight(0xffffff, 0.55)); + const dir = new THREE.DirectionalLight(0xffffff, 0.75); + dir.position.set(6, 10, 8); + this.scene.add(dir); + const fill = new THREE.DirectionalLight(0x9B5DE5, 0.2); + fill.position.set(-5, 3, -4); + this.scene.add(fill); + + /* orbit camera */ + this._drag = false; + this._prevX = 0; this._prevY = 0; + this._rotY = 0.6; this._rotX = 0.45; + this._dist = 14; + this._autoSpin = true; + this._idleTime = 0; + + const el = this.renderer.domElement; + el.style.cursor = 'grab'; + this._clickStart = null; + el.addEventListener('pointerdown', e => { + this._clickStart = { x: e.clientX, y: e.clientY }; + this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; + this._autoSpin = false; this._idleTime = 0; + if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode) el.style.cursor = 'grabbing'; + }); + window.addEventListener('pointerup', e => { + const wasDrag = this._clickStart && + (Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4); + this._drag = false; + if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); } + else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); } + else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); } + else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); } + else el.style.cursor = 'grab'; + }); + window.addEventListener('pointermove', e => { + this._onHoverMove(e); + if (!this._drag) return; + this._rotY += (e.clientX - this._prevX) * 0.007; + this._rotX += (e.clientY - this._prevY) * 0.007; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._prevX = e.clientX; this._prevY = e.clientY; + this._idleTime = 0; + }); + el.addEventListener('wheel', e => { + e.preventDefault(); + this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02)); + }, { passive: false }); + + /* touch — orbit + pinch zoom */ + this._touchDist = 0; + el.addEventListener('touchstart', e => { + if (e.touches.length === 1) { + this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; + this._autoSpin = false; this._idleTime = 0; + } else if (e.touches.length === 2) { + this._drag = false; + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + this._touchDist = Math.sqrt(dx * dx + dy * dy); + } + }, { passive: true }); + el.addEventListener('touchmove', e => { + if (e.touches.length === 2) { + // pinch zoom + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const newDist = Math.sqrt(dx * dx + dy * dy); + if (this._touchDist > 0) { + const scale = this._touchDist / newDist; + this._dist = Math.max(4, Math.min(40, this._dist * scale)); + } + this._touchDist = newDist; + return; + } + if (!this._drag || e.touches.length !== 1) return; + const t = e.touches[0]; + this._rotY += (t.clientX - this._prevX) * 0.007; + this._rotX += (t.clientY - this._prevY) * 0.007; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._prevX = t.clientX; this._prevY = t.clientY; + }, { passive: true }); + el.addEventListener('touchend', () => { this._drag = false; this._touchDist = 0; }); + + /* resize */ + this._ro = new ResizeObserver(() => this.fit()); + this._ro.observe(container); + + /* groups */ + this._figGroup = new THREE.Group(); + this._labelGroup = new THREE.Group(); + this._sectionGroup = new THREE.Group(); + this._sphereGroup = new THREE.Group(); + this._measureGroup = new THREE.Group(); + this._gridGroup = new THREE.Group(); + this.scene.add(this._gridGroup); + this.scene.add(this._figGroup); + this.scene.add(this._sectionGroup); + this.scene.add(this._sphereGroup); + this.scene.add(this._measureGroup); + this.scene.add(this._labelGroup); + + /* state */ + this.figureType = 'cube'; + this.params = { a: 4, b: 3, c: 5, h: 5, r: 2, R: 3, n: 4 }; + this.showEdges = true; + this.showVertices = true; + this.showLabels = true; + this.showAxes = true; + this.showGrid = true; + this.opacity = 0.3; + + this.showSection = false; + this.sectionHeight = 0.5; // 0..1 + this.sectionType = 'horizontal'; // horizontal | diagonal | custom + this.sectionAngle = 0.5; // 0..1 for diagonal tilt + + this._unfold = false; + this._unfoldProgress = 0; + this._unfoldTarget = 0; + + this.showInscribed = false; + this.showCircumscribed = false; + this.showHeight = false; + this.showApothem = false; + this.showDiagonals = false; + this.showMidpoints = false; + + this._measureMode = false; + this._measurePicks = []; + this._measurements = []; + + /* angle modes */ + this._angleMode = null; // 'edge' | 'linePlane' | 'dihedral' | 'pointPlane' + this._anglePicks = []; // picked vertices/points + this._angleGroup = new THREE.Group(); + this.scene.add(this._angleGroup); + + /* hover coordinate tooltip */ + this._tooltipEl = null; + this._initTooltip(); + + /* custom points & connections */ + this._pointMode = false; // place points on edges + this._connectMode = false; // connect two points with a line + this._customPoints = []; // [{pos: Vector3, edgeIdx: number, t: number, label: string}] + this._connections = []; // [{from: idx, to: idx}] + this._connectPicks = []; // temp picks for connecting + this._pointGroup = new THREE.Group(); + this.scene.add(this._pointGroup); + this._nextPointId = 1; + + this._vertices = []; // [{pos: Vector3, label: string}] + this._edges = []; // [{from: Vector3, to: Vector3}] + this._faces = []; // [[Vector3, ...]] + + this.onUpdate = null; + + this._buildGrid(); + this._buildFigure(); + this.fit(); + this.play(); + } + + /* ════════════════ PUBLIC API ════════════════ */ + + setFigure(type) { + this.figureType = type; + this._unfold = false; this._unfoldProgress = 0; this._unfoldTarget = 0; + this.showSection = false; + this.showInscribed = false; this.showCircumscribed = false; + this.showHeight = false; this.showApothem = false; + this.showDiagonals = false; this.showMidpoints = false; + this._measurements = []; + this._measurePicks = []; + this._customPoints = []; + this._connections = []; + this._connectPicks = []; + this._anglePicks = []; + this._angleMode = null; + this._nextPointId = 1; + this._clearGroup(this._pointGroup); + this._clearGroup(this._angleGroup); + this._clearGroup(this._measureGroup); + this._buildFigure(); + this._notify(); + } + + setParam(key, val) { + this.params[key] = val; + this._buildFigure(); + this._notify(); + } + + setOpacity(v) { + this.opacity = v; + this._buildFigure(); + } + + toggleEdges(v) { this.showEdges = v; this._buildFigure(); } + toggleVertices(v) { this.showVertices = v; this._buildFigure(); } + toggleLabels(v) { this.showLabels = v; this._buildFigure(); } + toggleAxes(v) { this.showAxes = v; this._buildGrid(); } + toggleGrid(v) { this.showGrid = v; this._buildGrid(); } + + toggleSection(on) { + this.showSection = on; + this._updateSection(); + } + setSectionHeight(v) { + this.sectionHeight = v; + this._updateSection(); + this._notify(); + } + setSectionType(t) { + this.sectionType = t; + this._updateSection(); + this._notify(); + } + + toggleUnfold(on) { + this._unfold = on; + this._unfoldTarget = on ? 1 : 0; + } + + toggleInscribed(on) { + this.showInscribed = on; + this._updateSpheres(); + this._notify(); + } + toggleCircumscribed(on) { + this.showCircumscribed = on; + this._updateSpheres(); + this._notify(); + } + + toggleHeight(on) { + this.showHeight = on; + this._buildFigure(); + this._notify(); + } + + toggleApothem(on) { + this.showApothem = on; + this._buildFigure(); + this._notify(); + } + + toggleDiagonals(on) { + this.showDiagonals = on; + this._buildFigure(); + this._notify(); + } + + toggleMidpoints(on) { + this.showMidpoints = on; + this._buildFigure(); + this._notify(); + } + + toggleMeasure(on) { + this._measureMode = on; + this._pointMode = false; + this._angleMode = null; + this._measurePicks = []; + if (!on) { this._clearGroup(this._measureGroup); this._measurements = []; } + this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; + } + + setAngleMode(mode) { + // mode: 'edge' | 'linePlane' | 'dihedral' | null + this._angleMode = mode; + this._anglePicks = []; + this._measureMode = false; + this._pointMode = false; + this._connectMode = false; + if (!mode) { this._clearGroup(this._angleGroup); } + this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab'; + } + + setSectionAngle(v) { + this.sectionAngle = v; + if (this.showSection && this.sectionType === 'diagonal') this._updateSection(); + this._notify(); + } + + /* ── Point mode: place points on edges ── */ + togglePointMode(on) { + this._pointMode = on; + this._measureMode = false; + this._connectPicks = []; + this.renderer.domElement.style.cursor = on ? 'cell' : 'grab'; + } + + toggleConnectMode(on) { + this._connectMode = on; + this._pointMode = false; + this._measureMode = false; + this._connectPicks = []; + this.renderer.domElement.style.cursor = on ? 'pointer' : 'grab'; + } + + clearCustomPoints() { + this._customPoints = []; + this._connections = []; + this._connectPicks = []; + this._nextPointId = 1; + this._clearGroup(this._pointGroup); + if (this.showSection && this.sectionType === 'custom') this._updateSection(); + this._notify(); + } + + removeLastPoint() { + if (!this._customPoints.length) return; + // Remove connections referencing last point + const lastIdx = this._customPoints.length - 1; + this._connections = this._connections.filter(c => c.from !== lastIdx && c.to !== lastIdx); + this._customPoints.pop(); + this._nextPointId = Math.max(1, this._nextPointId - 1); + this._rebuildPointVisuals(); + if (this.showSection && this.sectionType === 'custom') this._updateSection(); + this._notify(); + } + + getCustomPoints() { return this._customPoints; } + getConnections() { return this._connections; } + + getFormulas() { + const p = this.params; + const PI = Math.PI; + const r = (v) => Math.round(v * 100) / 100; + switch (this.figureType) { + case 'cube': { + const a = p.a; + const rIn = a / 2, rOut = r(a * Math.sqrt(3) / 2); + return { V: r(a**3), S: r(6*a**2), S_side: r(4*a**2), d: r(a*Math.sqrt(3)), h: a, + formulas: [`V = a³ = ${r(a**3)}`, `S = 6a² = ${r(6*a**2)}`, `d = a√3 = ${r(a*Math.sqrt(3))}`, + `r_вп = a/2 = ${r(rIn)}`, `R_оп = a√3/2 = ${rOut}`] }; + } + case 'parallelepiped': { + const {a,b,c} = p; + return { V: r(a*b*c), S: r(2*(a*b+b*c+a*c)), S_side: r(2*c*(a+b)), d: r(Math.sqrt(a**2+b**2+c**2)), h: c, + formulas: [`V = abc = ${r(a*b*c)}`, `S = 2(ab+bc+ac) = ${r(2*(a*b+b*c+a*c))}`, `d = √(a²+b²+c²) = ${r(Math.sqrt(a**2+b**2+c**2))}`, + `r_вп = ${r(Math.min(a,b,c)/2)}`, `R_оп = ${r(Math.sqrt(a**2+b**2+c**2)/2)}`] }; + } + case 'pyramid': { + const {a, h, n} = p; + const sBase = n * a**2 / (4 * Math.tan(PI/n)); + const apothem = a / (2 * Math.tan(PI/n)); + const slantH = Math.sqrt(h**2 + apothem**2); + const sLat = n * a * slantH / 2; + const Vp = sBase * h / 3; + const rIn = r(3 * Vp / (sBase + sLat)); + const Rb = a / (2 * Math.sin(PI / n)); + const rOut = r((Rb**2 + h**2) / (2 * h)); + return { V: r(Vp), S: r(sBase+sLat), S_side: r(sLat), d: null, h, + formulas: [`V = ⅓·S_осн·h = ${r(Vp)}`, `S_осн = ${r(sBase)}`, `S_бок = ${r(sLat)}`, `S_полн = ${r(sBase+sLat)}`, + `a_осн = ${r(apothem)}`, `a_бок = ${r(slantH)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; + } + case 'tetrahedron': { + const a = p.a; + const rIn = r(a / (2 * Math.sqrt(6))), rOut = r(a * Math.sqrt(6) / 4); + return { V: r(a**3*Math.sqrt(2)/12), S: r(a**2*Math.sqrt(3)), S_side: r(a**2*Math.sqrt(3)*3/4), d: null, h: r(a*Math.sqrt(2/3)), + formulas: [`V = a³√2/12 = ${r(a**3*Math.sqrt(2)/12)}`, `S = a²√3 = ${r(a**2*Math.sqrt(3))}`, `h = a√(2/3) = ${r(a*Math.sqrt(2/3))}`, + `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; + } + case 'cylinder': { + const {r: rad, h} = p; + const rIn = r(Math.min(rad, h/2)), rOut = r(Math.sqrt(rad**2 + (h/2)**2)); + return { V: r(PI*rad**2*h), S: r(2*PI*rad*(rad+h)), S_side: r(2*PI*rad*h), d: r(2*rad), h, + formulas: [`V = πr²h = ${r(PI*rad**2*h)}`, `S_бок = 2πrh = ${r(2*PI*rad*h)}`, `S_полн = 2πr(r+h) = ${r(2*PI*rad*(rad+h))}`, + `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; + } + case 'cone': { + const {r: rad, h} = p; + const l = Math.sqrt(rad**2+h**2); + const rIn = r(rad * h / (rad + l)), rOut = r((rad**2 + h**2) / (2 * h)); + return { V: r(PI*rad**2*h/3), S: r(PI*rad*(rad+l)), S_side: r(PI*rad*l), d: r(2*rad), h, + formulas: [`V = ⅓πr²h = ${r(PI*rad**2*h/3)}`, `l = √(r²+h²) = ${r(l)}`, `S_бок = πrl = ${r(PI*rad*l)}`, + `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; + } + case 'trunccone': { + const {R: R1, r: r1, h} = p; + const l = Math.sqrt((R1-r1)**2+h**2); + const V = PI*h*(R1**2+R1*r1+r1**2)/3; + return { V: r(V), S: r(PI*(R1**2+r1**2+(R1+r1)*l)), S_side: r(PI*(R1+r1)*l), d: null, h, + formulas: [`V = ⅓πh(R²+Rr+r²) = ${r(V)}`, `l = ${r(l)}`, `S_бок = π(R+r)l = ${r(PI*(R1+r1)*l)}`] }; + } + case 'sphere': { + const rad = p.r; + return { V: r(4*PI*rad**3/3), S: r(4*PI*rad**2), S_side: null, d: r(2*rad), h: r(2*rad), + formulas: [`V = ⁴⁄₃πr³ = ${r(4*PI*rad**3/3)}`, `S = 4πr² = ${r(4*PI*rad**2)}`, `d = 2r = ${r(2*rad)}`] }; + } + case 'prism': { + const {a, h, n} = p; + const sBase = n * a**2 / (4 * Math.tan(PI/n)); + const apothem = a / (2 * Math.tan(PI/n)); + const Rb = a / (2 * Math.sin(PI/n)); + const rIn = r(Math.min(apothem, h/2)), rOut = r(Math.sqrt(Rb**2 + (h/2)**2)); + return { V: r(sBase*h), S: r(2*sBase + n*a*h), S_side: r(n*a*h), d: null, h, + formulas: [`V = S_осн·h = ${r(sBase*h)}`, `S_осн = ${r(sBase)}`, `S_бок = nah = ${r(n*a*h)}`, + `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; + } + default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] }; + } + } + + getSectionArea() { + if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0; + return this._polygonArea(this._sectionPolygon); + } + + info() { + const f = this.getFormulas(); + return { + type: this.figureType, + V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h, + sectionArea: this.showSection ? this.getSectionArea() : null, + inscribedR: this.showInscribed ? this._inscribedRadius() : null, + circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null, + customPoints: this._customPoints.length, + connections: this._connections.length, + }; + } + + fit() { + const w = this.container.clientWidth || 600; + const h = this.container.clientHeight || 400; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(w, h); + } + + play() { if (!this._running) { this._running = true; this._loop(); } } + stop() { this._running = false; } + pause() { this._running = false; } + + /* ════════════════ GRID + AXES ════════════════ */ + + _buildGrid() { + this._clearGroup(this._gridGroup); + if (this.showGrid) { + const grid = new THREE.GridHelper(20, 20, 0x222244, 0x151530); + grid.position.y = -0.01; + this._gridGroup.add(grid); + } + if (this.showAxes) { + const axes = new THREE.AxesHelper(6); + axes.material.transparent = true; + axes.material.opacity = 0.4; + this._gridGroup.add(axes); + } + } + + /* ════════════════ FIGURE BUILDER ════════════════ */ + + _buildFigure() { + this._clearGroup(this._figGroup); + this._clearGroup(this._labelGroup); + this._vertices = []; + this._edges = []; + this._faces = []; + + const builders = { + cube: () => this._buildCube(), + parallelepiped: () => this._buildParallelepiped(), + pyramid: () => this._buildPyramid(), + tetrahedron: () => this._buildTetrahedron(), + cylinder: () => this._buildCylinder(), + cone: () => this._buildCone(), + trunccone: () => this._buildTruncCone(), + sphere: () => this._buildSphere(), + prism: () => this._buildPrism(), + }; + (builders[this.figureType] || builders.cube)(); + + this._updateSection(); + this._updateSpheres(); + this._drawHeightLine(); + this._drawApothemLine(); + this._drawDiagonals(); + this._drawMidpoints(); + } + + /* ── BOX helpers ── */ + _buildBox(sx, sy, sz) { + const hx = sx/2, hy = sy/2, hz = sz/2; + // 8 vertices + const v = [ + new THREE.Vector3(-hx, 0, hz), // A (0) + new THREE.Vector3( hx, 0, hz), // B (1) + new THREE.Vector3( hx, 0, -hz), // C (2) + new THREE.Vector3(-hx, 0, -hz), // D (3) + new THREE.Vector3(-hx, sy, hz), // E (4) + new THREE.Vector3( hx, sy, hz), // F (5) + new THREE.Vector3( hx, sy,-hz), // G (6) + new THREE.Vector3(-hx, sy,-hz), // H (7) + ]; + const labels = ['A','B','C','D','E','F','G','H']; + this._vertices = v.map((pos, i) => ({ pos, label: labels[i] })); + + const edgeIdx = [[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]]; + this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] })); + + this._faces = [ + [v[0],v[1],v[2],v[3]], // bottom + [v[4],v[5],v[6],v[7]], // top + [v[0],v[1],v[5],v[4]], // front + [v[2],v[3],v[7],v[6]], // back + [v[1],v[2],v[6],v[5]], // right + [v[0],v[3],v[7],v[4]], // left + ]; + + // transparent mesh + const geo = new THREE.BoxGeometry(sx, sy, sz); + geo.translate(0, sy/2, 0); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0x9B5DE5, transparent: true, opacity: this.opacity, + side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, + clearcoat: 0.3, depthWrite: false, + }); + this._figGroup.add(new THREE.Mesh(geo, mat)); + + this._addEdges(); + this._addVerticesAndLabels(); + } + + _buildCube() { const a = this.params.a; this._buildBox(a, a, a); } + _buildParallelepiped() { this._buildBox(this.params.a, this.params.c, this.params.b); } + + /* ── PYRAMID ── */ + _buildPyramid() { + const { a, h, n } = this.params; + const baseVerts = this._regularPolygon(n, a); + const apex = new THREE.Vector3(0, h, 0); + const labels = 'ABCDEFGH'.split(''); + + this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` })); + this._vertices.push({ pos: apex, label: 'S' }); + + // edges: base ring + apex connections + for (let i = 0; i < n; i++) { + this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); + this._edges.push({ from: baseVerts[i], to: apex }); + } + + // faces: base + lateral triangles + this._faces.push([...baseVerts]); + for (let i = 0; i < n; i++) { + this._faces.push([baseVerts[i], baseVerts[(i+1)%n], apex]); + } + + this._addMeshFromFaces(0x06D6A0); + this._addEdges(); + this._addVerticesAndLabels(); + } + + /* ── TETRAHEDRON ── */ + _buildTetrahedron() { + const a = this.params.a; + const h = a * Math.sqrt(2/3); + const r = a / Math.sqrt(3); + const v = [ + new THREE.Vector3(0, 0, r), + new THREE.Vector3(a/2, 0, -r/2), + new THREE.Vector3(-a/2, 0, -r/2), + new THREE.Vector3(0, h, 0), + ]; + const labels = ['A','B','C','D']; + this._vertices = v.map((pos, i) => ({ pos, label: labels[i] })); + + const edgeIdx = [[0,1],[1,2],[2,0],[0,3],[1,3],[2,3]]; + this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] })); + + this._faces = [ + [v[0],v[1],v[2]], + [v[0],v[1],v[3]], + [v[1],v[2],v[3]], + [v[0],v[2],v[3]], + ]; + + this._addMeshFromFaces(0x06D6E0); + this._addEdges(); + this._addVerticesAndLabels(); + } + + /* ── CYLINDER ── */ + _buildCylinder() { + const { r, h } = this.params; + const geo = new THREE.CylinderGeometry(r, r, h, 48, 1, false); + geo.translate(0, h/2, 0); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0xF59E0B, transparent: true, opacity: this.opacity, + side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false, + }); + this._figGroup.add(new THREE.Mesh(geo, mat)); + + // wireframe rings + const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48); + const ringMat = new THREE.MeshBasicMaterial({ color: 0xFFD166, side: THREE.DoubleSide }); + const bottomRing = new THREE.Mesh(ringGeo, ringMat); + bottomRing.rotation.x = -Math.PI/2; + this._figGroup.add(bottomRing); + const topRing = bottomRing.clone(); + topRing.position.y = h; + this._figGroup.add(topRing); + + // edges: two vertical lines + center + const n = 8; + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2; + const x = r * Math.cos(angle), z = r * Math.sin(angle); + this._edges.push({ from: new THREE.Vector3(x, 0, z), to: new THREE.Vector3(x, h, z) }); + } + // center axis + this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' }); + this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' }); + this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) }); + + this._addEdges(0.4); + this._addVerticesAndLabels(); + } + + /* ── CONE ── */ + _buildCone() { + const { r, h } = this.params; + const geo = new THREE.CylinderGeometry(0, r, h, 48, 1, false); + geo.translate(0, h/2, 0); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0xE0335E, transparent: true, opacity: this.opacity, + side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false, + }); + this._figGroup.add(new THREE.Mesh(geo, mat)); + + // bottom ring + const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48); + const ringMat = new THREE.MeshBasicMaterial({ color: 0xFF6B8A, side: THREE.DoubleSide }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI/2; + this._figGroup.add(ring); + + const apex = new THREE.Vector3(0, h, 0); + this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O' }); + this._vertices.push({ pos: apex, label: 'S' }); + // slant lines + const n = 8; + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2; + const x = r * Math.cos(angle), z = r * Math.sin(angle); + this._edges.push({ from: new THREE.Vector3(x, 0, z), to: apex }); + } + this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: apex }); + + this._addEdges(0.4); + this._addVerticesAndLabels(); + } + + /* ── TRUNCATED CONE ── */ + _buildTruncCone() { + const { R, r, h } = this.params; + const geo = new THREE.CylinderGeometry(r, R, h, 48, 1, false); + geo.translate(0, h/2, 0); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0x60A5FA, transparent: true, opacity: this.opacity, + side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false, + }); + this._figGroup.add(new THREE.Mesh(geo, mat)); + + // rings + for (const [rad, y] of [[R, 0], [r, h]]) { + const rg = new THREE.RingGeometry(rad - 0.02, rad + 0.02, 48); + const rm = new THREE.MeshBasicMaterial({ color: 0x93C5FD, side: THREE.DoubleSide }); + const mesh = new THREE.Mesh(rg, rm); + mesh.rotation.x = -Math.PI/2; mesh.position.y = y; + this._figGroup.add(mesh); + } + + this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' }); + this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' }); + this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) }); + + const n = 8; + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2; + this._edges.push({ + from: new THREE.Vector3(R * Math.cos(angle), 0, R * Math.sin(angle)), + to: new THREE.Vector3(r * Math.cos(angle), h, r * Math.sin(angle)), + }); + } + + this._addEdges(0.4); + this._addVerticesAndLabels(); + } + + /* ── SPHERE ── */ + _buildSphere() { + const rad = this.params.r; + const geo = new THREE.SphereGeometry(rad, 48, 32); + geo.translate(0, rad, 0); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0x9B5DE5, transparent: true, opacity: this.opacity, + side: THREE.DoubleSide, metalness: 0.1, roughness: 0.3, + clearcoat: 0.5, depthWrite: false, + }); + this._figGroup.add(new THREE.Mesh(geo, mat)); + + // equator + meridian wireframes + const createCircle = (radius, y, rotX) => { + const pts = []; + for (let i = 0; i <= 64; i++) { + const a = (i/64)*Math.PI*2; + pts.push(new THREE.Vector3(radius*Math.cos(a), 0, radius*Math.sin(a))); + } + const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); + const lineMat = new THREE.LineBasicMaterial({ color: 0xCCCCFF, transparent: true, opacity: 0.5 }); + const line = new THREE.Line(lineGeo, lineMat); + line.position.y = y; + if (rotX) line.rotation.x = rotX; + return line; + }; + this._figGroup.add(createCircle(rad, rad, 0)); + this._figGroup.add(createCircle(rad, rad, Math.PI/2)); + + this._vertices.push({ pos: new THREE.Vector3(0, rad, 0), label: 'O' }); + this._vertices.push({ pos: new THREE.Vector3(0, 2*rad, 0), label: 'N' }); + this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'S' }); + this._addVerticesAndLabels(); + } + + /* ── PRISM ── */ + _buildPrism() { + const { a, h, n } = this.params; + const baseVerts = this._regularPolygon(n, a); + const topVerts = baseVerts.map(v => new THREE.Vector3(v.x, h, v.z)); + const labels = 'ABCDEFGH'.split(''); + + this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` })); + topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' })); + + // edges + for (let i = 0; i < n; i++) { + this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); // base + this._edges.push({ from: topVerts[i], to: topVerts[(i+1)%n] }); // top + this._edges.push({ from: baseVerts[i], to: topVerts[i] }); // vertical + } + + // faces + this._faces.push([...baseVerts]); + this._faces.push([...topVerts]); + for (let i = 0; i < n; i++) { + const j = (i+1) % n; + this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]); + } + + this._addMeshFromFaces(0x06D6A0); + this._addEdges(); + this._addVerticesAndLabels(); + } + + /* ════════════════ GEOMETRY HELPERS ════════════════ */ + + _regularPolygon(n, sideLength) { + const r = sideLength / (2 * Math.sin(Math.PI / n)); + const pts = []; + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2 - Math.PI / 2; + pts.push(new THREE.Vector3(r * Math.cos(angle), 0, r * Math.sin(angle))); + } + return pts; + } + + _addMeshFromFaces(color) { + // Build a single mesh from faces + const positions = []; + const indices = []; + let vi = 0; + + for (const face of this._faces) { + const base = vi; + for (const v of face) { + positions.push(v.x, v.y, v.z); + vi++; + } + // triangulate fan + for (let i = 1; i < face.length - 1; i++) { + indices.push(base, base + i, base + i + 1); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex(indices); + geo.computeVertexNormals(); + + const mat = new THREE.MeshPhysicalMaterial({ + color, transparent: true, opacity: this.opacity, + side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, + clearcoat: 0.2, depthWrite: false, + }); + this._figGroup.add(new THREE.Mesh(geo, mat)); + } + + _addEdges(opac = 0.8) { + if (!this.showEdges) return; + for (const e of this._edges) { + const pts = [e.from, e.to]; + const geo = new THREE.BufferGeometry().setFromPoints(pts); + const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 }); + this._figGroup.add(new THREE.Line(geo, mat)); + } + } + + _addVerticesAndLabels() { + for (const v of this._vertices) { + if (this.showVertices) { + const sGeo = new THREE.SphereGeometry(0.08, 12, 12); + const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF }); + const sphere = new THREE.Mesh(sGeo, sMat); + sphere.position.copy(v.pos); + this._figGroup.add(sphere); + } + + if (this.showLabels) { + const sprite = this._makeTextSprite(v.label); + sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0)); + this._labelGroup.add(sprite); + } + } + } + + _makeTextSprite(text, color = '#ffffff', size = 64) { + const canvas = document.createElement('canvas'); + canvas.width = 128; canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.font = `bold ${size}px Manrope, sans-serif`; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 64, 32); + + const tex = new THREE.CanvasTexture(canvas); + tex.minFilter = THREE.LinearFilter; + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); + const sprite = new THREE.Sprite(mat); + sprite.scale.set(0.8, 0.4, 1); + return sprite; + } + + /* ════════════════ SECTION PLANE ════════════════ */ + + _updateSection() { + this._clearGroup(this._sectionGroup); + this._sectionPolygon = null; + if (!this.showSection) return; + + const figH = this._figureHeight(); + const t = this.sectionType; + + if (t === 'horizontal') { + const y = figH * this.sectionHeight; + this._sectionPolygon = this._sliceAtY(y); + if (this._sectionPolygon.length >= 3) { + this._drawSectionPolygon(this._sectionPolygon, y); + // translucent plane + const planeGeo = new THREE.PlaneGeometry(16, 16); + const planeMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.08, side: THREE.DoubleSide }); + const planeMesh = new THREE.Mesh(planeGeo, planeMat); + planeMesh.rotation.x = -Math.PI/2; + planeMesh.position.y = y; + this._sectionGroup.add(planeMesh); + } + } else if (t === 'diagonal') { + this._sectionPolygon = this._sliceDiagonal(); + if (this._sectionPolygon.length >= 3) { + this._drawSectionPolygon3D(this._sectionPolygon); + this._drawSectionInfo(this._sectionPolygon); + } + } else if (t === 'custom') { + // Need ≥3 custom points to define a plane + if (this._customPoints.length >= 3) { + const pts3 = this._customPoints.slice(0, 3).map(p => p.pos); + this._sectionPolygon = this._sliceByPlane(pts3[0], pts3[1], pts3[2]); + if (this._sectionPolygon.length >= 3) { + this._drawSectionPolygon3D(this._sectionPolygon); + this._drawSectionInfo(this._sectionPolygon); + } + } + } + } + + _figureHeight() { + const p = this.params; + switch (this.figureType) { + case 'cube': return p.a; + case 'parallelepiped': return p.c; + case 'pyramid': case 'prism': return p.h; + case 'tetrahedron': return p.a * Math.sqrt(2/3); + case 'cylinder': case 'cone': case 'trunccone': return p.h; + case 'sphere': return p.r * 2; + default: return p.h || p.a || 4; + } + } + + _sliceAtY(y) { + // Find intersections of all edges with horizontal plane y + const points = []; + for (const e of this._edges) { + const y1 = e.from.y, y2 = e.to.y; + if ((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y)) { + if (Math.abs(y2 - y1) < 1e-9) continue; + const t = (y - y1) / (y2 - y1); + if (t < -0.001 || t > 1.001) continue; + const pt = new THREE.Vector3().lerpVectors(e.from, e.to, t); + points.push(pt); + } + } + // For cylinders/cones/spheres with no explicit edges, generate circle + if (points.length < 3) { + const circleR = this._radiusAtHeight(y); + if (circleR > 0.01) { + const n = 48; + for (let i = 0; i < n; i++) { + const a = (i/n)*Math.PI*2; + points.push(new THREE.Vector3(circleR*Math.cos(a), y, circleR*Math.sin(a))); + } + } + } + // Sort by angle from centroid + return this._sortByAngle(points); + } + + _radiusAtHeight(y) { + const p = this.params; + const fh = this._figureHeight(); + const t = Math.max(0, Math.min(1, y / fh)); + switch (this.figureType) { + case 'cylinder': return p.r; + case 'cone': return p.r * (1 - t); + case 'trunccone': return p.R + (p.r - p.R) * t; + case 'sphere': { + const dy = y - p.r; // center at (0, r, 0) + const r2 = p.r**2 - dy**2; + return r2 > 0 ? Math.sqrt(r2) : 0; + } + default: return 0; + } + } + + _sliceDiagonal() { + const fh = this._figureHeight(); + const y = fh * this.sectionHeight; + // Tilt angle: sectionAngle 0=horizontal, 1=nearly vertical (~80°) + const tiltRad = this.sectionAngle * Math.PI * 0.44; // 0..~80° + // Normal rotated from vertical (0,1,0) toward X axis + const normal = new THREE.Vector3(Math.sin(tiltRad), Math.cos(tiltRad), 0).normalize(); + const pointOnPlane = new THREE.Vector3(0, y, 0); + return this._sliceByNormal(normal, pointOnPlane); + } + + _sliceByPlane(p1, p2, p3) { + // Plane through 3 points + const v1 = new THREE.Vector3().subVectors(p2, p1); + const v2 = new THREE.Vector3().subVectors(p3, p1); + const normal = new THREE.Vector3().crossVectors(v1, v2).normalize(); + if (normal.length() < 1e-9) return []; + return this._sliceByNormal(normal, p1); + } + + _sliceByNormal(normal, pointOnPlane) { + const d = -normal.dot(pointOnPlane); + const points = []; + + // Intersect with all edges + for (const e of this._edges) { + const d1 = normal.dot(e.from) + d; + const d2 = normal.dot(e.to) + d; + if ((d1 <= 0 && d2 >= 0) || (d2 <= 0 && d1 >= 0)) { + if (Math.abs(d2 - d1) < 1e-9) continue; + const t = -d1 / (d2 - d1); + if (t < -0.001 || t > 1.001) continue; + points.push(new THREE.Vector3().lerpVectors(e.from, e.to, Math.max(0, Math.min(1, t)))); + } + } + + // For cylinders/cones/spheres — sample the intersection curve + if (points.length < 3 && ['cylinder','cone','trunccone','sphere'].includes(this.figureType)) { + const fh = this._figureHeight(); + const samples = 64; + for (let i = 0; i < samples; i++) { + const angle = (i / samples) * Math.PI * 2; + // Walk along height, find where the plane intersects at this angle + for (let step = 0; step <= 100; step++) { + const y = (step / 100) * fh; + const r = this._radiusAtHeight(y); + if (r < 0.01) continue; + const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle)); + const dist = normal.dot(pt) + d; + if (Math.abs(dist) < fh * 0.012) { + points.push(pt); + break; + } + } + } + } + + // Sort into convex polygon order (project onto plane, sort by angle) + return this._sortByAngle3D(points, normal); + } + + _sortByAngle(points) { + if (points.length < 3) return points; + const cx = points.reduce((s,p) => s+p.x, 0) / points.length; + const cz = points.reduce((s,p) => s+p.z, 0) / points.length; + points.sort((a,b) => Math.atan2(a.z-cz, a.x-cx) - Math.atan2(b.z-cz, b.x-cx)); + return points; + } + + _sortByAngle3D(points, normal) { + if (points.length < 3) return points; + // Remove near-duplicates + const unique = [points[0]]; + for (let i = 1; i < points.length; i++) { + let dup = false; + for (const u of unique) { + if (points[i].distanceTo(u) < 0.01) { dup = true; break; } + } + if (!dup) unique.push(points[i]); + } + if (unique.length < 3) return unique; + + // Centroid + const c = new THREE.Vector3(); + unique.forEach(p => c.add(p)); + c.divideScalar(unique.length); + + // Build local 2D basis on the plane + const u = new THREE.Vector3().subVectors(unique[0], c).normalize(); + const v = new THREE.Vector3().crossVectors(normal, u).normalize(); + + // Project and sort by angle + unique.sort((a, b) => { + const da = new THREE.Vector3().subVectors(a, c); + const db = new THREE.Vector3().subVectors(b, c); + return Math.atan2(da.dot(v), da.dot(u)) - Math.atan2(db.dot(v), db.dot(u)); + }); + return unique; + } + + _drawSectionInfo(pts) { + if (pts.length < 3) return; + const area = this._polygonArea(pts); + let perimeter = 0; + for (let i = 0; i < pts.length; i++) { + perimeter += pts[i].distanceTo(pts[(i + 1) % pts.length]); + } + // Label at centroid + const c = new THREE.Vector3(); + pts.forEach(p => c.add(p)); + c.divideScalar(pts.length); + const label = this._makeTextSprite(`S=${area.toFixed(1)} P=${perimeter.toFixed(1)}`, '#06D6E0', 36); + label.position.copy(c).add(new THREE.Vector3(0, 0.4, 0)); + label.scale.set(1.6, 0.5, 1); + this._sectionGroup.add(label); + + // Vertex markers on section polygon + for (const p of pts) { + const sGeo = new THREE.SphereGeometry(0.06, 8, 8); + const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 }); + const sm = new THREE.Mesh(sGeo, sMat); + sm.position.copy(p); + this._sectionGroup.add(sm); + } + } + + _drawSectionPolygon(pts, y) { + if (pts.length < 3) return; + // Fill polygon + const shape = new THREE.Shape(); + shape.moveTo(pts[0].x, pts[0].z); + for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i].x, pts[i].z); + shape.closePath(); + const geo = new THREE.ShapeGeometry(shape); + const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); + const mesh = new THREE.Mesh(geo, mat); + mesh.rotation.x = -Math.PI/2; + mesh.position.y = y + 0.005; + this._sectionGroup.add(mesh); + + // outline + const linePts = [...pts, pts[0]]; + const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts); + const lineMat = new THREE.LineBasicMaterial({ color: 0x06D6E0, linewidth: 2 }); + this._sectionGroup.add(new THREE.Line(lineGeo, lineMat)); + } + + _drawSectionPolygon3D(pts) { + if (pts.length < 3) return; + const positions = []; + const indices = []; + pts.forEach(p => positions.push(p.x, p.y, p.z)); + for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i+1); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex(indices); + geo.computeVertexNormals(); + const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); + this._sectionGroup.add(new THREE.Mesh(geo, mat)); + + const linePts = [...pts, pts[0]]; + const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts); + this._sectionGroup.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x06D6E0 }))); + } + + _polygonArea(pts) { + // Shoelace in 3D — project onto best-fit plane + if (pts.length < 3) return 0; + let area = 0; + const n = pts.length; + const cx = pts.reduce((s,p)=>s+p.x,0)/n; + const cy = pts.reduce((s,p)=>s+p.y,0)/n; + const cz = pts.reduce((s,p)=>s+p.z,0)/n; + // Cross-product sum + const cross = new THREE.Vector3(0,0,0); + for (let i = 0; i < n; i++) { + const a = new THREE.Vector3().subVectors(pts[i], new THREE.Vector3(cx,cy,cz)); + const b = new THREE.Vector3().subVectors(pts[(i+1)%n], new THREE.Vector3(cx,cy,cz)); + cross.add(new THREE.Vector3().crossVectors(a, b)); + } + return Math.round(cross.length() / 2 * 100) / 100; + } + + /* ════════════════ INSCRIBED / CIRCUMSCRIBED ════════════════ */ + + _inscribedRadius() { + const p = this.params; + const PI = Math.PI; + switch (this.figureType) { + case 'cube': return p.a / 2; + case 'parallelepiped': return Math.min(p.a, p.b, p.c) / 2; + case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); + case 'sphere': return p.r; + case 'cylinder': return Math.min(p.r, p.h / 2); + case 'cone': { + // r_in = r * h / (r + sqrt(r²+h²)) + const l = Math.sqrt(p.r ** 2 + p.h ** 2); + return p.r * p.h / (p.r + l); + } + case 'trunccone': { + // inscribed sphere radius = h * (R - r) / (2 * sqrt((R-r)² + h²))... approximate + // exact for truncated cone: r_in = h / (1 + sqrt(1 + ((R-r)/h)²)) * (not standard) + // simpler: r_in = h * min(R, r) / sqrt((R-r)² + h²) — approximate + // Actually: for truncated cone, inscribed sphere touches both bases and lateral surface + // r_in = h * (R + r - sqrt((R-r)² + h²)) / (2 * (R - r)) when R ≠ r + if (Math.abs(p.R - p.r) < 0.001) return Math.min(p.R, p.h / 2); // cylinder-like + const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2); + return p.h * (p.R + p.r - l) / (2 * Math.abs(p.R - p.r)); + } + case 'pyramid': { + // r_in = 3V / S_total + const { a, h, n } = p; + const sBase = n * a ** 2 / (4 * Math.tan(PI / n)); + const apothem = a / (2 * Math.tan(PI / n)); + const slantH = Math.sqrt(h ** 2 + apothem ** 2); + const sLat = n * a * slantH / 2; + const V = sBase * h / 3; + return 3 * V / (sBase + sLat); + } + case 'prism': { + // r_in = apothem of base (if h >= 2*apothem), else h/2 + const { a, h, n } = p; + const apothem = a / (2 * Math.tan(PI / n)); + return Math.min(apothem, h / 2); + } + default: return null; + } + } + + _circumscribedRadius() { + const p = this.params; + const PI = Math.PI; + switch (this.figureType) { + case 'cube': return p.a * Math.sqrt(3) / 2; + case 'parallelepiped': return Math.sqrt(p.a ** 2 + p.b ** 2 + p.c ** 2) / 2; + case 'tetrahedron': return p.a * Math.sqrt(6) / 4; + case 'sphere': return p.r; + case 'cylinder': return Math.sqrt(p.r ** 2 + (p.h / 2) ** 2); + case 'cone': { + // circumscribed sphere around cone: passes through apex and base circle + // R = (r² + h²) / (2h) + return (p.r ** 2 + p.h ** 2) / (2 * p.h); + } + case 'trunccone': { + // R = sqrt(R² + (h/2 + (R²-r²)/(2h))²) — approximate + const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h); + return Math.sqrt(p.R ** 2 + hc ** 2); + } + case 'pyramid': { + // R = sqrt(R_base² + h²_offset) where R_base = circumradius of base polygon + // center of circumscribed sphere at height y: y = h - R, R² = R_base² + (h - y)² + // solving: R = (R_base² + h²) / (2h) + const { a, h, n } = p; + const Rb = a / (2 * Math.sin(PI / n)); + return (Rb ** 2 + h ** 2) / (2 * h); + } + case 'prism': { + // R = sqrt(R_base² + (h/2)²) + const { a, h, n } = p; + const Rb = a / (2 * Math.sin(PI / n)); + return Math.sqrt(Rb ** 2 + (h / 2) ** 2); + } + default: return null; + } + } + + _inscribedCenter() { + const p = this.params; + const PI = Math.PI; + switch (this.figureType) { + case 'cube': return p.a / 2; + case 'parallelepiped': return p.c / 2; + case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); // r_in from base + case 'sphere': return p.r; + case 'cylinder': return p.h / 2; + case 'cone': { + const l = Math.sqrt(p.r ** 2 + p.h ** 2); + return p.r * p.h / (p.r + l); // r_in = height of center + } + case 'trunccone': return p.h / 2; + case 'pyramid': { + // inscribed sphere center at height r_in from base + const { a, h, n } = p; + const sBase = n * a ** 2 / (4 * Math.tan(PI / n)); + const apothem = a / (2 * Math.tan(PI / n)); + const slantH = Math.sqrt(h ** 2 + apothem ** 2); + const sLat = n * a * slantH / 2; + const V = sBase * h / 3; + return 3 * V / (sBase + sLat); // r_in + } + case 'prism': return p.h / 2; + default: return this._figureHeight() / 2; + } + } + + _circumscribedCenter() { + const p = this.params; + const PI = Math.PI; + switch (this.figureType) { + case 'cube': return p.a / 2; + case 'parallelepiped': return p.c / 2; + case 'tetrahedron': { + // center at h - R from base = h - a*sqrt(6)/4 + const h = p.a * Math.sqrt(2 / 3); + return h - p.a * Math.sqrt(6) / 4; + } + case 'sphere': return p.r; + case 'cylinder': return p.h / 2; + case 'cone': { + // center at y = R_circ from apex? No. center at y = h - R + h_offset + // R = (r²+h²)/(2h), center at y = h - R = h - (r²+h²)/(2h) = (h²-r²)/(2h) + // but if r > h, center goes below base — clamp + const R = (p.r ** 2 + p.h ** 2) / (2 * p.h); + return p.h - R; + } + case 'trunccone': { + const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h); + return hc; // height of center from base + } + case 'pyramid': { + const { a, h, n } = p; + const Rb = a / (2 * Math.sin(PI / n)); + const R = (Rb ** 2 + h ** 2) / (2 * h); + return h - R; // from base + } + case 'prism': return p.h / 2; + default: return this._figureHeight() / 2; + } + } + + _updateSpheres() { + this._clearGroup(this._sphereGroup); + + if (this.showInscribed) { + const r = this._inscribedRadius(); + const cy = this._inscribedCenter(); + if (r && r > 0) { + const geo = new THREE.SphereGeometry(r, 32, 24); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0x06D6E0, transparent: true, opacity: 0.12, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.y = cy; + this._sphereGroup.add(mesh); + const wf = new THREE.LineSegments( + new THREE.WireframeGeometry(geo), + new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.2 }) + ); + wf.position.y = cy; + this._sphereGroup.add(wf); + // radius label + const lbl = this._makeTextSprite(`r=${r.toFixed(2)}`, '#06D6E0', 36); + lbl.position.set(r * 0.5, cy + r * 0.3, 0); + lbl.scale.set(1.0, 0.4, 1); + this._sphereGroup.add(lbl); + // radius line + const lineGeo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, cy, 0), new THREE.Vector3(r, cy, 0) + ]); + const lineMat = new THREE.LineDashedMaterial({ color: 0x06D6E0, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._sphereGroup.add(line); + } + } + + if (this.showCircumscribed) { + const R = this._circumscribedRadius(); + const cy = this._circumscribedCenter(); + if (R && R > 0) { + const geo = new THREE.SphereGeometry(R, 32, 24); + const mat = new THREE.MeshPhysicalMaterial({ + color: 0xF59E0B, transparent: true, opacity: 0.08, + side: THREE.DoubleSide, depthWrite: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.y = cy; + this._sphereGroup.add(mesh); + const wf = new THREE.LineSegments( + new THREE.WireframeGeometry(geo), + new THREE.LineBasicMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.15 }) + ); + wf.position.y = cy; + this._sphereGroup.add(wf); + // radius label + const lbl = this._makeTextSprite(`R=${R.toFixed(2)}`, '#F59E0B', 36); + lbl.position.set(R * 0.5, cy + R * 0.3, 0); + lbl.scale.set(1.0, 0.4, 1); + this._sphereGroup.add(lbl); + // radius line + const lineGeo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, cy, 0), new THREE.Vector3(R, cy, 0) + ]); + const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._sphereGroup.add(line); + } + } + } + + /* ════════════════ MEASUREMENT MODE ════════════════ */ + + _onMeasureClick(e) { + if (!this._measureMode) return; + const { mx, my } = this._screenCoords(e); + + // Find closest vertex OR custom point in screen space + let bestDist = 0.08; // threshold in NDC + let bestPick = null; + + // Check vertices + for (const v of this._vertices) { + const projected = v.pos.clone().project(this.camera); + const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos, label: v.label }; } + } + + // Check custom points + for (const cp of this._customPoints) { + const projected = cp.pos.clone().project(this.camera); + const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos, label: cp.label }; } + } + + if (!bestPick) return; + + this._measurePicks.push(bestPick); + + // Highlight picked point + const sGeo = new THREE.SphereGeometry(0.14, 12, 12); + const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(bestPick.pos); + this._measureGroup.add(s); + + if (this._measurePicks.length === 2) { + const [a, b] = this._measurePicks; + const dist = a.pos.distanceTo(b.pos); + const mid = new THREE.Vector3().addVectors(a.pos, b.pos).multiplyScalar(0.5); + + // Dashed line + const pts = [a.pos, b.pos]; + const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); + const lineMat = new THREE.LineDashedMaterial({ color: 0xFFD166, dashSize: 0.15, gapSize: 0.1, transparent: true, opacity: 0.9 }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._measureGroup.add(line); + + // Label + const label = this._makeTextSprite(`${a.label}${b.label} = ${dist.toFixed(2)}`, '#FFD166', 40); + label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0)); + label.scale.set(1.4, 0.5, 1); + this._measureGroup.add(label); + + this._measurements.push({ from: a.label, to: b.label, dist: Math.round(dist * 100) / 100 }); + this._measurePicks = []; + } + } + + /* ════════════════ POINT MODE — place points on edges ════════════════ */ + + _screenCoords(e) { + const rect = this.renderer.domElement.getBoundingClientRect(); + return { + mx: ((e.clientX - rect.left) / rect.width) * 2 - 1, + my: -((e.clientY - rect.top) / rect.height) * 2 + 1, + }; + } + + _onPointClick(e) { + const { mx, my } = this._screenCoords(e); + + // Find the nearest edge in screen space + let bestDist = 0.08; // threshold in NDC + let bestEdge = -1; + let bestT = 0; + let bestPos = null; + + for (let ei = 0; ei < this._edges.length; ei++) { + const edge = this._edges[ei]; + const p1 = edge.from.clone().project(this.camera); + const p2 = edge.to.clone().project(this.camera); + + // Point-to-segment distance in 2D + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const lenSq = dx * dx + dy * dy; + if (lenSq < 1e-9) continue; + let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq; + t = Math.max(0.02, Math.min(0.98, t)); // clamp away from endpoints + const px = p1.x + t * dx, py = p1.y + t * dy; + const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2); + + if (dist < bestDist) { + bestDist = dist; + bestEdge = ei; + bestT = t; + bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t); + } + } + + // Also check: click near a vertex snap to vertex + for (const v of this._vertices) { + const proj = v.pos.clone().project(this.camera); + const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); + if (dist < 0.06) { + bestPos = v.pos.clone(); + bestEdge = -2; // special: vertex + bestT = 0; + bestDist = dist; + } + } + + if (!bestPos) return; + + const label = String(this._nextPointId++); + this._customPoints.push({ + pos: bestPos, + edgeIdx: bestEdge, + t: bestT, + label, + }); + + this._rebuildPointVisuals(); + + // If custom section mode and ≥3 points, update section + if (this.showSection && this.sectionType === 'custom') this._updateSection(); + this._notify(); + } + + _onConnectClick(e) { + const { mx, my } = this._screenCoords(e); + + // Find nearest custom point OR vertex + let bestDist = 0.08; + let bestIdx = -1; + + // Check custom points + for (let i = 0; i < this._customPoints.length; i++) { + const proj = this._customPoints[i].pos.clone().project(this.camera); + const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); + if (dist < bestDist) { bestDist = dist; bestIdx = i; } + } + + // Also check vertices (mapped as negative indices: -1 vertex 0, -2 vertex 1, etc.) + for (let i = 0; i < this._vertices.length; i++) { + const proj = this._vertices[i].pos.clone().project(this.camera); + const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); + if (dist < bestDist) { bestDist = dist; bestIdx = -(i + 100); } // encode vertex + } + + if (bestIdx === -1 && bestIdx !== -(99 + this._vertices.length)) return; // nothing found + // Actually check: any valid pick + if (bestDist >= 0.08) return; + + this._connectPicks.push(bestIdx); + + // Highlight pick + const pos = bestIdx >= 0 ? this._customPoints[bestIdx].pos : this._vertices[-(bestIdx + 100)].pos; + const sGeo = new THREE.SphereGeometry(0.12, 10, 10); + const sMat = new THREE.MeshBasicMaterial({ color: 0xF59E0B }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(pos); + this._pointGroup.add(s); + + if (this._connectPicks.length === 2) { + const [idxA, idxB] = this._connectPicks; + if (idxA !== idxB) { + this._connections.push({ from: idxA, to: idxB }); + } + this._connectPicks = []; + this._rebuildPointVisuals(); + this._notify(); + } + } + + _getPointPos(idx) { + if (idx >= 0) return this._customPoints[idx]?.pos; + return this._vertices[-(idx + 100)]?.pos; + } + + _getPointLabel(idx) { + if (idx >= 0) return this._customPoints[idx]?.label || '?'; + return this._vertices[-(idx + 100)]?.label || '?'; + } + + _rebuildPointVisuals() { + this._clearGroup(this._pointGroup); + + // Draw custom points + for (const pt of this._customPoints) { + // Sphere marker + const sGeo = new THREE.SphereGeometry(0.1, 10, 10); + const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(pt.pos); + this._pointGroup.add(s); + + // Label + const label = this._makeTextSprite(pt.label, '#FFD166', 40); + label.position.copy(pt.pos).add(new THREE.Vector3(0.2, 0.2, 0)); + label.scale.set(0.6, 0.3, 1); + this._pointGroup.add(label); + } + + // Draw connections + for (const conn of this._connections) { + const posA = this._getPointPos(conn.from); + const posB = this._getPointPos(conn.to); + if (!posA || !posB) continue; + + const lineGeo = new THREE.BufferGeometry().setFromPoints([posA, posB]); + const lineMat = new THREE.LineDashedMaterial({ + color: 0xF59E0B, dashSize: 0.12, gapSize: 0.06, + transparent: true, opacity: 0.9, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._pointGroup.add(line); + + // Distance label + const dist = posA.distanceTo(posB); + const mid = new THREE.Vector3().addVectors(posA, posB).multiplyScalar(0.5); + const lbl = this._makeTextSprite(dist.toFixed(2), '#F59E0B', 36); + lbl.position.copy(mid).add(new THREE.Vector3(0, 0.25, 0)); + lbl.scale.set(0.8, 0.4, 1); + this._pointGroup.add(lbl); + } + } + + /* ════════════════ HEIGHT LINE ════════════════ */ + + _drawHeightLine() { + if (!this.showHeight) return; + const p = this.params; + const fh = this._figureHeight(); + let from, to, label; + + switch (this.figureType) { + case 'cube': + case 'parallelepiped': + case 'prism': + case 'cylinder': { + // vertical height: center of bottom base center of top base + from = new THREE.Vector3(0, 0, 0); + to = new THREE.Vector3(0, fh, 0); + label = `h = ${fh.toFixed(2)}`; + break; + } + case 'pyramid': { + // apex to base center + from = new THREE.Vector3(0, 0, 0); + to = new THREE.Vector3(0, p.h, 0); + label = `h = ${p.h.toFixed(2)}`; + break; + } + case 'tetrahedron': { + const h = p.a * Math.sqrt(2 / 3); + from = new THREE.Vector3(0, 0, 0); + to = new THREE.Vector3(0, h, 0); + label = `h = ${h.toFixed(2)}`; + break; + } + case 'cone': { + from = new THREE.Vector3(0, 0, 0); + to = new THREE.Vector3(0, p.h, 0); + label = `h = ${p.h.toFixed(2)}`; + break; + } + case 'trunccone': { + from = new THREE.Vector3(0, 0, 0); + to = new THREE.Vector3(0, p.h, 0); + label = `h = ${p.h.toFixed(2)}`; + break; + } + case 'sphere': { + from = new THREE.Vector3(0, 0, 0); + to = new THREE.Vector3(0, 2 * p.r, 0); + label = `d = ${(2 * p.r).toFixed(2)}`; + break; + } + default: return; + } + + // dashed line + const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); + const lineMat = new THREE.LineDashedMaterial({ + color: 0xF9A8D4, dashSize: 0.15, gapSize: 0.08, + transparent: true, opacity: 0.85, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._figGroup.add(line); + + // small dots at endpoints + for (const pt of [from, to]) { + const sGeo = new THREE.SphereGeometry(0.06, 8, 8); + const sMat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4 }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(pt); + this._figGroup.add(s); + } + + // label at midpoint + const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); + const lbl = this._makeTextSprite(label, '#F9A8D4', 36); + lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0, 0)); + lbl.scale.set(1.2, 0.4, 1); + this._labelGroup.add(lbl); + + // right-angle marker at base (for pyramid/cone/tetrahedron) + if (['pyramid', 'tetrahedron', 'cone'].includes(this.figureType)) { + this._drawRightAngleMarker(from, new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 0, 0), 0.3); + } + } + + _drawRightAngleMarker(origin, dir1, dir2, size) { + const p1 = origin.clone().add(dir1.clone().normalize().multiplyScalar(size)); + const p2 = origin.clone().add(dir2.clone().normalize().multiplyScalar(size)); + const p3 = p1.clone().add(dir2.clone().normalize().multiplyScalar(size)); + const pts = [p1, p3, p2]; + const geo = new THREE.BufferGeometry().setFromPoints(pts); + const mat = new THREE.LineBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.6 }); + this._figGroup.add(new THREE.Line(geo, mat)); + } + + /* ════════════════ APOTHEM LINE ════════════════ */ + + _drawApothemLine() { + if (!this.showApothem) return; + const p = this.params; + const PI = Math.PI; + + if (this.figureType === 'pyramid') { + const { a, h, n } = p; + const apothem = a / (2 * Math.tan(PI / n)); // base apothem + const slantH = Math.sqrt(h ** 2 + apothem ** 2); // slant apothem + + // Base apothem: center of base to midpoint of first base edge + const midEdge = this._getBaseMidpoint(n, a, 0); + this._drawDashedSegment( + new THREE.Vector3(0, 0, 0), midEdge, + `a_осн = ${apothem.toFixed(2)}`, '#7BF5A4' + ); + + // Slant apothem: apex to midpoint of first base edge + this._drawDashedSegment( + new THREE.Vector3(0, h, 0), midEdge, + `a_бок = ${slantH.toFixed(2)}`, '#60a5fa' + ); + + // Right angle at midEdge (between base apothem and base edge direction) + const edgeDir = this._getBaseEdgeDir(n, a, 0); + const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize(); + this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25); + + } else if (this.figureType === 'prism') { + const { a, h, n } = p; + const apothem = a / (2 * Math.tan(PI / n)); + + // Base apothem + const midEdge = this._getBaseMidpoint(n, a, 0); + this._drawDashedSegment( + new THREE.Vector3(0, 0, 0), midEdge, + `a = ${apothem.toFixed(2)}`, '#7BF5A4' + ); + + // Right angle marker + const edgeDir = this._getBaseEdgeDir(n, a, 0); + const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize(); + this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25); + + } else if (this.figureType === 'cone') { + // Slant height (образующая) + const l = Math.sqrt(p.r ** 2 + p.h ** 2); + this._drawDashedSegment( + new THREE.Vector3(0, p.h, 0), + new THREE.Vector3(p.r, 0, 0), + `l = ${l.toFixed(2)}`, '#60a5fa' + ); + + } else if (this.figureType === 'trunccone') { + const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2); + this._drawDashedSegment( + new THREE.Vector3(p.r, p.h, 0), + new THREE.Vector3(p.R, 0, 0), + `l = ${l.toFixed(2)}`, '#60a5fa' + ); + } + } + + _getBaseMidpoint(n, a, edgeIndex) { + const r = a / (2 * Math.sin(Math.PI / n)); + const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2; + const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2; + return new THREE.Vector3( + (r * Math.cos(a1) + r * Math.cos(a2)) / 2, + 0, + (r * Math.sin(a1) + r * Math.sin(a2)) / 2 + ); + } + + _getBaseEdgeDir(n, a, edgeIndex) { + const r = a / (2 * Math.sin(Math.PI / n)); + const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2; + const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2; + const p1 = new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)); + const p2 = new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)); + return new THREE.Vector3().subVectors(p2, p1).normalize(); + } + + _drawDashedSegment(from, to, label, color) { + const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); + const lineMat = new THREE.LineDashedMaterial({ + color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06, + transparent: true, opacity: 0.85, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._figGroup.add(line); + + // dots + for (const pt of [from, to]) { + const sGeo = new THREE.SphereGeometry(0.05, 8, 8); + const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color) }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(pt); + this._figGroup.add(s); + } + + // label + const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); + const lbl = this._makeTextSprite(label, color, 34); + lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0)); + lbl.scale.set(1.2, 0.4, 1); + this._labelGroup.add(lbl); + } + + /* ════════════════ ANGLE MEASUREMENT MODES ════════════════ */ + + _pickNearestPoint(e) { + const { mx, my } = this._screenCoords(e); + let bestDist = 0.08; + let bestPick = null; + + for (const v of this._vertices) { + const projected = v.pos.clone().project(this.camera); + const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos.clone(), label: v.label }; } + } + for (const cp of this._customPoints) { + const projected = cp.pos.clone().project(this.camera); + const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos.clone(), label: cp.label }; } + } + return bestPick; + } + + _pickNearestFace(e) { + // Pick the face whose projected centroid is closest to click + const { mx, my } = this._screenCoords(e); + let bestDist = 0.15; + let bestFace = null; + let bestIdx = -1; + + for (let fi = 0; fi < this._faces.length; fi++) { + const face = this._faces[fi]; + const c = new THREE.Vector3(); + face.forEach(v => c.add(v)); + c.divideScalar(face.length); + const proj = c.clone().project(this.camera); + const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestFace = face; bestIdx = fi; } + } + return bestFace; + } + + _pickNearestEdge(e) { + const { mx, my } = this._screenCoords(e); + let bestDist = 0.06; + let bestEdge = null; + + for (const edge of this._edges) { + const p1 = edge.from.clone().project(this.camera); + const p2 = edge.to.clone().project(this.camera); + const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; + const d = Math.sqrt((mid.x - mx) ** 2 + (mid.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestEdge = edge; } + } + return bestEdge; + } + + _highlightPick(pos, color = 0xFFD166) { + const sGeo = new THREE.SphereGeometry(0.12, 10, 10); + const sMat = new THREE.MeshBasicMaterial({ color }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(pos); + this._angleGroup.add(s); + } + + _onAngleClick(e) { + if (!this._angleMode) return; + + if (this._angleMode === 'edge') { + this._onEdgeAngleClick(e); + } else if (this._angleMode === 'linePlane') { + this._onLinePlaneAngleClick(e); + } else if (this._angleMode === 'dihedral') { + this._onDihedralAngleClick(e); + } else if (this._angleMode === 'pointPlane') { + this._onPointPlaneClick(e); + } + } + + /* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */ + _onEdgeAngleClick(e) { + const pick = this._pickNearestPoint(e); + if (!pick) return; + + this._anglePicks.push(pick); + this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166); + + if (this._anglePicks.length === 3) { + const [A, B, C] = this._anglePicks; + const BA = new THREE.Vector3().subVectors(A.pos, B.pos); + const BC = new THREE.Vector3().subVectors(C.pos, B.pos); + const cosAngle = BA.dot(BC) / (BA.length() * BC.length()); + const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI; + + // Draw the angle arc + this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F'); + + // Label + const bisect = new THREE.Vector3().addVectors( + BA.clone().normalize(), BC.clone().normalize() + ).normalize().multiplyScalar(0.8); + const lblPos = B.pos.clone().add(bisect); + const lbl = this._makeTextSprite( + `∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36 + ); + lbl.position.copy(lblPos); + lbl.scale.set(2.0, 0.5, 1); + this._angleGroup.add(lbl); + + // Lines + this._drawAngleLine(B.pos, A.pos, '#EF476F'); + this._drawAngleLine(B.pos, C.pos, '#EF476F'); + + this._anglePicks = []; + } + } + + /* ── Line-Plane angle: pick 2 points (line), then 1 face ── */ + _onLinePlaneAngleClick(e) { + if (this._anglePicks.length < 2) { + // Picking line endpoints + const pick = this._pickNearestPoint(e); + if (!pick) return; + this._anglePicks.push(pick); + this._highlightPick(pick.pos); + + if (this._anglePicks.length === 2) { + // Draw the line + this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa'); + } + } else { + // Pick a face + const face = this._pickNearestFace(e); + if (!face || face.length < 3) return; + + const [A, B] = this._anglePicks; + const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize(); + + // Face normal + const v1 = new THREE.Vector3().subVectors(face[1], face[0]); + const v2 = new THREE.Vector3().subVectors(face[2], face[0]); + const normal = new THREE.Vector3().crossVectors(v1, v2).normalize(); + + // Angle between line and plane = 90° - angle between line and normal + const sinAngle = Math.abs(lineDir.dot(normal)); + const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI; + + // Highlight face + const positions = []; + const indices = []; + face.forEach(v => positions.push(v.x, v.y, v.z)); + for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex(indices); + const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide }); + this._angleGroup.add(new THREE.Mesh(geo, mat)); + + // Label at face centroid + const c = new THREE.Vector3(); + face.forEach(v => c.add(v)); + c.divideScalar(face.length); + const lbl = this._makeTextSprite( + `∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36 + ); + lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0)); + lbl.scale.set(2.4, 0.5, 1); + this._angleGroup.add(lbl); + + // Draw projection of line onto plane + const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize(); + const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos))); + this._drawAngleLine(A.pos, projEnd, '#60a5fa', true); + + this._anglePicks = []; + } + } + + /* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */ + _onDihedralAngleClick(e) { + const pick = this._pickNearestPoint(e); + if (!pick) return; + + this._anglePicks.push(pick); + this._highlightPick(pick.pos, 0xc4b5fd); + + if (this._anglePicks.length === 2) { + const [P1, P2] = this._anglePicks; + const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos); + + // Find two faces sharing this edge (both contain P1 and P2) + const adjFaces = []; + const eps = 0.1; + for (const face of this._faces) { + let hasP1 = false, hasP2 = false; + for (const v of face) { + if (v.distanceTo(P1.pos) < eps) hasP1 = true; + if (v.distanceTo(P2.pos) < eps) hasP2 = true; + } + if (hasP1 && hasP2) adjFaces.push(face); + } + + if (adjFaces.length >= 2) { + // Compute normals of both faces + const normal1 = this._faceNormal(adjFaces[0]); + const normal2 = this._faceNormal(adjFaces[1]); + + // Dihedral angle = π - angle between outward normals + const cosAngle = normal1.dot(normal2); + const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI; + // Dihedral is the interior angle + const dihedralAngle = 180 - rawAngle; + + // Highlight both faces + for (const face of [adjFaces[0], adjFaces[1]]) { + const positions = []; + const indices = []; + face.forEach(v => positions.push(v.x, v.y, v.z)); + for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex(indices); + const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide }); + this._angleGroup.add(new THREE.Mesh(geo, mat)); + } + + // Label at edge midpoint + const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5); + const lbl = this._makeTextSprite( + `∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36 + ); + lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3)); + lbl.scale.set(2.4, 0.5, 1); + this._angleGroup.add(lbl); + + // Draw the edge + this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd'); + } else { + // Not enough adjacent faces — show error sprite + const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5); + const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36); + lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0)); + lbl.scale.set(2.0, 0.5, 1); + this._angleGroup.add(lbl); + } + + this._anglePicks = []; + } + } + + _faceNormal(face) { + if (face.length < 3) return new THREE.Vector3(0, 1, 0); + const v1 = new THREE.Vector3().subVectors(face[1], face[0]); + const v2 = new THREE.Vector3().subVectors(face[2], face[0]); + return new THREE.Vector3().crossVectors(v1, v2).normalize(); + } + + _drawAngleArc(center, dir1, dir2, angleDeg, radius, color) { + const n1 = dir1.clone().normalize(); + const n2 = dir2.clone().normalize(); + const angleRad = angleDeg * Math.PI / 180; + const steps = Math.max(8, Math.round(angleDeg / 5)); + const pts = []; + + // Build rotation from n1 toward n2 + const axis = new THREE.Vector3().crossVectors(n1, n2).normalize(); + if (axis.length() < 0.001) return; // parallel vectors + + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const a = t * angleRad; + const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius); + pts.push(center.clone().add(rotated)); + } + + const geo = new THREE.BufferGeometry().setFromPoints(pts); + const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 }); + this._angleGroup.add(new THREE.Line(geo, mat)); + } + + _drawAngleLine(from, to, color, dashed = false) { + const geo = new THREE.BufferGeometry().setFromPoints([from, to]); + let mat; + if (dashed) { + mat = new THREE.LineDashedMaterial({ + color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06, + transparent: true, opacity: 0.7, + }); + } else { + mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 }); + } + const line = new THREE.Line(geo, mat); + if (dashed) line.computeLineDistances(); + this._angleGroup.add(line); + } + + /* ════════════════ DIAGONALS ════════════════ */ + + _drawDiagonals() { + if (!this.showDiagonals) return; + const t = this.figureType; + + if (t === 'cube' || t === 'parallelepiped') { + // 8 vertices: 0-3 bottom, 4-7 top (from _buildBox) + const v = this._vertices.map(vt => vt.pos); + if (v.length < 8) return; + + // Space diagonals (4) + const spaceDiags = [[0,6],[1,7],[2,4],[3,5]]; + for (const [a, b] of spaceDiags) { + this._drawDashedSegment(v[a], v[b], '', '#fbbf24'); + } + + // Face diagonals (12) — 2 per face + const faceDiags = [ + [0,2],[1,3], // bottom + [4,6],[5,7], // top + [0,5],[1,4], // front + [2,7],[3,6], // back + [1,6],[2,5], // right + [0,7],[3,4], // left + ]; + for (const [a, b] of faceDiags) { + const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]); + const lineMat = new THREE.LineDashedMaterial({ + color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, + transparent: true, opacity: 0.35, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._figGroup.add(line); + } + + // Space diagonal label — only one, the longest + const d = v[0].distanceTo(v[6]); + const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5); + const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34); + lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0)); + lbl.scale.set(1.2, 0.4, 1); + this._labelGroup.add(lbl); + + } else if (t === 'prism') { + const n = this.params.n; + const v = this._vertices.map(vt => vt.pos); + if (v.length < n * 2) return; + + // Base diagonals (bottom) + for (let i = 0; i < n; i++) { + for (let j = i + 2; j < n; j++) { + if (j === (i + n - 1) % n + i) continue; // skip adjacent + if (i === 0 && j === n - 1) continue; + const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); + const lineMat = new THREE.LineDashedMaterial({ + color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, + transparent: true, opacity: 0.35, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._figGroup.add(line); + } + } + // Top diagonals + for (let i = n; i < 2 * n; i++) { + for (let j = i + 2; j < 2 * n; j++) { + if (i === n && j === 2 * n - 1) continue; + const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); + const lineMat = new THREE.LineDashedMaterial({ + color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, + transparent: true, opacity: 0.35, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._figGroup.add(line); + } + } + // Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0 + for (let i = 0; i < n; i++) { + for (let k = 1; k < n; k++) { + const j = n + (i + k) % n; + this._drawDashedSegment(v[i], v[j], '', '#fbbf24'); + } + } + + } else if (t === 'pyramid') { + // Base diagonals only + const n = this.params.n; + const v = this._vertices.map(vt => vt.pos); + for (let i = 0; i < n; i++) { + for (let j = i + 2; j < n; j++) { + if (i === 0 && j === n - 1) continue; + const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); + const lineMat = new THREE.LineDashedMaterial({ + color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, + transparent: true, opacity: 0.4, + }); + const line = new THREE.Line(lineGeo, lineMat); + line.computeLineDistances(); + this._figGroup.add(line); + } + } + } + } + + /* ════════════════ MIDPOINTS ════════════════ */ + + _drawMidpoints() { + if (!this.showMidpoints) return; + + for (let i = 0; i < this._edges.length; i++) { + const e = this._edges[i]; + const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5); + + // small cyan sphere + const sGeo = new THREE.SphereGeometry(0.06, 8, 8); + const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(mid); + this._figGroup.add(s); + + // label "M" + index + const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28); + lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0)); + lbl.scale.set(0.5, 0.25, 1); + this._labelGroup.add(lbl); + } + } + + /* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */ + + _onPointPlaneClick(e) { + if (this._anglePicks.length < 1) { + // First pick: a vertex/point + const pick = this._pickNearestPoint(e); + if (!pick) return; + this._anglePicks.push(pick); + this._highlightPick(pick.pos, 0xF9A8D4); + } else { + // Second pick: a face + const face = this._pickNearestFace(e); + if (!face || face.length < 3) return; + + const pt = this._anglePicks[0]; + const normal = this._faceNormal(face); + const planePoint = face[0]; + + // Distance = |dot(pt - planePoint, normal)| + const diff = new THREE.Vector3().subVectors(pt.pos, planePoint); + const dist = Math.abs(diff.dot(normal)); + + // Projection of point onto plane + const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal))); + + // Draw perpendicular line + this._drawAngleLine(pt.pos, proj, '#F9A8D4', true); + + // Highlight foot of perpendicular + this._highlightPick(proj, 0xF9A8D4); + + // Right angle marker + const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize(); + this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4'); + + // Highlight face + const positions = []; + const indices = []; + face.forEach(v => positions.push(v.x, v.y, v.z)); + for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex(indices); + const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide }); + this._angleGroup.add(new THREE.Mesh(geo, mat)); + + // Label + const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5); + const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36); + lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0)); + lbl.scale.set(2.2, 0.5, 1); + this._angleGroup.add(lbl); + + this._anglePicks = []; + } + } + + _drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) { + const d1 = normalDir.clone().normalize().multiplyScalar(size); + const d2 = tangentDir.clone().normalize().multiplyScalar(size); + const p1 = origin.clone().add(d1); + const p2 = origin.clone().add(d2); + const p3 = p1.clone().add(d2); + const pts = [p1, p3, p2]; + const geo = new THREE.BufferGeometry().setFromPoints(pts); + const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 }); + this._angleGroup.add(new THREE.Line(geo, mat)); + } + + /* ════════════════ COORDINATE TOOLTIP ════════════════ */ + + _initTooltip() { + this._tooltipEl = document.createElement('div'); + Object.assign(this._tooltipEl.style, { + position: 'absolute', pointerEvents: 'none', + background: 'rgba(13,13,26,0.85)', color: '#ccc', + fontSize: '11px', fontFamily: 'Manrope, monospace', + padding: '3px 7px', borderRadius: '4px', + border: '1px solid rgba(155,93,229,0.3)', + display: 'none', zIndex: '50', whiteSpace: 'nowrap', + }); + this.container.style.position = 'relative'; + this.container.appendChild(this._tooltipEl); + } + + _onHoverMove(e) { + const rect = this.renderer.domElement.getBoundingClientRect(); + if (e.clientX < rect.left || e.clientX > rect.right || + e.clientY < rect.top || e.clientY > rect.bottom) { + if (this._tooltipEl) this._tooltipEl.style.display = 'none'; + return; + } + + const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const my = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + let bestDist = 0.06; + let bestV = null; + + for (const v of this._vertices) { + const proj = v.pos.clone().project(this.camera); + const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestV = v; } + } + for (const cp of this._customPoints) { + const proj = cp.pos.clone().project(this.camera); + const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); + if (d < bestDist) { bestDist = d; bestV = cp; } + } + + if (bestV && this._tooltipEl) { + const p = bestV.pos; + this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`; + this._tooltipEl.style.display = 'block'; + this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px'; + this._tooltipEl.style.top = (e.clientY - rect.top - 10) + 'px'; + } else if (this._tooltipEl) { + this._tooltipEl.style.display = 'none'; + } + } + + /* ════════════════ UNFOLD ANIMATION ════════════════ */ + // Simplified unfold: flatten figure by reducing Y coordinates + _applyUnfold(progress) { + // This is a visual-only effect — squash Y toward 0 + if (progress < 0.01) return; + this._figGroup.children.forEach(child => { + if (child.geometry) { + // Already built, don't modify geometry — just scale Y + } + }); + this._figGroup.scale.y = 1 - progress * 0.85; + this._figGroup.position.y = progress * 0.5; + } + + /* ════════════════ UTILS ════════════════ */ + + _clearGroup(group) { + while (group.children.length) { + const c = group.children[0]; + if (c.geometry) c.geometry.dispose(); + if (c.material) { + if (c.material.map) c.material.map.dispose(); + c.material.dispose(); + } + group.remove(c); + } + } + + _notify() { + if (this.onUpdate) this.onUpdate(this.info()); + } + + /* ════════════════ ANIMATION LOOP ════════════════ */ + + _loop() { + if (!this._running) return; + requestAnimationFrame(() => this._loop()); + + // Auto-spin after idle + this._idleTime++; + if (this._idleTime > 300 && !this._drag) this._autoSpin = true; + if (this._autoSpin) this._rotY += 0.002; + + // Unfold animation + if (this._unfold && this._unfoldProgress < this._unfoldTarget) { + this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015); + this._applyUnfold(this._unfoldProgress); + } else if (!this._unfold && this._unfoldProgress > 0) { + this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015); + this._applyUnfold(this._unfoldProgress); + if (this._unfoldProgress <= 0) { + this._figGroup.scale.y = 1; + this._figGroup.position.y = 0; + } + } + + // Camera orbit + this.camera.position.set( + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), + this._dist * Math.sin(this._rotX), + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) + ); + this.camera.lookAt(0, this._figureHeight() / 2, 0); + + this.renderer.render(this.scene, this.camera); + } +} diff --git a/frontend/js/labs/thinlens.js b/frontend/js/labs/thinlens.js new file mode 100644 index 0000000..ed4a0b9 --- /dev/null +++ b/frontend/js/labs/thinlens.js @@ -0,0 +1,445 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + ThinLensSim — thin lens ray tracing simulation + 1/f = 1/d + 1/d' M = -d'/d + Three principal rays · draggable object & focal point + Converging (f>0) and diverging (f<0) lenses + ══════════════════════════════════════════════════════════════ */ + +class ThinLensSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* physics (px units) */ + this.f = 100; // focal length + this.d = 200; // object distance (positive, measured from lens) + this.h = 50; // object height + + /* drag state */ + this._drag = null; // 'object' | 'focus' | null + + /* callback */ + this.onUpdate = null; + + this._bindEvents(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── public API ─────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = 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)); + if (h !== undefined) this.h = Math.max(20, Math.min(80, +h)); + this.draw(); + this._emit(); + } + + reset() { + this.f = 100; this.d = 200; this.h = 50; + this.draw(); + this._emit(); + } + + info() { + const { f, d, h } = this; + const denom = d - f; + const dPrime = Math.abs(denom) < 0.01 ? Infinity : (f * d) / denom; + const M = Math.abs(denom) < 0.01 ? Infinity : -dPrime / d; + const hPrime = M === Infinity ? Infinity : M * h; + const isVirtual = dPrime < 0; + return { + f: +f.toFixed(1), + d: +d.toFixed(1), + dPrime: dPrime === Infinity ? Infinity : +dPrime.toFixed(1), + M: M === Infinity ? Infinity : +M.toFixed(3), + imageType: isVirtual ? 'мнимое' : 'действительное', + h: +h.toFixed(1), + hPrime: hPrime === Infinity ? Infinity : +Math.abs(hPrime).toFixed(1), + }; + } + + /* ── internals ─────────────────────────────── */ + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + /** Convert simulation coords to canvas coords. + * Origin = lens center; +x right, +y up. + * Canvas: lensX = W/2, axisY = H/2 */ + _toCanvas(sx, sy) { + return { cx: this.W / 2 + sx, cy: this.H / 2 - sy }; + } + + _fromCanvas(cx, cy) { + return { sx: cx - this.W / 2, sy: this.H / 2 - cy }; + } + + /* ── draw ──────────────────────────────────── */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H) return; + + const { f, d, h } = this; + const lensX = W / 2; + const axisY = H / 2; + + /* background */ + ctx.fillStyle = '#0D0D1A'; + ctx.fillRect(0, 0, W, H); + + /* optical axis */ + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(W, axisY); ctx.stroke(); + ctx.setLineDash([]); + + /* lens */ + this._drawLens(ctx, lensX, axisY, f); + + /* focal & 2F points */ + this._drawFocalPoints(ctx, lensX, axisY, f); + + /* object arrow */ + const objX = lensX - d; + this._drawArrow(ctx, objX, axisY, objX, axisY - h, '#9B5DE5', false); + + /* compute image */ + const denom = d - f; + let dPrime, hPrime; + if (Math.abs(denom) < 0.5) { + /* object at focal point — rays parallel, no image */ + dPrime = null; + hPrime = null; + } else { + dPrime = (f * d) / denom; + const M = -dPrime / d; + hPrime = M * h; + } + + /* principal rays */ + this._drawRays(ctx, lensX, axisY, d, h, f, dPrime, hPrime); + + /* image arrow */ + if (dPrime !== null && isFinite(dPrime)) { + const isVirtual = dPrime < 0; + const imgX = lensX + dPrime; + const imgTop = axisY - hPrime; + this._drawArrow(ctx, imgX, axisY, imgX, imgTop, + isVirtual ? '#FFD166' : '#EF476F', isVirtual); + } + + /* labels */ + this._drawLabels(ctx, lensX, axisY, d, f, dPrime, hPrime); + } + + _drawLens(ctx, lx, ay, f) { + const lensH = Math.min(this.H * 0.38, 140); + const converging = f > 0; + + ctx.strokeStyle = 'rgba(155,93,229,0.8)'; + ctx.lineWidth = 2.5; + + if (converging) { + /* biconvex shape */ + const bulge = Math.min(18, Math.abs(f) * 0.12); + ctx.beginPath(); + ctx.moveTo(lx, ay - lensH); + ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(lx, ay - lensH); + ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); + ctx.stroke(); + /* arrowheads (converging) */ + this._lensArrow(ctx, lx, ay - lensH, -1); + this._lensArrow(ctx, lx, ay + lensH, 1); + } else { + /* biconcave shape */ + const bulge = Math.min(14, Math.abs(f) * 0.1); + ctx.beginPath(); + ctx.moveTo(lx, ay - lensH); + ctx.quadraticCurveTo(lx - bulge, ay, lx, ay + lensH); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(lx, ay - lensH); + ctx.quadraticCurveTo(lx + bulge, ay, lx, ay + lensH); + ctx.stroke(); + /* arrowheads (diverging) */ + this._lensArrowDiv(ctx, lx, ay - lensH, -1); + this._lensArrowDiv(ctx, lx, ay + lensH, 1); + } + + /* center line */ + ctx.strokeStyle = 'rgba(155,93,229,0.3)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(lx, ay - lensH); ctx.lineTo(lx, ay + lensH); ctx.stroke(); + } + + _lensArrow(ctx, x, y, dir) { + const sz = 7; + ctx.fillStyle = 'rgba(155,93,229,0.8)'; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - sz, y + dir * sz * 1.2); + ctx.lineTo(x + sz, y + dir * sz * 1.2); + ctx.closePath(); ctx.fill(); + } + + _lensArrowDiv(ctx, x, y, dir) { + const sz = 6; + ctx.fillStyle = 'rgba(155,93,229,0.8)'; + ctx.beginPath(); + ctx.moveTo(x - sz, y); + ctx.lineTo(x, y - dir * sz); + ctx.lineTo(x + sz, y); + ctx.closePath(); ctx.fill(); + } + + _drawFocalPoints(ctx, lx, ay, f) { + const pts = [ + { sx: f, label: "F'" }, + { sx: -f, label: 'F' }, + { sx: 2 * f, label: "2F'" }, + { sx: -2 * f, label: '2F' }, + ]; + + for (const p of pts) { + const px = lx + p.sx; + if (px < 10 || px > this.W - 10) continue; + const isFocal = !p.label.startsWith('2'); + const r = isFocal ? 5 : 3.5; + const col = isFocal ? '#06D6E0' : 'rgba(6,214,224,0.5)'; + + ctx.fillStyle = col; + ctx.beginPath(); ctx.arc(px, ay, r, 0, Math.PI * 2); ctx.fill(); + + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = col; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(p.label, px, ay + 10); + } + } + + _drawArrow(ctx, x1, y1, x2, y2, color, dashed) { + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 2.5; + + if (dashed) ctx.setLineDash([6, 4]); + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + if (dashed) ctx.setLineDash([]); + + /* arrowhead */ + const angle = Math.atan2(y2 - y1, x2 - x1); + const aLen = 10; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - aLen * Math.cos(angle - 0.35), y2 - aLen * Math.sin(angle - 0.35)); + ctx.lineTo(x2 - aLen * Math.cos(angle + 0.35), y2 - aLen * Math.sin(angle + 0.35)); + ctx.closePath(); ctx.fill(); + } + + _drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime) { + const objX = lx - d; + const objY = ay - h; + const colors = ['#06D6E0', '#7BF5A4', '#FFD166']; + const hasImage = dPrime !== null && isFinite(dPrime); + const isVirtual = hasImage && dPrime < 0; + + ctx.lineWidth = 1.5; + + /* Ray 1: parallel to axis through F' (converging) or from F' (diverging) */ + { + ctx.strokeStyle = colors[0]; + ctx.setLineDash([]); + /* incoming: object tip lens, parallel */ + ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, objY); ctx.stroke(); + /* outgoing */ + if (hasImage) { + const imgX = lx + dPrime; + const imgY = ay - hPrime; + if (!isVirtual) { + ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); + /* extend past image */ + this._extendRay(ctx, lx, objY, imgX, imgY, colors[0]); + } else { + /* diverging outgoing ray + dashed virtual extension */ + const outSlope = (objY - ay) / f; + ctx.beginPath(); ctx.moveTo(lx, objY); + ctx.lineTo(lx + 300, objY + outSlope * 300); ctx.stroke(); + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(lx, objY); ctx.lineTo(imgX, imgY); ctx.stroke(); + ctx.setLineDash([]); + } + } + } + + /* Ray 2: through center straight */ + { + ctx.strokeStyle = colors[1]; + ctx.setLineDash([]); + const slope = (objY - ay) / (objX - lx); + const farX = lx + 350; + const farY = ay + slope * 350; + ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(farX, farY); ctx.stroke(); + if (isVirtual) { + /* extend behind lens too */ + const backX = lx - 350; + const backY = ay - slope * 350; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(lx, ay); ctx.lineTo(backX, backY); ctx.stroke(); + ctx.setLineDash([]); + } + } + + /* Ray 3: through F parallel after lens */ + { + ctx.strokeStyle = colors[2]; ctx.setLineDash([]); + const fx = lx - f; + const slope = (objY - ay) / (objX - fx); + const hitY = objY + slope * (lx - objX); + ctx.beginPath(); ctx.moveTo(objX, objY); ctx.lineTo(lx, hitY); ctx.stroke(); + const endX = hasImage && !isVirtual ? Math.max(lx + dPrime + 60, lx + 300) : lx + 300; + ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(endX, hitY); ctx.stroke(); + if (hasImage && isVirtual) { + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(lx, hitY); ctx.lineTo(lx + dPrime, ay - hPrime); ctx.stroke(); + ctx.setLineDash([]); + } + } + } + + _extendRay(ctx, x1, y1, x2, y2, color) { + const dx = x2 - x1, dy = y2 - y1; + const len = Math.hypot(dx, dy); + if (len < 1) return; + const ex = x2 + (dx / len) * 80; + const ey = y2 + (dy / len) * 80; + ctx.globalAlpha = 0.3; + ctx.strokeStyle = color; + ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(ex, ey); ctx.stroke(); + ctx.globalAlpha = 1; + } + + _drawLabels(ctx, lx, ay, d, f, dPrime, hPrime) { + ctx.font = '12px Manrope, system-ui, sans-serif'; + ctx.textBaseline = 'top'; + + /* d label */ + const objX = lx - d; + ctx.fillStyle = '#9B5DE5'; + ctx.textAlign = 'center'; + ctx.fillText(`d = ${d.toFixed(0)}`, (objX + lx) / 2, ay + 26); + + /* f label */ + ctx.fillStyle = '#06D6E0'; + ctx.fillText(`f = ${f.toFixed(0)}`, lx, ay + 42); + + /* d' label */ + if (dPrime !== null && isFinite(dPrime)) { + const imgX = lx + dPrime; + ctx.fillStyle = dPrime > 0 ? '#EF476F' : '#FFD166'; + ctx.textAlign = 'center'; + ctx.fillText(`d' = ${dPrime.toFixed(1)}`, (lx + imgX) / 2, ay + 26); + } + + /* formula box */ + const info = this.info(); + const boxW = 200, boxH = 52; + const bx = 12, by = 12; + ctx.fillStyle = 'rgba(22,22,38,0.85)'; + ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); + + ctx.font = '11px Manrope, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + const mStr = info.M === Infinity ? '---' : info.M.toFixed(2); + const dpStr = info.dPrime === Infinity ? '---' : info.dPrime.toFixed(1); + ctx.fillText(`1/f = 1/d + 1/d'`, bx + 10, by + 10); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillText(`M = ${mStr} d' = ${dpStr} ${info.imageType}`, bx + 10, by + 30); + } + + /* ── events ─────────────────────────────────── */ + + _bindEvents() { + const cv = this.canvas; + + const getPos = (e) => { + const r = cv.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { + mx: (t.clientX - r.left) * (this.W / r.width), + my: (t.clientY - r.top) * (this.H / r.height), + }; + }; + + const hitTest = (mx, my) => { + const lx = this.W / 2, ay = this.H / 2; + /* object tip */ + const objX = lx - this.d; + const objY = ay - this.h; + if (Math.hypot(mx - objX, my - objY) < 20) return 'object'; + /* focal point F (front) */ + const fx = lx - this.f; + if (Math.hypot(mx - fx, my - ay) < 16) return 'focus'; + return null; + }; + + const onDown = (e) => { + const { mx, my } = getPos(e); + this._drag = hitTest(mx, my); + }; + + const onMove = (e) => { + if (!this._drag) return; + if (e.cancelable) e.preventDefault(); + const { mx } = getPos(e); + const lx = this.W / 2; + + if (this._drag === 'object') { + this.d = Math.max(30, Math.min(400, lx - mx)); + } else if (this._drag === 'focus') { + const newF = lx - mx; + this.f = Math.max(-200, Math.min(200, newF)); + } + this.draw(); + this._emit(); + }; + + const onUp = () => { this._drag = null; }; + + /* mouse */ + cv.addEventListener('mousedown', onDown); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + + /* touch */ + cv.addEventListener('touchstart', e => { + if (e.touches.length === 1) onDown(e); + }, { passive: true }); + cv.addEventListener('touchmove', e => onMove(e), { passive: false }); + cv.addEventListener('touchend', onUp); + + /* cursor style */ + cv.addEventListener('mousemove', e => { + if (this._drag) { cv.style.cursor = 'grabbing'; return; } + const { mx, my } = getPos(e); + cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; + }); + } +} diff --git a/frontend/js/labs/titration.js b/frontend/js/labs/titration.js new file mode 100644 index 0000000..640f1f5 --- /dev/null +++ b/frontend/js/labs/titration.js @@ -0,0 +1,657 @@ +'use strict'; +/* ══════════════════════════════════════════════════════════════ + TitrationSim — acid-base titration simulation + Strong acid (HCl) / weak acid (CH₃COOH) + strong base (NaOH) + Left 60%: burette + Erlenmeyer flask with indicator colour + Right 40%: real-time pH vs V(base) titration curve + Henderson-Hasselbalch for weak acid buffer region + ══════════════════════════════════════════════════════════════ */ + +class TitrationSim { + + static PINK = '#EF476F'; + static VIOLET = '#9B5DE5'; + static CYAN = '#06D6E0'; + static GREEN = '#7BF5A4'; + static YELLOW = '#FFD166'; + static BG = '#0D0D1A'; + static FONT = "Manrope, system-ui, sans-serif"; + + /* ── Constructor ────────────────────────────────────────── */ + + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; + + /* chemistry */ + this.acidConc = 0.1; // mol/L + this.baseConc = 0.1; // mol/L + this.acidVol = 50; // mL + this.acidType = 'strong'; // 'strong' | 'weak' + this.indicator = 'phenolphthalein'; // 'phenolphthalein' | 'methyl_orange' | 'litmus' + this.Ka = 1.8e-5; // CH₃COOH dissociation constant + + /* state */ + this.baseAdded = 0; // mL of base added + this._curve = []; // [{v, pH}] + this._drops = []; // [{x, y, vy, r}] + this._splashes = []; // [{x, y, vx, vy, r, life}] + this._ripples = []; // [{x, y, radius, life}] + this._dropAccum = 0; + this._wave = 0; + + /* animation */ + this.playing = false; + this._raf = null; + this._lastTs = null; + this.speed = 1; + + this.onUpdate = null; + + this._recordPoint(); + new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); + } + + /* ── Geometry ───────────────────────────────────────────── */ + + fit() { + const dpr = window.devicePixelRatio || 1; + const w = this.canvas.offsetWidth || 600; + const h = this.canvas.offsetHeight || 400; + this.canvas.width = w * dpr; + this.canvas.height = h * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = w; this.H = h; + } + + /* ── Public API ─────────────────────────────────────────── */ + + 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)); + if (acidVol !== undefined) this.acidVol = Math.max(25, Math.min(100, +acidVol)); + if (indicator !== undefined) this.indicator = indicator; + if (acidType !== undefined) this.acidType = acidType; + this.reset(); + } + + preset(name) { + const presets = { + strong_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'strong', indicator: 'phenolphthalein' }, + weak_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'weak', indicator: 'phenolphthalein' }, + concentrated: { acidConc: 0.5, baseConc: 0.5, acidVol: 25, acidType: 'strong', indicator: 'methyl_orange' }, + }; + const p = presets[name] || presets.strong_strong; + Object.assign(this, p); + this.reset(); + } + + reset() { + this.pause(); + this.baseAdded = 0; + this._curve = []; + this._drops = []; + this._splashes = []; + this._ripples = []; + this._dropAccum = 0; + this._wave = 0; + this._recordPoint(); + this.draw(); + this._emit(); + } + + play() { + if (this.playing) return; + this.playing = true; + this._lastTs = null; + this._tick(); + } + + pause() { + this.playing = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + start() { this.play(); } + stop() { this.pause(); } + + info() { + const eqVol = this._eqVolume(); + return { + pH: +this._calcPH(this.baseAdded).toFixed(2), + baseAdded: +this.baseAdded.toFixed(2), + eqPoint: +eqVol.toFixed(2), + indicator: this.indicator, + acidType: this.acidType, + }; + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } + + /* ── Chemistry ──────────────────────────────────────────── */ + + _eqVolume() { return (this.acidConc * this.acidVol) / this.baseConc; } + _maxVolume() { return this._eqVolume() * 1.5; } + + _calcPH(vBase) { + const nAcid = this.acidConc * this.acidVol / 1000; + const nBase = this.baseConc * vBase / 1000; + const vTotal = (this.acidVol + vBase) / 1000; + return this.acidType === 'strong' + ? this._strongPH(nAcid, nBase, vTotal) + : this._weakPH(nAcid, nBase, vTotal); + } + + _strongPH(nA, nB, vT) { + const d = nA - nB; + if (Math.abs(d) < 1e-10) return 7.0; + if (d > 0) return -Math.log10(d / vT); + return 14 + Math.log10(-d / vT); + } + + _weakPH(nA, nB, vT) { + const Ka = this.Ka; + const d = nA - nB; + if (d < -1e-10) return 14 + Math.log10(-d / vT); // excess base + if (Math.abs(d) < 1e-10) { // equivalence — hydrolysis + const Kb = 1e-14 / Ka; + return 14 + Math.log10(Math.sqrt(Kb * (nB / vT))); + } + if (nB < 1e-10) { // pure weak acid + const c = nA / vT; + const cH = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * c)) / 2; + return -Math.log10(cH); + } + return -Math.log10(Ka) + Math.log10((nB / vT) / (d / vT)); // Henderson-Hasselbalch + } + + _recordPoint() { + this._curve.push({ v: this.baseAdded, pH: this._calcPH(this.baseAdded) }); + } + + /* ── Indicator colour ───────────────────────────────────── */ + + _indicatorColor(pH) { + if (this.indicator === 'phenolphthalein') { + if (pH < 8.2) return 'rgba(255,255,255,0.04)'; + if (pH > 10) return 'rgba(220,20,120,0.60)'; + const t = (pH - 8.2) / 1.8; + return `rgba(220,${200 - Math.round(180 * t)},${255 - Math.round(135 * t)},${(0.04 + 0.56 * t).toFixed(2)})`; + } + if (this.indicator === 'methyl_orange') { + if (pH < 3.1) return 'rgba(220,40,40,0.50)'; + if (pH > 4.4) return 'rgba(240,210,60,0.35)'; + const t = (pH - 3.1) / 1.3; + return `rgba(${220 + Math.round(20 * t)},${40 + Math.round(170 * t)},${40 + Math.round(20 * t)},${(0.50 - 0.15 * t).toFixed(2)})`; + } + /* litmus */ + if (pH < 5) return 'rgba(220,50,60,0.55)'; + if (pH > 8) return 'rgba(60,80,210,0.55)'; + const t = (pH - 5) / 3; + return `rgba(${220 - Math.round(160 * t)},${50 + Math.round(30 * t)},${60 + Math.round(150 * t)},0.55)`; + } + + _liquidRGB(pH) { + if (this.indicator === 'phenolphthalein') { + if (pH < 8.2) return [180, 210, 255]; + const t = Math.min(1, (pH - 8.2) / 1.8); + return [180 + t * 40, 210 - t * 140, 255 - t * 135]; + } + if (this.indicator === 'methyl_orange') { + if (pH < 3.1) return [220, 80, 80]; + if (pH > 4.4) return [240, 210, 80]; + const t = (pH - 3.1) / 1.3; + return [220 + t * 20, 80 + t * 130, 80]; + } + /* litmus */ + if (pH < 5) return [220, 70, 70]; + if (pH > 8) return [80, 100, 220]; + const t = (pH - 5) / 3; + return [220 - 140 * t, 70 + 30 * t, 70 + 150 * t]; + } + + _phColor(pH) { + if (pH < 3) return TitrationSim.PINK; + if (pH < 5) return TitrationSim.YELLOW; + if (pH < 9) return TitrationSim.GREEN; + if (pH < 11) return TitrationSim.CYAN; + return TitrationSim.VIOLET; + } + + /* ── Animation loop ─────────────────────────────────────── */ + + _tick() { + if (!this.playing) return; + this._raf = requestAnimationFrame(ts => { + if (this._lastTs === null) this._lastTs = ts; + const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05); + this._lastTs = ts; + const dt = rawDt * this.speed; + + this._wave += rawDt * 2.0; + + const maxV = this._maxVolume(); + if (this.baseAdded < maxV) { + this.baseAdded = Math.min(this.baseAdded + (maxV / 14) * dt, maxV); + this._recordPoint(); + if (this._curve.length > 600) this._curve.shift(); + this._spawnDrops(dt); + } else { + this.pause(); + } + + /* move drops */ + for (const d of this._drops) { d.vy += 480 * dt; d.y += d.vy * dt; } + const surfY = this.H * 0.72; + /* spawn splashes when drops hit surface */ + for (const d of this._drops) { + if (d.y >= surfY && !d.hit) { + d.hit = true; + const bx = d.x; + for (let i = 0; i < 3; i++) { + const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI; + const s = 15 + Math.random() * 25; + this._splashes.push({ x: bx, y: surfY, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 8, r: 1 + Math.random(), life: 1 }); + } + this._ripples.push({ x: bx, y: surfY, radius: 2, life: 1 }); + } + } + this._drops = this._drops.filter(d => d.y < surfY + 4); + + /* animate splashes */ + for (const s of this._splashes) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 160 * dt; s.life -= dt * 3.5; } + this._splashes = this._splashes.filter(s => s.life > 0); + for (const r of this._ripples) { r.radius += dt * 30; r.life -= dt * 2.2; } + this._ripples = this._ripples.filter(r => r.life > 0); + + this.draw(); + this._emit(); + if (this.playing) this._tick(); + }); + } + + _spawnDrops(dt) { + this._dropAccum += dt; + const interval = 0.18 / Math.max(0.5, this.speed); + while (this._dropAccum >= interval) { + this._dropAccum -= interval; + const simW = this.W * 0.6; + const bx = simW * 0.42; + this._drops.push({ + x: bx + (Math.random() - 0.5) * 3, + y: this.H * 0.38 + 14, + vy: 10 + Math.random() * 8, + r: 2.2 + Math.random() * 1.4, + hit: false, + }); + } + } + + /* ═══════════════════════ Rendering ═══════════════════════ */ + + draw() { + const { ctx, W, H } = this; + if (!W || !H) return; + const simW = W * 0.6; + + ctx.fillStyle = TitrationSim.BG; + ctx.fillRect(0, 0, W, H); + + /* dot grid */ + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + for (let x = 0; x < W; x += 28) for (let y = 0; y < H; y += 28) { + ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill(); + } + + /* divider */ + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(simW, 16); ctx.lineTo(simW, H - 16); ctx.stroke(); + + this._drawStand(ctx, simW); + this._drawBurette(ctx, simW); + this._drawFlask(ctx, simW); + this._drawParticles(ctx); + this._drawOverlay(ctx); + this._drawPHCurve(ctx, simW, W, H); + } + + /* ── Lab stand ──────────────────────────────────────────── */ + + _drawStand(ctx, simW) { + const H = this.H, sx = simW * 0.2; + const g = ctx.createLinearGradient(sx - 3, 0, sx + 3, 0); + g.addColorStop(0, 'rgba(120,130,160,0.5)'); + g.addColorStop(0.5, 'rgba(180,190,210,0.7)'); + g.addColorStop(1, 'rgba(100,110,140,0.4)'); + ctx.fillStyle = g; + ctx.fillRect(sx - 3, H * 0.06, 6, H * 0.84); + + ctx.fillStyle = 'rgba(150,160,190,0.40)'; + ctx.beginPath(); ctx.roundRect(sx - 36, H * 0.90, 72, 7, 3); ctx.fill(); + + ctx.fillStyle = 'rgba(140,150,180,0.55)'; + ctx.fillRect(sx - 1, H * 0.12, simW * 0.22 + 2, 5); + } + + /* ── Burette ────────────────────────────────────────────── */ + + _drawBurette(ctx, simW) { + const H = this.H, FNT = TitrationSim.FONT; + const bx = simW * 0.42, bT = H * 0.06, bB = H * 0.38, bW = 12; + const maxV = this._maxVolume(); + const frac = Math.max(0, 1 - this.baseAdded / maxV); + + /* glass tube */ + const gg = ctx.createLinearGradient(bx - bW, 0, bx + bW, 0); + gg.addColorStop(0, 'rgba(120,170,255,0.18)'); + gg.addColorStop(0.4, 'rgba(160,200,255,0.08)'); + gg.addColorStop(0.6, 'rgba(160,200,255,0.08)'); + gg.addColorStop(1, 'rgba(100,150,240,0.15)'); + ctx.fillStyle = gg; + ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.fill(); + + /* liquid level */ + if (frac > 0.01) { + const lt = bT + (bB - bT) * (1 - frac) + 4; + const lg = ctx.createLinearGradient(0, lt, 0, bB); + lg.addColorStop(0, 'rgba(100,160,255,0.25)'); + lg.addColorStop(1, 'rgba(80,140,240,0.40)'); + ctx.fillStyle = lg; + ctx.beginPath(); ctx.roundRect(bx - bW + 2, lt, bW * 2 - 4, bB - lt - 4, 3); ctx.fill(); + } + + /* glass outline + highlight */ + ctx.strokeStyle = 'rgba(120,175,255,0.50)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.stroke(); + ctx.strokeStyle = 'rgba(200,225,255,0.25)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(bx - bW + 3, bT + 8); ctx.lineTo(bx - bW + 3, bB - 8); ctx.stroke(); + + /* graduations */ + ctx.strokeStyle = 'rgba(180,210,255,0.30)'; ctx.lineWidth = 0.8; + ctx.font = `8px ${FNT}`; ctx.fillStyle = 'rgba(180,210,255,0.45)'; ctx.textAlign = 'right'; + for (let i = 0; i <= 10; i++) { + const y = bT + 6 + (bB - bT - 12) * (i / 10), maj = i % 2 === 0; + ctx.beginPath(); ctx.moveTo(bx + bW, y); ctx.lineTo(bx + bW + (maj ? 8 : 4), y); ctx.stroke(); + if (maj) ctx.fillText(((i / 10) * maxV).toFixed(0), bx + bW + 22, y + 3); + } + + /* stopcock + nozzle */ + ctx.fillStyle = 'rgba(180,190,220,0.55)'; ctx.fillRect(bx - 4, bB - 2, 8, 8); + ctx.fillStyle = 'rgba(140,165,210,0.50)'; + ctx.beginPath(); + ctx.moveTo(bx - 3, bB + 6); ctx.lineTo(bx + 3, bB + 6); + ctx.lineTo(bx + 1.5, bB + 14); ctx.lineTo(bx - 1.5, bB + 14); + ctx.closePath(); ctx.fill(); + + /* forming drip */ + if (this.playing && this.baseAdded < maxV) { + const pulse = 0.5 + 0.5 * Math.sin(this._wave * 4); + const dr = 2.5 + pulse * 1.5; + ctx.save(); ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6; + ctx.fillStyle = 'rgba(100,180,255,0.65)'; + ctx.beginPath(); ctx.arc(bx, bB + 14 + dr, dr, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + + /* labels */ + ctx.fillStyle = 'rgba(180,210,255,0.60)'; ctx.font = `bold 10px ${FNT}`; ctx.textAlign = 'center'; + ctx.fillText('NaOH', bx, bT - 6); + ctx.fillText(`${this.baseConc} M`, bx, bT - 18); + } + + /* ── Erlenmeyer flask ───────────────────────────────────── */ + + _drawFlask(ctx, simW) { + const H = this.H, cx = simW * 0.42, pH = this._calcPH(this.baseAdded); + const fB = H * 0.88, fNT = H * 0.58, fNW = 10, fBW = simW * 0.22; + + const flaskP = () => { + ctx.beginPath(); + ctx.moveTo(cx - fNW, fNT); ctx.lineTo(cx - fNW, fNT + 16); + ctx.bezierCurveTo(cx - fNW, fNT + 40, cx - fBW, fB - 30, cx - fBW, fB); + ctx.lineTo(cx + fBW, fB); + ctx.bezierCurveTo(cx + fBW, fB - 30, cx + fNW, fNT + 40, cx + fNW, fNT + 16); + ctx.lineTo(cx + fNW, fNT); ctx.closePath(); + }; + + const lY = H * 0.72, [lr, lg, lb] = this._liquidRGB(pH); + const amp = 2 + (this.playing ? 1.5 : 0); + const wY = x => lY + Math.sin((x - cx) * 0.08 + this._wave) * amp + + Math.sin((x - cx) * 0.15 - this._wave * 1.4) * amp * 0.3; + + /* liquid clipped to flask */ + ctx.save(); flaskP(); ctx.clip(); + ctx.beginPath(); + for (let x = cx - fBW - 2; x <= cx + fBW + 2; x += 2) { + x === cx - fBW - 2 ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x)); + } + ctx.lineTo(cx + fBW + 2, fB + 4); ctx.lineTo(cx - fBW - 2, fB + 4); ctx.closePath(); + const lGrad = ctx.createLinearGradient(0, lY, 0, fB); + lGrad.addColorStop(0, `rgba(${lr},${lg},${lb},0.30)`); + lGrad.addColorStop(0.5, `rgba(${lr},${lg},${lb},0.45)`); + lGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0.55)`); + ctx.fillStyle = lGrad; ctx.fill(); + ctx.fillStyle = this._indicatorColor(pH); ctx.fill(); + + /* surface shimmer */ + ctx.beginPath(); + for (let x = cx - fBW; x <= cx + fBW; x += 2) { + x === cx - fBW ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x)); + } + ctx.strokeStyle = `rgba(${Math.min(255, lr + 80)},${Math.min(255, lg + 80)},${Math.min(255, lb + 80)},0.45)`; + ctx.lineWidth = 1.2; ctx.stroke(); + ctx.restore(); + + /* glass outline */ + ctx.strokeStyle = 'rgba(120,175,255,0.55)'; ctx.lineWidth = 2; flaskP(); ctx.stroke(); + + /* left highlight */ + ctx.save(); ctx.beginPath(); + ctx.moveTo(cx - fNW + 2, fNT + 4); ctx.lineTo(cx - fNW + 2, fNT + 18); + ctx.bezierCurveTo(cx - fNW + 2, fNT + 42, cx - fBW + 8, fB - 32, cx - fBW + 6, fB - 4); + const hg = ctx.createLinearGradient(cx - fBW, fNT, cx - fBW, fB); + hg.addColorStop(0, 'rgba(200,230,255,0.30)'); hg.addColorStop(1, 'rgba(200,230,255,0.03)'); + ctx.strokeStyle = hg; ctx.lineWidth = 3; ctx.stroke(); ctx.restore(); + + /* neck rim */ + ctx.strokeStyle = 'rgba(140,185,255,0.60)'; ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.moveTo(cx - fNW - 4, fNT); ctx.lineTo(cx + fNW + 4, fNT); ctx.stroke(); + + /* acid label */ + const label = this.acidType === 'strong' ? 'HCl' : 'CH\u2083COOH'; + ctx.fillStyle = 'rgba(180,210,255,0.55)'; ctx.font = `9px ${TitrationSim.FONT}`; ctx.textAlign = 'center'; + ctx.fillText(`${label} ${this.acidConc} M, ${this.acidVol} mL`, cx, fB + 14); + + /* pH value */ + ctx.font = `bold 14px ${TitrationSim.FONT}`; + ctx.fillStyle = this._phColor(pH); + ctx.fillText(`pH ${pH.toFixed(2)}`, cx, fB + 32); + } + + /* ── Drops / splashes / ripples ─────────────────────────── */ + + _drawParticles(ctx) { + for (const d of this._drops) { + ctx.save(); ctx.globalAlpha = 0.85; + ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 8; + const st = Math.min(2.5, 1 + d.vy * 0.003); + ctx.beginPath(); ctx.ellipse(d.x, d.y, d.r, d.r * st, 0, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(100,180,255,0.70)'; ctx.fill(); + ctx.fillStyle = 'rgba(220,240,255,0.55)'; + ctx.beginPath(); ctx.arc(d.x - d.r * 0.3, d.y - d.r * 0.4, d.r * 0.3, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + const [sr, sg, sb] = this._liquidRGB(this._calcPH(this.baseAdded)); + for (const s of this._splashes) { + ctx.save(); ctx.globalAlpha = s.life * 0.7; + ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); + ctx.fillStyle = `rgb(${Math.min(255, sr + 60)},${Math.min(255, sg + 60)},${Math.min(255, sb + 60)})`; ctx.fill(); + ctx.restore(); + } + for (const r of this._ripples) { + ctx.save(); ctx.globalAlpha = r.life * 0.4; + ctx.strokeStyle = 'rgba(180,220,255,0.6)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2); ctx.stroke(); + ctx.restore(); + } + } + + /* ── Stats overlay ──────────────────────────────────────── */ + + _drawOverlay(ctx) { + const pH = this._calcPH(this.baseAdded); + const eqV = this._eqVolume(); + const bx = 10, by = 10, bw = 150, bh = 78; + + ctx.fillStyle = 'rgba(5,5,20,0.82)'; + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke(); + + ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + const lh = 16; + + ctx.font = `bold 12px ${TitrationSim.FONT}`; + ctx.fillStyle = TitrationSim.CYAN; + ctx.fillText(`pH = ${pH.toFixed(2)}`, bx + 10, by + 8); + + ctx.font = `10px ${TitrationSim.FONT}`; + ctx.fillStyle = TitrationSim.YELLOW; + ctx.fillText(`V(NaOH) = ${this.baseAdded.toFixed(1)} mL`, bx + 10, by + 8 + lh); + + ctx.fillStyle = TitrationSim.GREEN; + ctx.fillText(`V\u044D\u043A\u0432 = ${eqV.toFixed(1)} mL`, bx + 10, by + 8 + lh * 2); + + const names = { phenolphthalein: '\u0424\u0435\u043D\u043E\u043B\u0444\u0442.', methyl_orange: '\u041C\u0435\u0442.\u043E\u0440.', litmus: '\u041B\u0430\u043A\u043C\u0443\u0441' }; + ctx.fillStyle = 'rgba(255,255,255,0.40)'; + ctx.fillText(names[this.indicator] || this.indicator, bx + 10, by + 8 + lh * 3); + } + + /* ── pH titration curve (right 40%) ─────────────────────── */ + + _drawPHCurve(ctx, x0, W, H) { + const gW = W - x0; + const pad = { l: 36, r: 12, t: 30, b: 32 }; + const px = x0 + pad.l, py = pad.t; + const pw = gW - pad.l - pad.r, ph = H - pad.t - pad.b; + const maxV = this._maxVolume(); + const eqV = this._eqVolume(); + const FNT = TitrationSim.FONT; + + /* panel bg */ + ctx.fillStyle = 'rgba(5,5,20,0.85)'; + ctx.fillRect(x0, 0, gW, H); + + /* title */ + ctx.fillStyle = 'rgba(200,220,255,0.65)'; + ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'center'; + ctx.fillText('\u041A\u0440\u0438\u0432\u0430\u044F \u0442\u0438\u0442\u0440\u043E\u0432\u0430\u043D\u0438\u044F', x0 + gW / 2, 14); + + /* grid + y labels */ + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.5; + ctx.fillStyle = 'rgba(180,210,255,0.35)'; + ctx.font = `9px ${FNT}`; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let p = 0; p <= 14; p += 2) { + const yl = py + ph - (p / 14) * ph; + ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke(); + ctx.fillText(p.toString(), px - 5, yl); + } + + /* x labels */ + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + const vs = maxV > 60 ? 20 : maxV > 30 ? 10 : 5; + for (let v = 0; v <= maxV; v += vs) { + const xl = px + (v / maxV) * pw; + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.beginPath(); ctx.moveTo(xl, py); ctx.lineTo(xl, py + ph); ctx.stroke(); + ctx.fillText(v.toFixed(0), xl, py + ph + 6); + } + ctx.fillStyle = 'rgba(180,210,255,0.50)'; ctx.font = `bold 10px ${FNT}`; + ctx.fillText('V (mL)', x0 + gW / 2, py + ph + 22); + + /* y-axis label */ + ctx.save(); + ctx.translate(x0 + 10, py + ph / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText('pH', 0, 0); + ctx.restore(); + + /* dashed pH=7 */ + const y7 = py + ph * (1 - 7 / 14); + ctx.setLineDash([4, 4]); + ctx.strokeStyle = 'rgba(123,245,164,0.25)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(px, y7); ctx.lineTo(px + pw, y7); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(123,245,164,0.40)'; ctx.font = `9px ${FNT}`; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('pH 7', px + pw + 4, y7); + + /* axes */ + ctx.strokeStyle = 'rgba(160,200,255,0.40)'; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py + ph); ctx.lineTo(px + pw, py + ph); ctx.stroke(); + + /* curve */ + if (this._curve.length > 1) { + ctx.strokeStyle = TitrationSim.CYAN; ctx.lineWidth = 2.5; + ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6; + ctx.beginPath(); + for (let i = 0; i < this._curve.length; i++) { + const pt = this._curve[i]; + const lx = px + (pt.v / maxV) * pw; + const ly = py + ph * (1 - Math.max(0, Math.min(14, pt.pH)) / 14); + i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly); + } + ctx.stroke(); ctx.shadowBlur = 0; + + /* current point dot + tooltip */ + const last = this._curve[this._curve.length - 1]; + const dx = px + (last.v / maxV) * pw; + const dy = py + ph * (1 - Math.max(0, Math.min(14, last.pH)) / 14); + ctx.save(); + ctx.shadowColor = '#FFF'; ctx.shadowBlur = 10; ctx.fillStyle = '#FFF'; + ctx.beginPath(); ctx.arc(dx, dy, 4.5, 0, Math.PI * 2); ctx.fill(); + ctx.shadowBlur = 0; + + const tw = 72, th = 30; + const tx = Math.min(dx + 8, px + pw - tw - 4); + const ty = Math.max(dy - th - 8, py + 2); + ctx.fillStyle = 'rgba(0,0,0,0.65)'; + ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 5); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = this._phColor(last.pH); + ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillText(`pH ${last.pH.toFixed(2)}`, tx + 6, ty + 5); + ctx.fillStyle = 'rgba(200,220,255,0.60)'; ctx.font = `9px ${FNT}`; + ctx.fillText(`${last.v.toFixed(1)} mL`, tx + 6, ty + 18); + ctx.restore(); + } + + /* equivalence point markers */ + const eqX = px + (eqV / maxV) * pw; + const eqPH = this._calcPH(eqV); + const eqY = py + ph * (1 - Math.max(0, Math.min(14, eqPH)) / 14); + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = 'rgba(155,93,229,0.45)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(eqX, py); ctx.lineTo(eqX, py + ph); ctx.stroke(); + ctx.setLineDash([]); + + /* equivalence diamond */ + ctx.save(); + ctx.shadowColor = TitrationSim.VIOLET; ctx.shadowBlur = 10; + ctx.fillStyle = TitrationSim.VIOLET; + ctx.beginPath(); + ctx.moveTo(eqX, eqY - 6); ctx.lineTo(eqX + 5, eqY); + ctx.lineTo(eqX, eqY + 6); ctx.lineTo(eqX - 5, eqY); + ctx.closePath(); ctx.fill(); + ctx.shadowBlur = 0; ctx.restore(); + + ctx.fillStyle = 'rgba(155,93,229,0.70)'; + ctx.font = `bold 9px ${FNT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText('\u044D\u043A\u0432', eqX, eqY - 10); + ctx.fillStyle = 'rgba(155,93,229,0.50)'; ctx.font = `8px ${FNT}`; + ctx.fillText(`${eqV.toFixed(1)} mL`, eqX, eqY - 20); + } +} + +if (typeof module !== 'undefined') module.exports = TitrationSim; diff --git a/frontend/js/labs/triangle.js b/frontend/js/labs/triangle.js new file mode 100644 index 0000000..d9b7756 --- /dev/null +++ b/frontend/js/labs/triangle.js @@ -0,0 +1,961 @@ +'use strict'; +/* ══════════════════════════════════════════════════════ + TriangleSim — interactive triangle geometry simulation + Draggable vertices A / B / C, toggleable layers: + medians, altitudes, bisectors, circumcircle, incircle +══════════════════════════════════════════════════════ */ + +class TriangleSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.pts = null; // [{x,y}, {x,y}, {x,y}] + this._dragging = null; + this._hovered = null; + + // visible layers + this.layers = { + medians : false, + altitudes : false, + bisectors : false, + circumcircle: false, + incircle : false, + eulerLine : false, + sineLaw : false, + cosineLaw : false, + pythagorean : false, + grid : true, + }; + + this.onUpdate = null; // cb(stats) + this._bindEvents(); + } + + /* ── sizing ── */ + + fit() { + const rect = this.canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.W = rect.width; + this.H = rect.height; + if (!this.pts) this._initPts(); + else this._clampPts(); + this.draw(); + } + + _initPts() { + const cx = this.W / 2, cy = this.H / 2; + const r = Math.min(this.W, this.H) * 0.30; + this.pts = [ + { x: cx, y: cy - r }, // A top + { x: cx - r * 0.88, y: cy + r * 0.62 }, // B bottom-left + { x: cx + r * 0.88, y: cy + r * 0.62 }, // C bottom-right + ]; + } + + _clampPts() { + const pad = 48; + for (const p of this.pts) { + p.x = Math.max(pad, Math.min(this.W - pad, p.x)); + p.y = Math.max(pad, Math.min(this.H - pad, p.y)); + } + } + + reset() { + this.pts = null; + this._initPts(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.stats()); + } + + /* ── pointer events ── */ + + _bindEvents() { + const c = this.canvas; + + const pos = e => { + const r = c.getBoundingClientRect(); + const s = e.touches ? e.touches[0] : e; + return { x: s.clientX - r.left, y: s.clientY - r.top }; + }; + + const hit = p => { + if (!this.pts) return -1; + for (let i = 0; i < 3; i++) { + if (Math.hypot(p.x - this.pts[i].x, p.y - this.pts[i].y) < 20) return i; + } + return -1; + }; + + const drag = (p) => { + this.pts[this._dragging].x = p.x; + this.pts[this._dragging].y = p.y; + this._clampPts(); + this.draw(); + if (this.onUpdate) this.onUpdate(this.stats()); + }; + + c.addEventListener('mousedown', e => { + const i = hit(pos(e)); + if (i >= 0) { this._dragging = i; c.style.cursor = 'grabbing'; } + }); + + c.addEventListener('mousemove', e => { + const p = pos(e); + if (this._dragging !== null) { drag(p); return; } + const i = hit(p); + const was = this._hovered; + this._hovered = i >= 0 ? i : null; + c.style.cursor = i >= 0 ? 'grab' : 'default'; + if (was !== this._hovered) this.draw(); + }); + + c.addEventListener('mouseup', () => { + this._dragging = null; + c.style.cursor = this._hovered !== null ? 'grab' : 'default'; + }); + + c.addEventListener('touchstart', e => { e.preventDefault(); const i = hit(pos(e)); if (i >= 0) this._dragging = i; }, { passive: false }); + c.addEventListener('touchmove', e => { e.preventDefault(); if (this._dragging !== null) drag(pos(e)); }, { passive: false }); + c.addEventListener('touchend', () => { this._dragging = null; }); + } + + /* ── layer toggles ── */ + + toggleLayer(name) { this.layers[name] = !this.layers[name]; this.draw(); } + setLayer(name, v) { this.layers[name] = v; this.draw(); } + + /* ══════════════════════════════════════ + Geometry helpers (canvas coords) + Scale: SCALE px = 1 unit for UI display + ══════════════════════════════════════ */ + + static SCALE = 50; // px per unit + + _len(P1, P2) { return Math.hypot(P2.x - P1.x, P2.y - P1.y); } + + _sides() { + const [A, B, C] = this.pts; + return { a: this._len(B, C), b: this._len(A, C), c: this._len(A, B) }; + } + + _area() { + const [A, B, C] = this.pts; + return Math.abs((B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y)) / 2; + } + + _angles() { + const { a, b, c } = this._sides(); + const cl = v => Math.max(-1, Math.min(1, v)); + const A = Math.acos(cl((b*b + c*c - a*a) / (2*b*c))); + const B = Math.acos(cl((a*a + c*c - b*b) / (2*a*c))); + const C = Math.PI - A - B; + return { A, B, C }; + } + + _centroid() { + const [A, B, C] = this.pts; + return { x: (A.x + B.x + C.x) / 3, y: (A.y + B.y + C.y) / 3 }; + } + + _circumcenter() { + const [A, B, C] = this.pts; + const D = 2 * (A.x*(B.y-C.y) + B.x*(C.y-A.y) + C.x*(A.y-B.y)); + if (Math.abs(D) < 1e-8) return null; + const a2 = A.x*A.x + A.y*A.y, b2 = B.x*B.x + B.y*B.y, c2 = C.x*C.x + C.y*C.y; + return { + x: (a2*(B.y-C.y) + b2*(C.y-A.y) + c2*(A.y-B.y)) / D, + y: (a2*(C.x-B.x) + b2*(A.x-C.x) + c2*(B.x-A.x)) / D, + }; + } + + _circumR() { + const O = this._circumcenter(); + return O ? this._len(this.pts[0], O) : 0; + } + + _incenter() { + const [A, B, C] = this.pts; + const { a, b, c } = this._sides(); + const s = a + b + c; + if (s < 1e-8) return null; + return { x: (a*A.x + b*B.x + c*C.x)/s, y: (a*A.y + b*B.y + c*C.y)/s }; + } + + _inR() { + const { a, b, c } = this._sides(); + const s = a + b + c; + return s < 1e-8 ? 0 : (2 * this._area()) / s; + } + + _orthocenter() { + const [A, B, C] = this.pts; + const bcDx = C.x - B.x, bcDy = C.y - B.y; + const acDx = C.x - A.x, acDy = C.y - A.y; + const denom = bcDy * (-acDx) - (-bcDx) * acDy; + if (Math.abs(denom) < 1e-8) return null; + const t = ((B.x-A.x)*(-acDy) - (B.y-A.y)*(-acDx)) / denom; + return { x: A.x + t*bcDy, y: A.y - t*bcDx }; + } + + _foot(P, L1, L2) { + const dx = L2.x - L1.x, dy = L2.y - L1.y; + const l2 = dx*dx + dy*dy; + if (l2 < 1e-10) return { ...L1 }; + const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2; + return { x: L1.x + t*dx, y: L1.y + t*dy }; + } + + _mid(P1, P2) { return { x: (P1.x+P2.x)/2, y: (P1.y+P2.y)/2 }; } + + /* bisector foot: divides opposite side by angle-bisector theorem */ + _bisFoot(V, L1, L2) { + const d1 = this._len(V, L1), d2 = this._len(V, L2); + const s = d1 + d2; + if (s < 1e-8) return { ...L1 }; + return { x: (d2*L1.x + d1*L2.x)/s, y: (d2*L1.y + d1*L2.y)/s }; + } + + stats() { + const S = TriangleSim.SCALE; + const { a, b, c } = this._sides(); + const { A, B, C } = this._angles(); + const area = this._area(); + const perim = a + b + c; + const R = this._circumR(); + const r = this._inR(); + + const deg = rad => rad * 180 / Math.PI; + const dA = deg(A), dB = deg(B), dC = deg(C); + + let type = ''; + const eps = 1.8; // degrees tolerance + const isRight = [dA, dB, dC].some(d => Math.abs(d - 90) < eps); + const isObtuse = [dA, dB, dC].some(d => d > 90 + eps); + const sidesArr = [a, b, c].sort((x, y) => x - y); + const isEquil = sidesArr[2] - sidesArr[0] < 2; + const isIsoc = !isEquil && ( + Math.abs(a - b) < 2 || Math.abs(b - c) < 2 || Math.abs(a - c) < 2 + ); + + if (isEquil) type = 'Равносторонний'; + else if (isRight) type = isIsoc ? 'Прямоугольный равнобедр.' : 'Прямоугольный'; + else if (isObtuse) type = isIsoc ? 'Тупоугольный равнобедр.' : 'Тупоугольный'; + else type = isIsoc ? 'Остроугольный равнобедр.' : 'Остроугольный'; + + return { + a: a/S, b: b/S, c: c/S, + A: dA, B: dB, C: dC, + S: area / (S*S), + perim: perim / S, + R: R / S, + r: r / S, + type, + }; + } + + /* ══════════════════════════════════════ + Drawing + ══════════════════════════════════════ */ + + draw() { + const ctx = this.ctx, W = this.W, H = this.H; + if (!W || !H || !this.pts) return; + + ctx.clearRect(0, 0, W, H); + + // Background + const bg = ctx.createLinearGradient(0, 0, W, H); + bg.addColorStop(0, '#07071A'); + bg.addColorStop(1, '#0D1830'); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + + if (this.layers.grid) this._drawGrid(ctx, W, H); + + // Layer order: circles behind fill construction lines edges vertices labels + if (this.layers.circumcircle) this._drawCircumcircle(ctx); + if (this.layers.incircle) this._drawIncircle(ctx); + + this._drawFill(ctx); + + if (this.layers.medians) this._drawMedians(ctx); + if (this.layers.altitudes) this._drawAltitudes(ctx); + if (this.layers.bisectors) this._drawBisectors(ctx); + + if (this.layers.eulerLine) this._drawEulerLine(ctx); + + if (this.layers.pythagorean) this._drawPythagorean(ctx); + if (this.layers.sineLaw) this._drawSineLaw(ctx); + if (this.layers.cosineLaw) this._drawCosineLaw(ctx); + + this._drawAngleArcs(ctx); + this._drawEdges(ctx); + this._drawRightAngleMark(ctx); + this._drawVertices(ctx); + this._drawSideLabels(ctx); + this._drawAngleLabels(ctx); + } + + /* helpers */ + _line(ctx, x1, y1, x2, y2) { + ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); + } + + _dot(ctx, x, y, r, fill, shadow) { + ctx.save(); + ctx.shadowColor = shadow || fill; ctx.shadowBlur = 12; + ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); + ctx.fillStyle = fill; ctx.fill(); ctx.restore(); + } + + _label(ctx, text, x, y, color, size=13) { + ctx.save(); + ctx.font = `bold ${size}px Manrope, sans-serif`; + ctx.fillStyle = color; + ctx.shadowColor = color; ctx.shadowBlur = 8; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y); ctx.restore(); + } + + /* ── grid ── */ + _drawGrid(ctx, W, H) { + const step = 50; + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.045)'; ctx.lineWidth = 1; + for (let x = 0; x <= W; x += step) this._line(ctx, x, 0, x, H); + for (let y = 0; y <= H; y += step) this._line(ctx, 0, y, W, y); + // axes + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1.2; + this._line(ctx, 0, H/2, W, H/2); + this._line(ctx, W/2, 0, W/2, H); + ctx.restore(); + } + + /* ── triangle fill ── */ + _drawFill(ctx) { + const [A, B, C] = this.pts; + ctx.save(); + const g = ctx.createLinearGradient(A.x, A.y, (B.x+C.x)/2, (B.y+C.y)/2); + g.addColorStop(0, 'rgba(155,93,229,0.20)'); + g.addColorStop(1, 'rgba(6,214,224,0.07)'); + ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath(); + ctx.fillStyle = g; ctx.fill(); + ctx.restore(); + } + + /* ── edges ── */ + _drawEdges(ctx) { + const [A, B, C] = this.pts; + ctx.save(); + ctx.shadowColor = 'rgba(155,93,229,0.55)'; ctx.shadowBlur = 10; + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; + ctx.beginPath(); ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.lineTo(C.x,C.y); ctx.closePath(); ctx.stroke(); + ctx.restore(); + } + + /* ── vertices ── */ + _drawVertices(ctx) { + const names = ['A', 'B', 'C']; + const colors = ['#9B5DE5', '#06D6E0', '#F15BB5']; + + this.pts.forEach((p, i) => { + const active = this._hovered === i || this._dragging === i; + const col = colors[i]; + ctx.save(); + ctx.shadowColor = col; ctx.shadowBlur = active ? 24 : 14; + + ctx.beginPath(); ctx.arc(p.x, p.y, active ? 13 : 10, 0, Math.PI*2); + ctx.fillStyle = active ? col : 'rgba(7,7,26,0.92)'; + ctx.fill(); + ctx.strokeStyle = col; ctx.lineWidth = 2.2; ctx.stroke(); + + if (!active) { + ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI*2); + ctx.fillStyle = col; ctx.shadowBlur = 0; ctx.fill(); + } + ctx.restore(); + }); + } + + /* ── vertex name labels (outside) ── */ + _drawSideLabels(ctx) { + const [A, B, C] = this.pts; + // sides: a=BC, b=AC, c=AB + const sides = [ + { from: B, to: C, label: 'a', col: '#9B5DE5' }, + { from: A, to: C, label: 'b', col: '#06D6E0' }, + { from: A, to: B, label: 'c', col: '#F15BB5' }, + ]; + ctx.save(); + sides.forEach(({ from, to, label, col }) => { + const mx = (from.x+to.x)/2, my = (from.y+to.y)/2; + const dx = to.x-from.x, dy = to.y-from.y; + const len = Math.hypot(dx, dy); if (len < 1) return; + // perpendicular outward — pick side toward centroid and invert + const cx_ = (this.pts[0].x+this.pts[1].x+this.pts[2].x)/3; + const cy_ = (this.pts[0].y+this.pts[1].y+this.pts[2].y)/3; + let nx = -dy/len, ny = dx/len; + if ((mx+nx*10-cx_)*nx + (my+ny*10-cy_)*ny < 0) { nx=-nx; ny=-ny; } + this._label(ctx, label, mx + nx*20, my + ny*20, col, 14); + }); + ctx.restore(); + } + + /* ── vertex A/B/C labels ── */ + _drawAngleLabels(ctx) { + const names = ['A', 'B', 'C']; + const colors = ['#9B5DE5', '#06D6E0', '#F15BB5']; + const nexts = [this.pts[1], this.pts[2], this.pts[0]]; + const prevs = [this.pts[2], this.pts[0], this.pts[1]]; + + this.pts.forEach((V, i) => { + const P = nexts[i], Q = prevs[i]; + // direction from vertex away from triangle (outward bisector) + const dp = { x: P.x-V.x, y: P.y-V.y }; const lp = Math.hypot(dp.x, dp.y); + const dq = { x: Q.x-V.x, y: Q.y-V.y }; const lq = Math.hypot(dq.x, dq.y); + if (lp < 1 || lq < 1) return; + // outward: negate inward bisector + const bx = dp.x/lp + dq.x/lq, by = dp.y/lp + dq.y/lq; + const bl = Math.hypot(bx, by) || 1; + const ox = -bx/bl * 26, oy = -by/bl * 26; + this._label(ctx, names[i], V.x + ox, V.y + oy, colors[i], 15); + }); + } + + /* ── angle arcs ── */ + _drawAngleArcs(ctx) { + const nexts = [this.pts[1], this.pts[2], this.pts[0]]; + const prevs = [this.pts[2], this.pts[0], this.pts[1]]; + const colors= ['rgba(155,93,229,0.7)','rgba(6,214,224,0.7)','rgba(241,91,181,0.7)']; + const fills = ['rgba(155,93,229,0.12)','rgba(6,214,224,0.12)','rgba(241,91,181,0.12)']; + + ctx.save(); + this.pts.forEach((V, i) => { + const P = nexts[i], Q = prevs[i]; + const r = 30; + const a1 = Math.atan2(P.y-V.y, P.x-V.x); + const a2 = Math.atan2(Q.y-V.y, Q.x-V.x); + let diff = a2 - a1; + while (diff > Math.PI) diff -= 2*Math.PI; + while (diff < -Math.PI) diff += 2*Math.PI; + const ccw = diff < 0; + + ctx.strokeStyle = colors[i]; ctx.lineWidth = 1.5; + ctx.fillStyle = fills[i]; + + ctx.beginPath(); + ctx.moveTo(V.x, V.y); + ctx.arc(V.x, V.y, r, a1, a2, ccw); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.arc(V.x, V.y, r, a1, a2, ccw); + ctx.stroke(); + }); + ctx.restore(); + } + + /* ── right angle mark ── */ + _drawRightAngleMark(ctx) { + const { A, B, C } = this._angles(); + const verts = this.pts; + const angles = [A, B, C]; + const nexts = [verts[1], verts[2], verts[0]]; + const prevs = [verts[2], verts[0], verts[1]]; + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 1.5; + + verts.forEach((V, i) => { + if (Math.abs(angles[i] - Math.PI/2) > 0.035) return; + const P = nexts[i], Q = prevs[i]; + const sz = 14; + const lp = Math.hypot(P.x-V.x, P.y-V.y), lq = Math.hypot(Q.x-V.x, Q.y-V.y); + if (lp < 1 || lq < 1) return; + const d1 = { x:(P.x-V.x)/lp*sz, y:(P.y-V.y)/lp*sz }; + const d2 = { x:(Q.x-V.x)/lq*sz, y:(Q.y-V.y)/lq*sz }; + ctx.beginPath(); + ctx.moveTo(V.x+d1.x, V.y+d1.y); + ctx.lineTo(V.x+d1.x+d2.x, V.y+d1.y+d2.y); + ctx.lineTo(V.x+d2.x, V.y+d2.y); + ctx.stroke(); + }); + ctx.restore(); + } + + /* ── medians (green) ── */ + _drawMedians(ctx) { + const [A, B, C] = this.pts; + const mA = this._mid(B, C), mB = this._mid(A, C), mC = this._mid(A, B); + const G = this._centroid(); + + ctx.save(); + ctx.strokeStyle = '#22d55e'; ctx.lineWidth = 1.8; + ctx.setLineDash([6, 4]); ctx.shadowColor = '#22d55e'; ctx.shadowBlur = 7; + + [[A, mA],[B, mB],[C, mC]].forEach(([fr, to]) => { + ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke(); + }); + ctx.setLineDash([]); + + // midpoint ticks + [mA, mB, mC].forEach(m => this._dot(ctx, m.x, m.y, 4, '#22d55e')); + + // centroid + this._dot(ctx, G.x, G.y, 7, '#22d55e'); + this._label(ctx, 'G', G.x + 14, G.y - 8, '#22d55e', 13); + + ctx.restore(); + } + + /* ── altitudes (amber) ── */ + _drawAltitudes(ctx) { + const [A, B, C] = this.pts; + const fA = this._foot(A, B, C); + const fB = this._foot(B, A, C); + const fC = this._foot(C, A, B); + const H = this._orthocenter(); + + ctx.save(); + ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.8; + ctx.setLineDash([6, 4]); ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 7; + + [[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => { + ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke(); + }); + ctx.setLineDash([]); + + // foot right-angle marks + [[A, B, C, fA],[B, A, C, fB],[C, A, B, fC]].forEach(([P, L1, L2, ft]) => { + const lp = Math.hypot(P.x-ft.x, P.y-ft.y); + const ll = Math.hypot(L2.x-L1.x, L2.y-L1.y); + if (lp < 1 || ll < 1) return; + const sz = 9; + const d1 = { x:(P.x-ft.x)/lp*sz, y:(P.y-ft.y)/lp*sz }; + const d2 = { x:(L2.x-L1.x)/ll*sz, y:(L2.y-L1.y)/ll*sz }; + ctx.strokeStyle = 'rgba(245,158,11,0.6)'; ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(ft.x+d1.x, ft.y+d1.y); + ctx.lineTo(ft.x+d1.x+d2.x, ft.y+d1.y+d2.y); + ctx.lineTo(ft.x+d2.x, ft.y+d2.y); + ctx.stroke(); + }); + + [fA, fB, fC].forEach(f => this._dot(ctx, f.x, f.y, 4, '#f59e0b')); + + if (H) { + this._dot(ctx, H.x, H.y, 7, '#f59e0b'); + this._label(ctx, 'H', H.x + 14, H.y - 8, '#f59e0b', 13); + } + ctx.restore(); + } + + /* ── bisectors (pink) ── */ + _drawBisectors(ctx) { + const [A, B, C] = this.pts; + const fA = this._bisFoot(A, B, C); + const fB = this._bisFoot(B, A, C); + const fC = this._bisFoot(C, A, B); + const I = this._incenter(); + + ctx.save(); + ctx.strokeStyle = '#ec4899'; ctx.lineWidth = 1.8; + ctx.setLineDash([6, 4]); ctx.shadowColor = '#ec4899'; ctx.shadowBlur = 7; + + [[A, fA],[B, fB],[C, fC]].forEach(([fr, to]) => { + ctx.beginPath(); ctx.moveTo(fr.x, fr.y); ctx.lineTo(to.x, to.y); ctx.stroke(); + }); + ctx.setLineDash([]); + + if (I) { + this._dot(ctx, I.x, I.y, 7, '#ec4899'); + this._label(ctx, 'I', I.x + 14, I.y - 8, '#ec4899', 13); + } + ctx.restore(); + } + + /* ── circumscribed circle (pink dashed) ── */ + _drawCircumcircle(ctx) { + const O = this._circumcenter(); + if (!O) return; + const R = this._circumR(); + + ctx.save(); + ctx.strokeStyle = 'rgba(241,91,181,0.75)'; ctx.lineWidth = 1.8; + ctx.setLineDash([7, 4]); ctx.shadowColor = '#F15BB5'; ctx.shadowBlur = 10; + ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI*2); ctx.stroke(); + ctx.setLineDash([]); + + // center O + this._dot(ctx, O.x, O.y, 5, '#F15BB5'); + this._label(ctx, 'O', O.x + 10, O.y - 10, '#F15BB5', 12); + + // radii (faint) + ctx.strokeStyle = 'rgba(241,91,181,0.18)'; ctx.lineWidth = 1; + this.pts.forEach(P => { + ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(P.x, P.y); ctx.stroke(); + }); + ctx.restore(); + } + + /* ── inscribed circle (cyan dashed) ── */ + _drawIncircle(ctx) { + const I = this._incenter(); + if (!I) return; + const r = this._inR(); + + ctx.save(); + ctx.strokeStyle = 'rgba(6,214,224,0.75)'; ctx.lineWidth = 1.8; + ctx.setLineDash([7, 4]); ctx.shadowColor = '#06D6E0'; ctx.shadowBlur = 10; + ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2); ctx.stroke(); + ctx.setLineDash([]); + + ctx.beginPath(); ctx.arc(I.x, I.y, r, 0, Math.PI*2); + ctx.fillStyle = 'rgba(6,214,224,0.05)'; ctx.fill(); + + this._dot(ctx, I.x, I.y, 5, '#06D6E0'); + ctx.restore(); + } + + /* ── Euler line: O G H ── */ + _drawEulerLine(ctx) { + const O = this._circumcenter(); + const G = this._centroid(); + const H = this._orthocenter(); + if (!O || !H) return; + + ctx.save(); + ctx.strokeStyle = 'rgba(255,255,100,0.5)'; ctx.lineWidth = 1.5; + ctx.setLineDash([4, 4]); ctx.shadowColor = 'rgba(255,255,100,0.4)'; ctx.shadowBlur = 6; + ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(H.x, H.y); ctx.stroke(); + ctx.setLineDash([]); + + // Label + const mx = (O.x + H.x)/2 + 16, my = (O.y + H.y)/2 - 8; + ctx.font = '11px Manrope, sans-serif'; + ctx.fillStyle = 'rgba(255,255,100,0.6)'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + ctx.fillText('прямая Эйлера', mx, my); + ctx.restore(); + } + + /* ══════════════════════════════════════════════════════ + THEOREM VISUALIZATIONS + ══════════════════════════════════════════════════════ */ + + /* ── Law of Sines: a/sinA = b/sinB = c/sinC = 2R ── */ + _drawSineLaw(ctx) { + const [A, B, C] = this.pts; + const { a, b, c } = this._sides(); + const angles = this._angles(); + const S = TriangleSim.SCALE; + const O = this._circumcenter(); + const R = this._circumR(); + + if (!O || R < 1) return; + + ctx.save(); + + // Draw circumscribed circle (faint, if not already enabled) + if (!this.layers.circumcircle) { + ctx.strokeStyle = 'rgba(96,165,250,0.3)'; ctx.lineWidth = 1.2; + ctx.setLineDash([5, 4]); + ctx.beginPath(); ctx.arc(O.x, O.y, R, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + this._dot(ctx, O.x, O.y, 3, 'rgba(96,165,250,0.5)'); + } + + // Draw radii from O to each vertex with labels + const verts = [A, B, C]; + const sideNames = ['a', 'b', 'c']; + const angNames = ['A', 'B', 'C']; + const angVals = [angles.A, angles.B, angles.C]; + const sideVals = [a, b, c]; + const colors = ['#60a5fa', '#34d399', '#fbbf24']; + + // Draw radius lines from center to vertices + ctx.strokeStyle = 'rgba(96,165,250,0.25)'; ctx.lineWidth = 1; + verts.forEach(v => { + ctx.beginPath(); ctx.moveTo(O.x, O.y); ctx.lineTo(v.x, v.y); ctx.stroke(); + }); + + // Compute 2R + const twoR = 2 * R / S; + + // For each side, show a/sinA value annotation near the side midpoint + for (let i = 0; i < 3; i++) { + const ratio = (sideVals[i] / S) / Math.sin(angVals[i]); + const from = verts[(i + 1) % 3], to = verts[(i + 2) % 3]; + const mx = (from.x + to.x) / 2, my = (from.y + to.y) / 2; + + // offset outward from centroid + const cx_ = (A.x + B.x + C.x) / 3, cy_ = (A.y + B.y + C.y) / 3; + const dx = mx - cx_, dy = my - cy_; + const dl = Math.hypot(dx, dy) || 1; + const ox = dx / dl * 38, oy = dy / dl * 38; + + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.fillStyle = colors[i]; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.shadowColor = colors[i]; ctx.shadowBlur = 4; + ctx.fillText(`${sideNames[i]}/sin${angNames[i]} = ${ratio.toFixed(2)}`, mx + ox, my + oy); + ctx.shadowBlur = 0; + } + + // Formula box at bottom + this._drawFormulaBox(ctx, this.W, this.H, + `a/sinA = b/sinB = c/sinC = 2R = ${twoR.toFixed(2)}`, + '#60a5fa'); + + ctx.restore(); + } + + /* ── Law of Cosines: c² = a² + b² − 2ab·cosC ── */ + _drawCosineLaw(ctx) { + const [A, B, C] = this.pts; + const sides = this._sides(); + const angles = this._angles(); + const S = TriangleSim.SCALE; + + // Pick the largest angle to demonstrate + const angArr = [angles.A, angles.B, angles.C]; + const maxIdx = angArr.indexOf(Math.max(...angArr)); + const angVertex = this.pts[maxIdx]; + const oppFrom = this.pts[(maxIdx + 1) % 3]; + const oppTo = this.pts[(maxIdx + 2) % 3]; + + const sNames = ['a', 'b', 'c']; + const aNames = ['A', 'B', 'C']; + const sVals = [sides.a, sides.b, sides.c]; + + // The opposite side to the chosen angle + const oppSide = sVals[maxIdx] / S; + const adjSide1Name = sNames[(maxIdx + 1) % 3]; // side opposite to vertex (maxIdx+1) + const adjSide2Name = sNames[(maxIdx + 2) % 3]; // side opposite to vertex (maxIdx+2) + const adjSide1 = sVals[(maxIdx + 1) % 3] / S; + const adjSide2 = sVals[(maxIdx + 2) % 3] / S; + const oppSideName = sNames[maxIdx]; + const angName = aNames[maxIdx]; + const angDeg = angArr[maxIdx] * 180 / Math.PI; + + ctx.save(); + + // Highlight the two adjacent sides with thicker lines + ctx.lineWidth = 3.5; ctx.lineCap = 'round'; + + ctx.strokeStyle = 'rgba(251,191,36,0.7)'; + ctx.shadowColor = '#fbbf24'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.moveTo(angVertex.x, angVertex.y); ctx.lineTo(oppFrom.x, oppFrom.y); ctx.stroke(); + + ctx.strokeStyle = 'rgba(52,211,153,0.7)'; + ctx.shadowColor = '#34d399'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.moveTo(angVertex.x, angVertex.y); ctx.lineTo(oppTo.x, oppTo.y); ctx.stroke(); + + // Highlight the opposite side + ctx.strokeStyle = 'rgba(239,71,111,0.7)'; + ctx.shadowColor = '#EF476F'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.moveTo(oppFrom.x, oppFrom.y); ctx.lineTo(oppTo.x, oppTo.y); ctx.stroke(); + ctx.shadowBlur = 0; + + // Angle arc highlight at the chosen vertex + const r = 36; + const a1 = Math.atan2(oppFrom.y - angVertex.y, oppFrom.x - angVertex.x); + const a2 = Math.atan2(oppTo.y - angVertex.y, oppTo.x - angVertex.x); + let diff = a2 - a1; + while (diff > Math.PI) diff -= 2 * Math.PI; + while (diff < -Math.PI) diff += 2 * Math.PI; + const ccw = diff < 0; + + ctx.fillStyle = 'rgba(251,191,36,0.15)'; + ctx.beginPath(); + ctx.moveTo(angVertex.x, angVertex.y); + ctx.arc(angVertex.x, angVertex.y, r, a1, a2, ccw); + ctx.closePath(); ctx.fill(); + + ctx.strokeStyle = 'rgba(251,191,36,0.6)'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(angVertex.x, angVertex.y, r, a1, a2, ccw); ctx.stroke(); + + // Angle label + const aMid = a1 + diff / 2; + ctx.font = 'bold 12px Manrope, sans-serif'; + ctx.fillStyle = '#fbbf24'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(`${angDeg.toFixed(1)}°`, angVertex.x + Math.cos(aMid) * 50, angVertex.y + Math.sin(aMid) * 50); + + // Compute c² vs a²+b²-2ab·cosC + const c2 = oppSide ** 2; + const check = adjSide1 ** 2 + adjSide2 ** 2 - 2 * adjSide1 * adjSide2 * Math.cos(angArr[maxIdx]); + + // Formula + this._drawFormulaBox(ctx, this.W, this.H, + `${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² − 2·${adjSide1Name}·${adjSide2Name}·cos${angName} ${c2.toFixed(2)} = ${check.toFixed(2)}`, + '#fbbf24'); + + ctx.restore(); + } + + /* ── Pythagorean theorem: c² = a² + b² ── */ + _drawPythagorean(ctx) { + const [A, B, C] = this.pts; + const { a, b, c } = this._sides(); + const angles = this._angles(); + const S = TriangleSim.SCALE; + + // Find the largest angle (closest to being right) + const angArr = [angles.A, angles.B, angles.C]; + const maxIdx = angArr.indexOf(Math.max(...angArr)); + const maxAngle = angArr[maxIdx]; + const isRight = Math.abs(maxAngle - Math.PI / 2) < 0.035; + + const hyp = this.pts[maxIdx]; // vertex at the largest angle + const p1 = this.pts[(maxIdx + 1) % 3]; + const p2 = this.pts[(maxIdx + 2) % 3]; + + // Side lengths (the side opposite the right angle is the hypotenuse) + const sNames = ['a', 'b', 'c']; + const sVals = [a / S, b / S, c / S]; + const hypSide = sVals[maxIdx]; + const leg1 = sVals[(maxIdx + 1) % 3]; + const leg2 = sVals[(maxIdx + 2) % 3]; + const hypName = sNames[maxIdx]; + const leg1Name = sNames[(maxIdx + 1) % 3]; + const leg2Name = sNames[(maxIdx + 2) % 3]; + + ctx.save(); + + // Draw squares on each side + this._drawSideSquare(ctx, p1, p2, '#EF476F', 0.12); // hypotenuse + this._drawSideSquare(ctx, hyp, p2, '#06D6E0', 0.12); // leg 1 + this._drawSideSquare(ctx, hyp, p1, '#9B5DE5', 0.12); // leg 2 + + // Labels on squares with areas + const hypArea = hypSide ** 2; + const leg1Area = leg1 ** 2; + const leg2Area = leg2 ** 2; + + this._labelSquare(ctx, p1, p2, `${hypName}² = ${hypArea.toFixed(2)}`, '#EF476F'); + this._labelSquare(ctx, hyp, p2, `${leg1Name}² = ${leg1Area.toFixed(2)}`, '#06D6E0'); + this._labelSquare(ctx, hyp, p1, `${leg2Name}² = ${leg2Area.toFixed(2)}`, '#9B5DE5'); + + // Status: how close to Pythagorean + 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)})` + : `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)} ≠ ${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`; + + this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol); + + // Hint if not right angle + if (!isRight) { + ctx.font = '11px Manrope, sans-serif'; + ctx.fillStyle = 'rgba(245,158,11,0.7)'; + ctx.textAlign = 'center'; + ctx.fillText('Перетащи вершину чтобы ∠ ≈ 90°', this.W / 2, this.H - 16); + } + + ctx.restore(); + } + + /* ── Helpers for theorem visuals ── */ + + _drawSideSquare(ctx, p1, p2, color, alpha) { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + // perpendicular direction (outward from triangle centroid) + const cx_ = (this.pts[0].x + this.pts[1].x + this.pts[2].x) / 3; + const cy_ = (this.pts[0].y + this.pts[1].y + this.pts[2].y) / 3; + let nx = -dy, ny = dx; // perpendicular + // ensure outward + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + if ((mx + nx * 0.1 - cx_) * nx + (my + ny * 0.1 - cy_) * ny < 0) { nx = -nx; ny = -ny; } + + const q1 = { x: p1.x + nx, y: p1.y + ny }; + const q2 = { x: p2.x + nx, y: p2.y + ny }; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + ctx.lineTo(p2.x, p2.y); + ctx.lineTo(q2.x, q2.y); + ctx.lineTo(q1.x, q1.y); + ctx.closePath(); + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.5; + ctx.beginPath(); + ctx.moveTo(p1.x, p1.y); + ctx.lineTo(p2.x, p2.y); + ctx.lineTo(q2.x, q2.y); + ctx.lineTo(q1.x, q1.y); + ctx.closePath(); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.restore(); + } + + _labelSquare(ctx, p1, p2, text, color) { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const cx_ = (this.pts[0].x + this.pts[1].x + this.pts[2].x) / 3; + const cy_ = (this.pts[0].y + this.pts[1].y + this.pts[2].y) / 3; + let nx = -dy, ny = dx; + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + if ((mx + nx * 0.1 - cx_) * nx + (my + ny * 0.1 - cy_) * ny < 0) { nx = -nx; ny = -ny; } + + const center = { x: mx + nx * 0.5, y: my + ny * 0.5 }; + + ctx.save(); + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.fillStyle = color; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.shadowColor = 'rgba(0,0,0,0.7)'; ctx.shadowBlur = 4; + ctx.fillText(text, center.x, center.y); + ctx.restore(); + } + + _drawFormulaBox(ctx, W, H, text, color) { + ctx.save(); + const bw = ctx.measureText(text).width || 300; + const pad = 12; + const boxW = Math.min(W - 40, bw + pad * 2 + 20); + const boxH = 28; + const bx = (W - boxW) / 2; + const by = H - 42; + + ctx.font = 'bold 12px Manrope, monospace'; + + // background + ctx.fillStyle = 'rgba(7,7,26,0.85)'; + ctx.strokeStyle = color; + ctx.lineWidth = 1.2; + ctx.globalAlpha = 0.9; + const r = 8; + ctx.beginPath(); + ctx.moveTo(bx + r, by); ctx.lineTo(bx + boxW - r, by); + ctx.quadraticCurveTo(bx + boxW, by, bx + boxW, by + r); + ctx.lineTo(bx + boxW, by + boxH - r); + ctx.quadraticCurveTo(bx + boxW, by + boxH, bx + boxW - r, by + boxH); + ctx.lineTo(bx + r, by + boxH); + ctx.quadraticCurveTo(bx, by + boxH, bx, by + boxH - r); + ctx.lineTo(bx, by + r); + ctx.quadraticCurveTo(bx, by, bx + r, by); + ctx.closePath(); + ctx.fill(); ctx.stroke(); + ctx.globalAlpha = 1; + + // text + ctx.fillStyle = color; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.shadowColor = color; ctx.shadowBlur = 6; + ctx.fillText(text, W / 2, by + boxH / 2); + ctx.restore(); + } +} diff --git a/frontend/js/labs/trigcircle.js b/frontend/js/labs/trigcircle.js new file mode 100644 index 0000000..c51b339 --- /dev/null +++ b/frontend/js/labs/trigcircle.js @@ -0,0 +1,969 @@ +'use strict'; + +/* ═══════════════════════════════════════════════════════════════════════ + TrigCircleSim — premium interactive unit-circle + graph visualisation + v3 — maximum polish + ═══════════════════════════════════════════════════════════════════════ */ + +const _TC_NOTABLE = [ + { a: 0, l: '0', d: '0°' }, + { a: Math.PI / 6, l: 'π/6', d: '30°' }, + { a: Math.PI / 4, l: 'π/4', d: '45°' }, + { a: Math.PI / 3, l: 'π/3', d: '60°' }, + { a: Math.PI / 2, l: 'π/2', d: '90°' }, + { a: 2*Math.PI / 3, l: '2π/3', d: '120°' }, + { a: 3*Math.PI / 4, l: '3π/4', d: '135°' }, + { a: 5*Math.PI / 6, l: '5π/6', d: '150°' }, + { a: Math.PI, l: 'π', d: '180°' }, + { a: 7*Math.PI / 6, l: '7π/6', d: '210°' }, + { a: 5*Math.PI / 4, l: '5π/4', d: '225°' }, + { a: 4*Math.PI / 3, l: '4π/3', d: '240°' }, + { a: 3*Math.PI / 2, l: '3π/2', d: '270°' }, + { a: 5*Math.PI / 3, l: '5π/3', d: '300°' }, + { a: 7*Math.PI / 4, l: '7π/4', d: '315°' }, + { a: 11*Math.PI / 6, l: '11π/6', d: '330°' }, +]; + +const _TC = { + sin: '#EF476F', cos: '#06D6E0', tan: '#FFD166', cot: '#7BF5A4', + point: '#9B5DE5', violet: '#9B5DE5', +}; + +function _tcRgb(hex) { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} +function _tcRgba(hex, a) { + const [r, g, b] = _tcRgb(hex); + return `rgba(${r},${g},${b},${a})`; +} + +class TrigCircleSim { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.W = 0; this.H = 0; this.dpr = 1; + + this.angle = Math.PI / 4; + this.showSin = true; + this.showCos = true; + this.showTan = false; + this.showCot = false; + this.showGraph = true; + this.graphFn = 'sin'; + this.snapToNotable = true; + this.animating = false; + + this._cx = 0; this._cy = 0; this._r = 0; + this._gx = 0; this._gw = 0; this._gh = 0; this._gy = 0; + + this._drag = false; + this._hover = false; + this._raf = null; + this._animTarget = null; + this._animSpeed = 3; + this._idlePulse = 0; + this._idleRaf = null; + + /* snap particles */ + this._particles = []; + this._lastSnap = -1; + + this.onUpdate = null; + + this._bindEvents(); + this._ro = new ResizeObserver(() => { this.fit(); this.draw(); }); + this._ro.observe(canvas.parentElement); + } + + /* ═══ Public ═══════════════════════════════════════════════════════ */ + + fit() { + const p = this.canvas.parentElement.getBoundingClientRect(); + this.dpr = window.devicePixelRatio || 1; + this.W = p.width || 800; this.H = p.height || 500; + this.canvas.width = this.W * this.dpr; + this.canvas.height = this.H * this.dpr; + this.canvas.style.width = this.W + 'px'; + this.canvas.style.height = this.H + 'px'; + this._layout(); + } + + draw() { + const c = this.ctx; + c.save(); c.scale(this.dpr, this.dpr); + c.clearRect(0, 0, this.W, this.H); + + this._drawBg(c); + this._drawCircle(c); + if (this.showGraph) { this._drawDivider(c); this._drawGraph(c); } + this._drawParticles(c); + + c.restore(); + this._fireUpdate(); + } + + setAngle(a) { this.angle = this._norm(a); this.draw(); } + setGraphFn(f){ this.graphFn = f; this.draw(); } + + toggleLayer(n, v) { + if (n === 'sin') this.showSin = v; + if (n === 'cos') this.showCos = v; + if (n === 'tan') this.showTan = v; + if (n === 'cot') this.showCot = v; + if (n === 'graph') this.showGraph = v; + this._layout(); this.draw(); + } + + goToAngle(rad) { + this._animTarget = this._norm(rad); + if (!this.animating) this._startAnim(); + } + + start() { this._startIdle(); } + stop() { this._stopAnim(); this._stopIdle(); } + + stats() { + const a = this.angle, s = Math.sin(a), co = Math.cos(a); + const t = Math.abs(co) > 1e-9 ? s / co : undefined; + const ct = Math.abs(s) > 1e-9 ? co / s : undefined; + const deg = a * 180 / Math.PI; + const q = a < Math.PI/2 ? 1 : a < Math.PI ? 2 : a < 3*Math.PI/2 ? 3 : 4; + return { angle: a, deg, radLabel: this._radLbl(a), sin: s, cos: co, tan: t, cot: ct, quadrant: q }; + } + + /* ═══ Layout ═══════════════════════════════════════════════════════ */ + + _layout() { + const m = 44; + if (this.showGraph) { + const cW = this.W * 0.50; + this._r = Math.min(cW - m * 2, this.H - m * 2) / 2 * 0.76; + this._cx = cW / 2; + this._cy = this.H / 2; + this._gx = cW + 24; + this._gw = this.W - this._gx - m; + this._gh = this.H - m * 2; + this._gy = m; + } else { + this._r = Math.min(this.W - m * 2, this.H - m * 2) / 2 * 0.76; + this._cx = this.W / 2; + this._cy = this.H / 2; + } + this._r = Math.max(55, this._r); + } + + /* ═══ Background ═══════════════════════════════════════════════════ */ + + _drawBg(c) { + const g = c.createRadialGradient(this._cx, this._cy, 0, this._cx, this._cy, this._r * 2.4); + g.addColorStop(0, 'rgba(155,93,229,0.055)'); + g.addColorStop(0.5,'rgba(155,93,229,0.02)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + c.fillStyle = g; c.fillRect(0, 0, this.W, this.H); + + /* decorative rings */ + c.strokeStyle = 'rgba(255,255,255,0.016)'; c.lineWidth = 1; + for (let i = 1; i <= 3; i++) { + c.beginPath(); c.arc(this._cx, this._cy, this._r * (0.5 + i * 0.35), 0, Math.PI * 2); + c.stroke(); + } + } + + /* ═══ Unit Circle ══════════════════════════════════════════════════ */ + + _drawCircle(c) { + const cx = this._cx, cy = this._cy, r = this._r; + const a = this.angle; + const cosA = Math.cos(a), sinA = Math.sin(a); + const px = cx + r * cosA, py = cy - r * sinA; + const ext = Math.min(55, r * 0.35); + + /* ── quadrant soft fill ── */ + const q = this.stats().quadrant; + const qS = [0, Math.PI/2, Math.PI, 3*Math.PI/2][q-1]; + c.fillStyle = 'rgba(155,93,229,0.022)'; + c.beginPath(); c.moveTo(cx, cy); + c.arc(cx, cy, r, -(qS + Math.PI/2), -qS); + c.closePath(); c.fill(); + + /* ── degree tick marks (every 10°, bigger every 30°) ── */ + for (let deg = 0; deg < 360; deg += 10) { + const rad = deg * Math.PI / 180; + const big = deg % 30 === 0; + const len = big ? 8 : 4; + const x1 = cx + (r - len) * Math.cos(rad); + const y1 = cy - (r - len) * Math.sin(rad); + const x2 = cx + r * Math.cos(rad); + const y2 = cy - r * Math.sin(rad); + c.strokeStyle = big ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.05)'; + c.lineWidth = big ? 1.5 : 1; + c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke(); + } + + /* ── axes (gradient fade) ── */ + const axGrad = (x1,y1,x2,y2) => { + const g = c.createLinearGradient(x1,y1,x2,y2); + g.addColorStop(0, 'rgba(255,255,255,0.0)'); + g.addColorStop(0.08,'rgba(255,255,255,0.30)'); + g.addColorStop(0.5, 'rgba(255,255,255,0.50)'); + g.addColorStop(0.92,'rgba(255,255,255,0.30)'); + g.addColorStop(1, 'rgba(255,255,255,0.0)'); + return g; + }; + c.lineWidth = 1.5; + c.strokeStyle = axGrad(cx - r - ext, cy, cx + r + ext, cy); + c.beginPath(); c.moveTo(cx - r - ext, cy); c.lineTo(cx + r + ext, cy); c.stroke(); + c.strokeStyle = axGrad(cx, cy + r + ext, cx, cy - r - ext); + c.beginPath(); c.moveTo(cx, cy + r + ext); c.lineTo(cx, cy - r - ext); c.stroke(); + + /* arrows */ + this._arrowH(c, cx + r + ext, cy, 0, 'rgba(255,255,255,0.5)'); + this._arrowH(c, cx, cy - r - ext, -Math.PI/2, 'rgba(255,255,255,0.5)'); + + /* axis labels */ + c.font = '700 13px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.45)'; + c.textAlign = 'left'; c.textBaseline = 'top'; + c.fillText('x', cx + r + ext - 12, cy + 8); + c.textAlign = 'right'; c.textBaseline = 'bottom'; + c.fillText('y', cx - 10, cy - r - ext + 16); + + /* ±1 ticks & labels */ + c.strokeStyle = 'rgba(255,255,255,0.30)'; c.lineWidth = 1.5; + c.font = '600 11px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.45)'; + const tk = 6; + c.beginPath(); c.moveTo(cx+r, cy-tk); c.lineTo(cx+r, cy+tk); c.stroke(); + c.textAlign='center'; c.textBaseline='top'; c.fillText('1', cx+r, cy+9); + c.beginPath(); c.moveTo(cx-r, cy-tk); c.lineTo(cx-r, cy+tk); c.stroke(); + c.fillText('−1', cx-r, cy+9); + c.beginPath(); c.moveTo(cx-tk, cy-r); c.lineTo(cx+tk, cy-r); c.stroke(); + c.textAlign='right'; c.textBaseline='middle'; c.fillText('1', cx-10, cy-r); + c.beginPath(); c.moveTo(cx-tk, cy+r); c.lineTo(cx+tk, cy+r); c.stroke(); + c.fillText('−1', cx-10, cy+r); + + /* origin dot */ + c.fillStyle = 'rgba(255,255,255,0.35)'; + c.beginPath(); c.arc(cx, cy, 2.5, 0, Math.PI*2); c.fill(); + + /* ── unit circle (multi-layer) ── */ + c.strokeStyle = _tcRgba(_TC.violet, 0.05 + Math.sin(this._idlePulse) * 0.02); + c.lineWidth = 14; + c.beginPath(); c.arc(cx, cy, r, 0, Math.PI*2); c.stroke(); + c.strokeStyle = 'rgba(255,255,255,0.13)'; c.lineWidth = 2; + c.beginPath(); c.arc(cx, cy, r, 0, Math.PI*2); c.stroke(); + + /* ── notable angle dots + labels ── */ + for (const n of _TC_NOTABLE) { + const nx = cx + r * Math.cos(n.a), ny = cy - r * Math.sin(n.a); + const act = Math.abs(a - n.a) < 0.03; + if (act) { + c.fillStyle = _tcRgba(_TC.violet, 0.5); + c.shadowColor = _TC.violet; c.shadowBlur = 10; + c.beginPath(); c.arc(nx, ny, 5, 0, Math.PI*2); c.fill(); + c.shadowBlur = 0; + c.strokeStyle = _TC.violet; c.lineWidth = 1.5; + c.beginPath(); c.arc(nx, ny, 5, 0, Math.PI*2); c.stroke(); + } else { + c.fillStyle = 'rgba(255,255,255,0.12)'; + c.beginPath(); c.arc(nx, ny, 2.5, 0, Math.PI*2); c.fill(); + } + if (n.l && n.l !== '0') { + const d = act ? 24 : 20; + const lx = cx + (r + d) * Math.cos(n.a); + const ly = cy - (r + d) * Math.sin(n.a); + c.font = act ? '700 11px Manrope,sans-serif' : '400 9px Manrope,sans-serif'; + c.fillStyle = act ? _tcRgba(_TC.violet, 0.95) : 'rgba(255,255,255,0.18)'; + c.textAlign = 'center'; c.textBaseline = 'middle'; + c.fillText(n.l, lx, ly); + } + } + + /* ── angle arc ── */ + if (a > 0.015) { + const ar = Math.min(r * 0.22, 44); + c.fillStyle = _tcRgba(_TC.violet, 0.06); + c.beginPath(); c.moveTo(cx, cy); c.arc(cx, cy, ar, 0, -a, true); c.closePath(); c.fill(); + const ag = c.createConicGradient(0, cx, cy); + ag.addColorStop(0, _tcRgba(_TC.violet, 0.7)); + ag.addColorStop(Math.min(a / (Math.PI*2), 0.99), _tcRgba(_TC.violet, 0.25)); + ag.addColorStop(1, _tcRgba(_TC.violet, 0.0)); + c.strokeStyle = ag; c.lineWidth = 2.5; + c.beginPath(); c.arc(cx, cy, ar, 0, -a, true); c.stroke(); + /* label */ + const mid = a / 2, lr = ar + 18; + c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.violet; + c.textAlign = 'center'; c.textBaseline = 'middle'; + c.fillText(this._radLbl(a), cx + lr * Math.cos(-mid), cy + lr * Math.sin(-mid)); + } + + /* ── radius ── */ + const rg = c.createLinearGradient(cx, cy, px, py); + rg.addColorStop(0, 'rgba(255,255,255,0.12)'); rg.addColorStop(1, 'rgba(255,255,255,0.40)'); + c.strokeStyle = rg; c.lineWidth = 1.5; + c.beginPath(); c.moveTo(cx, cy); c.lineTo(px, py); c.stroke(); + + /* ── projection dashes ── */ + c.strokeStyle = 'rgba(255,255,255,0.08)'; c.lineWidth = 1; + c.setLineDash([4, 4]); + c.beginPath(); c.moveTo(px, py); c.lineTo(px, cy); c.stroke(); + c.beginPath(); c.moveTo(px, py); c.lineTo(cx, py); c.stroke(); + c.setLineDash([]); + + const projX = cx + r * cosA; + + /* ── triangle fill (sin+cos) ── */ + if (this.showSin && this.showCos && Math.abs(cosA) > 0.04 && Math.abs(sinA) > 0.04) { + c.fillStyle = 'rgba(155,93,229,0.035)'; + c.beginPath(); c.moveTo(cx, cy); c.lineTo(projX, cy); c.lineTo(px, py); c.closePath(); c.fill(); + } + + /* ═══ trig segments ═══ */ + + if (this.showCos) { + this._glowLine(c, cx, cy, projX, cy, _TC.cos, 4); + c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cos; + c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'top' : 'bottom'; + c.fillText('cos', (cx + projX) / 2, cy + (sinA >= 0 ? 12 : -12)); + } + + if (this.showSin) { + this._glowLine(c, projX, cy, px, py, _TC.sin, 4); + c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.sin; + c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle'; + c.fillText('sin', projX + (cosA >= 0 ? 9 : -9), (cy + py) / 2); + } + + if (this.showTan && Math.abs(cosA) > 0.025) { + const tanV = sinA / cosA; + if (Math.abs(tanV) < 10) { + const tX = cosA >= 0 ? cx + r : cx - r; + const tY = cosA >= 0 ? cy - r * tanV : cy + r * tanV; + /* faint tangent guide line */ + c.strokeStyle = _tcRgba(_TC.tan, 0.06); c.lineWidth = 1; + c.beginPath(); c.moveTo(tX, cy - r - ext); c.lineTo(tX, cy + r + ext); c.stroke(); + this._glowLine(c, tX, cy, tX, tY, _TC.tan, 3.5); + c.strokeStyle = _tcRgba(_TC.tan, 0.18); c.lineWidth = 1; + c.setLineDash([5, 4]); c.beginPath(); c.moveTo(px, py); c.lineTo(tX, tY); c.stroke(); c.setLineDash([]); + c.fillStyle = _TC.tan; c.shadowColor = _TC.tan; c.shadowBlur = 8; + c.beginPath(); c.arc(tX, tY, 4.5, 0, Math.PI*2); c.fill(); c.shadowBlur = 0; + c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.tan; + c.textAlign = cosA >= 0 ? 'left' : 'right'; c.textBaseline = 'middle'; + c.fillText('tg', tX + (cosA >= 0 ? 8 : -8), (cy + tY) / 2); + } + } + + if (this.showCot && Math.abs(sinA) > 0.025) { + const cotV = cosA / sinA; + if (Math.abs(cotV) < 10) { + const cX = sinA >= 0 ? cx + r * cotV : cx - r * cotV; + const cY = sinA >= 0 ? cy - r : cy + r; + c.strokeStyle = _tcRgba(_TC.cot, 0.06); c.lineWidth = 1; + c.beginPath(); c.moveTo(cx - r - ext, cY); c.lineTo(cx + r + ext, cY); c.stroke(); + this._glowLine(c, cx, cY, cX, cY, _TC.cot, 3.5); + c.strokeStyle = _tcRgba(_TC.cot, 0.18); c.lineWidth = 1; + c.setLineDash([5, 4]); c.beginPath(); c.moveTo(px, py); c.lineTo(cX, cY); c.stroke(); c.setLineDash([]); + c.fillStyle = _TC.cot; c.shadowColor = _TC.cot; c.shadowBlur = 8; + c.beginPath(); c.arc(cX, cY, 4.5, 0, Math.PI*2); c.fill(); c.shadowBlur = 0; + c.font = 'bold 12px Manrope,sans-serif'; c.fillStyle = _TC.cot; + c.textAlign = 'center'; c.textBaseline = sinA >= 0 ? 'bottom' : 'top'; + c.fillText('ctg', (cx + cX) / 2, cY + (sinA >= 0 ? -8 : 8)); + } + } + + /* ── right-angle marker ── */ + if (this.showSin && this.showCos && Math.abs(cosA) > 0.06 && Math.abs(sinA) > 0.06) { + const sz = 8, dx = cosA > 0 ? -sz : sz, dy = sinA > 0 ? sz : -sz; + c.strokeStyle = 'rgba(255,255,255,0.18)'; c.lineWidth = 1; + c.beginPath(); c.moveTo(projX+dx, cy); c.lineTo(projX+dx, cy-dy); c.lineTo(projX, cy-dy); c.stroke(); + } + + /* ── axis value badges ── */ + if (this.showSin && Math.abs(sinA) > 0.04) + this._badge(c, cx - 12, py, this._fmt(sinA), _TC.sin, 'right', 'middle'); + if (this.showCos && Math.abs(cosA) > 0.04) + this._badge(c, projX, cy + 17, this._fmt(cosA), _TC.cos, 'center', 'top'); + + /* ── main point ── */ + const ps = this._hover || this._drag ? 10 : 8; + const blur = this._hover || this._drag ? 22 : 16; + c.fillStyle = _tcRgba(_TC.point, 0.10); + c.beginPath(); c.arc(px, py, ps + 10, 0, Math.PI*2); c.fill(); + c.shadowColor = _TC.point; c.shadowBlur = blur; + c.fillStyle = _TC.point; + c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.fill(); + c.shadowBlur = 0; + c.fillStyle = 'rgba(255,255,255,0.85)'; + c.beginPath(); c.arc(px, py, ps * 0.35, 0, Math.PI*2); c.fill(); + c.strokeStyle = 'rgba(255,255,255,0.50)'; c.lineWidth = 2; + c.beginPath(); c.arc(px, py, ps, 0, Math.PI*2); c.stroke(); + + /* ── coordinate tooltip ── */ + this._tooltip(c, px, py, cosA, sinA); + + /* ── quadrant roman numeral ── */ + const qOff = r * 0.46; + const qx = (q===1||q===4) ? cx+qOff : cx-qOff; + const qy = (q<=2) ? cy-qOff : cy+qOff; + c.font = 'bold 22px Manrope,sans-serif'; c.fillStyle = _tcRgba(_TC.violet, 0.07); + c.textAlign = 'center'; c.textBaseline = 'middle'; + c.fillText(['I','II','III','IV'][q-1]||'', qx, qy); + + /* ── sign pills per quadrant ── */ + this._quadSigns(c, cx, cy, r); + + /* ── Pythagorean identity bar ── */ + this._pythBar(c); + + /* ── connection line to graph ── */ + if (this.showGraph) this._connLine(c, px, py, sinA, cosA); + } + + /* ═══ Connection line: circle graph ═══════════════════════════════ */ + + _connLine(c, px, py, sinA, cosA) { + const fn = this.graphFn; + const val = fn === 'sin' ? sinA : fn === 'cos' ? cosA : + fn === 'tan' ? (Math.abs(cosA)>0.02 ? sinA/cosA : null) : + (Math.abs(sinA)>0.02 ? cosA/sinA : null); + if (val === null || !isFinite(val)) return; + + const yR = (fn === 'tan' || fn === 'cot') ? 4 : 1.5; + if (Math.abs(val) > yR * 2) return; + + const gy = this._gy, gh = this._gh; + const targetY = gy + gh/2 - val / yR * (gh/2); + + /* source Y = py for sin, cy for cos, depends on fn */ + const srcY = (fn === 'sin') ? py : (fn === 'cos') ? this._cy : py; + const srcX = (fn === 'sin' || fn === 'tan') ? px : this._cx; + + c.strokeStyle = _tcRgba(_TC[fn] || _TC.sin, 0.12); + c.lineWidth = 1; + c.setLineDash([3, 5]); + c.beginPath(); c.moveTo(srcX, srcY); c.lineTo(this._gx, targetY); c.stroke(); + c.setLineDash([]); + } + + /* ═══ Quadrant sign pills ═══════════════════════════════════════════ */ + + _quadSigns(c, cx, cy, r) { + const signs = [ + { q: 1, s:'+', co:'+', t:'+' }, { q: 2, s:'+', co:'−', t:'−' }, + { q: 3, s:'−', co:'−', t:'+' }, { q: 4, s:'−', co:'+', t:'−' }, + ]; + const curr = this.stats().quadrant; + const off = r * 0.78; + for (const sg of signs) { + const sx = (sg.q===1||sg.q===4) ? cx+off : cx-off; + const sy = (sg.q<=2) ? cy-off : cy+off; + const isCurr = sg.q === curr; + c.font = '500 8px Manrope,sans-serif'; + c.fillStyle = isCurr ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.07)'; + c.textAlign = 'center'; c.textBaseline = 'middle'; + const txt = `s${sg.s} c${sg.co} t${sg.t}`; + c.fillText(txt, sx, sy); + } + } + + /* ═══ Pythagorean identity bar ══════════════════════════════════════ */ + + _pythBar(c) { + const s = Math.sin(this.angle), co = Math.cos(this.angle); + const sin2 = s * s, cos2 = co * co; + const bw = Math.min(this._r * 1.4, 180); + const bh = 6; + const bx = this._cx - bw / 2; + const by = this._cy + this._r + 38; + if (by + bh + 16 > this.H) return; + + /* background track */ + c.fillStyle = 'rgba(255,255,255,0.04)'; + c.beginPath(); c.roundRect(bx, by, bw, bh, 3); c.fill(); + + /* sin² portion */ + const sw = bw * sin2; + if (sw > 0.5) { + c.fillStyle = _tcRgba(_TC.sin, 0.5); + c.beginPath(); c.roundRect(bx, by, sw, bh, [3,0,0,3]); c.fill(); + } + + /* cos² portion */ + const cw = bw * cos2; + if (cw > 0.5) { + c.fillStyle = _tcRgba(_TC.cos, 0.5); + c.beginPath(); c.roundRect(bx + sw, by, cw, bh, [0,3,3,0]); c.fill(); + } + + /* label */ + c.font = '500 9px Manrope,sans-serif'; c.fillStyle = 'rgba(255,255,255,0.25)'; + c.textAlign = 'center'; c.textBaseline = 'top'; + c.fillText(`sin² + cos² = 1`, this._cx, by + bh + 4); + } + + /* ═══ Divider ══════════════════════════════════════════════════════ */ + + _drawDivider(c) { + const x = this._gx - 14; + const pad = 20; + const lg = c.createLinearGradient(x, pad, x, this.H - pad); + lg.addColorStop(0, 'rgba(155,93,229,0.0)'); + lg.addColorStop(0.15,'rgba(155,93,229,0.18)'); + lg.addColorStop(0.5, 'rgba(155,93,229,0.28)'); + lg.addColorStop(0.85,'rgba(155,93,229,0.18)'); + lg.addColorStop(1, 'rgba(155,93,229,0.0)'); + c.strokeStyle = lg; c.lineWidth = 1; + c.beginPath(); c.moveTo(x, pad); c.lineTo(x, this.H - pad); c.stroke(); + /* glow */ + const gg = c.createLinearGradient(x - 16, 0, x + 16, 0); + gg.addColorStop(0, 'rgba(155,93,229,0.0)'); + gg.addColorStop(0.5, 'rgba(155,93,229,0.035)'); + gg.addColorStop(1, 'rgba(155,93,229,0.0)'); + c.fillStyle = gg; c.fillRect(x - 16, pad, 32, this.H - pad*2); + } + + /* ═══ Graph ════════════════════════════════════════════════════════ */ + + _drawGraph(c) { + const gx = this._gx, gy = this._gy, gw = this._gw, gh = this._gh; + if (gw < 50 || gh < 50) return; + + const fn = this.graphFn; + const col = _TC[fn] || _TC.sin; + const lbl = fn==='sin'?'y = sin x':fn==='cos'?'y = cos x':fn==='tan'?'y = tg x':'y = ctg x'; + const evFn = fn==='sin'?Math.sin:fn==='cos'?Math.cos:fn==='tan'?Math.tan:(x=>1/Math.tan(x)); + const yR = (fn==='tan'||fn==='cot') ? 4 : 1.5; + const xMin = -0.25*Math.PI, xMax = 2.25*Math.PI; + const sx = x => gx + (x-xMin)/(xMax-xMin)*gw; + const sy = y => gy + gh/2 - y/yR*(gh/2); + + /* ── glass panel ── */ + const pp = 12; + const px1 = gx-pp, py1 = gy-pp, pw = gw+pp*2, ph = gh+pp*2; + c.fillStyle = 'rgba(10,10,20,0.50)'; + c.beginPath(); c.roundRect(px1, py1, pw, ph, 20); c.fill(); + /* gradient border */ + const bg = c.createLinearGradient(px1, py1, px1+pw, py1+ph); + bg.addColorStop(0, 'rgba(155,93,229,0.18)'); + bg.addColorStop(0.3,'rgba(255,255,255,0.06)'); + bg.addColorStop(0.7,'rgba(255,255,255,0.06)'); + bg.addColorStop(1, 'rgba(155,93,229,0.18)'); + c.strokeStyle = bg; c.lineWidth = 1.5; + c.beginPath(); c.roundRect(px1, py1, pw, ph, 20); c.stroke(); + /* top highlight */ + const hg = c.createLinearGradient(px1, py1, px1, py1+50); + hg.addColorStop(0, 'rgba(255,255,255,0.025)'); hg.addColorStop(1, 'rgba(255,255,255,0.0)'); + c.fillStyle = hg; + c.beginPath(); c.roundRect(px1+1, py1+1, pw-2, 50, [20,20,0,0]); c.fill(); + + /* ── zero axis ── */ + const zy = sy(0); + c.strokeStyle = 'rgba(255,255,255,0.14)'; c.lineWidth = 1; + c.beginPath(); c.moveTo(gx, zy); c.lineTo(gx+gw, zy); c.stroke(); + /* y-axis on graph */ + const x0 = sx(0); + if (x0 > gx + 4 && x0 < gx + gw - 4) { + c.strokeStyle = 'rgba(255,255,255,0.08)'; c.lineWidth = 1; + c.beginPath(); c.moveTo(x0, gy); c.lineTo(x0, gy+gh); c.stroke(); + } + + /* ±1 lines */ + if (fn==='sin'||fn==='cos') { + c.strokeStyle = 'rgba(255,255,255,0.05)'; c.setLineDash([4, 4]); + [1,-1].forEach(v => { c.beginPath(); c.moveTo(gx, sy(v)); c.lineTo(gx+gw, sy(v)); c.stroke(); }); + c.setLineDash([]); + c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.22)'; + c.textAlign='right'; c.textBaseline='middle'; + c.fillText('1', gx-5, sy(1)); c.fillText('−1', gx-5, sy(-1)); + } + + /* x ticks */ + const ticks = [[0,'0'],[Math.PI/2,'π/2'],[Math.PI,'π'],[3*Math.PI/2,'3π/2'],[2*Math.PI,'2π']]; + c.font='500 10px Manrope,sans-serif'; c.fillStyle='rgba(255,255,255,0.20)'; + c.textAlign='center'; c.textBaseline='top'; + for (const [v,l] of ticks) { + const xx = sx(v); + if (xx < gx+6 || xx > gx+gw-6) continue; + c.strokeStyle='rgba(255,255,255,0.05)'; c.lineWidth=1; + c.setLineDash([3,3]); + c.beginPath(); c.moveTo(xx, gy); c.lineTo(xx, gy+gh); c.stroke(); + c.setLineDash([]); + c.fillText(l, xx, gy+gh+6); + } + + /* ── ghost curves (other functions, dimmed) ── */ + c.save(); + c.beginPath(); c.rect(gx, gy, gw, gh); c.clip(); + + const allFns = [ + { id: 'sin', ev: Math.sin, c: _TC.sin }, + { id: 'cos', ev: Math.cos, c: _TC.cos }, + { id: 'tan', ev: Math.tan, c: _TC.tan }, + { id: 'cot', ev: x => 1/Math.tan(x), c: _TC.cot }, + ]; + const step = (xMax - xMin) / (gw * 1.5); + + for (const f of allFns) { + if (f.id === fn) continue; /* skip active — draw it last */ + const show = (f.id==='sin'&&this.showSin) || (f.id==='cos'&&this.showCos) || + (f.id==='tan'&&this.showTan) || (f.id==='cot'&&this.showCot); + if (!show) continue; + const yRg = (f.id==='tan'||f.id==='cot') ? 4 : 1.5; + const syG = y => gy + gh/2 - y/yRg*(gh/2); + c.strokeStyle = _tcRgba(f.c, 0.18); c.lineWidth = 1.5; + c.beginPath(); let on = false; + for (let x = xMin; x <= xMax; x += step) { + const yv = f.ev(x); + if (!isFinite(yv) || Math.abs(yv) > yRg*2) { on = false; continue; } + const spx = sx(x), spy = syG(yv); + if (!on) { c.moveTo(spx, spy); on = true; } else c.lineTo(spx, spy); + } + c.stroke(); + } + + /* gradient fill under active curve (sin/cos) */ + if (fn==='sin'||fn==='cos') { + const pts = []; + for (let x = xMin; x <= xMax; x += step) { + const yv = evFn(x); + if (isFinite(yv)) pts.push({ sx: sx(x), sy: sy(yv) }); + } + if (pts.length > 2) { + const fg = c.createLinearGradient(0, gy, 0, gy+gh); + fg.addColorStop(0, _tcRgba(col, 0.10)); + fg.addColorStop(0.5, _tcRgba(col, 0.0)); + fg.addColorStop(1, _tcRgba(col, 0.10)); + c.fillStyle = fg; c.beginPath(); + c.moveTo(pts[0].sx, zy); + pts.forEach(p => c.lineTo(p.sx, p.sy)); + c.lineTo(pts[pts.length-1].sx, zy); + c.closePath(); c.fill(); + } + } + + /* active curve: glow + main */ + c.strokeStyle = _tcRgba(col, 0.12); c.lineWidth = 10; c.lineCap='round'; c.lineJoin='round'; + c.beginPath(); let on2 = false; + for (let x = xMin; x <= xMax; x += step) { + const yv = evFn(x); + if (!isFinite(yv)||Math.abs(yv)>yR*2) { on2 = false; continue; } + const spx = sx(x), spy = sy(yv); + if (!on2) { c.moveTo(spx, spy); on2 = true; } else c.lineTo(spx, spy); + } + c.stroke(); + c.strokeStyle = col; c.lineWidth = 2.5; + c.beginPath(); on2 = false; + for (let x = xMin; x <= xMax; x += step) { + const yv = evFn(x); + if (!isFinite(yv)||Math.abs(yv)>yR*2) { on2 = false; continue; } + const spx = sx(x), spy = sy(yv); + if (!on2) { c.moveTo(spx, spy); on2 = true; } else c.lineTo(spx, spy); + } + c.stroke(); + + /* ── current angle marker ── */ + const curY = evFn(this.angle); + if (isFinite(curY) && Math.abs(curY) <= yR*2) { + const mx = sx(this.angle), my = sy(curY); + c.strokeStyle = _tcRgba(_TC.violet, 0.18); c.lineWidth = 1; + c.setLineDash([4, 4]); + c.beginPath(); c.moveTo(mx, gy); c.lineTo(mx, gy+gh); c.stroke(); + c.strokeStyle = _tcRgba(col, 0.18); + c.beginPath(); c.moveTo(gx, my); c.lineTo(mx, my); c.stroke(); + c.setLineDash([]); + /* dot */ + c.fillStyle = _tcRgba(_TC.point, 0.12); + c.beginPath(); c.arc(mx, my, 13, 0, Math.PI*2); c.fill(); + c.fillStyle = _TC.point; c.shadowColor = _TC.point; c.shadowBlur = 12; + c.beginPath(); c.arc(mx, my, 5.5, 0, Math.PI*2); c.fill(); + c.shadowBlur = 0; + c.fillStyle = 'rgba(255,255,255,0.7)'; + c.beginPath(); c.arc(mx, my, 2, 0, Math.PI*2); c.fill(); + /* value badge */ + const txt = this._fmt(curY); + c.font = 'bold 11px Manrope,sans-serif'; + const tm = c.measureText(txt); + const bx2 = mx+10, by2 = my-22, bw2 = tm.width+14, bh2 = 20; + c.fillStyle='rgba(12,12,22,0.85)'; + c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.fill(); + c.strokeStyle = _tcRgba(col, 0.4); c.lineWidth = 1; + c.beginPath(); c.roundRect(bx2, by2-bh2/2, bw2, bh2, 6); c.stroke(); + c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle'; + c.fillText(txt, bx2+7, by2); + } + + c.restore(); + + /* fn name badge */ + c.font='bold 13px Manrope,sans-serif'; + const tm2 = c.measureText(lbl); + const bw3 = tm2.width+18, bh3 = 26; + c.fillStyle='rgba(12,12,22,0.7)'; + c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.fill(); + c.strokeStyle = _tcRgba(col, 0.25); c.lineWidth = 1; + c.beginPath(); c.roundRect(gx+8, gy+8, bw3, bh3, 8); c.stroke(); + c.fillStyle = col; c.textAlign='left'; c.textBaseline='middle'; + c.fillText(lbl, gx+17, gy+21); + } + + /* ═══ Snap particles ═══════════════════════════════════════════════ */ + + _spawnSnap(px, py) { + for (let i = 0; i < 8; i++) { + const ang = Math.random() * Math.PI * 2; + const speed = 30 + Math.random() * 50; + this._particles.push({ + x: px, y: py, + vx: Math.cos(ang) * speed, + vy: Math.sin(ang) * speed, + life: 1, + col: _TC.violet, + }); + } + } + + _drawParticles(c) { + const dt = 0.016; + for (let i = this._particles.length - 1; i >= 0; i--) { + const p = this._particles[i]; + p.x += p.vx * dt; p.y += p.vy * dt; + p.life -= dt * 1.8; + if (p.life <= 0) { this._particles.splice(i, 1); continue; } + c.fillStyle = _tcRgba(p.col, p.life * 0.6); + c.shadowColor = p.col; c.shadowBlur = 6; + c.beginPath(); c.arc(p.x, p.y, 2 * p.life, 0, Math.PI*2); c.fill(); + c.shadowBlur = 0; + } + } + + /* ═══ Drawing helpers ══════════════════════════════════════════════ */ + + _glowLine(c, x1, y1, x2, y2, col, w) { + c.lineCap = 'round'; + c.strokeStyle = _tcRgba(col, 0.14); c.lineWidth = w + 8; + c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke(); + c.strokeStyle = col; c.lineWidth = w; + c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke(); + c.strokeStyle = _tcRgba(col, 0.45); c.lineWidth = 1; + c.beginPath(); c.moveTo(x1,y1); c.lineTo(x2,y2); c.stroke(); + } + + _arrowH(c, x, y, angle, col) { + c.save(); c.translate(x, y); c.rotate(angle); + c.fillStyle = col; + c.beginPath(); c.moveTo(0,0); c.lineTo(-9,-4.5); c.lineTo(-9,4.5); c.closePath(); c.fill(); + c.restore(); + } + + _badge(c, x, y, txt, col, tA, tB) { + c.font='600 10px Manrope,sans-serif'; + const m = c.measureText(txt); + const pw = m.width+10, ph = 17; + let bx = x, by = y; + if (tA==='right') bx = x - pw; + else if (tA==='center') bx = x - pw/2; + if (tB==='middle') by = y - ph/2; + c.fillStyle='rgba(12,12,22,0.75)'; + c.beginPath(); c.roundRect(bx, by, pw, ph, 4); c.fill(); + c.strokeStyle = _tcRgba(col, 0.35); c.lineWidth = 1; + c.beginPath(); c.roundRect(bx, by, pw, ph, 4); c.stroke(); + c.fillStyle = col; c.textAlign='center'; c.textBaseline='middle'; + c.fillText(txt, bx + pw/2, by + ph/2); + } + + _tooltip(c, px, py, cosA, sinA) { + const txt = `(${this._fmt(cosA)}; ${this._fmt(sinA)})`; + c.font='600 11px Manrope,sans-serif'; + const m = c.measureText(txt); + const pw = m.width+16, ph = 24; + const offX = cosA >= 0 ? 16 : -pw-16; + const offY = sinA >= 0 ? -ph-12 : 12; + const bx = px+offX, by = py+offY; + c.fillStyle='rgba(12,12,22,0.80)'; + c.beginPath(); c.roundRect(bx, by, pw, ph, 8); c.fill(); + c.strokeStyle = _tcRgba(_TC.violet, 0.3); c.lineWidth = 1; + c.beginPath(); c.roundRect(bx, by, pw, ph, 8); c.stroke(); + c.fillStyle='rgba(255,255,255,0.82)'; + c.textAlign='center'; c.textBaseline='middle'; + c.fillText(txt, bx+pw/2, by+ph/2); + } + + /* ═══ Formatting ═══════════════════════════════════════════════════ */ + + _fmt(v) { + const a = Math.abs(v), s = v < 0 ? '−' : ''; + if (a < 5e-4) return '0'; + if (Math.abs(a-0.5)<1e-3) return s+'½'; + if (Math.abs(a-1)<1e-3) return s+'1'; + if (Math.abs(a-Math.SQRT2/2)<1e-3) return s+'√2/2'; + if (Math.abs(a-Math.sqrt(3)/2)<1e-3) return s+'√3/2'; + if (Math.abs(a-Math.sqrt(3)/3)<1e-3) return s+'√3/3'; + if (Math.abs(a-Math.sqrt(3))<1e-3) return s+'√3'; + if (Math.abs(a-2)<1e-3) return s+'2'; + if (Math.abs(a-2*Math.sqrt(3)/3)<1e-3)return s+'2√3/3'; + return v.toFixed(3); + } + + _radLbl(a) { + for (const n of _TC_NOTABLE) { if (Math.abs(a-n.a)<0.02) return n.l; } + return (a*180/Math.PI).toFixed(1)+'°'; + } + + _norm(a) { return ((a%(2*Math.PI))+2*Math.PI)%(2*Math.PI); } + _fireUpdate() { if (this.onUpdate) this.onUpdate(this.stats()); } + + /* ═══ Events ═══════════════════════════════════════════════════════ */ + + _bindEvents() { + const cv = this.canvas; + + const mp = e => { + const r = cv.getBoundingClientRect(); + return { x:(e.clientX??e.touches?.[0]?.clientX??0)-r.left, y:(e.clientY??e.touches?.[0]?.clientY??0)-r.top }; + }; + + const snapAngle = e => { + const m = mp(e); + let a = Math.atan2(-(m.y-this._cy), m.x-this._cx); + if (a<0) a += 2*Math.PI; + if (this.snapToNotable) { + for (const n of _TC_NOTABLE) { + if (Math.abs(a-n.a)<0.09) { a = n.a; break; } + } + } + return a; + }; + + const hit = e => { + const m = mp(e); + const px = this._cx + this._r*Math.cos(this.angle); + const py = this._cy - this._r*Math.sin(this.angle); + if (Math.hypot(m.x-px, m.y-py) < 30) return true; + return Math.abs(Math.hypot(m.x-this._cx, m.y-this._cy) - this._r) < 22; + }; + + const checkSnap = (newA) => { + for (const n of _TC_NOTABLE) { + if (Math.abs(newA-n.a)<0.02 && this._lastSnap !== n.a) { + this._lastSnap = n.a; + const nx = this._cx + this._r*Math.cos(n.a); + const ny = this._cy - this._r*Math.sin(n.a); + this._spawnSnap(nx, ny); + return; + } + } + }; + + const end = () => { + if (this._drag) { this._drag = false; this.draw(); } + cv.style.cursor = 'default'; + }; + + cv.addEventListener('mousedown', e => { + if (!hit(e)) return; + this._drag = true; this._stopAnim(); + const na = snapAngle(e); checkSnap(na); + this.angle = na; this.draw(); + cv.style.cursor = 'grabbing'; + }); + + cv.addEventListener('mousemove', e => { + if (this._drag) { + const na = snapAngle(e); checkSnap(na); + this.angle = na; this.draw(); + } else { + const h = hit(e); + if (h !== this._hover) { this._hover = h; this.draw(); } + cv.style.cursor = h ? 'grab' : 'default'; + } + }); + cv.addEventListener('mouseup', end); + cv.addEventListener('mouseleave', () => { if (this._hover){this._hover=false;this.draw();} end(); }); + + /* scroll wheel fine-tune */ + cv.addEventListener('wheel', e => { + e.preventDefault(); + const step = e.shiftKey ? 0.01 : (Math.PI / 180); + this.angle = this._norm(this.angle - Math.sign(e.deltaY) * step); + this._lastSnap = -1; + this.draw(); + }, { passive: false }); + + /* keyboard arrows */ + cv.setAttribute('tabindex', '0'); + cv.style.outline = 'none'; + cv.addEventListener('keydown', e => { + const step = e.shiftKey ? (Math.PI/180) : (Math.PI/36); /* 1° or 5° */ + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault(); this.angle = this._norm(this.angle + step); this.draw(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault(); this.angle = this._norm(this.angle - step); this.draw(); + } + }); + + /* touch */ + cv.addEventListener('touchstart', e => { + e.preventDefault(); + if (!hit(e)) return; + this._drag = true; this._stopAnim(); + const na = snapAngle(e); checkSnap(na); this.angle = na; this.draw(); + }, { passive: false }); + cv.addEventListener('touchmove', e => { + e.preventDefault(); + if (this._drag) { const na = snapAngle(e); checkSnap(na); this.angle = na; this.draw(); } + }, { passive: false }); + cv.addEventListener('touchend', end); + } + + /* ═══ Animation ════════════════════════════════════════════════════ */ + + _startAnim() { + this.animating = true; + let last = performance.now(); + const loop = now => { + if (!this.animating) return; + const dt = (now-last)/1000; last = now; + let d = this._animTarget - this.angle; + if (d > Math.PI) d -= 2*Math.PI; + if (d < -Math.PI) d += 2*Math.PI; + if (Math.abs(d) < 0.012) { + this.angle = this._animTarget; + this.animating = false; + /* snap particle at end */ + const nx = this._cx + this._r*Math.cos(this.angle); + const ny = this._cy - this._r*Math.sin(this.angle); + this._spawnSnap(nx, ny); + this.draw(); return; + } + const speed = this._animSpeed * Math.max(0.3, Math.min(1, Math.abs(d)/0.5)); + this.angle += Math.sign(d) * Math.min(Math.abs(d), speed * dt); + this.angle = this._norm(this.angle); + this.draw(); + this._raf = requestAnimationFrame(loop); + }; + this._raf = requestAnimationFrame(loop); + } + + _stopAnim() { + this.animating = false; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + + _startIdle() { + if (this._idleRaf) return; + let last = performance.now(); + const loop = now => { + const dt = (now-last)/1000; last = now; + this._idlePulse += dt * 1.5; + /* update particles */ + if (this._particles.length > 0 || (!this._drag && !this.animating)) this.draw(); + this._idleRaf = requestAnimationFrame(loop); + }; + this._idleRaf = requestAnimationFrame(loop); + } + + _stopIdle() { + if (this._idleRaf) { cancelAnimationFrame(this._idleRaf); this._idleRaf = null; } + } +} + +if (typeof window !== 'undefined') window.TrigCircleSim = TrigCircleSim; diff --git a/frontend/js/labs/waves.js b/frontend/js/labs/waves.js new file mode 100644 index 0000000..087251a --- /dev/null +++ b/frontend/js/labs/waves.js @@ -0,0 +1,452 @@ +'use strict'; +/* ═══════════════════════════════════════════ + WavesSim v2 — Волны и звук + Modes: transverse | longitudinal | superposition | standing + ─────────────────────────────────────────── */ +class WavesSim { + static BG = '#0D0D1A'; + static FONT = "700 12px 'Manrope',sans-serif"; + static V = '#9B5DE5'; /* violet */ + static C = '#06D6E0'; /* cyan */ + static P = '#F15BB5'; /* pink */ + static G = '#FFD166'; /* gold */ + + constructor(canvas) { + this._c = canvas; + this._ctx = canvas.getContext('2d'); + this._dpr = 1; + this._W = 0; + this._H = 0; + + this._mode = 'transverse'; + this._t = 0; + this._last = null; + this._raf = null; + this._paused = true; + + this._A1 = 50; this._f1 = 1.0; this._phi1 = 0; + this._A2 = 40; this._f2 = 1.5; this._phi2 = 0; + this._n = 1; + this._speed = 2.0; + + this._resizeObs = null; + this.onUpdate = null; + } + + /* ── публичное API ── */ + + fit() { + const par = this._c.parentElement; + const dpr = window.devicePixelRatio || 1; + const w = par.clientWidth || 600; + const h = par.clientHeight || 400; + this._c.width = Math.round(w * dpr); + this._c.height = Math.round(h * dpr); + this._dpr = dpr; + this._W = w; + this._H = h; + if (this._resizeObs) this._resizeObs.disconnect(); + this._resizeObs = new ResizeObserver(() => this.fit()); + this._resizeObs.observe(par); + this.draw(); + } + + setMode(mode) { + this._mode = mode; + this._t = 0; + this._last = null; + this.draw(); + this._emit(); + } + + 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)); + if (phi1 !== undefined) this._phi1 = +phi1; + if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2)); + if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2)); + if (phi2 !== undefined) this._phi2 = +phi2; + if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n))); + if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed)); + this.draw(); + this._emit(); + } + + play() { + this._paused = false; + this._last = null; + if (!this._raf) this._raf = requestAnimationFrame(t => this._tick(t)); + } + pause() { + this._paused = true; + if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } + } + start() { this.play(); } + stop() { this.pause(); } + reset() { this._t = 0; this._last = null; this.draw(); this._emit(); } + + info() { + const v = (this._W || 600) / 3; + return { + T: (1 / this._f1).toFixed(2), + lambda: (v / this._f1).toFixed(0), + v: v.toFixed(0), + f1: this._f1 + }; + } + + /* ── анимационный цикл ── */ + + _tick(ts) { + if (!this._paused) { + if (this._last !== null) + this._t += Math.min((ts - this._last) / 1000, 0.05) * this._speed; + this._last = ts; + this._raf = requestAnimationFrame(t => this._tick(t)); + } else { + this._raf = null; + } + this.draw(); + this._emit(); + } + + /* ── главный draw ── */ + + draw() { + const { _ctx: ctx, _W: W, _H: H, _dpr: dpr } = this; + if (!W || !H) return; + /* сбрасываем трансформ + заливаем фон */ + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = WavesSim.BG; + ctx.fillRect(0, 0, W, H); + if (this._mode === 'transverse') this._transvDraw(ctx, W, H); + else if (this._mode === 'longitudinal') this._longDraw(ctx, W, H); + else if (this._mode === 'superposition') this._superDraw(ctx, W, H); + else this._standDraw(ctx, W, H); + } + + /* ══════════════════════════════════════ + ПОПЕРЕЧНАЯ ВОЛНА + ══════════════════════════════════════ */ + _transvDraw(ctx, W, H) { + const PL = 48, PR = 20, PT = 50, PB = 48; + const cw = W - PL - PR; + const ch = H - PT - PB; + const cy = PT + ch / 2; + + this._grid(ctx, PL, PR, PT, PB, W, H); + this._axisLine(ctx, PL, PR, PT, PB, W, H, cy); + + const A = Math.max(4, Math.min(this._A1, ch / 2 - 8)); + const v = cw / 3; + const lam = v / this._f1; + const k = (2 * Math.PI) / lam; + const om = 2 * Math.PI * this._f1; + const t = this._t; + const phi = this._phi1; + const y = x => A * Math.sin(om * t - k * (x - PL) + phi); + + /* волновая кривая */ + ctx.save(); + ctx.shadowColor = WavesSim.V; + ctx.shadowBlur = 16; + ctx.strokeStyle = WavesSim.V; + ctx.lineWidth = 2.5; + ctx.beginPath(); + for (let x = PL; x <= PL + cw; x += 1) { + const py = cy + y(x); + x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py); + } + ctx.stroke(); + ctx.restore(); + + /* частицы */ + const step = Math.max(12, Math.floor(lam / 10)); + for (let x = PL + step * 0.5; x < PL + cw; x += step) { + const py = cy + y(x); + const norm = Math.abs(y(x)) / (A || 1); + ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, py); + ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.save(); + ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.arc(x, py, 4, 0, 6.28); + ctx.fillStyle = `rgba(155,93,229,${(0.4 + 0.6 * norm).toFixed(2)})`; ctx.fill(); + ctx.restore(); + } + + /* выделенная частица */ + const hx = PL + Math.min(lam * 0.5, cw * 0.22); + const hy = cy + y(hx); + ctx.save(); + ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18; + ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.28); + ctx.fillStyle = WavesSim.G; ctx.fill(); + ctx.restore(); + ctx.save(); + ctx.setLineDash([3, 4]); + ctx.strokeStyle = 'rgba(255,209,102,0.22)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(hx, cy - A); ctx.lineTo(hx, cy + A); ctx.stroke(); + ctx.restore(); + + /* аннотация длины волны */ + const ld = Math.min(lam, cw - 16); + if (ld > 36) { + const ay = cy + A + 26; + ctx.save(); + ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(PL + 8, ay); ctx.lineTo(PL + 8 + ld, ay); + ctx.moveTo(PL + 13, ay - 4); ctx.lineTo(PL + 8, ay); ctx.lineTo(PL + 13, ay + 4); + ctx.moveTo(PL + 8 + ld - 5, ay - 4); ctx.lineTo(PL + 8 + ld, ay); ctx.lineTo(PL + 8 + ld - 5, ay + 4); + ctx.stroke(); + ctx.fillStyle = WavesSim.C; ctx.textAlign = 'center'; + ctx.font = "700 10px 'Manrope',sans-serif"; + ctx.fillText('\u03bb = ' + ld.toFixed(0), PL + 8 + ld / 2, ay - 5); + ctx.restore(); + } + + /* аннотация амплитуды */ + if (A > 16) { + const ax = PL - 20; + ctx.save(); + ctx.strokeStyle = 'rgba(241,91,181,0.7)'; ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(ax, cy); ctx.lineTo(ax, cy - A); + ctx.moveTo(ax - 4, cy - A + 5); ctx.lineTo(ax, cy - A); ctx.lineTo(ax + 4, cy - A + 5); + ctx.moveTo(ax - 3, cy - 3); ctx.lineTo(ax, cy); ctx.lineTo(ax + 3, cy - 3); + ctx.stroke(); + ctx.fillStyle = WavesSim.P; ctx.textAlign = 'center'; + ctx.font = "700 10px 'Manrope',sans-serif"; + ctx.save(); ctx.translate(ax - 12, cy - A / 2); ctx.rotate(-Math.PI / 2); + ctx.fillText('A', 0, 0); ctx.restore(); + ctx.restore(); + } + + /* подпись */ + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.font = WavesSim.FONT; ctx.textAlign = 'left'; + ctx.fillText('y = A sin(\u03c9t \u2212 kx + \u03c6)', PL, PT - 14); + } + + /* ══════════════════════════════════════ + ПРОДОЛЬНАЯ ВОЛНА + ══════════════════════════════════════ */ + _longDraw(ctx, W, H) { + const PL = 24, PR = 24, PT = 50, PB = 60; + const cw = W - PL - PR; + const ch = H - PT - PB; + + const nRows = 5; + const rowH = ch / nRows; + const nPart = Math.max(20, Math.floor(cw / 10)); + const dx0 = cw / nPart; + + const v = cw / 3; + const lam = v / this._f1; + const k = (2 * Math.PI) / lam; + const om = 2 * Math.PI * this._f1; + const A = Math.min(this._A1 * 0.5, lam / 4, rowH * 0.36); + const t = this._t; + const phi = this._phi1; + + /* ряды частиц */ + for (let row = 0; row < nRows; row++) { + const cy = PT + rowH * (row + 0.5); + for (let i = 0; i < nPart; i++) { + const x0 = PL + (i + 0.5) * dx0; + const phase = om * t - k * (x0 - PL) + phi; + const disp = A * Math.sin(phase); + const xd = Math.max(PL + 1, Math.min(PL + cw - 1, x0 + disp)); + const dens = 1 / Math.max(0.15, 1 + (-A * k * Math.cos(phase))); + const alpha = Math.max(0.1, Math.min(0.95, dens * 0.55)); + ctx.beginPath(); ctx.arc(xd, cy, 3, 0, 6.28); + ctx.fillStyle = `rgba(155,93,229,${alpha.toFixed(2)})`; ctx.fill(); + } + } + + /* график давления */ + const pTop = PT + ch + 10; + const pH = H - pTop - 8; + if (pH > 20) { + const axY = pTop + pH / 2; + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(PL, axY); ctx.lineTo(PL + cw, axY); ctx.stroke(); + ctx.setLineDash([]); + ctx.save(); + ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 8; + ctx.strokeStyle = WavesSim.C; ctx.lineWidth = 2; + ctx.beginPath(); + for (let x = PL; x <= PL + cw; x += 1) { + const py = axY - Math.cos(om * t - k * (x - PL) + phi) * pH * 0.4; + x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py); + } + ctx.stroke(); ctx.restore(); + ctx.fillStyle = WavesSim.C; ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left'; + ctx.fillText('P(x,t)', PL + 2, pTop + 11); + } + + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.font = WavesSim.FONT; ctx.textAlign = 'left'; + ctx.fillText('Продольная волна', PL, PT - 16); + } + + /* ══════════════════════════════════════ + СУПЕРПОЗИЦИЯ + ══════════════════════════════════════ */ + _superDraw(ctx, W, H) { + const PL = 48, PR = 20, PT = 70, PB = 48; + const cw = W - PL - PR; + const ch = H - PT - PB; + const cy = PT + ch / 2; + + this._grid(ctx, PL, PR, PT, PB, W, H); + this._axisLine(ctx, PL, PR, PT, PB, W, H, cy); + + const v = cw / 3; + const t = this._t; + + const mk = (f, A) => { + const lam = v / f, k = (2 * Math.PI) / lam, om = 2 * Math.PI * f; + const amp = Math.max(4, Math.min(A, ch / 2 - 8)); + return { k, om, amp }; + }; + + const w1 = mk(this._f1, this._A1); + const w2 = mk(this._f2, this._A2); + const y1 = x => w1.amp * Math.sin(w1.om * t - w1.k * (x - PL) + this._phi1); + const y2 = x => w2.amp * Math.sin(w2.om * t - w2.k * (x - PL) + this._phi2); + const yR = x => y1(x) + y2(x); + + this._waveLine(ctx, PL, cw, cy, y1, WavesSim.V, 1.5, 0.45, false); + this._waveLine(ctx, PL, cw, cy, y2, WavesSim.C, 1.5, 0.45, false); + this._waveLine(ctx, PL, cw, cy, yR, WavesSim.P, 2.8, 1.0, true); + + /* легенда */ + const items = [ + { c: WavesSim.V, txt: 'y\u2081 = A\u2081 sin(\u03c9\u2081t \u2212 k\u2081x + \u03c6\u2081)' }, + { c: WavesSim.C, txt: 'y\u2082 = A\u2082 sin(\u03c9\u2082t \u2212 k\u2082x + \u03c6\u2082)' }, + { c: WavesSim.P, txt: 'y = y\u2081 + y\u2082' }, + ]; + ctx.font = "600 9px 'Manrope',sans-serif"; + items.forEach((it, i) => { + const lx = PL + 6, ly = PT - 56 + i * 18; + ctx.save(); + ctx.shadowColor = it.c; ctx.shadowBlur = 8; + ctx.fillStyle = it.c; + ctx.beginPath(); ctx.arc(lx + 4, ly + 4, 3.5, 0, 6.28); ctx.fill(); + ctx.restore(); + ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textAlign = 'left'; + ctx.fillText(it.txt, lx + 13, ly + 8); + }); + } + + /* ══════════════════════════════════════ + СТОЯЧАЯ ВОЛНА + ══════════════════════════════════════ */ + _standDraw(ctx, W, H) { + const PL = 48, PR = 20, PT = 50, PB = 48; + const cw = W - PL - PR; + const ch = H - PT - PB; + const cy = PT + ch / 2; + + this._grid(ctx, PL, PR, PT, PB, W, H); + this._axisLine(ctx, PL, PR, PT, PB, W, H, cy); + + const n = this._n; + const k = (n * Math.PI) / cw; + const om = 2 * Math.PI * this._f1; + const A = Math.max(4, Math.min(this._A1, ch / 2 - 10)); + const t = this._t; + + /* прямая и обратная (тусклые) */ + this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t - k * (x - PL)), WavesSim.V, 1.0, 0.25, false); + this._waveLine(ctx, PL, cw, cy, x => A * Math.sin(om * t + k * (x - PL) + Math.PI), WavesSim.C, 1.0, 0.25, false); + + /* огибающая */ + ctx.save(); + ctx.globalAlpha = 0.12; + ctx.fillStyle = WavesSim.V; + ctx.beginPath(); ctx.moveTo(PL, cy); + for (let x = PL; x <= PL + cw; x++) ctx.lineTo(x, cy - 2 * A * Math.abs(Math.sin(k * (x - PL)))); + for (let x = PL + cw; x >= PL; x--) ctx.lineTo(x, cy + 2 * A * Math.abs(Math.sin(k * (x - PL)))); + ctx.closePath(); ctx.fill(); ctx.restore(); + + /* стоячая волна */ + const cosT = Math.cos(om * t + this._phi1); + this._waveLine(ctx, PL, cw, cy, x => 2 * A * Math.sin(k * (x - PL)) * cosT, WavesSim.G, 2.8, 1.0, true); + + /* узлы (cyan) */ + ctx.save(); ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 10; ctx.fillStyle = WavesSim.C; + for (let m = 0; m <= n; m++) { + ctx.beginPath(); ctx.arc(PL + m * cw / n, cy, 5, 0, 6.28); ctx.fill(); + } + ctx.restore(); + + /* пучности (pink) */ + ctx.save(); ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12; ctx.fillStyle = WavesSim.P; + for (let m = 0; m < n; m++) { + const ax = PL + (m + 0.5) * cw / n; + const ay = cy + 2 * A * Math.sin(k * (ax - PL)) * cosT; + ctx.beginPath(); ctx.arc(ax, ay, 5, 0, 6.28); ctx.fill(); + } + ctx.restore(); + + /* легенда */ + const lx = W - PR - 128, ly = PT - 20; + ctx.font = "600 9px 'Manrope',sans-serif"; + [{ c: WavesSim.C, t: 'Узел (y\u22610)', dy: 0 }, { c: WavesSim.P, t: 'Пучность', dy: 16 }].forEach(r => { + ctx.save(); ctx.shadowColor = r.c; ctx.shadowBlur = 8; ctx.fillStyle = r.c; + ctx.beginPath(); ctx.arc(lx + 5, ly + r.dy + 5, 4, 0, 6.28); ctx.fill(); ctx.restore(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left'; + ctx.fillText(r.t, lx + 14, ly + r.dy + 9); + }); + + ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.font = WavesSim.FONT; ctx.textAlign = 'left'; + ctx.fillText('n = ' + n + ' \u03bb = 2L/' + n, PL, PT - 14); + } + + /* ══════════════════════════════════════ + ВСПОМОГАТЕЛЬНЫЕ + ══════════════════════════════════════ */ + + _waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) { + ctx.save(); + ctx.globalAlpha = alpha; + if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; } + ctx.strokeStyle = color; ctx.lineWidth = lw; + ctx.beginPath(); + for (let x = PL; x <= PL + cw; x += 1) { + const py = cy + fn(x); + x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py); + } + ctx.stroke(); ctx.restore(); + } + + _grid(ctx, PL, PR, PT, PB, W, H) { + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; + ctx.beginPath(); + for (let y = PT; y <= H - PB; y += 28) { ctx.moveTo(PL, y); ctx.lineTo(W - PR, y); } + for (let x = PL; x <= W - PR; x += 40) { ctx.moveTo(x, PT); ctx.lineTo(x, H - PB); } + ctx.stroke(); + } + + _axisLine(ctx, PL, PR, PT, PB, W, H, cy) { + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(PL, cy); ctx.lineTo(W - PR, cy); ctx.stroke(); + ctx.restore(); + ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(PL, PT - 6); ctx.lineTo(PL, H - PB); + ctx.moveTo(PL, H - PB); ctx.lineTo(W - PR + 6, H - PB); + ctx.stroke(); + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.font = "600 9px 'Manrope',sans-serif"; + ctx.textAlign = 'right'; ctx.fillText('y', PL - 4, PT); + ctx.textAlign = 'left'; ctx.fillText('x', W - PR + 8, H - PB + 4); + } + + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } +} diff --git a/frontend/js/libs/three.min.js b/frontend/js/libs/three.min.js new file mode 100644 index 0000000..24a3329 --- /dev/null +++ b/frontend/js/libs/three.min.js @@ -0,0 +1 @@ +Couldn't find the requested file /build/three.min.js in three. \ No newline at end of file diff --git a/frontend/js/whiteboard.js b/frontend/js/whiteboard.js new file mode 100644 index 0000000..53951fb --- /dev/null +++ b/frontend/js/whiteboard.js @@ -0,0 +1,3243 @@ +/* ── KaTeX CSS cache (module-level, shared across all Whiteboard instances) ── */ +let _katexCss = null; // null = not fetched yet; '' = failed/loading; string = ready +const _katexCssCbs = []; // pending render callbacks waiting for CSS + +function _loadKatexCss(cb) { + if (_katexCss !== null) { cb(_katexCss); return; } + _katexCssCbs.push(cb); + if (_katexCssCbs.length > 1) return; // fetch already in flight + fetch('https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css') + .then(r => r.text()) + .then(css => { + // Make font paths absolute so they resolve from inside an SVG Blob URL + _katexCss = css.replace( + /url\(fonts\//g, + 'url(https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/fonts/' + ); + }) + .catch(() => { _katexCss = ''; }) + .finally(() => { + const waiters = _katexCssCbs.splice(0); + waiters.forEach(w => w(_katexCss)); + }); +} + +/** + * Whiteboard — interactive drawing canvas + * Virtual space: 1920×1080. All coordinates stored in virtual pixels. + * + * Tools: 'pencil' | 'eraser' | 'highlighter' | 'laser' | 'select' | 'text' | + * 'sticky' | 'formula' | 'table' | 'connector' | + * 'rect' | 'ellipse' | 'line' | 'arrow' | + * 'triangle' | 'diamond' | 'hexagon' | 'star' | 'roundedrect' | 'callout' + * + * Stroke.tool (DB): 'pencil' | 'eraser' | 'highlighter' | 'shape' | 'text' | 'image' | + * 'sticky' | 'formula' | 'table' | 'connector' + * + * data shapes: + * pencil/eraser/highlighter : {points:[[x,y],...], color, width, lineStyle, opacity} + * shape : {shape, x1,y1,x2,y2, color, width, fill, lineStyle, opacity} + * connector : {x1,y1,x2,y2, color, width, arrowStart, arrowEnd, lineStyle, opacity} + * text : {text, x, y, fontSize, color} + * image : {src, x, y, w, h} + * sticky : {x, y, w, h, text, bgColor, textColor, fontSize} + * formula : {x, y, w, h, latex, color} + * table : {x, y, w, h, rows, cols, cells, borderColor, bgColor, textColor, fontSize} + */ +class Whiteboard { + static VW = 1920; + static VH = 1080; + + constructor(canvas, opts = {}) { + this._canvas = canvas; + this._ctx = canvas.getContext('2d'); + + this._readOnly = opts.readOnly || false; + this._onStrokeDone = opts.onStrokeDone || null; + this._onStrokeUndo = opts.onStrokeUndo || null; + this._onStrokeProgress = opts.onStrokeProgress || null; + this._onStrokeUpdated = opts.onStrokeUpdated || null; + this._onCursorMove = opts.onCursorMove || null; + this._cursorThrottle = 0; // timestamp of last cursor broadcast + + this._strokes = []; + this._undoStack = []; + this._redoStack = []; + + this._drawing = false; + this._curPts = []; + this._shapeStart = null; + this._shapeEnd = null; + + this._tool = 'pencil'; + this._color = '#ffffff'; + this._width = 4; + this._fill = false; + this._lineStyle = 'solid'; + this._opacity = 1.0; + this._template = opts.template || 'blank'; + this._pageNum = opts.pageNum || 1; + + 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._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', ...} + this._selectedOverlayIdx = -1; + this._onOverlayChange = opts.onOverlayChange || null; + this._showMeasurements = false; // auto-measurements toggle + + // Zoom / pan + this._zoom = 1; + this._panVX = 0; // virtual-space X of top-left corner + this._panVY = 0; + this._spaceDown = false; + this._panStartCss = null; // [cssX, cssY] at pan start + this._panStartPan = null; // [panVX, panVY] at pan start + this._onZoomChange = opts.onZoomChange || null; + + this._lastClickTime = 0; + this._lastClickVx = 0; + this._lastClickVy = 0; + + this._localIdCounter = -1; + this._liveId = null; + this._progressTimer = null; + this._liveStrokes = new Map(); + + this._fitPending = false; + this._bgNoise = null; // cached noise pattern for chalkboard texture + this._textInput = null; + this._textInputDocHandler = null; // document pointerdown handler for outside-click + this._objectInput = null; + this._onObjectCreated = opts.onObjectCreated || 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; + + // dynamic overlay canvas (selection, snap guides, live strokes, laser) + this._dynCanvas = document.createElement('canvas'); + this._dynCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;'; + const _wrap = canvas.parentElement; + if (_wrap) _wrap.appendChild(this._dynCanvas); + this._dynCtx = this._dynCanvas.getContext('2d'); + + // minimap canvas — navigation overview (bottom-right corner) + this._mmCanvas = document.createElement('canvas'); + this._mmCanvas.width = 192; + this._mmCanvas.height = 108; + this._mmCanvas.style.cssText = [ + 'position:absolute;bottom:10px;right:10px;z-index:25;', + 'width:192px;height:108px;border-radius:6px;', + 'border:1px solid rgba(155,93,229,0.35);', + 'box-shadow:0 2px 12px rgba(0,0,0,0.55);', + 'cursor:crosshair;display:none;', + 'transition:opacity 0.25s;' + ].join(''); + this._mmCtx = this._mmCanvas.getContext('2d'); + this._mmDrag = false; + if (_wrap) _wrap.appendChild(this._mmCanvas); + const _mmDown = e => { e.stopPropagation(); this._mmDrag = true; this._mmNavigate(e); }; + const _mmMove = e => { if (this._mmDrag) this._mmNavigate(e); }; + const _mmUp = () => { this._mmDrag = false; }; + this._mmCanvas.addEventListener('mousedown', _mmDown); + this._mmCanvas.addEventListener('mousemove', _mmMove); + this._mmCanvas.addEventListener('mouseup', _mmUp); + document.addEventListener('mouseup', _mmUp); + + this._bindEvents(); + this.fit(); + + this._ro = new ResizeObserver(() => this.fit()); + this._ro.observe(canvas.parentElement || canvas); + } + + /* ── backward-compat: single selectedId getter/setter ──────────────── */ + get _selectedId() { for (const id of this._selectedIds) return id; return null; } + set _selectedId(v) { this._selectedIds.clear(); if (v != null) this._selectedIds.add(v); } + + /* ── setup ─────────────────────────────────────────────────────────── */ + + _bindEvents() { + const c = this._canvas; + const onDown = e => { if (!this._readOnly) this._onPointerDown(e); }; + const onMove = e => { + 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) { + this._cursorThrottle = now; + this._onCursorMove(vx, vy); + } + } + }; + const onUp = e => { if (!this._readOnly) this._onPointerUp(e); }; + + c.addEventListener('pointerdown', onDown); + c.addEventListener('pointermove', onMove); + c.addEventListener('pointerup', onUp); + c.addEventListener('pointerleave', onUp); + c.addEventListener('pointercancel', onUp); + c.addEventListener('touchstart', e => e.preventDefault(), { passive: false }); + c.addEventListener('wheel', e => this._onWheel(e), { passive: false }); + + this._onKeyDown = e => this._handleKeyDown(e); + this._onKeyUp = e => { if (e.key === ' ') { this._spaceDown = false; if (!this._panStartCss) this._canvas.style.cursor = ''; } }; + this._onPaste = e => this._onClipboardPaste(e); + document.addEventListener('keydown', this._onKeyDown); + document.addEventListener('keyup', this._onKeyUp); + document.addEventListener('paste', this._onPaste); + } + + _handleKeyDown(e) { + if (this._readOnly) return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return; + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { this.copy(); } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); this.paste(); } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); this.undo(); } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); this.redo(); } + if (e.key === 'Delete' || e.key === 'Backspace') { + if (this._selectedIds.size > 0) { e.preventDefault(); this.deleteSelected(); } + } + if (e.key === 'Escape') { + 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); } + if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.zoomTo(this._zoom / 1.25); } + if ((e.ctrlKey || e.metaKey) && e.key === '0') { e.preventDefault(); this.resetView(); } + // Space = pan mode + if (e.key === ' ' && !this._spaceDown) { e.preventDefault(); this._spaceDown = true; this._canvas.style.cursor = 'grab'; } + } + + _isShapeTool() { + return ['rect','ellipse','line','arrow', + 'triangle','diamond','hexagon','star', + 'roundedrect','callout'].includes(this._tool); + } + + _isObjectStroke(s) { + return s.tool === 'image' || s.tool === 'sticky' || + s.tool === 'formula' || s.tool === 'table' || s.tool === 'coordinate' || + s.tool === 'numberline' || s.tool === 'compass'; + } + + _isResizableStroke(s) { + return this._isObjectStroke(s) || 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; + 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') { + 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, + }; + } + if (d.points && d.points.length) { + const xs = d.points.map(p => p[0]), ys = d.points.map(p => p[1]); + const bx = Math.min(...xs) - 10, by = Math.min(...ys) - 10; + return { x: bx, y: by, w: Math.max(...xs) - bx + 20, h: Math.max(...ys) - by + 20 }; + } + return { x: 0, y: 0, w: Whiteboard.VW, h: Whiteboard.VH }; + } + + /* Move any stroke by virtual delta */ + _moveStroke(stroke, dvx, dvy) { + const d = stroke.data; + if (this._isObjectStroke(stroke) || stroke.tool === 'text') { + d.x += dvx; d.y += dvy; + } else if (stroke.tool === 'shape' || stroke.tool === 'connector') { + 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]); + } + } + + /* Resize shape/connector by dragging a corner handle */ + _applyResizeShape(data, ds, vx, vy) { + const origMinX = Math.min(ds.origX1, ds.origX2); + const origMaxX = Math.max(ds.origX1, ds.origX2); + const origMinY = Math.min(ds.origY1, ds.origY2); + const origMaxY = Math.max(ds.origY1, ds.origY2); + const h = ds.handle; + let newMinX = origMinX, newMaxX = origMaxX; + let newMinY = origMinY, newMaxY = origMaxY; + if (h === 'tl' || h === 'bl') newMinX = Math.min(vx, origMaxX - 10); + else newMaxX = Math.max(vx, origMinX + 10); + if (h === 'tl' || h === 'tr') newMinY = Math.min(vy, origMaxY - 10); + else newMaxY = Math.max(vy, origMinY + 10); + // Map back to x1,y1,x2,y2 preserving which slot is min/max + if (ds.origX1 <= ds.origX2) { data.x1 = newMinX; data.x2 = newMaxX; } + else { data.x1 = newMaxX; data.x2 = newMinX; } + if (ds.origY1 <= ds.origY2) { data.y1 = newMinY; data.y2 = newMaxY; } + else { data.y1 = newMaxY; data.y2 = newMinY; } + } + + /* Snap guides: compute alignment guides for movingStroke vs. all other strokes */ + _computeSnapGuides(movingStroke) { + const SNAP = 8; // virtual-pixel threshold + const b = this._getStrokeBBox(movingStroke); + const guides = []; + const seenX = new Set(), seenY = new Set(); + for (const s of this._strokes) { + if (this._selectedIds.has(s.id)) continue; + const o = this._getStrokeBBox(s); + const xs = [o.x, o.x + o.w, o.x + o.w / 2]; + const ys = [o.y, o.y + o.h, o.y + o.h / 2]; + for (const sv of xs) { + if (seenX.has(sv)) continue; + const moving = [b.x, b.x + b.w, b.x + b.w / 2]; + if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'x', pos: sv }); seenX.add(sv); } + } + for (const sv of ys) { + if (seenY.has(sv)) continue; + const moving = [b.y, b.y + b.h, b.y + b.h / 2]; + if (moving.some(mv => Math.abs(mv - sv) < SNAP)) { guides.push({ axis: 'y', pos: sv }); seenY.add(sv); } + } + } + this._snapGuides = guides; + } + + /* 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]; + const b = this._getStrokeBBox(s); + if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) return s; + } + return null; + } + + fit() { + const c = this._canvas; + const wrap = c.parentElement; + if (!wrap) return; + const dpr = window.devicePixelRatio || 1; + const w = wrap.clientWidth; + const h = wrap.clientHeight; + if (w === 0 || h === 0) { + if (!this._fitPending) { + this._fitPending = true; + requestAnimationFrame(() => { this._fitPending = false; this.fit(); }); + } + return; + } + c.width = Math.round(w * dpr); + c.height = Math.round(h * dpr); + c.style.width = w + 'px'; + c.style.height = h + 'px'; + this._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + // size dynamic overlay canvas + const dc = this._dynCanvas; + dc.width = c.width; + dc.height = c.height; + dc.style.width = w + 'px'; + dc.style.height = h + 'px'; + this._dynCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + this._dpr = dpr; + this._cssW = w; + this._cssH = h; + this._staticDirty = true; + this.render(); + } + + /* ── coordinate helpers ─────────────────────────────────────────────── */ + + _toVirtual(cssX, cssY) { + const sx = (this._cssW || 1) / Whiteboard.VW; + const sy = (this._cssH || 1) / Whiteboard.VH; + return [ + cssX / (sx * this._zoom) + this._panVX, + cssY / (sy * this._zoom) + this._panVY, + ]; + } + + _toCanvas(vx, vy) { + const sx = (this._cssW || 300) / Whiteboard.VW; + const sy = (this._cssH || 150) / Whiteboard.VH; + return [ + (vx - this._panVX) * sx * this._zoom, + (vy - this._panVY) * sy * this._zoom, + ]; + } + + _pointerPos(e) { + const rect = this._canvas.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + return this._toVirtual(clientX - rect.left, clientY - rect.top); + } + + /* ── pointer handlers ───────────────────────────────────────────────── */ + + _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; + } + + const [vx, vy] = this._pointerPos(e); + + // ── overlay drag (ruler/protractor) — always checked first ──────────── + if (this._overlays.length > 0) { + const hit = this._hitTestOverlay(vx, vy); + if (hit) { + const ov = this._overlays[hit.idx]; + this._selectedOverlayIdx = hit.idx; + this._canvas.setPointerCapture(e.pointerId); + if (hit.zone === 'body') { + this._overlayDrag = { idx: hit.idx, type: 'move', + startVx: vx, startVy: vy, origX: ov.x, origY: ov.y }; + } else if (hit.zone === 'rot') { + this._overlayDrag = { idx: hit.idx, type: 'rotate', + startAngle: Math.atan2(vy - ov.y, vx - ov.x), + origAngle: ov.angle || 0 }; + } else if (hit.zone === 'resize') { + const angle = ov.angle || 0; + const dx = vx - ov.x, dy = vy - ov.y; + if (ov.type === 'ruler') { + this._overlayDrag = { idx: hit.idx, type: 'resize', + startDot: dx * Math.cos(angle) + dy * Math.sin(angle), + origWidth: ov.width }; + } else { + this._overlayDrag = { idx: hit.idx, type: 'resize', + startDist: Math.hypot(dx, dy), + origRadius: ov.radius || 80 }; + } + } + if (this._onOverlayChange) this._onOverlayChange(ov); + return; + } + } + + // ── select tool ────────────────────────────────────────────────────── + if (this._tool === 'select') { + const now = Date.now(); + const dbl = now - this._lastClickTime < 350 && + Math.abs(vx - this._lastClickVx) < 40 && + Math.abs(vy - this._lastClickVy) < 40; + this._lastClickTime = now; + this._lastClickVx = vx; + this._lastClickVy = vy; + + // Check handles on the primary selected stroke + const primarySel = this._selectedIds.size === 1 + ? this._strokes.find(s => s.id === this._selectedId) : null; + if (primarySel) { + // Rotation handle (object strokes only) + if (this._isObjectStroke(primarySel)) { + const rotH = this._hitTestObjectHandle(vx, vy, primarySel); + if (rotH === 'rot') { + const b = this._getStrokeBBox(primarySel); + const ocx = b.x + b.w / 2, ocy = b.y + b.h / 2; + this._dragState = { type: 'rotate', cx: ocx, cy: ocy, + origRotation: primarySel.data.rotation || 0, + startAngle: Math.atan2(vy - ocy, vx - ocx) }; + this._canvas.setPointerCapture(e.pointerId); + return; + } + } + if (this._isResizableStroke(primarySel)) { + const handle = this._hitTestObjectHandle(vx, vy, primarySel); + if (handle && handle !== 'rot') { + const d = primarySel.data; + if (this._isObjectStroke(primarySel)) { + this._dragState = { type: 'resize', handle, + startVx: vx, startVy: vy, + origX: d.x, origY: d.y, origW: d.w, origH: d.h }; + } else { + this._dragState = { type: 'resize_shape', handle, + origX1: d.x1, origY1: d.y1, origX2: d.x2, origY2: d.y2 }; + } + this._canvas.setPointerCapture(e.pointerId); + return; + } + } + } + // Check if click is inside any selected stroke (for move) + let clickedSelected = null; + for (const id of this._selectedIds) { + const s = this._strokes.find(x => x.id === id); + if (!s) continue; + const bbox = this._getStrokeBBox(s); + if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h) { + clickedSelected = s; break; + } + } + if (clickedSelected) { + if (dbl) { this._editObject(clickedSelected, vx, vy); return; } + this._dragState = { type: 'move', lastVx: vx, lastVy: vy }; + this._canvas.setPointerCapture(e.pointerId); + this.render(); + return; + } + + const hit = this._hitTestAny(vx, vy); + if (hit) { + if (dbl) { this._editObject(hit, vx, vy); return; } + if (e.shiftKey) { + // Shift+click: toggle in selection + if (this._selectedIds.has(hit.id)) this._selectedIds.delete(hit.id); + else this._selectedIds.add(hit.id); + } else { + this._selectedIds.clear(); + this._selectedIds.add(hit.id); + this._dragState = { type: 'move', lastVx: vx, lastVy: vy }; + this._canvas.setPointerCapture(e.pointerId); + } + } else { + // Click on empty space: start lasso + if (!e.shiftKey) this._selectedIds.clear(); + this._lassoRect = { x1: vx, y1: vy, x2: vx, y2: vy }; + this._canvas.setPointerCapture(e.pointerId); + } + this._snapGuides = []; + this.render(); + return; + } + + // ── text tool ──────────────────────────────────────────────────────── + if (this._tool === 'text') { + e.preventDefault(); // prevent browser default focus shift during pointerdown + this._placeTextInput([vx, vy]); + return; + } + + // ── sticky tool ────────────────────────────────────────────────────── + if (this._tool === 'sticky') { this._createSticky(vx, vy); return; } + + // ── formula tool ───────────────────────────────────────────────────── + if (this._tool === 'formula') { this._placeFormula(vx, vy); return; } + + // ── 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)}`; + if (this._onStrokeProgress) + this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); + this.render(); + return; + } + + // ── coordinate tool ─────────────────────────────────────────────────── + if (this._tool === 'coordinate') { + const W = 600, H = 500; + 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)); + const stroke = { + id: this._localIdCounter--, tool: 'coordinate', + data: { x, y, w: W, h: H, xMin: -10, xMax: 10, yMin: -10, yMax: 10, + gridStep: 1, showLabels: true, functions: [] }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + return; + } + + // ── number line tool ───────────────────────────────────────────────── + if (this._tool === 'numberline') { + const W = 700, H = 120; + 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)); + const stroke = { + id: this._localIdCounter--, tool: 'numberline', + data: { x, y, w: W, h: H, min: -10, max: 10, step: 1, points: [], intervals: [] }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + 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)); + 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 }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + return; + } + + // ── drawing tools (pencil, eraser, highlighter, shapes, connector, table) ── + this._canvas.setPointerCapture(e.pointerId); + this._drawing = true; + this._liveId = `${Date.now()}${Math.random().toString(36).slice(2, 5)}`; + if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { + this._shapeStart = [vx, vy]; + this._shapeEnd = [vx, vy]; + } else { + this._curPts = [[vx, vy]]; + } + } + + _onPointerMove(e) { + // ── pan drag ───────────────────────────────────────────────────────── + if (this._panStartCss) { + 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; + const sx = (this._cssW || 300) / Whiteboard.VW; + const sy = (this._cssH || 150) / Whiteboard.VH; + this._panVX = this._panStartPan[0] - (cx - this._panStartCss[0]) / (sx * this._zoom); + this._panVY = this._panStartPan[1] - (cy - this._panStartCss[1]) / (sy * this._zoom); + this._clampPan(); + this._staticDirty = true; + this.render(); + return; + } + + const [vx, vy] = this._pointerPos(e); + + // Broadcast cursor position to other participants (throttled to 50ms) + if (this._onCursorMove) { + const now = Date.now(); + if (now - this._cursorThrottle > 100) { + this._cursorThrottle = now; + this._onCursorMove(vx, vy); + } + } + + // ── overlay drag ────────────────────────────────────────────────────── + if (this._overlayDrag) { + const od = this._overlayDrag; + const ov = this._overlays[od.idx]; + if (ov) { + if (od.type === 'move') { + ov.x = od.origX + (vx - od.startVx); + ov.y = od.origY + (vy - od.startVy); + } else if (od.type === 'rotate') { + const cur = Math.atan2(vy - ov.y, vx - ov.x); + ov.angle = od.origAngle + (cur - od.startAngle); + } else if (od.type === 'resize') { + const angle = ov.angle || 0; + const dx = vx - ov.x, dy = vy - ov.y; + if (ov.type === 'ruler') { + const dot = dx * Math.cos(angle) + dy * Math.sin(angle); + ov.width = Math.max(100, od.origWidth + (dot - od.startDot)); + } else if (ov.type === 'protractor') { + ov.radius = Math.max(40, od.origRadius + (Math.hypot(dx, dy) - od.startDist)); + } + } + if (this._onOverlayChange) this._onOverlayChange(ov); + } + this.render(); + return; + } + + // ── laser: update position ──────────────────────────────────────────── + if (this._tool === 'laser' && this._drawing) { + this._laserPos = [vx, vy]; + if (this._onStrokeProgress && this._liveId) + this._onStrokeProgress({ liveId: this._liveId, tool: 'laser', data: { points: [[vx, vy]] } }); + this.render(); + return; + } + + // ── select: update cursor + drag ────────────────────────────────────── + if (this._tool === 'select') { + // Lasso update + if (this._lassoRect) { + this._lassoRect.x2 = vx; + this._lassoRect.y2 = vy; + this.render(); + return; + } + + if (!this._dragState) { + let cur = 'default'; + if (this._selectedIds.size === 1) { + const sel = this._strokes.find(s => s.id === this._selectedId); + if (sel) { + if (this._isResizableStroke(sel)) { + const h = this._hitTestObjectHandle(vx, vy, sel); + if (h === 'tl' || h === 'br') cur = 'nwse-resize'; + else if (h === 'tr' || h === 'bl') cur = 'nesw-resize'; + } + if (cur === 'default') { + const bbox = this._getStrokeBBox(sel); + if (vx >= bbox.x && vx <= bbox.x + bbox.w && vy >= bbox.y && vy <= bbox.y + bbox.h) + cur = 'move'; + } + } + } + if (cur === 'default') { + for (const id of this._selectedIds) { + const s = this._strokes.find(x => x.id === id); + if (!s) continue; + const b = this._getStrokeBBox(s); + if (vx >= b.x && vx <= b.x + b.w && vy >= b.y && vy <= b.y + b.h) { cur = 'move'; break; } + } + } + if (cur === 'default' && this._hitTestAny(vx, vy)) cur = 'move'; + this._canvas.style.cursor = cur; + } + + if (!this._dragState) return; + const ds = this._dragState; + if (ds.type === 'move') { + const dvx = vx - ds.lastVx, dvy = vy - ds.lastVy; + ds.lastVx = vx; ds.lastVy = vy; + // Move all selected strokes + for (const id of this._selectedIds) { + const s = this._strokes.find(x => x.id === id); + if (s) this._moveStroke(s, dvx, dvy); + } + // Compute snap guides for primary selected stroke + if (this._selectedIds.size === 1) { + const sel = this._strokes.find(s => s.id === this._selectedId); + if (sel) this._computeSnapGuides(sel); + } else { this._snapGuides = []; } + this._staticDirty = true; + } else if (ds.type === 'resize') { + const sel = this._strokes.find(s => s.id === this._selectedId); + if (!sel) return; + this._applyResize(sel.data, ds, vx - ds.startVx, vy - ds.startVy); + this._staticDirty = true; + } else if (ds.type === 'resize_shape') { + const sel = this._strokes.find(s => s.id === this._selectedId); + if (!sel) return; + this._applyResizeShape(sel.data, ds, vx, vy); + this._staticDirty = true; + } else if (ds.type === 'rotate') { + const sel = this._strokes.find(s => s.id === this._selectedId); + if (!sel) return; + const angle = Math.atan2(vy - ds.cy, vx - ds.cx); + let rotation = ds.origRotation + (angle - ds.startAngle); + if (e.shiftKey) rotation = Math.round(rotation / (Math.PI / 12)) * (Math.PI / 12); + sel.data.rotation = rotation; + this._staticDirty = true; + } + this.render(); + return; + } + + if (!this._drawing) return; + if (this._isShapeTool() || this._tool === 'connector' || this._tool === 'table') { + this._shapeEnd = [vx, vy]; + this.render(); + } else { + this._curPts.push([vx, vy]); + this.render(); + } + if (this._onStrokeProgress && !this._progressTimer) { + this._progressTimer = setTimeout(() => { + this._progressTimer = null; + this._flushProgress(); + }, 50); + } + } + + _onWheel(e) { + e.preventDefault(); + const rect = this._canvas.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const [vx, vy] = this._toVirtual(cx, cy); + const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; + this.zoomTo(this._zoom * factor, vx, vy); + } + + zoomTo(newZoom, pivotVX, pivotVY) { + const sx = (this._cssW || 300) / Whiteboard.VW; + const sy = (this._cssH || 150) / Whiteboard.VH; + // Pivot in CSS before zoom change + const pcx = pivotVX != null ? (pivotVX - this._panVX) * sx * this._zoom : (this._cssW || 300) / 2; + const pcy = pivotVY != null ? (pivotVY - this._panVY) * sy * this._zoom : (this._cssH || 150) / 2; + this._zoom = Math.max(0.25, Math.min(8, newZoom)); + if (pivotVX != null) { + this._panVX = pivotVX - pcx / (sx * this._zoom); + this._panVY = pivotVY - pcy / (sy * this._zoom); + } + this._clampPan(); + this._staticDirty = true; + this.render(); + if (this._onZoomChange) this._onZoomChange(this._zoom); + } + + resetView() { + this._zoom = 1; + this._panVX = 0; + this._panVY = 0; + this._staticDirty = true; + this.render(); + if (this._onZoomChange) this._onZoomChange(1); + } + + _clampPan() { + const VW = Whiteboard.VW, VH = Whiteboard.VH; + const visW = VW / this._zoom; + const visH = VH / this._zoom; + if (visW <= VW) { + this._panVX = Math.max(0, Math.min(this._panVX, VW - visW)); + } else { + this._panVX = (VW - visW) / 2; + } + if (visH <= VH) { + this._panVY = Math.max(0, Math.min(this._panVY, VH - visH)); + } else { + this._panVY = (VH - visH) / 2; + } + } + + _applyResize(data, ds, dvx, dvy) { + const h = ds.handle; + if (h === 'br') { + data.w = Math.max(20, ds.origW + dvx); + data.h = Math.max(20, ds.origH + dvy); + } else if (h === 'tr') { + data.w = Math.max(20, ds.origW + dvx); + const nh = Math.max(20, ds.origH - dvy); + data.y = ds.origY + (ds.origH - nh); + data.h = nh; + } else if (h === 'bl') { + const nw = Math.max(20, ds.origW - dvx); + data.x = ds.origX + (ds.origW - nw); + data.w = nw; + data.h = Math.max(20, ds.origH + dvy); + } else if (h === 'tl') { + const nw = Math.max(20, ds.origW - dvx); + const nh = Math.max(20, ds.origH - dvy); + data.x = ds.origX + (ds.origW - nw); + data.y = ds.origY + (ds.origH - nh); + data.w = nw; + data.h = nh; + } + } + + _flushProgress() { + if (!this._drawing || !this._onStrokeProgress || !this._liveId) return; + let data; + if (this._isShapeTool()) { + 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 }; + } 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 }; + } else { + if (this._curPts.length === 0) return; + data = { points: [...this._curPts], + color: this._tool === 'eraser' ? null : this._color, width: this._width }; + } + this._onStrokeProgress({ + liveId: this._liveId, + tool: this._isShapeTool() ? 'shape' : this._tool, + data, + }); + } + + _onPointerUp(_e) { + // ── pan end ─────────────────────────────────────────────────────────── + if (this._panStartCss) { + this._panStartCss = null; + this._panStartPan = null; + this._canvas.style.cursor = this._spaceDown ? 'grab' : ''; + return; + } + + // ── overlay drag end ────────────────────────────────────────────────── + if (this._overlayDrag) { + const ov = this._overlays[this._overlayDrag.idx]; + this._overlayDrag = null; + if (ov && this._onOverlayChange) this._onOverlayChange(ov); + return; + } + + // ── laser pointer: cancel preview ───────────────────────────────────── + if (this._tool === 'laser') { + this._drawing = false; + this._laserPos = null; + const liveId = this._liveId; + this._liveId = null; + if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); + this.render(); + return; + } + + // ── select tool ────────────────────────────────────────────────────── + if (this._tool === 'select') { + // Finish lasso selection + if (this._lassoRect) { + const lr = this._lassoRect; + const lx1 = Math.min(lr.x1, lr.x2), lx2 = Math.max(lr.x1, lr.x2); + const ly1 = Math.min(lr.y1, lr.y2), ly2 = Math.max(lr.y1, lr.y2); + if (lx2 - lx1 > 4 || ly2 - ly1 > 4) { + for (const s of this._strokes) { + const b = this._getStrokeBBox(s); + if (b.x < lx2 && b.x + b.w > lx1 && b.y < ly2 && b.y + b.h > ly1) + this._selectedIds.add(s.id); + } + } + this._lassoRect = null; + this.render(); + return; + } + if (this._dragState) { + this._snapGuides = []; + // Notify server of updated positions for all moved strokes + for (const id of this._selectedIds) { + const sel = this._strokes.find(s => s.id === id); + if (sel && this._onStrokeUpdated) this._onStrokeUpdated(sel); + } + this._dragState = null; + this.render(); + } + return; + } + + if (!this._drawing) return; + this._drawing = false; + if (this._progressTimer) { clearTimeout(this._progressTimer); this._progressTimer = null; } + const liveId = this._liveId; + this._liveId = null; + + // ── table tool ──────────────────────────────────────────────────────── + if (this._tool === 'table') { + const [x1, y1] = this._shapeStart; + const [x2, y2] = this._shapeEnd || this._shapeStart; + let vx = Math.min(x1, x2), vy = Math.min(y1, y2); + let vw = Math.abs(x2 - x1), vh = Math.abs(y2 - y1); + if (vw < 40) vw = 360; + if (vh < 40) vh = 240; + vx = Math.min(vx, Whiteboard.VW - vw); + vy = Math.min(vy, Whiteboard.VH - vh); + 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: [['','','',''], ['','','',''], ['','','','']], + borderColor: '#9B5DE5', + bgColor: 'rgba(26,22,37,0.85)', + textColor: '#e8e0f7', + fontSize: 14, + }, + }; + this._shapeStart = null; this._shapeEnd = null; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + return; + } + + // ── connector tool ──────────────────────────────────────────────────── + if (this._tool === 'connector') { + const [x1, y1] = this._shapeStart; + const [x2, y2] = this._shapeEnd || this._shapeStart; + if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) { + if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); + return; + } + 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 }, + }; + this._shapeStart = null; this._shapeEnd = null; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + return; + } + + // ── shape tools ──────────────────────────────────────────────────────── + if (this._isShapeTool()) { + const [x1, y1] = this._shapeStart; + const [x2, y2] = this._shapeEnd || this._shapeStart; + if (Math.abs(x2 - x1) < 2 && Math.abs(y2 - y1) < 2) { + if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); + return; + } + const stroke = { + id: this._localIdCounter--, tool: 'shape', + data: { shape: this._tool, x1, y1, x2, y2, + color: this._color, width: this._width, fill: this._fill, + lineStyle: this._lineStyle, opacity: this._opacity }, + }; + this._shapeStart = null; this._shapeEnd = null; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + return; + } + + // ── pencil / eraser ──────────────────────────────────────────────────── + if (this._curPts.length === 0) { + if (liveId && this._onStrokeProgress) this._onStrokeProgress({ liveId, cancel: true }); + return; + } + const stroke = { + id: this._localIdCounter--, tool: this._tool, + data: { points: this._curPts, + color: this._tool === 'eraser' ? null : this._color, width: this._width, + lineStyle: this._lineStyle, opacity: this._opacity }, + }; + this._curPts = []; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + } + + /* ── text tool ──────────────────────────────────────────────────────── */ + + _placeTextInput([vx, vy]) { + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeTextInput(); + const cw = this._cssW || 300; + const ch = this._cssH || 150; + const [cx, cy] = this._toCanvas(vx, vy); + + // Position textarea accounting for canvas offset within the parent wrapper + const canvasRect = this._canvas.getBoundingClientRect(); + const wrapRect = wrap.getBoundingClientRect(); + const offX = canvasRect.left - wrapRect.left; + const offY = canvasRect.top - wrapRect.top; + + const fs = Math.max(14, Math.round((22 / 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 ta = document.createElement('textarea'); + ta.placeholder = 'Введите текст… (Enter — вставить, Esc — отмена)'; + ta.rows = 3; + 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}; + background:rgba(12,8,24,0.92); + border:1.5px solid rgba(155,93,229,0.7); + border-radius:8px; outline:none; resize:none; + padding:6px 10px; caret-color:${this._color}; + z-index:20; line-height:1.45; + box-shadow:0 4px 24px rgba(0,0,0,0.6); + `; + wrap.appendChild(ta); + this._textInput = ta; + + const commit = () => { + const text = ta.value.trim(); + this._removeTextInput(); + if (!text) return; + const stroke = { + id: this._localIdCounter--, tool: 'text', + data: { text, x: vx, y: vy, fontSize: 22, color: this._color }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + }; + + ta.addEventListener('keydown', ev => { + ev.stopPropagation(); // don't leak to canvas key handler + if (ev.key === 'Escape') { ev.preventDefault(); this._removeTextInput(); } + // Enter without Shift commits; Shift+Enter inserts newline + if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commit(); } + }); + + // Outside-click detection: use capture-phase pointerdown on document. + // Registered via setTimeout to skip the current pointerdown that spawned this textarea. + const onDocDown = e => { + if (ta.contains(e.target) || e.target === ta) return; + this._textInputDocHandler = null; + document.removeEventListener('pointerdown', onDocDown, true); + commit(); + }; + this._textInputDocHandler = onDocDown; + setTimeout(() => { + if (ta.isConnected) document.addEventListener('pointerdown', onDocDown, true); + }, 0); + + // Focus after the current event cycle so pointer events on the canvas + // can't steal focus back immediately. + requestAnimationFrame(() => { if (ta.isConnected) ta.focus(); }); + } + + _removeTextInput() { + if (this._textInputDocHandler) { + document.removeEventListener('pointerdown', this._textInputDocHandler, true); + this._textInputDocHandler = null; + } + if (this._textInput) { this._textInput.remove(); this._textInput = null; } + } + + /* ── sticky tool ────────────────────────────────────────────────────── */ + + _createSticky(vx, vy) { + const W = 220, H = 200; + 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 stroke = { + id: this._localIdCounter--, tool: 'sticky', + data: { x, y, w: W, h: H, text: '', bgColor, textColor: '#1a1a2e', fontSize: 16 }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + this._editSticky(stroke); + } + + _editSticky(stroke) { + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeObjectInput(); + const d = stroke.data; + const [cx, cy] = this._toCanvas(d.x, d.y); + const cw = (d.w / Whiteboard.VW) * this._cssW; + const ch = (d.h / Whiteboard.VH) * this._cssH; + const pad = 10; + const fs = Math.round((d.fontSize / Whiteboard.VH) * this._cssH); + const ta = document.createElement('textarea'); + ta.value = d.text || ''; + ta.style.cssText = ` + position:absolute; left:${cx + pad}px; top:${cy + pad}px; + width:${cw - 2 * pad}px; height:${ch - 2 * pad}px; + font-size:${fs}px; font-family:'Manrope',sans-serif; + color:${d.textColor}; background:transparent; + border:none; outline:none; resize:none; + padding:0; line-height:1.4; caret-color:${d.textColor}; z-index:10; + `; + wrap.style.position = 'relative'; + wrap.appendChild(ta); + ta.focus(); + this._objectInput = { el: ta, strokeId: stroke.id }; + + const commit = () => { + const text = ta.value; + this._removeObjectInput(); + const s = this._strokes.find(x => x.id === stroke.id); + if (!s) return; + s.data.text = text; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(s); + }; + ta.addEventListener('keydown', ev => { if (ev.key === 'Escape') commit(); }); + ta.addEventListener('blur', () => setTimeout(commit, 80)); + } + + /* ── formula tool ───────────────────────────────────────────────────── */ + + _placeFormula(vx, vy) { + // If an external formula editor is wired up, delegate to it + if (this._onFormulaInsert) { + this._onFormulaInsert(vx, vy); + return; + } + // Fallback: inline input (minimal, shown when no modal is wired) + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeObjectInput(); + const [cx, cy] = this._toCanvas(vx, vy); + const inp = document.createElement('input'); + inp.type = 'text'; + inp.placeholder = 'LaTeX, напр: \\frac{a}{b}'; + inp.style.cssText = ` + position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px; + font-size:14px; font-family:'Manrope',sans-serif; + color:#e8e0f7; background:rgba(12,8,24,0.95); + border:1.5px solid #9B5DE5; border-radius:6px; + outline:none; width:270px; padding:7px 10px; + caret-color:#9B5DE5; z-index:20; + box-shadow:0 4px 20px rgba(0,0,0,0.55); + `; + wrap.appendChild(inp); + inp.focus(); + this._objectInput = { el: inp, strokeId: null }; + + const commit = () => { + const latex = inp.value.trim(); + this._removeObjectInput(); + if (!latex) return; + this.insertFormula(vx, vy, latex); + }; + inp.addEventListener('keydown', ev => { + if (ev.key === 'Enter') { ev.preventDefault(); commit(); } + if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); } + }); + inp.addEventListener('blur', () => setTimeout(commit, 100)); + } + + _editFormula(stroke) { + // If external editor is wired, use it for edit too + if (this._onFormulaInsert) { + const d = stroke.data; + // We pass the stroke ID so the modal can update instead of insert + this._editingFormulaStroke = stroke; + this._onFormulaInsert(d.x + d.w / 2, d.y + d.h / 2, d.latex); + return; + } + // Fallback: inline input + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeObjectInput(); + const d = stroke.data; + const [cx, cy] = this._toCanvas(d.x, d.y); + const inp = document.createElement('input'); + inp.type = 'text'; + inp.value = d.latex || ''; + inp.placeholder = 'LaTeX формула'; + inp.style.cssText = ` + position:absolute; left:${Math.min(cx, (this._cssW||300)-280)}px; top:${cy}px; + font-size:14px; font-family:'Manrope',sans-serif; + color:#e8e0f7; background:rgba(12,8,24,0.95); + border:1.5px solid #9B5DE5; border-radius:6px; + outline:none; width:270px; padding:7px 10px; + caret-color:#9B5DE5; z-index:20; + box-shadow:0 4px 20px rgba(0,0,0,0.55); + `; + wrap.appendChild(inp); + inp.focus(); + inp.select(); + this._objectInput = { el: inp, strokeId: stroke.id }; + + const commit = () => { + const latex = inp.value.trim(); + this._removeObjectInput(); + const s = this._strokes.find(x => x.id === stroke.id); + if (!s || !latex) return; + s.data.latex = latex; + s._formulaImg = null; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(s); + }; + inp.addEventListener('keydown', ev => { + if (ev.key === 'Enter') { ev.preventDefault(); commit(); } + if (ev.key === 'Escape') { ev.stopPropagation(); this._removeObjectInput(); } + }); + inp.addEventListener('blur', () => setTimeout(commit, 100)); + } + + /* ── table tool ─────────────────────────────────────────────────────── */ + + _getTableCell(stroke, vx, vy) { + const d = stroke.data; + if (vx < d.x || vx > d.x + d.w || vy < d.y || vy > d.y + d.h) return null; + return { + row: Math.min(Math.floor((vy - d.y) / (d.h / d.rows)), d.rows - 1), + col: Math.min(Math.floor((vx - d.x) / (d.w / d.cols)), d.cols - 1), + }; + } + + _editTableCell(stroke, row, col) { + const wrap = this._canvas.parentElement; + if (!wrap) return; + this._removeObjectInput(); + const d = stroke.data; + const cellVW = d.w / d.cols; + const cellVH = d.h / d.rows; + const [cx, cy] = this._toCanvas(d.x + col * cellVW, d.y + row * cellVH); + const cw = (cellVW / Whiteboard.VW) * this._cssW; + const ch = (cellVH / Whiteboard.VH) * this._cssH; + const pad = 4; + const fs = Math.round(((d.fontSize || 14) / Whiteboard.VH) * this._cssH); + const inp = document.createElement('input'); + inp.type = 'text'; + inp.value = (d.cells[row] && d.cells[row][col]) || ''; + inp.style.cssText = ` + position:absolute; left:${cx + pad}px; top:${cy + pad}px; + width:${cw - 2 * pad}px; height:${ch - 2 * pad}px; + font-size:${fs}px; font-family:'Manrope',sans-serif; + color:${d.textColor || '#e8e0f7'}; + background:rgba(155,93,229,0.12); + border:1.5px solid #9B5DE5; border-radius:3px; + outline:none; padding:0 4px; z-index:10; box-sizing:border-box; + `; + wrap.style.position = 'relative'; + wrap.appendChild(inp); + inp.focus(); + inp.select(); + this._objectInput = { el: inp, strokeId: stroke.id }; + + const commit = () => { + const val = inp.value; + this._removeObjectInput(); + const s = this._strokes.find(x => x.id === stroke.id); + if (!s) return; + if (!s.data.cells[row]) s.data.cells[row] = []; + s.data.cells[row][col] = val; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(s); + }; + inp.addEventListener('keydown', ev => { + if (ev.key === 'Enter' || ev.key === 'Tab') { ev.preventDefault(); commit(); } + if (ev.key === 'Escape') { this._removeObjectInput(); } + }); + inp.addEventListener('blur', () => setTimeout(commit, 80)); + } + + _removeObjectInput() { + if (this._objectInput) { this._objectInput.el.remove(); this._objectInput = null; } + } + + /* ── edit dispatch ──────────────────────────────────────────────────── */ + + _editObject(stroke, vx, vy) { + 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 === 'sticky') { this._editSticky(stroke); return; } + if (stroke.tool === 'formula') { this._editFormula(stroke); return; } + if (stroke.tool === 'table') { + const cell = this._getTableCell(stroke, vx, vy); + if (cell) this._editTableCell(stroke, cell.row, cell.col); + return; + } + if (stroke.tool === 'text') { + const d = stroke.data; + this._strokes = this._strokes.filter(s => s.id !== stroke.id); + const i = this._undoStack.indexOf(stroke.id); + if (i !== -1) this._undoStack.splice(i, 1); + this._selectedId = null; + this._staticDirty = true; + this.render(); + this._placeTextInput([d.x, d.y]); + if (this._textInput) this._textInput.value = d.text || ''; + } + if (stroke.tool === 'image') { + // images are not editable inline + } + } + + /* ── live stroke API (remote preview) ──────────────────────────────── */ + + setLiveStroke(liveId, tool, data, userName, color) { + this._liveStrokes.set(liveId, { tool, data, userName, color }); + this.render(); + } + + removeLiveStroke(liveId) { + if (this._liveStrokes.delete(liveId)) this.render(); + } + + clearAllLiveStrokes() { + if (this._liveStrokes.size === 0) return; + this._liveStrokes.clear(); + this.render(); + } + + /* ── render ─────────────────────────────────────────────────────────── */ + + /* ── chalkboard background ──────────────────────────────────────────── */ + + _getBgNoise() { + if (this._bgNoise) return this._bgNoise; + 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'; + 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)); + } + 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(); + } + this._bgNoise = this._ctx.createPattern(oc, 'repeat'); + } catch { this._bgNoise = false; } + return this._bgNoise; + } + + _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 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)'); + ctx.fillStyle = vg; + ctx.fillRect(0, 0, W, H); + } + + _renderTemplate(ctx) { + const W = this._cssW || 300; + const H = this._cssH || 150; + 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.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.beginPath(); + for (let y = stepY; y < H; y += stepY) { ctx.moveTo(0, y); ctx.lineTo(W, y); } + ctx.stroke(); + } else if (this._template === 'dots') { + 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)'; + 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(); + } + } else if (this._template === 'coordinate') { + const ox = W / 2, oy = H / 2; + const stepX = (40 / Whiteboard.VW) * W; + const stepY = (40 / Whiteboard.VH) * H; + // Light grid + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; 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.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)'; + 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; + 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); } + for (let y = oy + stepY; y < H - ar; y += stepY) { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); } + for (let y = oy - stepY; y > 0; y -= stepY) { ctx.moveTo(ox-tk, y); ctx.lineTo(ox+tk, y); } + ctx.stroke(); + } + ctx.restore(); + } + + render() { + if (this._staticDirty) { this._renderStatic(); this._staticDirty = false; } + this._renderDynamic(); + this._renderMinimap(); + } + + _renderStatic() { + 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); + } + + _renderDynamic() { + const ctx = this._dynCtx; + const W = this._cssW || 300, H = this._cssH || 150; + ctx.clearRect(0, 0, W, H); + + // Remote live strokes (other users drawing in real-time) + // Name/cursor dot is handled by DOM cursors (_showRemoteCursor) — no duplication here + for (const [, ls] of this._liveStrokes) { + 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); + ctx.save(); + ctx.shadowColor = '#ff2222'; ctx.shadowBlur = 15; + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); ctx.arc(lcx, lcy, 6, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + } + + // Selection overlays (all selected strokes) + for (const id of this._selectedIds) { + const sel = this._strokes.find(s => s.id === id); + if (sel) { + this._renderObjectSelection(ctx, sel); + // Auto-measurements for shapes + if (this._showMeasurements && sel.tool === 'shape') this.renderMeasurements(ctx, sel); + } + } + + // Lasso rubber-band rectangle + if (this._lassoRect) { + const lr = this._lassoRect; + const [cx1, cy1] = this._toCanvas(Math.min(lr.x1, lr.x2), Math.min(lr.y1, lr.y2)); + const [cx2, cy2] = this._toCanvas(Math.max(lr.x1, lr.x2), Math.max(lr.y1, lr.y2)); + ctx.save(); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); + ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1); + ctx.fillStyle = 'rgba(155,93,229,0.06)'; + ctx.fillRect(cx1, cy1, cx2 - cx1, cy2 - cy1); + ctx.setLineDash([]); ctx.restore(); + } + + // Snap guides (cyan lines) + if (this._snapGuides.length > 0) { + ctx.save(); + ctx.strokeStyle = '#06D6E0'; ctx.lineWidth = 1; ctx.globalAlpha = 0.7; + ctx.setLineDash([4, 4]); + for (const g of this._snapGuides) { + const [gx, gy] = this._toCanvas(g.axis === 'x' ? g.pos : 0, g.axis === 'y' ? g.pos : 0); + ctx.beginPath(); + if (g.axis === 'x') { ctx.moveTo(gx, 0); ctx.lineTo(gx, H); } + else { ctx.moveTo(0, gy); ctx.lineTo(W, gy); } + ctx.stroke(); + } + ctx.setLineDash([]); ctx.restore(); + } + + // Live drawing preview (current user) + if (this._drawing) { + if ((this._isShapeTool() || this._tool === 'connector') && this._shapeStart && this._shapeEnd) { + if (this._tool === 'connector') { + 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, + }}); + } 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, + }}); + } + } else if (this._tool === 'table' && this._shapeStart && this._shapeEnd) { + const [x1, y1] = this._shapeStart, [x2, y2] = this._shapeEnd; + const [cx1, cy1] = this._toCanvas(Math.min(x1, x2), Math.min(y1, y2)); + const [cx2, cy2] = this._toCanvas(Math.max(x1, x2), Math.max(y1, y2)); + ctx.save(); + ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); + ctx.strokeRect(cx1, cy1, cx2 - cx1, cy2 - cy1); + ctx.setLineDash([]); ctx.restore(); + } else if (!this._isShapeTool() && this._tool !== 'connector' && this._tool !== 'table' && this._curPts.length > 0) { + this._renderStroke(ctx, { tool: this._tool, + data: { points: this._curPts, color: this._color, width: this._width } }); + } + } + + // Ruler / Protractor overlays + if (this._overlays.length > 0) this._renderOverlays(ctx); + } + + _renderStroke(ctx, stroke) { + 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; } + if (stroke.tool === 'sticky') { this._renderSticky(ctx, stroke); return; } + if (stroke.tool === 'formula') { this._renderFormula(ctx, stroke); return; } + if (stroke.tool === 'table') { this._renderTable(ctx, stroke); return; } + if (stroke.tool === 'connector') { this._renderConnector(ctx, stroke); return; } + if (stroke.tool === 'highlighter') { this._renderHighlighter(ctx, stroke); return; } + if (stroke.tool === 'laser') { this._renderLaser(ctx, stroke); return; } + if (stroke.tool === 'coordinate') { this._renderCoordinate(ctx, stroke); return; } + if (stroke.tool === 'numberline') { this._renderNumberLine(ctx, stroke); return; } + if (stroke.tool === 'compass') { this._renderCompass(ctx, stroke); return; } + + // pencil / eraser + const pts = stroke.data.points; + if (!pts || pts.length === 0) return; + + ctx.save(); + if (stroke.tool === 'eraser') { + ctx.globalCompositeOperation = 'destination-out'; + ctx.strokeStyle = 'rgba(0,0,0,1)'; + ctx.fillStyle = 'rgba(0,0,0,1)'; + } else { + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = stroke.data.color || '#ffffff'; + ctx.fillStyle = stroke.data.color || '#ffffff'; + // Chalk effect: soft powdery edges + slight transparency, modulated by opacity + ctx.globalAlpha = (stroke.data.opacity ?? 1.0) * 0.88; + ctx.shadowColor = stroke.data.color || '#ffffff'; + ctx.shadowBlur = 1.4; + } + ctx.lineWidth = (stroke.data.width / Whiteboard.VW) * (this._cssW || 300); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + ctx.beginPath(); + const [x0, y0] = this._toCanvas(pts[0][0], pts[0][1]); + ctx.moveTo(x0, y0); + + if (pts.length === 1) { + ctx.arc(x0, y0, ctx.lineWidth / 2, 0, Math.PI * 2); + ctx.fill(); + } else if (pts.length === 2) { + const [x1, y1] = this._toCanvas(pts[1][0], pts[1][1]); + ctx.lineTo(x1, y1); ctx.stroke(); + } else { + // Catmull-Rom spline for smooth curves + for (let i = 0; i < pts.length - 1; i++) { + const i0 = Math.max(0, i - 1), i3 = Math.min(pts.length - 1, i + 2); + const p0 = this._toCanvas(pts[i0][0], pts[i0][1]); + const p1 = this._toCanvas(pts[i][0], pts[i][1]); + const p2 = this._toCanvas(pts[i + 1][0], pts[i + 1][1]); + const p3 = this._toCanvas(pts[i3][0], pts[i3][1]); + const cp1x = p1[0] + (p2[0] - p0[0]) / 6; + const cp1y = p1[1] + (p2[1] - p0[1]) / 6; + const cp2x = p2[0] - (p3[0] - p1[0]) / 6; + const cp2y = p2[1] - (p3[1] - p1[1]) / 6; + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1]); + } + ctx.stroke(); + } + ctx.restore(); + } + + _renderShape(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 lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300)); + + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = d.opacity ?? 1.0; + ctx.strokeStyle = d.color || '#ffffff'; + ctx.fillStyle = d.color || '#ffffff'; + ctx.lineWidth = lw; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2] + : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : []; + ctx.setLineDash(dash); + + const minX = Math.min(cx1, cx2), minY = Math.min(cy1, cy2); + const W = Math.abs(cx2 - cx1), H = Math.abs(cy2 - cy1); + const midX = (cx1 + cx2) / 2, midY = (cy1 + cy2) / 2; + + ctx.beginPath(); + switch (d.shape) { + case 'rect': + ctx.rect(minX, minY, W, H); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + + case 'ellipse': { + ctx.ellipse(midX, midY, W / 2 || 1, H / 2 || 1, 0, 0, Math.PI * 2); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + } + + case 'line': + ctx.moveTo(cx1, cy1); ctx.lineTo(cx2, cy2); ctx.stroke(); + break; + + case 'triangle': + ctx.moveTo(midX, minY); + ctx.lineTo(Math.max(cx1, cx2), Math.max(cy1, cy2)); + ctx.lineTo(Math.min(cx1, cx2), Math.max(cy1, cy2)); + ctx.closePath(); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + + case 'diamond': + ctx.moveTo(midX, minY); + ctx.lineTo(Math.max(cx1, cx2), midY); + ctx.lineTo(midX, Math.max(cy1, cy2)); + ctx.lineTo(Math.min(cx1, cx2), midY); + ctx.closePath(); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + + case 'hexagon': { + const angles = [0, 60, 120, 180, 240, 300].map(a => a * Math.PI / 180); + ctx.moveTo(midX + W / 2 * Math.cos(angles[0]), midY + H / 2 * Math.sin(angles[0])); + for (let i = 1; i < 6; i++) + ctx.lineTo(midX + W / 2 * Math.cos(angles[i]), midY + H / 2 * Math.sin(angles[i])); + ctx.closePath(); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + } + + case 'star': { + const outerR = Math.min(W, H) / 2; + const innerR = outerR * 0.4; + for (let i = 0; i < 10; i++) { + const r = i % 2 === 0 ? outerR : innerR; + const angle = (i * Math.PI / 5) - Math.PI / 2; + const sx = midX + r * Math.cos(angle); + const sy = midY + r * Math.sin(angle); + i === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy); + } + ctx.closePath(); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + } + + case 'roundedrect': { + const r = Math.min(W, H) * 0.15; + ctx.roundRect(minX, minY, W, H, r); + if (d.fill) ctx.fill(); ctx.stroke(); + break; + } + + case 'callout': { + const r = Math.min(W, H) * 0.12; + const tailH = H * 0.22; + const tailW = W * 0.16; + const tailX = minX + W * 0.18; + const boxH = H - tailH; + ctx.roundRect(minX, minY, W, boxH, r); + if (d.fill) ctx.fill(); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(tailX, minY + boxH); + ctx.lineTo(tailX + tailW, minY + boxH); + ctx.lineTo(tailX + tailW * 0.35, minY + H); + ctx.closePath(); + if (d.fill) ctx.fill(); else ctx.stroke(); + break; + } + + case 'arrow': { + // Block arrow from cx1,cy1 to cx2,cy2 + const dx = cx2 - cx1, dy = cy2 - cy1; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / len, uy = dy / len; + const bodyW = Math.max(4, H * 0.3); + const headH = Math.min(W * 0.35, H * 0.85); + const mx = cx2 - ux * headH, my = cy2 - uy * headH; + const px = -uy * bodyW, py = ux * bodyW; + const hpx = -uy * H * 0.5, hpy = ux * H * 0.5; + ctx.moveTo(cx1 + px, cy1 + py); + ctx.lineTo(mx + px, my + py); + ctx.lineTo(mx + hpx, my + hpy); + ctx.lineTo(cx2, cy2); + ctx.lineTo(mx - hpx, my - hpy); + ctx.lineTo(mx - px, my - py); + ctx.lineTo(cx1 - px, cy1 - py); + ctx.closePath(); + if (d.fill) ctx.fill(); else { ctx.fill(); } + break; + } + } + 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 lw = Math.max(1, (d.width / Whiteboard.VW) * (this._cssW || 300)); + + const dx = cx2 - cx1, dy = cy2 - cy1; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / len, uy = dy / len; + const headL = Math.max(8, Math.min(18, len * 0.22)); + const headW = headL * 0.55; + + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = d.opacity ?? 1.0; + ctx.strokeStyle = d.color || '#ffffff'; + ctx.fillStyle = d.color || '#ffffff'; + ctx.lineWidth = lw; + ctx.lineCap = 'round'; + const dash = d.lineStyle === 'dashed' ? [lw * 3, lw * 2] + : d.lineStyle === 'dotted' ? [lw, lw * 2.5] : []; + ctx.setLineDash(dash); + + // Line + ctx.beginPath(); + 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.beginPath(); + ctx.moveTo(cx2, cy2); + ctx.lineTo(cx2 - ux * headL + uy * headW, cy2 - uy * headL - ux * headW); + ctx.lineTo(cx2 - ux * headL - uy * headW, cy2 - uy * headL + ux * headW); + ctx.closePath(); + ctx.fill(); + } + + // Start arrowhead + if (d.arrowStart) { + ctx.beginPath(); + ctx.moveTo(cx1, cy1); + ctx.lineTo(cx1 + ux * headL + uy * headW, cy1 + uy * headL - ux * headW); + ctx.lineTo(cx1 + ux * headL - uy * headW, cy1 + uy * headL + ux * headW); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); + } + + _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 [fx, fy] = this._toCanvas(d.points[0][0], d.points[0][1]); + ctx.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); + } + ctx.stroke(); + ctx.restore(); + } + + _renderLaser(ctx, stroke) { + const d = stroke.data; + if (!d.points || d.points.length === 0) return; + 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.fillStyle = '#ff4444'; + ctx.beginPath(); + ctx.arc(cx, cy, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + _renderText(ctx, stroke) { + const d = stroke.data; + 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)); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = d.color || '#ffffff'; + ctx.font = `${fontSize}px 'Manrope', sans-serif`; + ctx.textBaseline = 'top'; + // chalk effect on text too + ctx.shadowColor = d.color || '#ffffff'; + ctx.shadowBlur = 1.2; + const lines = d.text.split('\n'); + const lh = fontSize * 1.45; + lines.forEach((line, i) => ctx.fillText(line, cx, cy + i * lh)); + ctx.restore(); + } + + _renderImage(ctx, stroke) { + const d = stroke.data; + if (!d.src) return; + 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); + if (!stroke._img) { + stroke._img = new Image(); + stroke._img.onload = () => { this._staticDirty = true; this.render(); }; + stroke._img.src = d.src; + } + if (stroke._img.complete && stroke._img.naturalWidth > 0) { + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + 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); + ctx.restore(); + } + } + + _renderSticky(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); + + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + 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); } + + // Shadow + ctx.shadowColor = 'rgba(0,0,0,0.35)'; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 3; + ctx.shadowOffsetY = 4; + + // Body + ctx.fillStyle = d.bgColor || '#FFE066'; + ctx.beginPath(); + ctx.roundRect(cx, cy, cw, ch, 3); + ctx.fill(); + ctx.shadowColor = 'transparent'; + + // Folded corner (top-right) + const fold = Math.min(cw, ch) * 0.14; + ctx.fillStyle = 'rgba(0,0,0,0.18)'; + ctx.beginPath(); + ctx.moveTo(cx + cw - fold, cy); + ctx.lineTo(cx + cw, cy + fold); + ctx.lineTo(cx + cw, cy); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = this._darkenHex(d.bgColor || '#FFE066', 25); + ctx.beginPath(); + ctx.moveTo(cx + cw - fold, cy); + ctx.lineTo(cx + cw - fold, cy + fold); + ctx.lineTo(cx + cw, cy + fold); + ctx.closePath(); + ctx.fill(); + + // Text + if (d.text) { + const fs = Math.round((d.fontSize / Whiteboard.VH) * (this._cssH || 150)); + const pad = Math.max(8, cw * 0.08); + ctx.fillStyle = d.textColor || '#1a1a2e'; + ctx.font = `${fs}px 'Manrope', sans-serif`; + ctx.textBaseline = 'top'; + const maxW = cw - 2 * pad; + const lines = this._wrapText(ctx, d.text, maxW); + let ty = cy + pad; + const lineH = fs * 1.4; + for (const line of lines) { + if (ty + lineH > cy + ch - pad * 0.5) break; + ctx.fillText(line, cx + pad, ty); + ty += lineH; + } + } + ctx.restore(); + } + + _darkenHex(hex, amount) { + try { + const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount); + const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount); + const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } catch { return hex; } + } + + _wrapText(ctx, text, maxW) { + const lines = []; + for (const para of text.split('\n')) { + const words = para.split(' '); + let cur = ''; + for (const w of words) { + const test = cur ? cur + ' ' + w : w; + if (ctx.measureText(test).width > maxW && cur) { + lines.push(cur); cur = w; + } else { cur = test; } + } + lines.push(cur); + } + return lines; + } + + _renderFormula(ctx, stroke) { + const d = stroke.data; + if (!d.latex) return; + 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); + + ctx.save(); + 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.fillStyle = 'rgba(26,22,37,0.75)'; + ctx.beginPath(); + ctx.roundRect(cx, cy, cw, ch, 6); + ctx.fill(); + + const fi = stroke._formulaImg; + if (fi === null || fi === undefined) { + // Not started yet — kick off async render and show placeholder text + this._renderFormulaAsync(stroke, cw, ch); + this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); + } else if (fi === 'pending') { + // CSS / image loading in progress + this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); + } else if (fi === false) { + // Render failed — show raw LaTeX so user knows what's there + this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); + } else if (fi.complete && fi.naturalWidth > 0) { + ctx.drawImage(fi, cx, cy, cw, ch); + } else { + this._drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch); + } + ctx.restore(); + } + + _drawFormulaPlaceholder(ctx, d, cx, cy, cw, ch) { + const fs = Math.max(11, Math.round((13 / Whiteboard.VH) * (this._cssH || 150))); + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.font = `italic ${fs}px 'Manrope',sans-serif`; + ctx.textBaseline = 'middle'; + // Clip text to box so it doesn't overflow + ctx.save(); + ctx.beginPath(); + ctx.rect(cx + 4, cy, cw - 8, ch); + ctx.clip(); + ctx.fillText(d.latex, cx + 8, cy + ch / 2); + ctx.restore(); + } + + _renderFormulaAsync(stroke, cw, ch) { + stroke._formulaImg = 'pending'; + if (!window.katex) { stroke._formulaImg = false; return; } + _loadKatexCss(css => { + // Stroke may have been removed while CSS was loading + if (!this._strokes.includes(stroke)) { stroke._formulaImg = false; return; } + try { + const html = window.katex.renderToString(stroke.data.latex, { + throwOnError: false, displayMode: true, output: 'html', + }); + const color = stroke.data.color || '#e8e0f7'; + const vfs = stroke.data.fontSize || 32; + // Render at 3× for crisp, PowerPoint-quality formulas + const scale = 3; + const W = Math.ceil(cw * scale); + const H = Math.ceil(ch * scale); + const fs = Math.max(18, Math.round((vfs / Whiteboard.VH) * (this._cssH || 150) * 2.8 * scale)); + const svgStr = [ + ``, + ``, + `
`, + ``, + `
`, + html, + `
`, + ].join(''); + const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + stroke._formulaImg = img; + this._staticDirty = true; + this.render(); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + stroke._formulaImg = false; // use false (not null) to avoid re-render loop + this._staticDirty = true; + this.render(); + }; + img.src = url; + } catch { stroke._formulaImg = false; } + }); + } + + _renderTable(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 cellW = cw / d.cols; + const cellH = ch / d.rows; + const fs = Math.round(((d.fontSize || 14) / Whiteboard.VH) * (this._cssH || 150)); + const border = d.borderColor || '#9B5DE5'; + const bg = d.bgColor || 'rgba(26,22,37,0.85)'; + const tc = d.textColor || '#e8e0f7'; + + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + 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); } + + // Background + ctx.fillStyle = bg; + ctx.beginPath(); + ctx.roundRect(cx, cy, cw, ch, 4); + ctx.fill(); + + // Grid + ctx.strokeStyle = border; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(cx, cy, cw, ch, 4); + ctx.stroke(); + + for (let c = 1; c < d.cols; c++) { + const x = cx + c * cellW; + ctx.beginPath(); + ctx.moveTo(x, cy); ctx.lineTo(x, cy + ch); ctx.stroke(); + } + for (let r = 1; r < d.rows; r++) { + const y = cy + r * cellH; + ctx.beginPath(); + ctx.moveTo(cx, y); ctx.lineTo(cx + cw, y); ctx.stroke(); + } + + // Cell text + ctx.fillStyle = tc; + ctx.font = `${fs}px 'Manrope', sans-serif`; + ctx.textBaseline = 'middle'; + const pad = Math.max(4, cellW * 0.06); + + for (let r = 0; r < d.rows; r++) { + for (let c = 0; c < d.cols; c++) { + const text = (d.cells && d.cells[r] && d.cells[r][c]) || ''; + if (!text) continue; + ctx.save(); + ctx.beginPath(); + ctx.rect(cx + c * cellW + 1, cy + r * cellH + 1, cellW - 2, cellH - 2); + ctx.clip(); + ctx.fillText(text, cx + c * cellW + pad, cy + r * cellH + cellH / 2); + ctx.restore(); + } + } + ctx.restore(); + } + + /* ── select tool helpers ─────────────────────────────────────────────── */ + + _hitTestObject(vx, vy) { + for (let i = this._strokes.length - 1; i >= 0; i--) { + const s = this._strokes[i]; + if (!this._isObjectStroke(s)) continue; + const d = s.data; + if (vx >= d.x && vx <= d.x + d.w && vy >= d.y && vy <= d.y + d.h) return s; + } + return null; + } + + _hitTestObjectHandle(vx, vy, stroke) { + const r = (12 / (this._cssW || 300)) * Whiteboard.VW; + const b = this._getStrokeBBox(stroke); + // Rotation handle (object strokes only): above top-center + if (this._isObjectStroke(stroke)) { + const rotVX = b.x + b.w / 2; + const rotVY = b.y - (28 / (this._cssH || 150)) * Whiteboard.VH; + if (Math.abs(vx - rotVX) < r && Math.abs(vy - rotVY) < r) return 'rot'; + } + const corners = { + tl: [b.x, b.y], + tr: [b.x + b.w, b.y], + bl: [b.x, b.y + b.h], + br: [b.x + b.w, b.y + b.h], + }; + for (const [name, [hx, hy]] of Object.entries(corners)) { + if (Math.abs(vx - hx) < r && Math.abs(vy - hy) < r) return name; + } + return null; + } + + _renderObjectSelection(ctx, stroke) { + const b = this._getStrokeBBox(stroke); + const [cx, cy] = this._toCanvas(b.x, b.y); + const cw = (b.w / Whiteboard.VW) * (this._cssW || 300); + const ch = (b.h / Whiteboard.VH) * (this._cssH || 150); + const HS = 8; + + ctx.save(); + ctx.strokeStyle = '#06D6E0'; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 3]); + ctx.strokeRect(cx, cy, cw, ch); + ctx.setLineDash([]); + + // Resize handles only for resizable strokes + if (this._isResizableStroke(stroke)) { + for (const [hx, hy] of [[cx, cy], [cx + cw, cy], [cx, cy + ch], [cx + cw, cy + ch]]) { + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#06D6E0'; + ctx.lineWidth = 1.5; + ctx.fillRect(hx - HS / 2, hy - HS / 2, HS, HS); + ctx.strokeRect(hx - HS / 2, hy - HS / 2, HS, HS); + } + } + // Rotation handle (object strokes only) + if (this._isObjectStroke(stroke)) { + const rotCx = cx + cw / 2; + const rotCy = cy - 28; + ctx.strokeStyle = '#9B5DE5'; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(cx + cw / 2, cy); ctx.lineTo(rotCx, rotCy); ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#9B5DE5'; + ctx.beginPath(); ctx.arc(rotCx, rotCy, 6, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + ctx.restore(); + } + + /* ── public API ─────────────────────────────────────────────────────── */ + + setTool(name) { + this._removeTextInput(); + this._removeObjectInput(); + this._drawing = false; + this._shapeStart = null; + if (name !== 'select') { + this._selectedId = null; + this._dragState = null; + this._canvas.style.cursor = ''; + } + this._tool = name; + } + + setColor(hex) { + this._color = hex; + if (this._textInput) this._textInput.style.color = hex; + } + setWidth(px) { this._width = px; } + setFill(v) { this._fill = v; } + setReadOnly(v) { this._readOnly = v; } + setLineStyle(style) { this._lineStyle = style; } + setOpacity(v) { this._opacity = Math.max(0.05, Math.min(1, v)); } + setTemplate(name) { this._template = name || 'blank'; this._staticDirty = true; this.render(); } + setPageNum(n) { this._pageNum = n; } + + exportPNG() { + const off = document.createElement('canvas'); + off.width = Whiteboard.VW; off.height = Whiteboard.VH; + const ctx = off.getContext('2d'); + const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; + this._cssW = Whiteboard.VW; this._cssH = Whiteboard.VH; + this._zoom = 1; this._panVX = 0; this._panVY = 0; + this._renderBg(ctx); + if (this._template && this._template !== 'blank') this._renderTemplate(ctx); + for (const s of this._strokes) this._renderStroke(ctx, s); + this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; + off.toBlob(blob => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `whiteboard-p${this._pageNum}.png`; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(a.href), 3000); + }, 'image/png'); + } + + _renderMinimap() { + if (!this._mmCanvas) return; + const visible = this._zoom > 1.04; + this._mmCanvas.style.display = visible ? 'block' : 'none'; + if (!visible) return; + + const mm = this._mmCtx; + const MW = 192, MH = 108; + mm.clearRect(0, 0, MW, MH); + + // Blit the static layer (full board content) scaled to minimap size + mm.drawImage(this._canvas, 0, 0, MW, MH); + + // Darken areas outside the viewport with a subtle vignette + const vpW = MW / this._zoom; + const vpH = MH / this._zoom; + const vpX = (this._panVX / Whiteboard.VW) * MW; + const vpY = (this._panVY / Whiteboard.VH) * MH; + + // Dark overlay on non-viewport areas + mm.fillStyle = 'rgba(0,0,0,0.42)'; + mm.fillRect(0, 0, MW, vpY); // top strip + mm.fillRect(0, vpY + vpH, MW, MH - vpY - vpH); // bottom strip + mm.fillRect(0, vpY, vpX, vpH); // left strip + mm.fillRect(vpX + vpW, vpY, MW - vpX - vpW, vpH); // right strip + + // Viewport border + mm.strokeStyle = 'rgba(155,93,229,0.95)'; + mm.lineWidth = 1.5; + mm.strokeRect(vpX, vpY, vpW, vpH); + + // Current position crosshair at viewport center + const cx = vpX + vpW / 2, cy = vpY + vpH / 2; + mm.strokeStyle = 'rgba(155,93,229,0.55)'; + mm.lineWidth = 0.7; + mm.beginPath(); mm.moveTo(cx - 5, cy); mm.lineTo(cx + 5, cy); mm.stroke(); + mm.beginPath(); mm.moveTo(cx, cy - 5); mm.lineTo(cx, cy + 5); mm.stroke(); + } + + _mmNavigate(e) { + const rect = this._mmCanvas.getBoundingClientRect(); + const mx = (e.clientX - rect.left) * (192 / rect.width); + const my = (e.clientY - rect.top) * (108 / rect.height); + // Center the viewport on the clicked virtual position + this._panVX = (mx / 192) * Whiteboard.VW - Whiteboard.VW / (2 * this._zoom); + this._panVY = (my / 108) * Whiteboard.VH - Whiteboard.VH / (2 * this._zoom); + this._clampPan(); + this._staticDirty = true; + this.render(); + } + + renderThumbnail(targetCanvas) { + const ctx = targetCanvas.getContext('2d'); + const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; + this._cssW = targetCanvas.width; this._cssH = targetCanvas.height; + this._zoom = 1; this._panVX = 0; this._panVY = 0; + this._renderBg(ctx); + if (this._template && this._template !== 'blank') this._renderTemplate(ctx); + for (const s of this._strokes) this._renderStroke(ctx, s); + this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; + } + + updateOpacity(v) { + if (this._selectedId == null) return; + const sel = this._strokes.find(s => s.id === this._selectedId); + if (!sel) return; + sel.data.opacity = Math.max(0.05, Math.min(1, v)); + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(sel); + } + + /* ── formula insert (called by external modal) ──────────────────────── */ + + insertFormula(vx, vy, latex, fontSize = 32) { + if (!latex) return; + this._editingFormulaStroke = null; // clear edit-mode flag + const W = Math.round(460 * (fontSize / 32)); + const H = Math.round(160 * (fontSize / 32)); + 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)); + const stroke = { + id: this._localIdCounter--, tool: 'formula', + data: { x, y, w: W, h: H, latex, fontSize, color: this._color }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + } + + /* Called by external modal when editing an existing formula */ + updateFormula(stroke, latex, fontSize) { + if (!stroke || !latex) return; + stroke.data.latex = latex; + stroke.data.fontSize = fontSize || stroke.data.fontSize; + stroke._formulaImg = null; + this._editingFormulaStroke = null; + this._staticDirty = true; + this.render(); + if (this._onStrokeUpdated) this._onStrokeUpdated(stroke); + } + + /* ── Clipboard paste (system clipboard: images & text) ──────────────── */ + + _onClipboardPaste(e) { + if (this._readOnly) return; + // Don't intercept paste when a real input/textarea is focused + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return; + + const items = e.clipboardData?.items; + if (!items) return; + + // Prefer image over text + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + const blob = item.getAsFile(); + if (blob) this._pasteImageBlob(blob); + return; + } + } + for (const item of Array.from(items)) { + if (item.type === 'text/plain') { + e.preventDefault(); + item.getAsString(text => { + if (!text.trim()) return; + // Place pasted text at upper-left area of the board + const stroke = { + id: this._localIdCounter--, tool: 'text', + data: { text: text.trim(), x: 80, y: 80, fontSize: 22, color: this._color }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + }); + return; + } + } + } + + _pasteImageBlob(blob) { + const maxPx = 800; + const reader = new FileReader(); + reader.onload = ev => { + const img = new Image(); + img.onload = () => { + 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.8); + // Virtual dimensions: fit into ~800×600 vp, maintain aspect ratio + const maxVW = 800, maxVH = 600; + let vw = maxVW, vh = Math.round(maxVW * ph / pw); + if (vh > maxVH) { vh = maxVH; vw = Math.round(maxVH * pw / ph); } + const vx = Math.round((Whiteboard.VW - vw) / 2); + const vy = Math.round((Whiteboard.VH - vh) / 2); + const stroke = { + id: this._localIdCounter--, tool: 'image', + data: { src, x: vx, y: vy, w: vw, h: vh }, + }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + if (this._onObjectCreated) this._onObjectCreated(stroke); + }; + img.src = ev.target.result; + }; + reader.readAsDataURL(blob); + } + + /* ── copy / paste / delete / z-order ─────────────────────────────────── */ + + copy() { + if (this._selectedId == null) return; + const sel = this._strokes.find(s => s.id === this._selectedId); + if (!sel) return; + this._clipboard = { tool: sel.tool, data: JSON.parse(JSON.stringify(sel.data)) }; + } + + paste() { + if (!this._clipboard) return; + const data = JSON.parse(JSON.stringify(this._clipboard.data)); + const OFF = 30; + if (data.x1 != null) { + // shape / connector + data.x1 += OFF; data.y1 += OFF; data.x2 += OFF; data.y2 += OFF; + } else if (data.points) { + // pencil / highlighter + data.points = data.points.map(([px, py]) => [px + OFF, py + OFF]); + } else if (data.x != null && data.w != null) { + // object stroke (image/sticky/formula/table/text with w) + data.x = Math.min(data.x + OFF, Whiteboard.VW - (data.w || 10)); + data.y = Math.min(data.y + OFF, Whiteboard.VH - (data.h || 10)); + } else if (data.x != null) { + // text (no w) + data.x += OFF; data.y += OFF; + } + const stroke = { id: this._localIdCounter--, tool: this._clipboard.tool, data }; + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._redoStack = []; + this._selectedId = stroke.id; + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + } + + deleteSelected() { + if (this._selectedIds.size === 0) return; + const ids = [...this._selectedIds]; + this._selectedIds.clear(); + this._dragState = null; + this._strokes = this._strokes.filter(s => !ids.includes(s.id)); + for (const id of ids) { + const ui = this._undoStack.indexOf(id); + if (ui !== -1) this._undoStack.splice(ui, 1); + } + this._staticDirty = true; + this.render(); + for (const id of ids) { if (this._onStrokeUndo) this._onStrokeUndo(id); } + } + + bringToFront() { + if (this._selectedIds.size === 0) return; + const toMove = this._strokes.filter(s => this._selectedIds.has(s.id)); + this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id)); + this._strokes.push(...toMove); + this._staticDirty = true; + this.render(); + for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); } + } + + sendToBack() { + if (this._selectedIds.size === 0) return; + const toMove = this._strokes.filter(s => this._selectedIds.has(s.id)); + this._strokes = this._strokes.filter(s => !this._selectedIds.has(s.id)); + this._strokes.unshift(...toMove); + this._staticDirty = true; + this.render(); + for (const s of toMove) { if (this._onStrokeUpdated) this._onStrokeUpdated(s); } + } + + /* ── strokes management ─────────────────────────────────────────────── */ + + loadStrokes(strokes) { + this._strokes = (strokes || []).map(s => this._normalise(s)); + this._liveStrokes.clear(); + this._staticDirty = true; + this.render(); + } + + /* Returns strokes not yet confirmed by server (negative IDs = locally drawn). */ + getLocalStrokes() { + return this._strokes.filter(s => s.id < 0); + } + + addStrokes(strokes) { + let changed = false; + for (const s of strokes) { + if (this._strokes.some(x => x.id === s.id)) continue; + this._strokes.push(this._normalise(s)); + changed = true; + } + if (changed) { this._staticDirty = true; this.render(); } + } + + _normalise(s) { + return { + id: s.id, + tool: s.tool, + data: typeof s.data === 'string' ? JSON.parse(s.data) : s.data, + }; + } + + removeStroke(id) { + this._strokes = this._strokes.filter(s => s.id !== id); + if (this._selectedIds.has(id)) { this._selectedIds.delete(id); this._dragState = null; } + this._staticDirty = true; + this.render(); + } + + updateStroke(id, data) { + const s = this._strokes.find(x => x.id === id); + if (!s) return; + s.data = typeof data === 'string' ? JSON.parse(data) : { ...data }; + s._img = null; + s._formulaImg = null; + this._staticDirty = true; + this.render(); + } + + confirmStroke(localId, serverId) { + const s = this._strokes.find(x => x.id === localId); + if (s) { + s.id = serverId; + const before = this._strokes.length; + this._strokes = this._strokes.filter(x => x === s || x.id !== serverId); + if (this._strokes.length !== before) { this._staticDirty = true; this.render(); } + } + const i = this._undoStack.indexOf(localId); + if (i !== -1) this._undoStack[i] = serverId; + } + + undo() { + if (this._undoStack.length === 0) return; + const id = this._undoStack.pop(); + const stroke = this._strokes.find(s => s.id === id); + this._strokes = this._strokes.filter(s => s.id !== id); + if (stroke) this._redoStack.push(stroke); + this._staticDirty = true; + this.render(); + if (this._onStrokeUndo) this._onStrokeUndo(id); + } + + redo() { + if (this._redoStack.length === 0) return; + const stroke = this._redoStack.pop(); + this._strokes.push(stroke); + this._undoStack.push(stroke.id); + this._staticDirty = true; + this.render(); + if (this._onStrokeDone) this._onStrokeDone(stroke); + } + + clearPage() { + this._strokes = []; + this._undoStack = []; + this._redoStack = []; + this._curPts = []; + this._drawing = false; + this._shapeStart = null; + this._selectedIds.clear(); + this._dragState = null; + this._lassoRect = null; + this._snapGuides = []; + this._liveStrokes.clear(); + this._removeTextInput(); + this._removeObjectInput(); + this._staticDirty = true; + this.render(); + } + + destroy() { + 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); + if (this._dynCanvas?.parentElement) this._dynCanvas.parentElement.removeChild(this._dynCanvas); + if (this._mmCanvas?.parentElement) this._mmCanvas.parentElement.removeChild(this._mmCanvas); + } + + /* ── Phase 5: Coordinate system stroke ─────────────────────────────── */ + + _renderCoordinate(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 xMin = d.xMin ?? -10, xMax = d.xMax ?? 10; + const yMin = d.yMin ?? -10, yMax = d.yMax ?? 10; + const step = d.gridStep || 1; + + ctx.save(); + 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); } + // Background + ctx.fillStyle = 'rgba(18,13,30,0.88)'; + ctx.strokeStyle = 'rgba(155,93,229,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6); + else ctx.rect(cx, cy, cw, ch); + ctx.fill(); ctx.stroke(); + + ctx.beginPath(); ctx.rect(cx, cy, cw, ch); ctx.clip(); + + const xRange = xMax - xMin, yRange = yMax - yMin; + const scaleX = cw / xRange, scaleY = ch / yRange; + // Origin in canvas coords (0,0 of math space) + const ox = cx + (-xMin) * scaleX; + const oy = cy + yMax * scaleY; + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; + ctx.lineWidth = 0.5; + for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) { + const px = cx + (v - xMin) * scaleX; + ctx.beginPath(); ctx.moveTo(px, cy); ctx.lineTo(px, cy + ch); ctx.stroke(); + } + for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) { + const py = cy + (yMax - v) * scaleY; + ctx.beginPath(); ctx.moveTo(cx, py); ctx.lineTo(cx + cw, py); ctx.stroke(); + } + + // Axes + ctx.strokeStyle = 'rgba(255,255,255,0.55)'; + ctx.lineWidth = 1.5; + // X axis + ctx.beginPath(); ctx.moveTo(cx, oy); ctx.lineTo(cx + cw, oy); ctx.stroke(); + // Y axis + ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox, cy + ch); ctx.stroke(); + + // Tick marks + labels + if (d.showLabels !== false) { + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.font = `${Math.max(8, Math.round(9 * cw / 400))}px Manrope,sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 0.8; + const tickH = 4; + for (let v = Math.ceil(xMin / step) * step; v <= xMax; v += step) { + if (Math.abs(v) < 1e-9) continue; + const px = cx + (v - xMin) * scaleX; + ctx.beginPath(); ctx.moveTo(px, oy - tickH); ctx.lineTo(px, oy + tickH); ctx.stroke(); + ctx.fillText(v, px, oy + tickH + 1); + } + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) { + if (Math.abs(v) < 1e-9) continue; + const py = cy + (yMax - v) * scaleY; + ctx.beginPath(); ctx.moveTo(ox - tickH, py); ctx.lineTo(ox + tickH, py); ctx.stroke(); + ctx.fillText(v, ox - tickH - 2, py); + } + ctx.fillText('0', ox - tickH - 2, oy + tickH + 1); + } + + // Arrow heads on axes + const aw = 6, ah = 4; + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + // X arrow right + const xEnd = cx + cw; + ctx.beginPath(); ctx.moveTo(xEnd, oy); ctx.lineTo(xEnd - aw, oy - ah); ctx.lineTo(xEnd - aw, oy + ah); ctx.closePath(); ctx.fill(); + // Y arrow up + ctx.beginPath(); ctx.moveTo(ox, cy); ctx.lineTo(ox - ah, cy + aw); ctx.lineTo(ox + ah, cy + aw); ctx.closePath(); ctx.fill(); + + // Plot functions + for (const fn of (d.functions || [])) { + ctx.strokeStyle = fn.color || '#06D6E0'; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.9; + ctx.beginPath(); + let started = false; + for (let px = cx; px <= cx + cw; px += 0.8) { + const mathX = xMin + (px - cx) / scaleX; + let mathY; + try { mathY = this._evalMath(fn.expr, mathX); } catch { continue; } + if (!isFinite(mathY)) { started = false; continue; } + const py = oy - mathY * scaleY; + if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py); + } + ctx.stroke(); + ctx.globalAlpha = 1; + } + + ctx.restore(); + } + + /* Simple recursive descent math expression evaluator */ + _evalMath(expr, x) { + const tokens = this._mathTokenise(expr.replace(/\s+/g, '')); + let pos = 0; + const peek = () => tokens[pos]; + const consume = () => tokens[pos++]; + const num = () => { + const t = peek(); + if (t === '(') { consume(); const v = addSub(); consume(/* ')' */); return v; } + if (t === '-') { consume(); return -num(); } + if (t === 'x') { consume(); return x; } + if (/^[a-z]+$/.test(t)) { + consume(); + const arg = peek() === '(' ? (consume(), (() => { const v = addSub(); consume(); return v; })()) : num(); + const fns = { sin: Math.sin, cos: Math.cos, tan: Math.tan, sqrt: Math.sqrt, + abs: Math.abs, log: Math.log, exp: Math.exp, asin: Math.asin, + acos: Math.acos, atan: Math.atan, floor: Math.floor, ceil: Math.ceil }; + if (fns[t]) return fns[t](arg); + if (t === 'pi') { pos--; return Math.PI; } + if (t === 'e') { pos--; return Math.E; } + return NaN; + } + consume(); return parseFloat(t); + }; + const pow = () => { let v = num(); while (peek() === '^') { consume(); v = Math.pow(v, num()); } return v; }; + const mulDiv = () => { + let v = pow(); + while (peek() === '*' || peek() === '/') { + const op = consume(); + v = op === '*' ? v * pow() : v / pow(); + } + return v; + }; + const addSub = () => { + let v = mulDiv(); + while (peek() === '+' || peek() === '-') { + const op = consume(); + v = op === '+' ? v + mulDiv() : v - mulDiv(); + } + return v; + }; + return addSub(); + } + + _mathTokenise(expr) { + const tokens = []; + let i = 0; + const consts = { pi: Math.PI, e: Math.E }; + while (i < expr.length) { + if (/\d|\./.test(expr[i])) { + let n = ''; + while (i < expr.length && /[\d.]/.test(expr[i])) n += expr[i++]; + tokens.push(n); + } else if (/[a-z]/i.test(expr[i])) { + let w = ''; + while (i < expr.length && /[a-z]/i.test(expr[i])) w += expr[i++].toLowerCase(); + if (w === 'pi' || w === 'e') tokens.push(parseFloat(consts[w]).toString()); + else tokens.push(w); + } else { + tokens.push(expr[i++]); + } + } + tokens.push(null); // EOF sentinel + return tokens; + } + + /* ── Phase 5: Ruler/Protractor overlays ────────────────────────────── */ + // Overlays are rendered on the dynamic layer and are not saved to DB. + + _renderOverlays(ctx) { + for (const ov of this._overlays) { + if (ov.type === 'ruler') this._renderRuler(ctx, ov); + else if (ov.type === 'protractor') this._renderProtractor(ctx, ov); + } + } + + _renderRuler(ctx, ov) { + const [cx, cy] = this._toCanvas(ov.x, ov.y); + const rulerW = (ov.width / Whiteboard.VW) * (this._cssW || 300); + const rulerH = Math.max(18, rulerW * 0.07); + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(ov.angle || 0); + ctx.fillStyle = 'rgba(255,230,180,0.15)'; + ctx.strokeStyle = 'rgba(255,230,180,0.5)'; + ctx.lineWidth = 1; + ctx.fillRect(0, 0, rulerW, rulerH); + ctx.strokeRect(0, 0, rulerW, rulerH); + // Tick marks every ~40vp + const tickStep = (40 / Whiteboard.VW) * (this._cssW || 300); + ctx.strokeStyle = 'rgba(255,230,180,0.4)'; + ctx.font = `${Math.max(6, Math.round(7 * rulerW / 300))}px Manrope,sans-serif`; + ctx.fillStyle = 'rgba(255,230,180,0.5)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + for (let t = 0; t * tickStep <= rulerW; t++) { + const tx = t * tickStep; + const th = t % 5 === 0 ? rulerH * 0.6 : rulerH * 0.3; + ctx.beginPath(); ctx.moveTo(tx, rulerH); ctx.lineTo(tx, rulerH - th); ctx.stroke(); + if (t % 5 === 0 && t > 0) ctx.fillText(t, tx, rulerH - th - 1); + } + + // ── handles ──────────────────────────────────────────────────────────── + // Rotation handle — purple circle above ruler center + const rotHx = rulerW / 2, rotHy = -20; + ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; + ctx.setLineDash([3, 2]); + ctx.beginPath(); ctx.moveTo(rulerW / 2, 0); ctx.lineTo(rotHx, rotHy); ctx.stroke(); + ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(rotHx, rotHy, 7, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); + // ↺ symbol inside + ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('↺', rotHx, rotHy); + + // Resize handle — cyan circle at right end + const resHx = rulerW + 10, resHy = rulerH / 2; + ctx.beginPath(); ctx.arc(resHx, resHy, 7, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('↔', resHx, resHy); + + ctx.restore(); + } + + _renderProtractor(ctx, ov) { + const [cx, cy] = this._toCanvas(ov.x, ov.y); + const vR = ov.radius || 80; + const r = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300)); + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(ov.angle || 0); + ctx.fillStyle = 'rgba(6,214,224,0.08)'; + ctx.strokeStyle = 'rgba(6,214,224,0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 0, r, Math.PI, 0, false); // top semicircle + ctx.lineTo(-r, 0); ctx.closePath(); + ctx.fill(); ctx.stroke(); + // Degree ticks + ctx.font = `${Math.max(6, Math.round(7 * r / 80))}px Manrope,sans-serif`; + ctx.fillStyle = 'rgba(6,214,224,0.6)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + for (let deg = 0; deg <= 180; deg += 10) { + const rad = (Math.PI - deg * Math.PI / 180); + const major = deg % 30 === 0; + const tl = major ? r * 0.15 : r * 0.08; + ctx.strokeStyle = major ? 'rgba(6,214,224,0.7)' : 'rgba(6,214,224,0.3)'; + ctx.beginPath(); + ctx.moveTo(Math.cos(rad) * (r - tl), Math.sin(rad) * (r - tl)); + ctx.lineTo(Math.cos(rad) * r, Math.sin(rad) * r); + ctx.stroke(); + if (major && deg > 0 && deg < 180) { + const lr = r - tl - 5; + ctx.fillText(deg, Math.cos(rad) * lr, Math.sin(rad) * lr - 1); + } + } + + // ── handles ──────────────────────────────────────────────────────────── + // Rotation handle — above center of flat edge (local y negative = upward) + const pRotHy = -(r + 20); + ctx.strokeStyle = 'rgba(155,93,229,0.55)'; ctx.lineWidth = 1; + ctx.setLineDash([3, 2]); + ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(0, pRotHy); ctx.stroke(); + ctx.setLineDash([]); + ctx.beginPath(); ctx.arc(0, pRotHy, 7, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(155,93,229,0.75)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('↺', 0, pRotHy); + + // Resize handle — at right end of flat edge + ctx.beginPath(); ctx.arc(r + 10, 0, 7, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(6,214,224,0.75)'; ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.stroke(); + ctx.fillStyle = '#fff'; ctx.font = '8px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('↔', r + 10, 0); + + ctx.restore(); + } + + /* Overlay drag helpers */ + _hitTestOverlay(vx, vy) { + const [pcx, pcy] = this._toCanvas(vx, vy); + for (let i = this._overlays.length - 1; i >= 0; i--) { + const ov = this._overlays[i]; + const [ox, oy] = this._toCanvas(ov.x, ov.y); + const angle = ov.angle || 0; + const dx = pcx - ox, dy = pcy - oy; + // rotate pointer into overlay local space + const lx = dx * Math.cos(-angle) - dy * Math.sin(-angle); + const ly = dx * Math.sin(-angle) + dy * Math.cos(-angle); + + if (ov.type === 'ruler') { + const W = (ov.width / Whiteboard.VW) * (this._cssW || 300); + const H = Math.max(18, W * 0.07); + if (Math.hypot(lx - W / 2, ly + 20) < 10) return { idx: i, zone: 'rot' }; + if (Math.hypot(lx - (W + 10), ly - H / 2) < 10) return { idx: i, zone: 'resize' }; + if (lx >= -5 && lx <= W + 5 && ly >= -5 && ly <= H + 5) return { idx: i, zone: 'body' }; + } + + if (ov.type === 'protractor') { + const vR = ov.radius || 80; + const r = Math.max(30, (vR / Whiteboard.VW) * (this._cssW || 300)); + if (Math.hypot(lx, ly + r + 20) < 10) return { idx: i, zone: 'rot' }; + if (Math.hypot(lx - r - 10, ly) < 10) return { idx: i, zone: 'resize' }; + const dist = Math.hypot(lx, ly); + if (dist < r && ly <= 0) return { idx: i, zone: 'body' }; + if (Math.abs(ly) < 8 && Math.abs(lx) <= r) return { idx: i, zone: 'body' }; + } + } + return null; + } + + /* ── Number line stroke ─────────────────────────────────────────────── */ + + _renderNumberLine(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 min = d.min ?? -10, max = d.max ?? 10; + const step = d.step || 1; + const range = max - min; + + ctx.save(); + if (d.rotation) { const [ocx, ocy] = [cx + cw / 2, cy + ch / 2]; ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } + + // Background + ctx.fillStyle = 'rgba(18,13,30,0.82)'; + ctx.strokeStyle = 'rgba(155,93,229,0.3)'; + ctx.lineWidth = 1; + if (ctx.roundRect) ctx.roundRect(cx, cy, cw, ch, 6); else ctx.rect(cx, cy, cw, ch); + ctx.fill(); ctx.stroke(); + + const axY = cy + ch / 2; + const padX = cw * 0.04; + const axX1 = cx + padX, axX2 = cx + cw - padX; + const scaleX = (axX2 - axX1) / range; + + // Main axis line + ctx.strokeStyle = 'rgba(255,255,255,0.75)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(axX1 - 6, axY); ctx.lineTo(axX2 + 8, axY); + ctx.stroke(); + + // Arrow right + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.beginPath(); ctx.moveTo(axX2 + 8, axY); + ctx.lineTo(axX2 + 2, axY - 5); ctx.lineTo(axX2 + 2, axY + 5); ctx.closePath(); ctx.fill(); + + // Tick marks + labels + const fs = Math.max(8, Math.round((11 / Whiteboard.VH) * (this._cssH || 150))); + ctx.font = `${fs}px Manrope,sans-serif`; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let v = Math.ceil(min / step) * step; v <= max; v += step) { + const px = axX1 + (v - min) * scaleX; + const major = (v % (step * 5) === 0) || step >= 1; + const th = major ? ch * 0.22 : ch * 0.12; + ctx.strokeStyle = major ? 'rgba(255,255,255,0.6)' : 'rgba(255,255,255,0.25)'; + ctx.lineWidth = major ? 1.2 : 0.7; + ctx.beginPath(); ctx.moveTo(px, axY - th); ctx.lineTo(px, axY + th); ctx.stroke(); + if (major) { + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.fillText(v === 0 ? '0' : v.toString(), px, axY + th + 2); + } + } + + // Origin line + if (min <= 0 && 0 <= max) { + const ox = axX1 + (0 - min) * scaleX; + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(ox, axY - ch * 0.3); ctx.lineTo(ox, axY + ch * 0.3); ctx.stroke(); + } + + // Points + if (d.points) { + for (const pt of d.points) { + const px = axX1 + (pt.val - min) * scaleX; + if (px < cx || px > cx + cw) continue; + const color = pt.color || '#06D6E0'; + ctx.fillStyle = pt.open ? 'transparent' : color; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(px, axY, ch * 0.12, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + if (pt.label) { + ctx.fillStyle = color; + ctx.font = `bold ${fs}px Manrope,sans-serif`; + ctx.fillText(pt.label, px, axY - ch * 0.35); + } + } + } + + // Intervals (shaded ranges) + if (d.intervals) { + for (const iv of d.intervals) { + const x1 = axX1 + (Math.max(iv.from, min) - min) * scaleX; + const x2 = axX1 + (Math.min(iv.to, max) - min) * scaleX; + ctx.fillStyle = (iv.color || '#9B5DE5') + '33'; + ctx.fillRect(x1, axY - ch * 0.1, x2 - x1, ch * 0.2); + } + } + + ctx.restore(); + } + + /* ── Compass stroke ─────────────────────────────────────────────────── */ + + _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; + + ctx.save(); + if (d.rotation) { ctx.translate(ocx, ocy); ctx.rotate(d.rotation); ctx.translate(-ocx, -ocy); } + + // 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; + 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.beginPath(); + ctx.arc(ocx + Math.cos(pivotAngle + angle) * legLen, ocy + Math.sin(pivotAngle + angle) * legLen, + arcR, arcStart, arcStart + arcAngle); + ctx.stroke(); + + // 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); + } + ctx.restore(); + } + + /* ── Auto-measurements: display geometric annotations on shapes ─────── */ + + renderMeasurements(ctx, stroke) { + if (!stroke || stroke.tool !== 'shape') return; + const d = stroke.data; + const fs = Math.max(9, Math.round((10 / Whiteboard.VH) * (this._cssH || 150))); + ctx.save(); + ctx.font = `${fs}px Manrope,sans-serif`; + ctx.fillStyle = 'rgba(255,230,100,0.85)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + + const [cx1, cy1] = this._toCanvas(d.x1, d.y1); + const [cx2, cy2] = this._toCanvas(d.x2, d.y2); + const midCx = (cx1 + cx2) / 2, midCy = (cy1 + cy2) / 2; + + if (d.shape === 'line' || d.shape === 'arrow') { + const len = Math.hypot(d.x2 - d.x1, d.y2 - d.y1); + const ang = Math.atan2(d.y2 - d.y1, d.x2 - d.x1) * 180 / Math.PI; + this._drawMeasLabel(ctx, `${len.toFixed(0)} vp ${ang.toFixed(1)}°`, midCx, midCy - 12); + } else if (d.shape === 'rect' || d.shape === 'roundedrect') { + const vw = Math.abs(d.x2 - d.x1), vh = Math.abs(d.y2 - d.y1); + this._drawMeasLabel(ctx, `${vw.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8); + this._drawMeasLabel(ctx, `${vh.toFixed(0)}`, Math.max(cx1, cx2) + 8, midCy); + } else if (d.shape === 'ellipse') { + const rx = Math.abs(d.x2 - d.x1) / 2, ry = Math.abs(d.y2 - d.y1) / 2; + this._drawMeasLabel(ctx, `rx=${rx.toFixed(0)} ry=${ry.toFixed(0)}`, midCx, Math.min(cy1, cy2) - 8); + } else if (d.shape === 'triangle') { + const base = Math.abs(d.x2 - d.x1), height = Math.abs(d.y2 - d.y1); + const area = (base * height / 2).toFixed(0); + this._drawMeasLabel(ctx, `A≈${area}`, midCx, Math.min(cy1, cy2) - 8); + } + ctx.restore(); + } + + _drawMeasLabel(ctx, text, x, y) { + const m = ctx.measureText(text); + const pad = 3; + ctx.fillStyle = 'rgba(26,20,40,0.7)'; + ctx.fillRect(x - m.width / 2 - pad, y - 7, m.width + pad * 2, 14); + ctx.fillStyle = 'rgba(255,230,100,0.9)'; + ctx.fillText(text, x, y); + } + + toggleMeasurements() { + this._showMeasurements = !this._showMeasurements; + this.render(); + return this._showMeasurements; + } + + toggleRuler() { + const idx = this._overlays.findIndex(o => o.type === 'ruler'); + if (idx >= 0) this._overlays.splice(idx, 1); + else this._overlays.push({ type: 'ruler', x: 200, y: 200, angle: 0, width: 400 }); + this.render(); + } + + toggleProtractor() { + const idx = this._overlays.findIndex(o => o.type === 'protractor'); + if (idx >= 0) this._overlays.splice(idx, 1); + else this._overlays.push({ type: 'protractor', x: 960, y: 350, radius: 80, angle: 0 }); + this.render(); + } +} + +if (typeof module !== 'undefined') module.exports = Whiteboard; diff --git a/frontend/knowledge-map.html b/frontend/knowledge-map.html new file mode 100644 index 0000000..a31d6e6 --- /dev/null +++ b/frontend/knowledge-map.html @@ -0,0 +1,1910 @@ + + + + + + Карта знаний — LearnSpace + + + + + + + + + + +
+ + +
+ + +
+
+ +
+
+
Карта знаний
+
Визуализация освоенности тем
+
+
+ + + + + + +
+ + + + + +
+ + +
+
+ + + +
+
≥70%
+
30–69%
+
<30%
+
Новое
+
+
+ + +
+ +
Загрузка…
+
+
+
Нет тем для этого предмета
+
+ +
+
+ +
Что учить дальше
+ +
+
+
+
Колёсико — zoom · Перетащите узел · Клик — фокус
+
+
+ + +
+
+ + +
+
Тема
+ + +
+ +
+ + +
+
+
+
+
Быстрый тест
+ +
+
+
+
+ + +
+
+
+
+
+ + +
+ +
+ +
+
+ + + + + + + + diff --git a/frontend/lab.html b/frontend/lab.html new file mode 100644 index 0000000..baea92c --- /dev/null +++ b/frontend/lab.html @@ -0,0 +1,8010 @@ + + + + + + Лаборатория — LearnSpace + + + + + + + + + +
+ +
+ +
+ + +
+ +
+
+ +
+
+
Лаборатория
+
Интерактивные симуляции по математике и физике
+
+
+ +
+ + + + + + +
+ +
+
+ + +
+ + +
+ +
График функции
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+
Функции
+ + +
+
+
+ y = + +
+
+
Синтаксическая ошибка
+
+ + +
+
+
+ y = + +
+
+
Синтаксическая ошибка
+
+ + +
+
+
+ y = + +
+
+
Синтаксическая ошибка
+
+ +
+
Примеры
+ +
+
Линейные / степенные
+
+ + + + + +
+
+ +
+
Тригонометрия
+
+ + + + + + +
+
+ +
+
Показательные / логарифмы
+
+ + + + +
+
+ +
+
Прочие
+
+ + + + + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ x = + +
+
+
+ y₁ = + +
+
+
+ y₂ = + +
+
+
+ y₃ = + +
+
Скролл — зум · Перетащи — панорама
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/lesson-editor.html b/frontend/lesson-editor.html new file mode 100644 index 0000000..d79bb46 --- /dev/null +++ b/frontend/lesson-editor.html @@ -0,0 +1,3428 @@ + + + + + + Редактор урока — LearnSpace + + + + + + + + + + + + + + + + + + +
+ + Назад + +
Загрузка…
+ + + +
+ + +
+ + + + + + + + + + + + +
+
+ +
+ +
+ +
+
Текст
+ + + + + +
+
+
Медиа
+ + +
+
+
Контент
+ + + +
+
+
Интерактив
+ + + + + + +
+
+
Расширенные
+ + + + + + + +
+
+
Прочее
+ + +
+
+ + +
+ + + + +
+
+ + +
+
Структура
+
+
+
+ + + +
+
+
+
+
+ + + + + diff --git a/frontend/lesson.html b/frontend/lesson.html new file mode 100644 index 0000000..164e51b --- /dev/null +++ b/frontend/lesson.html @@ -0,0 +1,1831 @@ + + + + + + Урок — LearnSpace + + + + + + + + + + + + + + + + +
+ +
+
+
+ + +
+ + +
+ + + Курс + +
Загрузка…
+
+ + + +
+
+
+
+
+ + +
+
+
+ + + + + + + +
+ + +
+ +
+
+
+ + + + + + + + diff --git a/frontend/library.html b/frontend/library.html new file mode 100644 index 0000000..10d89d0 --- /dev/null +++ b/frontend/library.html @@ -0,0 +1,1174 @@ + + + + + + Библиотека материалов — LearnSpace + + + + + + +
+ +
+
+ +
+
Библиотека материалов
+
Методички, конспекты, задачники и другие учебные материалы
+ + +
+ + / + +
+ + +
+ + + + + + +
+ + +
+ +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/frontend/live-quiz.html b/frontend/live-quiz.html new file mode 100644 index 0000000..331b170 --- /dev/null +++ b/frontend/live-quiz.html @@ -0,0 +1,853 @@ + + + + + + Live-квиз — LearnSpace + + + + + + + +
+ +
+
+ + +
+
+
+
Live-квиз
+ +
+
+ + +
+ + +
+
+ + Выберите класс +
+
+
+
+ Загрузка классов… +
+
+
+ + + +
+
+ + +
+ + +
+
+
+
Нет активной сессии
+
Выберите класс и нажмите «Начать сессию»,
чтобы запустить Live-квиз
+
+
+ + + +
+
+
+
+ + + + + + + + diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..e939faa --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,1016 @@ + + + + + + Вход — LearnSpace + + + + + + + + +
+ + + + diff --git a/frontend/parent.html b/frontend/parent.html new file mode 100644 index 0000000..4a922b6 --- /dev/null +++ b/frontend/parent.html @@ -0,0 +1,828 @@ + + + + + + Родительский кабинет — LearnSpace + + + + + + + +
+ +
+
Родительский кабинет
+ + + +
+ +
+
+ + Уведомления +
+
+
+ +
+
+
+
Загрузка данных...
+
+
+ + + + diff --git a/frontend/pet.html b/frontend/pet.html new file mode 100644 index 0000000..e595588 --- /dev/null +++ b/frontend/pet.html @@ -0,0 +1,1755 @@ + + + + + + Питомец — LearnSpace + + + + + + +
+ +
+ +
+
+ +
+
+ +
+
+
Мой питомец
+
Заботься о нём — учись каждый день!
+
+
+ +
+ +
+ +
+ +
+
+
+
+
zzZ
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+
Эволюция
+
+
+
+ + +
+
Цвет
+
+ +
+ +
+ + + +
+ + +
+ + +
+
+
+
+
Серия
+
+
+
+
+
Рекорд
+
+
+
+
+
Монеты
+
+
+
+
+
Поглажен
+
+
+ + +
+
Задания питомца
+
Загрузка…
+
+ + +
+
Опыт
+
+
Ур. —
+
0 / XP
+
+
+
+ + +
+
Сытость
+
+
+
+
+
+ + +
+
+
+
+
Совет
+
Загрузка…
+
+
+
+
+ +
+
+ + +
+ +
+ +
+
Активность за 7 дней
+
+
Загрузка…
+
+
+ +
+
Последняя активность
+
Нет активности
+
+
+ + + +
+ +
+
+
+ + + + + + + + + + + + diff --git a/frontend/profile.html b/frontend/profile.html new file mode 100644 index 0000000..67b5c1a --- /dev/null +++ b/frontend/profile.html @@ -0,0 +1,1381 @@ + + + + + + Профиль — LearnSpace + + + + + + + +
+ + +
+
+ + +
+
+
+
+
+ +
+ +
+
+
LS
+
+ +
+
+ +
+ +
+ + + + + + + + + + +
+ + + + + +
+
+ + +
+
Мой профиль
+ +
+ + + + + +
+ + +
+ + +
+
+
+
+
Информация
+
Ваши данные в системе
+
+
+
+
+
Имя
+
+
+
+
Роль
+
+
+
+
Email
+
+
+
+
+ + +
+
+
+
+
Отображаемое имя
+
Изменить имя в профиле
+
+
+
+ + +
+ +
+ +
+ +
+
+
Выход из аккаунта
+
Завершить текущую сессию
+
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+

Магазин наград

+
+ 0 монет +
+
+
+ + + + +
+
+
+ + +
+
+
+
+
+
Мои закладки
+
Сохранённые уроки, курсы и файлы
+
+
+
+ + + + +
+
+
+
+ + +
+
+
+
+
+
Доступ для родителей
+
Создайте ссылку, чтобы родители видели ваш прогресс
+
+
+
+
+ + +
+
+
Ссылка создана! Скопируйте и отправьте родителю:
+
+ +
+
+
+
+ + +
+
+
+
+
+
Смена пароля
+
Рекомендуем использовать надёжный пароль
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+
+ + + + + + + diff --git a/frontend/question-bank.html b/frontend/question-bank.html new file mode 100644 index 0000000..9c4e69c --- /dev/null +++ b/frontend/question-bank.html @@ -0,0 +1,633 @@ + + + + + + Банк вопросов — LearnSpace + + + + + + + +
+ +
+
+ + +
+
+
+
+
Банк вопросов
+
0
+
+
+
+ + +
+ + +
+
+
Поиск
+ +
+ +
+
Предмет
+ +
+ +
+
Тема
+ +
+ +
+
Сложность
+
+ + + +
+
+ +
+
Тип вопроса
+ +
+ +
+
Сортировка
+ +
+ + +
+ + +
+
+
Найдено: 0 вопросов
+
+
+ +
+
+
+
+ + + + + + + + diff --git a/frontend/red-book-biomes.html b/frontend/red-book-biomes.html new file mode 100644 index 0000000..fe2ed79 --- /dev/null +++ b/frontend/red-book-biomes.html @@ -0,0 +1,884 @@ + + + + + + Биомы — Красная книга РБ + + + + + +
+ + + +
+
+
+ Красная книга + Онлайн-урок +

Биомы Беларуси

+
+ + + +
+ +
+ + + +
+ + +
+

Загрузка...

+

Выберите биом выше для исследования.

+
+
+ + +
+ + + + +
+
+
+
+
+ + + + + + + + diff --git a/frontend/red-book-ecosystem.html b/frontend/red-book-ecosystem.html new file mode 100644 index 0000000..28eaf92 --- /dev/null +++ b/frontend/red-book-ecosystem.html @@ -0,0 +1,807 @@ + + + + + + Экосистемы — Красная книга РБ + + + + + +
+ + + +
+
+ + +
+ Красная книга + Онлайн-урок +

Пищевые сети · Симулятор экосистем

+ + + +
+ + +
+ + +
+ + + + +
+ Результат симуляции + +
+ + +
+

Симулятор воздействия

+ + + +
+ + +
+

Сценарии

+
+
+ Исчезновение рысиЧто если хищник исчезнет? +
+
+ Осушение болотПотеря 70% болотных видов +
+
+ БраконьерствоДавление на крупных млекопитающих +
+
+ Восстановление бобраПоложительный эффект +
+
+
+ + +
+

Легенда

+
+
CR
+
EN
+
VU
+
NT
+
LC
+
+

+ Размер узла = биомасса. Стрелки = «хищник жертва».
+ Нажмите на узел для выбора. +

+
+
+
+
+
+
+ + + + + + + + diff --git a/frontend/red-book-games.html b/frontend/red-book-games.html new file mode 100644 index 0000000..f1703bf --- /dev/null +++ b/frontend/red-book-games.html @@ -0,0 +1,648 @@ + + + + + + Игры — Красная книга РБ + + + + + + + +
+ + + + +
+
+ +

Игры

+

Узнавай виды и восстанавливай экосистемы

+ + +
+
+
+
Угадай вид
+

По иконке и подсказкам определи вид из Красной книги

+
+
+
+
Пищевая цепочка
+

Расставь виды в правильном порядке от продуцента до хищника

+
+
+ + +
+
+

Угадай вид

+
+ + 0 / 0 +
+
+
+ +
+ +
+ +
+
+

Игра завершена!

+

+ +
+
+ + +
+
+

Восстанови пищевую цепочку

+

Расставь виды от продуцента (растения) до высшего хищника

+
+
+ +
+ +
+

Перетащи виды в нужный порядок:

+
+
+ + + +
+
+ +
+
+
+ +
+ + + + + + + diff --git a/frontend/red-book.html b/frontend/red-book.html new file mode 100644 index 0000000..be4e785 --- /dev/null +++ b/frontend/red-book.html @@ -0,0 +1,1509 @@ + + + + + + Красная книга РБ — LearnSpace + + + + + + + +
+ + + + +
+ + +
+ +
+
+
Республика Беларусь · Официальный список
+

Красная книга
Беларуси

+

видов под угрозой. Исследуйте, открывайте, защищайте.

+ +
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+
+ + +
+

+

+
+
+ + +
+
+ + + + + + + + + + + diff --git a/frontend/test-result.html b/frontend/test-result.html new file mode 100644 index 0000000..f9580d5 --- /dev/null +++ b/frontend/test-result.html @@ -0,0 +1,377 @@ + + + + + + Результат теста — LearnSpace + + + + + + + + + + + +
+
+
+ + + + + + + diff --git a/frontend/test-run.html b/frontend/test-run.html new file mode 100644 index 0000000..009ee35 --- /dev/null +++ b/frontend/test-run.html @@ -0,0 +1,818 @@ + + + + + + Тест — LearnSpace + + + + + + + + + + + + + +
+
+
Загружаем тест...
+
+ + + + + + + + +
+
+
+
Завершить тест?
+
+
+ + +
+
+
+ + + + + diff --git a/frontend/theory.html b/frontend/theory.html new file mode 100644 index 0000000..1cd0072 --- /dev/null +++ b/frontend/theory.html @@ -0,0 +1,778 @@ + + + + + + Теория — LearnSpace + + + + + + + +
+ +
+
+ + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + + + + + + + +
+
+
+
Шаблоны курсов
+ +
+
+ + + + + +
+
+
+
+
+
+ + + + + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..9eb7851 --- /dev/null +++ b/js/api.js @@ -0,0 +1,1175 @@ +const API = '/api'; + +/* ── токен ────────────────────────────────────────────────────────────── */ +function getToken() { return localStorage.getItem('ls_token'); } +function setToken(t) { localStorage.setItem('ls_token', t); } +function removeToken() { localStorage.removeItem('ls_token'); } +function getUser() { return JSON.parse(localStorage.getItem('ls_user') || 'null'); } +function setUser(u) { localStorage.setItem('ls_user', JSON.stringify(u)); } +function removeUser() { localStorage.removeItem('ls_user'); } + +function isLoggedIn() { return !!getToken(); } + +function logout() { + removeToken(); + removeUser(); + window.location.href = '/login'; +} + +/* ── базовый fetch ────────────────────────────────────────────────────── */ +async function req(method, path, body) { + const headers = { 'Content-Type': 'application/json' }; + const token = getToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(API + path, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + if (res.status === 401) { + localStorage.removeItem('ls_token'); localStorage.removeItem('ls_user'); + if (window.location.pathname !== '/login') { window.location.href = '/login'; return; } + throw Object.assign(new Error(data.error || 'Unauthorized'), { status: 401, data }); + } + if (res.status === 403) { + if (typeof LS !== 'undefined' && LS.toast) LS.toast('Нет доступа', 'error'); + throw Object.assign(new Error(data.error || 'Forbidden'), { status: 403, data }); + } + throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data }); + } + return data; +} + +/* ── auth ─────────────────────────────────────────────────────────────── */ +async function register(email, password, name) { + const data = await req('POST', '/auth/register', { email, password, name }); + setToken(data.token); + setUser(data.user); + return data; +} + +async function login(email, password) { + const data = await req('POST', '/auth/login', { email, password }); + setToken(data.token); + setUser(data.user); + return data; +} + +async function fetchMe() { + return req('GET', '/auth/me'); +} + +async function updateProfile(data) { + const result = await req('PATCH', '/auth/profile', data); + if (result.token) setToken(result.token); + if (result.user) setUser(result.user); + return result; +} + +/* ── subjects ─────────────────────────────────────────────────────────── */ +async function getSubjects() { return req('GET', '/subjects'); } +async function updateSubject(slug, data) { return req('PATCH', `/subjects/${slug}`, data); } +async function getTopics(slug) { return req('GET', `/subjects/${slug}/topics`); } + +/* ── sessions ─────────────────────────────────────────────────────────── */ +async function startSession(subject_slug, mode = 'exam', count = 25, topic_id = null, test_id = null) { + return req('POST', '/sessions', { subject_slug, mode, count, topic_id, test_id }); +} + +async function sendAnswer(session_id, question_id, option_id, time_spent_sec, answer_text, chosen_options) { + return req('POST', `/sessions/${session_id}/answer`, { question_id, option_id, time_spent_sec, answer_text, chosen_options }); +} + +async function finishSession(session_id) { + return req('POST', `/sessions/${session_id}/finish`); +} + +async function getResult(session_id) { + return req('GET', `/sessions/${session_id}/result`); +} + +async function getHistory(page = 1, limit = 20) { + return req('GET', `/sessions/history?page=${page}&limit=${limit}`); +} + +async function getSessionQuestions(id) { + return req('GET', `/sessions/${id}/questions`); +} + +async function getWeakTopics() { + return req('GET', '/sessions/weak-topics'); +} +async function getStudentStats() { + return req('GET', '/sessions/stats'); +} + +/* ── questions ────────────────────────────────────────────────────────── */ +async function getQuestions(subject, topic_id, sort, page, limit) { + const p = new URLSearchParams(); + if (subject) p.set('subject', subject); + if (topic_id) p.set('topic_id', topic_id); + if (sort) p.set('sort', sort); + if (page) p.set('page', page); + if (limit) p.set('limit', limit); + const data = await req('GET', `/questions?${p}`); + // API returns { rows, total, page, limit } — extract rows for compat + return Array.isArray(data) ? data : data.rows; +} +async function createQuestion(data) { return req('POST', '/questions', data); } +async function duplicateQuestion(id) { return req('POST', `/questions/${id}/copy`, {}); } +async function updateQuestion(id, data) { return req('PUT', `/questions/${id}`, data); } +async function deleteQuestion(id) { return req('DELETE', `/questions/${id}`); } +async function importQuestions(formData) { + const token = getToken(); + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(API + '/questions/import', { method: 'POST', headers, body: formData }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data }); + return data; +} + +/* ── admin ────────────────────────────────────────────────────────────── */ +async function adminGetStats() { return req('GET', '/admin/stats'); } +async function adminGetUsers(params = {}) { + const p = new URLSearchParams(); + if (params.page) p.set('page', params.page); + if (params.limit) p.set('limit', params.limit); + if (params.role) p.set('role', params.role); + if (params.q) p.set('q', params.q); + const data = await req('GET', `/admin/users?${p}`); + // API returns { users, total, page, limit } — extract users for compat + return Array.isArray(data) ? data : data.users; +} +async function adminUpdateRole(id, role) { return req('PATCH', `/admin/users/${id}/role`, { role }); } +async function adminGetUserSessions(id) { return req('GET', `/admin/users/${id}/sessions`); } +async function adminGetSessions(params = {}) { + const p = new URLSearchParams(); + if (params.subject) p.set('subject', params.subject); + if (params.user_id) p.set('user_id', params.user_id); + if (params.limit) p.set('limit', params.limit); + if (params.offset) p.set('offset', params.offset); + return req('GET', `/admin/sessions?${p}`); +} +async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); } +async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); } +async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); } +async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); } +async function adminDeleteUser(id) { return req('DELETE', `/admin/users/${id}`); } + +/* ── classes (teacher/admin) ──────────────────────────────────────────── */ +async function getClasses() { return req('GET', '/classes'); } +async function createClass(data) { return req('POST', '/classes', data); } +async function getClassDetail(id) { return req('GET', `/classes/${id}`); } +async function updateClass(id, data) { return req('PATCH', `/classes/${id}`, data); } +async function deleteClass(id) { return req('DELETE', `/classes/${id}`); } +async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); } +async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); } +async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); } +async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); } +async function createDirectAssignment(data) { return req('POST', '/assignments', data); } +async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); } +async function deleteAssignment(id) { return req('DELETE', `/assignments/${id}`); } + +/* ── classes (student) ────────────────────────────────────────────────── */ +async function joinClass(invite_code) { return req('POST', '/classes/join', { invite_code }); } +async function myClasses() { return req('GET', '/classes/student/my'); } +async function getStudents() { return req('GET', '/classes/students'); } +async function classFeed(classId) { return req('GET', `/classes/${classId}/feed`); } + +/* ── assignments (student) ────────────────────────────────────────────── */ +async function myAssignments() { return req('GET', '/assignments/my'); } +async function teacherAssignments() { return req('GET', '/assignments/teacher'); } +async function startAssignment(id) { return req('POST', `/assignments/${id}/start`); } +async function assignmentResults(id) { return req('GET', `/assignments/${id}/results`); } +async function assignmentSessionReview(assignment_id, session_id) { + return req('GET', `/assignments/${assignment_id}/sessions/${session_id}/review`); +} +async function assignmentQuestionStats(id) { return req('GET', `/assignments/${id}/question-stats`); } + +/* ── tests ────────────────────────────────────────────────────────────── */ +async function getTests(subject) { + const p = subject ? `?subject=${subject}` : ''; + return req('GET', `/tests${p}`); +} +async function createTest(data) { return req('POST', '/tests', data); } +async function getTest(id) { return req('GET', `/tests/${id}`); } +async function updateTest(id, data) { return req('PUT', `/tests/${id}`, data); } +async function deleteTest(id) { return req('DELETE', `/tests/${id}`); } +async function addQuestionsToTest(id, question_ids){ return req('POST', `/tests/${id}/questions`, { question_ids }); } +async function removeQFromTest(tid, qid) { return req('DELETE', `/tests/${tid}/questions/${qid}`); } + +/* ── gamification ────────────────────────────────────────────────────── */ +async function getGamificationMe() { return req('GET', '/gamification/me'); } +async function getGamAchievements() { return req('GET', '/gamification/achievements'); } +async function getLeaderboard(params = {}) { + const p = new URLSearchParams(); + if (params.class_id) p.set('class_id', params.class_id); + if (params.period) p.set('period', params.period); + return req('GET', `/gamification/leaderboard?${p}`); +} +async function getXPHistory(limit) { return req('GET', `/gamification/xp-history?limit=${limit || 20}`); } +async function getChallenges() { return req('GET', '/gamification/challenges'); } +async function claimChallenge(id) { return req('POST', `/gamification/challenges/${id}/claim`); } +async function setGoalTier(tier) { return req('POST', '/gamification/goal-tier', { tier }); } +async function getFrames() { return req('GET', '/gamification/frames'); } +async function setFrame(frame) { return req('POST', '/gamification/frame', { frame }); } +async function reportLabActivity(reactionsDiscovered) { return req('POST', '/gamification/lab-activity', { reactionsDiscovered: reactionsDiscovered || 0 }); } + +/* ── утилиты ──────────────────────────────────────────────────────────── */ +function escapeHtml(str) { + if (typeof str !== 'string') return str; + return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} +const esc = escapeHtml; + +function parseDate(dateStr) { + if (!dateStr) return new Date(0); + return new Date(dateStr.replace(' ', 'T') + (dateStr.includes('Z') || dateStr.includes('+') ? '' : 'Z')); +} + +function fmtRelTime(dateStr) { + const d = parseDate(dateStr); + const m = Math.floor((Date.now() - d.getTime()) / 60000); + if (m < 1) return 'только что'; + if (m < 60) return `${m} мин назад`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} ч назад`; + return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' }); +} + +function safeHref(link) { return link && /^\/[a-z]/.test(link) ? link : '#'; } + +function requireAuth() { + if (!isLoggedIn()) { + window.location.href = '/login'; + return false; + } + return true; +} + +/* ── SVG-иконки ──────────────────────────────────────────────────────── */ +const _ICONS = { + trophy: '', + target: '', + flame: '', + zap: '', + sparkles: '', + diamond: '', + 'book-open':'', + running: '', + 'check-circle':'', + hundred: '100', + school: '', + 'file-text':'', + books: '', + star: '', + crown: '', + brain: '', + party: '', + 'thumbs-up':'', + muscle: '', + trash: '', + 'help-circle':'', + clipboard: '', + lock: '', + check: '', + x: '', + 'x-close': '', + square: '', + 'alert-tri':'', + info: '', + warning: '', + 'check-sq': '', + 'bar-chart':'', + lightbulb: '', + image: '', + link: '', + pin: '', + video: '', + explosion: '', + clock: '', + atom: '', + droplet: '', + compass: '', + moon: '', + 'grip-v': '', + 'arrow-up': '', + 'arrow-down':'', + shuffle: '', + coins: '', + 'shopping-bag':'', + gift: '', + dna: '', +}; + +function lsIcon(name, size = 18, cls = '') { + const d = _ICONS[name]; + if (!d) return ''; + return `${d}`; +} + +/* ── Toast-уведомления ────────────────────────────────────────────────── */ +function lsToast(message, type = 'info', duration = 3500) { + if (!document.getElementById('ls-toast-style')) { + const s = document.createElement('style'); + s.id = 'ls-toast-style'; + s.textContent = ` + #ls-toast-wrap{position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;} + .ls-toast{display:flex;align-items:center;gap:10px;padding:12px 18px;border-radius:14px;min-width:220px;max-width:360px; + font-family:'Manrope',sans-serif;font-size:0.875rem;font-weight:600;color:#fff;pointer-events:auto; + box-shadow:0 8px 32px rgba(15,23,42,0.22);transform:translateX(120%);opacity:0; + transition:transform .28s cubic-bezier(.34,1.56,.64,1),opacity .22s ease;} + .ls-toast.show{transform:translateX(0);opacity:1;} + .ls-toast.hide{transform:translateX(120%);opacity:0;} + .ls-toast.success{background:linear-gradient(135deg,#00C87A,#06B96E);} + .ls-toast.error {background:linear-gradient(135deg,#F15BB5,#E0335E);} + .ls-toast.info {background:linear-gradient(135deg,#06D6E0,#9B5DE5);} + .ls-toast.warn {background:linear-gradient(135deg,#FF9F1C,#E07A00);} + .ls-toast-icon{font-size:1.1rem;flex-shrink:0;} + .ls-toast-msg{flex:1;line-height:1.4;} + .ls-toast-close{background:none;border:none;color:rgba(255,255,255,0.7);font-size:1rem;cursor:pointer;padding:0 2px;flex-shrink:0;line-height:1;} + .ls-toast-close:hover{color:#fff;} + `; + document.head.appendChild(s); + } + let wrap = document.getElementById('ls-toast-wrap'); + if (!wrap) { wrap = document.createElement('div'); wrap.id = 'ls-toast-wrap'; document.body.appendChild(wrap); } + + const _tIcons = { success: 'check-circle', error: 'x-close', info: 'info', warn: 'warning' }; + const el = document.createElement('div'); + el.className = `ls-toast ${type}`; + el.innerHTML = `${lsIcon(_tIcons[type] || 'info', 18)}`; + el.querySelector('.ls-toast-msg').textContent = message; + wrap.appendChild(el); + requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show'))); + + const hide = () => { + el.classList.add('hide'); + setTimeout(() => el.remove(), 320); + }; + const timer = setTimeout(hide, duration); + el.querySelector('.ls-toast-close').addEventListener('click', () => clearTimeout(timer)); +} + +/* ── Skeleton-заглушки ────────────────────────────────────────────────── */ +function lsSkeleton(count = 3, variant = 'card') { + if (!document.getElementById('ls-skeleton-style')) { + const s = document.createElement('style'); + s.id = 'ls-skeleton-style'; + s.textContent = ` + @keyframes ls-shimmer{from{background-position:-600px 0}to{background-position:600px 0}} + .ls-sk{ + border-radius:12px; + background:linear-gradient(90deg,rgba(155,93,229,0.06) 20%,rgba(155,93,229,0.15) 40%,rgba(255,255,255,0.65) 50%,rgba(155,93,229,0.15) 60%,rgba(155,93,229,0.06) 80%); + background-size:1200px 100%; + animation:ls-shimmer 1.8s infinite ease-in-out; + } + .ls-sk-card{display:flex;align-items:center;gap:18px;padding:18px 20px;border:1.5px solid rgba(155,93,229,0.08);border-radius:18px;margin-bottom:10px;background:rgba(255,255,255,0.7);} + .ls-sk-icon{width:48px;height:48px;border-radius:12px;flex-shrink:0;} + .ls-sk-body{flex:1;display:flex;flex-direction:column;gap:8px;} + .ls-sk-title{height:14px;border-radius:6px;width:55%;} + .ls-sk-meta{height:10px;border-radius:6px;width:80%;} + .ls-sk-right{width:56px;display:flex;flex-direction:column;gap:8px;align-items:flex-end;} + .ls-sk-pct{width:42px;height:22px;border-radius:8px;} + .ls-sk-btn{width:80px;height:32px;border-radius:999px;} + .ls-sk-row{height:48px;border-radius:12px;margin-bottom:10px;} + `; + document.head.appendChild(s); + } + if (variant === 'row') { + return Array.from({ length: count }, () => + `
` + ).join(''); + } + return Array.from({ length: count }, () => ` +
+
+
+
+
+
+
+
+
+
+
`).join(''); +} + +/* ── Красивый диалог подтверждения ───────────────────────────────────── */ +function lsConfirm(message, { title = 'Подтверждение', confirmText = 'Подтвердить', danger = true } = {}) { + return new Promise(resolve => { + if (!document.getElementById('ls-confirm-style')) { + const s = document.createElement('style'); + s.id = 'ls-confirm-style'; + s.textContent = ` + .ls-ov{position:fixed;inset:0;z-index:9000;display:flex;align-items:center;justify-content:center;padding:20px; + background:rgba(15,23,42,0.45);backdrop-filter:blur(12px);opacity:0;transition:opacity .2s ease;} + .ls-ov.open{opacity:1;} + .ls-box{background:#fff;border-radius:24px;padding:36px 40px;width:100%;max-width:420px;text-align:center; + box-shadow:0 40px 100px rgba(15,23,42,0.24);transform:scale(.9) translateY(12px);transition:transform .22s ease;} + .ls-ov.open .ls-box{transform:scale(1) translateY(0);} + .ls-icon{font-size:2.4rem;margin-bottom:12px;} + .ls-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;margin-bottom:10px;} + .ls-msg{font-size:0.88rem;color:#3D4F6B;line-height:1.65;white-space:pre-line;margin-bottom:28px;} + .ls-btns{display:flex;gap:10px;justify-content:center;} + .ls-cancel{padding:10px 26px;border:1.5px solid rgba(15,23,42,0.2);border-radius:999px;background:transparent; + font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:600;color:#8898AA;cursor:pointer;transition:all .2s;} + .ls-cancel:hover{border-color:#9B5DE5;color:#9B5DE5;} + .ls-ok{padding:10px 28px;border:none;border-radius:999px;color:#fff; + font-family:'Manrope',sans-serif;font-size:0.88rem;font-weight:700;cursor:pointer;transition:opacity .2s; + background:linear-gradient(135deg,#06D6E0,#9B5DE5);} + .ls-ok.danger{background:linear-gradient(135deg,#F15BB5,#9B5DE5);} + .ls-ok:hover{opacity:.88;} + `; + document.head.appendChild(s); + } + + const el = document.createElement('div'); + el.className = 'ls-ov'; + el.setAttribute('tabindex', '-1'); + el.innerHTML = ` +
+
${danger ? lsIcon('trash', 36) : lsIcon('help-circle', 36)}
+
+
+
+ + +
+
`; + el.querySelector('.ls-title').textContent = title; + el.querySelector('.ls-msg').textContent = message; + el.querySelector('.ls-ok').textContent = confirmText; + document.body.appendChild(el); + requestAnimationFrame(() => el.classList.add('open')); + + const done = result => { + el.classList.remove('open'); + setTimeout(() => el.remove(), 230); + resolve(result); + }; + + el.querySelector('.ls-cancel').onclick = () => done(false); + el.querySelector('.ls-ok').onclick = () => done(true); + el.addEventListener('click', e => { if (e.target === el) done(false); }); + el.addEventListener('keydown', e => { + if (e.key === 'Enter') done(true); + if (e.key === 'Escape') done(false); + }); + setTimeout(() => el.focus(), 10); + }); +} + +/* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */ +function applyRoleSidebar(user) { + if (!user) return; + if (['teacher', 'admin'].includes(user.role)) { + document.querySelectorAll('.sb-teacher-only').forEach(el => el.style.display = ''); + } +} + +/* ── initPage — consolidates page init boilerplate ─────────────────── */ +function initPage({ requireLogin = true } = {}) { + if (requireLogin && !requireAuth()) return null; + const user = getUser(); + const isTeacher = user && ['teacher', 'admin'].includes(user.role); + const isAdmin = user?.role === 'admin'; + + // Nav avatar + const navUser = document.getElementById('nav-user'); + const navAvatar = document.getElementById('nav-avatar'); + if (navUser) navUser.textContent = user?.name || user?.email || ''; + if (navAvatar) navAvatar.textContent = (user?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS'; + + // Sidebar collapsed state + if (localStorage.getItem('ls_sb_collapsed') === '1') { + document.querySelector('.app-layout')?.classList.add('sb-collapsed'); + } + + // Sidebar toggle wiring + const togBtn = document.querySelector('.sb-toggle'); + if (togBtn) togBtn.addEventListener('click', () => { + const layout = document.querySelector('.app-layout'); + const collapsed = layout.classList.toggle('sb-collapsed'); + localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '0'); + }); + + // Sidebar active link + const currentPath = location.pathname.replace(/\.html$/, '').replace(/^\//, '') || 'dashboard'; + document.querySelectorAll('.sidebar .sb-item').forEach(a => { + const href = a.getAttribute('href')?.replace(/^\//, '').replace(/\.html$/, '') || ''; + if (href === currentPath) a.classList.add('active'); + }); + + // Cosmetics + applyCosmetics(); + + // Hide disabled game features in sidebar + hideDisabledFeatures(); + + // Board link — показываем всем залогиненным пользователям + const boardEl = document.getElementById('btn-board') || document.getElementById('sbl-board'); + if (boardEl) boardEl.style.display = ''; + + // Teacher-only sidebar items + applyRoleSidebar(user); + + return { user, isTeacher, isAdmin }; +} + +/* ── Feature flags (cached per page load, bust on demand) ───────────── */ +let _featuresCache = null; +async function loadFeatures() { + if (_featuresCache) return _featuresCache; + try { + _featuresCache = await apiFetch('/api/features'); + } catch { _featuresCache = {}; } + return _featuresCache; +} +function clearFeaturesCache() { _featuresCache = null; } + +/** + * Show board sidebar link only for teachers/admins and students in a class. + * Call after LS.initPage(). Uses features cache (_no_class flag). + */ +async function showBoardIfAllowed() { + const el = document.getElementById('btn-board') || document.getElementById('sbl-board'); + if (!el) return; + const user = getUser(); + if (!user) return; + if (user.role === 'teacher' || user.role === 'admin') { el.style.display = ''; return; } + // Student: check if in a class + const feats = await loadFeatures(); + if (!feats._no_class) el.style.display = ''; +} + +async function hideDisabledFeatures() { + const feats = await loadFeatures(); + const map = { + hangman: ['/hangman'], + crossword: ['/crossword'], + pet: ['/pet'], + red_book: ['/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'], + collection: ['/collection.html', '/collection'], + lab: ['/lab'], + knowledge_map: ['/knowledge-map'], + flashcards: ['/flashcards'], + board: ['/board'], + biochem: ['/biochem', '/biochem-library', '/biochem-reactions'], + live_quiz: ['/live-quiz'], + }; + for (const [key, hrefs] of Object.entries(map)) { + if (feats[key] === false) { + hrefs.forEach(href => { + document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none'); + }); + // Redirect away if currently on a disabled page + const cur = window.location.pathname; + if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) { + window.location.href = '/dashboard.html'; + } + } + } + if (feats.gamification === false) { + document.body.classList.add('no-gamification'); + // If student is already viewing achievements or shop tab, redirect to account tab + const active = document.querySelector('#tab-achievements.active, #tab-shop.active'); + if (active) { + document.querySelector('[onclick*="tab-account"]')?.click(); + } + } + + // Student with no class — restrict to dashboard, homework, library, theory + if (feats._no_class) { + const classOnlyHrefs = [ + '/board', '/lab', '/hangman', '/crossword', '/pet', + '/collection', '/collection.html', '/knowledge-map', + '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html', + '/flashcards', '/live-quiz', + ]; + classOnlyHrefs.forEach(href => { + document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none'); + }); + // Redirect if currently on a class-required page + const cur = window.location.pathname; + const classOnlyPaths = [ + '/board', '/lab', '/hangman', '/crossword', '/pet', + '/collection', '/collection-rb', '/knowledge-map', + '/red-book', '/red-book-ecosystem', '/red-book-biomes', '/red-book-games', + '/flashcards', '/live-quiz', + ]; + if (classOnlyPaths.some(h => cur === h || cur === h + '.html')) { + window.location.href = '/dashboard'; + } + document.body.classList.add('no-class'); + document.body.classList.add('no-gamification'); // no class no gamification + } +} + +/* ── generic authenticated fetch (full path like /api/courses) ─────── */ +async function apiFetch(path, options = {}) { + const token = getToken(); + const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(path, { ...options, headers }); + if (res.status === 401) { + removeToken(); removeUser(); + window.location.href = '/login'; + throw new Error('Session expired'); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status, data }); + return data; +} + +/* ── Biochemistry API ────────────────────────────────────────────────── */ +async function biochemGetElements() { return req('GET', '/biochem/elements'); } +async function biochemGetMolecules(p={}) { return req('GET', `/biochem/molecules?${new URLSearchParams(p)}`); } +async function biochemGetMolecule(id) { return req('GET', `/biochem/molecules/${id}`); } +async function biochemValidate(atoms,bonds){ return req('POST','/biochem/validate',{atoms,bonds}); } +async function biochemGetReactions() { return req('GET', '/biochem/reactions'); } +async function biochemGetChallenges() { return req('GET', '/biochem/challenges'); } +async function biochemSolveChallenge(id,payload) { return req('POST',`/biochem/challenges/${id}/solve`,payload); } +async function biochemGetSaved() { return req('GET', '/biochem/saved'); } +async function biochemSave(atoms,bonds,name){ return req('POST','/biochem/saved',{atoms,bonds,name}); } +async function biochemDeleteSaved(id) { return req('DELETE',`/biochem/saved/${id}`); } + +window.LS = { + getToken, setToken, removeToken, + getUser, setUser, removeUser, + isLoggedIn, logout, requireAuth, + register, login, fetchMe, updateProfile, + getSubjects, updateSubject, getTopics, + startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions, + adminGetStats, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, + getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, + getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, + regenerateInviteCode, classJournal, + joinClass, myClasses, getStudents, classFeed, + getAnnouncements, createAnnouncement, deleteAnnouncement, + getNotifications, markNotifRead, markAllNotifsRead, connectSSE, + listTemplates, saveTemplate, deleteTemplate, bulkCreateAssignment, + myAssignments, teacherAssignments, startAssignment, assignmentResults, assignmentSessionReview, assignmentQuestionStats, + getTests, createTest, getTest, updateTest, deleteTest, addQuestionsToTest, removeQFromTest, + getGamificationMe, getGamAchievements, getLeaderboard, getXPHistory, getChallenges, claimChallenge, setGoalTier, getFrames, setFrame, reportLabActivity, + getFiles, uploadFile, downloadFileUrl, deleteFile, getFileAccess, assignFile, unassignFile, + getFolders, createFolder, renameFolder, deleteFolder, moveFile, + getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, + submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, + getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, + getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, + getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, + getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, + globalSearch, + getShopItems, purchaseItem, getUserPurchases, getCoins, getMyActiveCosmetics, activateShopItem, + adminShopGetItems, adminShopCreateItem, adminShopUpdateItem, adminShopDeleteItem, adminShopAwardCoins, adminShopStats, + adminGamAward, adminGamReset, adminGamStats, adminGamGetUser, + parentGetLinks, parentCreateLink, parentUpdateLink, parentDeleteLink, + crCreateSession, crGetSession, crEndSession, crGetActiveByClass, crGetMyActive, + crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession, + escapeHtml, esc, + parseDate, fmtRelTime, safeHref, + initPage, + applyRoleSidebar, + icon: lsIcon, + confirm: lsConfirm, + toast: lsToast, + skeleton: lsSkeleton, + api: apiFetch, + get: (path) => apiFetch(path, { method: 'GET' }), + post: (path, body) => apiFetch(path, { method: 'POST', body: JSON.stringify(body) }), + put: (path, body) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }), + del: (path) => apiFetch(path, { method: 'DELETE' }), + patch: (path, body) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), + applyCosmetics: applyCosmetics, + loadFeatures, + clearFeaturesCache, + hideDisabledFeatures, + showBoardIfAllowed, + biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, + biochemGetReactions, biochemGetChallenges, biochemSolveChallenge, + biochemGetSaved, biochemSave, biochemDeleteSaved, +}; + +/* ═══════════════════════════════════════════════════════════════════════ + Global Cosmetics Applier — call on any page after auth + ═══════════════════════════════════════════════════════════════════════ */ +async function applyCosmetics() { + if (!isLoggedIn()) return; + try { + const c = await getMyActiveCosmetics(); + if (!c) return; + + // ── Title: show under nav username ── + if (c.title && c.title.text) { + const nameEl = document.getElementById('nav-user'); + if (nameEl && !document.getElementById('nav-title-badge')) { + const badge = document.createElement('div'); + badge.id = 'nav-title-badge'; + badge.textContent = c.title.text; + badge.style.cssText = `font-size:0.55rem;font-weight:700;color:${c.title.color || '#9B5DE5'};margin-top:-2px;letter-spacing:0.5px;text-transform:uppercase;`; + nameEl.after(badge); + } + } + + // ── Effect: particle animation on avatar ── + if (c.effect && c.effect.effect) { + _applyEffect(c.effect.effect); + } + } catch {} +} + + +function _applyEffect(name) { + const avatar = document.getElementById('nav-avatar'); + if (!avatar) return; + avatar.style.position = 'relative'; + + if (name === 'pulse') { + if (!document.getElementById('ls-pulse-style')) { + const s = document.createElement('style'); + s.id = 'ls-pulse-style'; + s.textContent = `@keyframes ls-pulse{0%,100%{box-shadow:0 0 0 0 rgba(155,93,229,0.5)}50%{box-shadow:0 0 0 8px rgba(155,93,229,0)}} .ls-effect-pulse{animation:ls-pulse 2s infinite}`; + document.head.appendChild(s); + } + avatar.classList.add('ls-effect-pulse'); + } + + if (name === 'sparkle') { + if (!document.getElementById('ls-sparkle-style')) { + const s = document.createElement('style'); + s.id = 'ls-sparkle-style'; + s.textContent = ` +.ls-sparkle-wrap{position:relative;display:inline-flex} +.ls-sparkle{position:absolute;width:4px;height:4px;border-radius:50%;background:#FFD166;pointer-events:none;animation:ls-sparkle-fly 1.4s ease-out infinite} +@keyframes ls-sparkle-fly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--sx),var(--sy)) scale(0)}}`; + document.head.appendChild(s); + } + const wrap = document.createElement('span'); + wrap.className = 'ls-sparkle-wrap'; + avatar.parentNode.insertBefore(wrap, avatar); + wrap.appendChild(avatar); + for (let i = 0; i < 5; i++) { + const sp = document.createElement('span'); + sp.className = 'ls-sparkle'; + const angle = (i / 5) * Math.PI * 2; + sp.style.cssText = `--sx:${Math.cos(angle) * 16}px;--sy:${Math.sin(angle) * 16}px;animation-delay:${i * 0.28}s;top:50%;left:50%;margin:-2px`; + wrap.appendChild(sp); + } + } + + if (name === 'snow') { + if (!document.getElementById('ls-snow-style')) { + const s = document.createElement('style'); + s.id = 'ls-snow-style'; + s.textContent = ` +.ls-snow-wrap{position:relative;display:inline-flex;overflow:visible} +.ls-snowflake{position:absolute;width:3px;height:3px;border-radius:50%;background:#fff;pointer-events:none;opacity:0.7;animation:ls-snow-fall 2s linear infinite} +@keyframes ls-snow-fall{0%{opacity:0.8;transform:translateY(-10px) translateX(0)}50%{transform:translateY(6px) translateX(4px)}100%{opacity:0;transform:translateY(18px) translateX(-2px)}}`; + document.head.appendChild(s); + } + const wrap = document.createElement('span'); + wrap.className = 'ls-snow-wrap'; + avatar.parentNode.insertBefore(wrap, avatar); + wrap.appendChild(avatar); + for (let i = 0; i < 6; i++) { + const fl = document.createElement('span'); + fl.className = 'ls-snowflake'; + fl.style.cssText = `left:${4 + i * 4}px;top:-4px;animation-delay:${i * 0.33}s`; + wrap.appendChild(fl); + } + } +} + +/* ── files (library) ──────────────────────────────────────────────────── */ +async function getFiles(params = {}) { + const p = new URLSearchParams(); + if (params.subject) p.set('subject', params.subject); + if (params.my) p.set('my', '1'); + return req('GET', `/files?${p}`); +} +async function uploadFile(formData) { + const token = getToken(); + const res = await fetch(API + '/files', { + method: 'POST', + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + body: formData, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); + return data; +} +function downloadFileUrl(id) { return `${API}/files/${id}/download`; } +async function deleteFile(id) { return req('DELETE', `/files/${id}`); } +async function getFileAccess(id) { return req('GET', `/files/${id}/access`); } +async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); } +async function unassignFile(id, type, targetId) { return req('DELETE', `/files/${id}/assign/${encodeURIComponent(type)}/${targetId}`); } +async function getFolders() { return req('GET', '/files/folders'); } +async function createFolder(name) { return req('POST', '/files/folders', { name }); } +async function renameFolder(id, name) { return req('PUT', `/files/folders/${id}`, { name }); } +async function deleteFolder(id) { return req('DELETE', `/files/folders/${id}`); } +async function moveFile(id, folder_id) { return req('PATCH', `/files/${id}/move`, { folder_id }); } +async function getFolderAccess(id) { return req('GET', `/files/folders/${id}/access`); } +async function clearFolderAccess(id) { return req('DELETE', `/files/folders/${id}/access`); } +async function assignFolder(id, data) { return req('POST', `/files/folders/${id}/assign`, data); } +async function unassignFolder(id, type, targetId) { return req('DELETE', `/files/folders/${id}/assign/${encodeURIComponent(type)}/${targetId}`); } +async function getStudentsList() { const d = await req('GET', '/admin/users?role=student&limit=500'); return d.users || []; } + +/* ── class members (admin/teacher) ──────────────────────────────────────── */ +async function addClassMember(classId, email, user_id) { return req('POST', `/classes/${classId}/members`, user_id ? { user_id } : { email }); } + +/* ── announcements ───────────────────────────────────────────────────────── */ +async function getAnnouncements(classId) { return req('GET', `/classes/${classId}/announcements`); } +async function createAnnouncement(classId, text){ return req('POST', `/classes/${classId}/announcements`, { text }); } +async function deleteAnnouncement(classId, id) { return req('DELETE', `/classes/${classId}/announcements/${id}`); } + +/* ── submissions ─────────────────────────────────────────────────────────── */ +async function submitWork(formData) { + const token = getToken(); + const res = await fetch(API + '/submissions', { + method: 'POST', + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + body: formData, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); + return data; +} +async function resubmitWork(id, formData) { + const token = getToken(); + const res = await fetch(API + `/submissions/${id}/resubmit`, { + method: 'POST', + headers: token ? { 'Authorization': `Bearer ${token}` } : {}, + body: formData, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw Object.assign(new Error(data.error || 'Upload failed'), { status: res.status }); + return data; +} +async function getMySubmissions() { return req('GET', '/submissions/my'); } +async function getClassSubmissions(classId) { return req('GET', `/submissions?class_id=${classId}`); } +async function reviewSubmission(id, data) { return req('PATCH', `/submissions/${id}`, data); } +async function deleteSubmission(id) { return req('DELETE',`/submissions/${id}`); } +function submissionDownloadUrl(id) { return `${API}/submissions/${id}/download`; } + +/* ── permissions (admin only) ────────────────────────────────────────────── */ +async function getPermissions() { return req('GET', '/permissions'); } +async function setPermission(role, permission, enabled) { return req('POST', '/permissions', { role, permission, enabled }); } +async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); } +async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); } +async function resetUserPermissions(uid, permission) { return req('DELETE', `/permissions/users/${uid}/reset`, permission ? { permission } : undefined); } + +/* ── notifications ───────────────────────────────────────────────────────── */ +async function getNotifications() { return req('GET', '/notifications'); } +async function markNotifRead(id) { return req('PATCH',`/notifications/${id}/read`); } +async function markAllNotifsRead() { return req('POST', '/notifications/read-all'); } + +/* ── SSE real-time notifications ─────────────────────────────────────────── */ +/* ── Shared SSE singleton — all listeners share one EventSource ─────── */ +let _sseShared = null; +let _sseRetryMs = 2000; +let _sseEverConnected = false; // tracks whether SSE has successfully opened before +const _sseListeners = new Set(); + +function _sseConnect() { + const token = getToken(); + if (!token) return; + const url = `${API}/notifications/stream?token=${encodeURIComponent(token)}`; + const es = new EventSource(url); + _sseShared = es; + es.onopen = () => { + const isReconnect = _sseEverConnected; + _sseEverConnected = true; + _sseRetryMs = 2000; + // Notify listeners of reconnect so they can re-sync missed state + if (isReconnect) { + for (const fn of _sseListeners) try { fn({ type: '_sse_reconnect' }); } catch {} + } + }; + es.onmessage = e => { + let data; try { data = JSON.parse(e.data); } catch { return; } + for (const fn of _sseListeners) try { fn(data); } catch {} + }; + es.onerror = () => { + es.close(); + _sseShared = null; + const delay = Math.min(_sseRetryMs, 30000); + _sseRetryMs = Math.min(_sseRetryMs * 2, 30000); + setTimeout(_sseConnect, delay); + }; +} + +function connectSSE(onEvent) { + if (!getToken()) return null; + _sseListeners.add(onEvent); + if (!_sseShared) _sseConnect(); + return { close: () => _sseListeners.delete(onEvent) }; +} + +// Proactively close SSE when navigating away to prevent HTTP/1.1 +// connection pool exhaustion during View Transition (old page stays alive ~260ms) +window.addEventListener('pagehide', () => { + if (_sseShared) { _sseShared.close(); _sseShared = null; } + _sseListeners.clear(); +}); + +/* ── assignment templates ─────────────────────────────────────────────────── */ +async function listTemplates() { return req('GET', '/assignments/templates'); } +async function saveTemplate(data) { return req('POST', '/assignments/templates', data); } +async function deleteTemplate(id) { return req('DELETE', `/assignments/templates/${id}`); } +async function bulkCreateAssignment(data){ return req('POST', '/assignments/bulk', data); } + +/* ── templates (course & lesson) ───────────────────────────────────────── */ +async function getCourseTemplates(params = {}) { const p = new URLSearchParams(); if (params.subject) p.set('subject', params.subject); if (params.my) p.set('my', '1'); return req('GET', `/templates/courses?${p}`); } +async function saveCourseTemplate(data) { return req('POST', '/templates/courses', data); } +async function createFromCourseTemplate(id, d) { return req('POST', `/templates/courses/${id}/create`, d); } +async function deleteCourseTemplate(id) { return req('DELETE', `/templates/courses/${id}`); } +async function getLessonTemplates(params = {}) { const p = new URLSearchParams(); if (params.category) p.set('category', params.category); if (params.my) p.set('my', '1'); return req('GET', `/templates/lessons?${p}`); } +async function saveLessonTemplate(data) { return req('POST', '/templates/lessons', data); } +async function createFromLessonTemplate(id, d) { return req('POST', `/templates/lessons/${id}/create`, d); } +async function deleteLessonTemplate(id) { return req('DELETE', `/templates/lessons/${id}`); } + +/* ── shop ──────────────────────────────────────────────────────────────── */ +async function getShopItems() { return req('GET', '/shop/items'); } +async function purchaseItem(itemId) { return req('POST', `/shop/items/${itemId}/purchase`); } +async function getUserPurchases() { return req('GET', '/shop/purchases'); } +async function getCoins() { return req('GET', '/shop/coins'); } +async function getMyActiveCosmetics() { return req('GET', '/shop/my-active'); } +async function activateShopItem(itemId, type) { return req('POST', '/shop/activate', { itemId, type }); } + +/* ── shop admin ────────────────────────────────────────────────────────── */ +async function adminShopGetItems() { return req('GET', '/shop/admin/items'); } +async function adminShopCreateItem(data) { return req('POST', '/shop/admin/items', data); } +async function adminShopUpdateItem(id, data) { return req('PUT', `/shop/admin/items/${id}`, data); } +async function adminShopDeleteItem(id) { return req('DELETE', `/shop/admin/items/${id}`); } +async function adminShopAwardCoins(data) { return req('POST', '/shop/admin/award-coins', data); } +async function adminShopStats() { return req('GET', '/shop/admin/stats'); } + +/* ── bookmarks ────────────────────────────────────────────────────────── */ +async function getBookmarks(type) { const p = type ? `?type=${type}` : ''; return req('GET', `/bookmarks${p}`); } +async function addBookmark(entityType, entityId) { return req('POST', '/bookmarks', { entityType, entityId }); } +async function removeBookmark(id) { return req('DELETE', `/bookmarks/${id}`); } +async function removeBookmarkByEntity(type, entityId) { return req('DELETE', `/bookmarks/entity/${type}/${entityId}`); } +async function checkBookmark(type, entityId) { return req('GET', `/bookmarks/check/${type}/${entityId}`); } + +/* ── search ───────────────────────────────────────────────────────────── */ +async function globalSearch(q, type, limit) { + const p = new URLSearchParams({ q }); + if (type) p.set('type', type); + if (limit) p.set('limit', limit); + return req('GET', `/search?${p}`); +} + +/* ── parent link management (student side) ─────────────────────────────── */ +async function parentGetLinks() { return req('GET', '/parent/my-links'); } +async function parentCreateLink(label) { return req('POST', '/parent/links', { label }); } +async function parentUpdateLink(id, d) { return req('PATCH', `/parent/links/${id}`, d); } +async function parentDeleteLink(id) { return req('DELETE', `/parent/links/${id}`); } + +/* ── classroom (online lesson) ─────────────────────────────────────────── */ +async function crCreateSession(data) { return req('POST', '/classroom', data); } +async function crGetSession(id) { return req('GET', `/classroom/${id}`); } +async function crEndSession(id) { return req('DELETE', `/classroom/${id}`); } +async function crGetActiveByClass(classId) { return req('GET', `/classroom/class/${classId}/active`); } +async function crGetMyActive() { return req('GET', '/classroom/my/active'); } +async function crGetMySession() { return req('GET', '/classroom/my/session'); } +async function crJoin(id) { return req('POST', `/classroom/${id}/join`); } +async function crLeave(id) { return req('POST', `/classroom/${id}/leave`); } +async function crSendChat(id, message) { return req('POST', `/classroom/${id}/chat`, { message }); } +async function crGetChat(id) { return req('GET', `/classroom/${id}/chat`); } +async function crGetAttendance(id) { return req('GET', `/classroom/${id}/attendance`); } +async function crSignal(id, targetUserId, payload) { return req('POST', `/classroom/${id}/signal`, { target_user_id: targetUserId, payload }); } +async function crGetOnlineStudents() { return req('GET', '/classroom/online-students'); } + +/* ── gamification admin ────────────────────────────────────────────────── */ +async function adminGamAward(data) { return req('POST', '/gamification/admin/award', data); } +async function adminGamReset(data) { return req('POST', '/gamification/admin/reset', data); } +async function adminGamStats() { return req('GET', '/gamification/admin/stats'); } +async function adminGamGetUser(id) { return req('GET', `/gamification/admin/user/${id}`); } + +/* ── Live Quiz Student Overlay ──────────────────────────────────────────── */ +(function initLiveOverlay() { + const token = getToken(); + if (!token) return; + let payload; try { payload = JSON.parse(atob(token.split('.')[1])); } catch { return; } + if (!payload || payload.role === 'teacher' || payload.role === 'admin') return; + + const STYLE = ` + #ls-live-overlay{position:fixed;inset:0;z-index:9000;background:rgba(10,2,32,0.92); + backdrop-filter:blur(12px);display:none;align-items:center;justify-content:center;padding:20px;} + #ls-live-overlay.open{display:flex;} + .ls-live-box{background:#fff;border-radius:24px;padding:32px 28px;width:100%;max-width:520px; + box-shadow:0 40px 120px rgba(0,0,0,0.5);position:relative;} + .ls-live-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:99px; + background:rgba(155,93,229,0.1);color:#9B5DE5;font-size:0.72rem;font-weight:800; + text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;} + .ls-live-badge::before{content:'';width:7px;height:7px;border-radius:50%; + background:#9B5DE5;animation:ls-live-pulse 1s ease infinite;} + @keyframes ls-live-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(1.3)}} + .ls-live-q{font-size:1rem;line-height:1.65;color:#0F172A;margin-bottom:20px;font-weight:500;} + .ls-live-opts{display:flex;flex-direction:column;gap:8px;margin-bottom:16px;} + .ls-live-opt{display:flex;align-items:center;gap:12px;padding:12px 16px;border:1.5px solid #E2E8F0; + border-radius:14px;cursor:pointer;transition:all .15s;font-size:.9rem;} + .ls-live-opt:hover{border-color:#9B5DE5;background:rgba(155,93,229,.04);} + .ls-live-opt.selected{border-color:#06D6E0;background:rgba(6,214,224,.07);} + .ls-live-opt.correct{border-color:#06D6A0!important;background:rgba(6,214,160,.1)!important;color:#059652;} + .ls-live-opt.wrong{border-color:#EF476F!important;background:rgba(239,71,111,.06)!important;color:#EF476F;} + .ls-live-opt-key{width:28px;height:28px;border-radius:8px;background:#F1F5F9; + display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:700; + color:#64748B;flex-shrink:0;transition:.15s;} + .ls-live-opt.selected .ls-live-opt-key{background:#06D6E0;color:#fff;} + .ls-live-opt.correct .ls-live-opt-key{background:#06D6A0;color:#fff;} + .ls-live-opt.wrong .ls-live-opt-key{background:#EF476F;color:#fff;} + .ls-live-status{text-align:center;font-size:.84rem;color:#8898AA;padding:8px 0;} + .ls-live-result-bar-wrap{margin:4px 0;} + .ls-live-result-bar{height:8px;border-radius:99px;background:#E2E8F0;margin-top:4px;overflow:hidden;} + .ls-live-result-fill{height:100%;border-radius:99px;background:#9B5DE5;transition:width .6s ease;} + .ls-live-result-fill.correct-fill{background:#06D6A0;} + .ls-live-result-pct{font-size:.72rem;font-weight:700;color:#64748B;float:right;} + `; + + const el = document.createElement('div'); + el.id = 'ls-live-overlay'; + el.innerHTML = `
+
Live Quiz
+
+
+
+
`; + + const styleEl = document.createElement('style'); + styleEl.textContent = STYLE; + + document.addEventListener('DOMContentLoaded', () => { + document.head.appendChild(styleEl); + document.body.appendChild(el); + }); + + let currentLiveId = null; + let answered = false; + + function openOverlay(liveId, question, options) { + currentLiveId = liveId; + answered = false; + document.getElementById('lslq-text').textContent = question.text; + const keys = 'АБВГДЕ'; + document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => ` +
+ ${keys[i] || i+1} + ${esc(o.text)} +
`).join(''); + document.getElementById('lslq-status').textContent = 'Выберите ответ'; + document.getElementById('ls-live-overlay').classList.add('open'); + } + + function showResults(options, stats) { + const total = stats?.total || 1; + document.getElementById('lslq-status').textContent = + `Ответили: ${stats?.total || 0} · Правильно: ${stats?.correct || 0}`; + document.querySelectorAll('.ls-live-opt').forEach(el => { el.onclick = null; el.style.cursor = 'default'; }); + const keys = 'АБВГДЕ'; + document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => { + const pct = total ? Math.round((o.chosen_count || 0) * 100 / total) : 0; + const cls = o.is_correct ? 'correct' : ''; + return `
+
+ ${keys[i]||i+1} + ${esc(o.text)} + ${pct}% +
+
+
+
+
`; + }).join(''); + } + + window._lsLiveAnswer = async function(liveId, optionId, el) { + if (answered) return; + answered = true; + document.querySelectorAll('.ls-live-opt').forEach(o => { o.onclick = null; o.style.cursor = 'default'; }); + el.classList.add('selected'); + document.getElementById('lslq-status').innerHTML = 'Ответ отправлен '; + try { + const r = await apiFetch(`/api/live/${liveId}/answer`, { method: 'POST', body: JSON.stringify({ option_id: optionId }) }); + if (r.is_correct === 1) el.classList.replace('selected','correct'); + else if (r.is_correct === 0) el.classList.replace('selected','wrong'); + } catch {} + }; + + document.addEventListener('DOMContentLoaded', () => { + connectSSE(d => { + if (d.type === 'live_question') { + openOverlay(d.liveId, d.question, d.question?.options); + } else if (d.type === 'live_results') { + showResults(d.options, d.stats); + } else if (d.type === 'live_ended') { + setTimeout(() => document.getElementById('ls-live-overlay')?.classList.remove('open'), 2000); + document.getElementById('lslq-status').textContent = 'Сессия завершена'; + } + }); + }); +})(); + +/* ── Classroom started notification (students on any page) ─────────────── */ +(function initClassroomNotify() { + const token = getToken(); + if (!token) return; + let payload; try { payload = JSON.parse(atob(token.split('.')[1])); } catch { return; } + // Only show for students (teachers navigate manually) + if (!payload || payload.role === 'teacher' || payload.role === 'admin') return; + + // Don't show if already on classroom page + if (window.location.pathname === '/classroom') return; + + const STYLE = ` + #ls-cr-notify{position:fixed;bottom:24px;right:24px;z-index:8000; + background:#0F172A;border:1.5px solid rgba(155,93,229,.4);border-radius:18px; + padding:18px 20px;max-width:320px;box-shadow:0 20px 60px rgba(0,0,0,.5); + display:none;animation:ls-cr-in .3s cubic-bezier(.34,1.56,.64,1);} + #ls-cr-notify.open{display:block;} + @keyframes ls-cr-in{from{opacity:0;transform:translateY(16px) scale(.95)}to{opacity:1;transform:none}} + .ls-cr-ntop{display:flex;align-items:center;gap:10px;margin-bottom:10px;} + .ls-cr-ndot{width:8px;height:8px;border-radius:50%;background:#9B5DE5;flex-shrink:0; + animation:ls-live-pulse 1s ease infinite;} + .ls-cr-nbadge{font-size:.7rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.06em;} + .ls-cr-nteacher{font-size:.82rem;color:#94A3B8;margin-bottom:12px;} + .ls-cr-nbtn{display:block;width:100%;padding:10px;border-radius:12px; + background:linear-gradient(135deg,#9B5DE5,#7C3ACD);color:#fff; + font-size:.88rem;font-weight:700;text-align:center;text-decoration:none; + transition:opacity .15s;cursor:pointer;border:none;} + .ls-cr-nbtn:hover{opacity:.9;} + .ls-cr-nclose{position:absolute;top:10px;right:12px;background:none;border:none; + color:#475569;cursor:pointer;font-size:1rem;line-height:1;padding:4px;} + `; + + const el = document.createElement('div'); + el.id = 'ls-cr-notify'; + el.innerHTML = ` + +
Онлайн-урок
+
+ Присоединиться`; + + const styleEl = document.createElement('style'); + styleEl.textContent = STYLE; + + document.addEventListener('DOMContentLoaded', () => { + document.head.appendChild(styleEl); + document.body.appendChild(el); + connectSSE(d => { + if (d.type === 'classroom_started') { + document.getElementById('ls-cr-nteacher').textContent = + `${d.teacherName || 'Учитель'} начал урок${d.title ? ': ' + d.title : ''}`; + el.classList.add('open'); + } else if (d.type === 'classroom_ended') { + el.classList.remove('open'); + } + }); + }); +})(); diff --git a/js/mobile.js b/js/mobile.js new file mode 100644 index 0000000..88008ec --- /dev/null +++ b/js/mobile.js @@ -0,0 +1,155 @@ +/* ═══════════════════════════════════════════════════════ + LearnSpace — Mobile Drawer & Topbar + Injected into all sidebar-layout pages + ═══════════════════════════════════════════════════════ */ + +(function () { + const MOBILE_BP = 768; + + function isMobile() { return window.innerWidth <= MOBILE_BP; } + + /* ── Inject mob-bar and backdrop ── */ + function injectMobBar() { + if (document.getElementById('ls-mob-bar')) return; + const layout = document.querySelector('.app-layout'); + if (!layout) return; + + /* Backdrop overlay */ + const backdrop = document.createElement('div'); + backdrop.className = 'sb-backdrop'; + backdrop.id = 'ls-sb-backdrop'; + document.body.appendChild(backdrop); + + /* Top bar */ + const bar = document.createElement('div'); + bar.className = 'mob-bar'; + bar.id = 'ls-mob-bar'; + bar.innerHTML = ` + +
+ + +
+ `; + document.body.insertBefore(bar, document.body.firstChild); + } + + /* ── Open / Close drawer ── */ + function openDrawer() { + const layout = document.querySelector('.app-layout'); + const backdrop = document.getElementById('ls-sb-backdrop'); + if (!layout) return; + layout.classList.add('sb-open'); + if (backdrop) backdrop.classList.add('open'); + document.body.style.overflow = 'hidden'; + } + + function closeDrawer() { + const layout = document.querySelector('.app-layout'); + const backdrop = document.getElementById('ls-sb-backdrop'); + if (!layout) return; + layout.classList.remove('sb-open'); + if (backdrop) backdrop.classList.remove('open'); + document.body.style.overflow = ''; + } + + /* ── Wire up events ── */ + function wireEvents() { + /* Hamburger */ + document.addEventListener('click', function (e) { + if (e.target.closest('#mob-hamburger')) { + const layout = document.querySelector('.app-layout'); + if (layout && layout.classList.contains('sb-open')) { + closeDrawer(); + } else { + openDrawer(); + } + return; + } + + /* Backdrop tap */ + if (e.target.id === 'ls-sb-backdrop') { + closeDrawer(); + return; + } + + /* Sidebar nav link tap — close drawer */ + if (isMobile() && e.target.closest('.sb-link')) { + setTimeout(closeDrawer, 80); + return; + } + + /* Mob notif button — delegate to LS.notif.toggle or legacy toggleNotifDrop */ + if (e.target.closest('#mob-notif-btn')) { + if (typeof LS !== 'undefined' && LS.notif && LS.notif.toggle) { + LS.notif.toggle(); + } else if (typeof toggleNotifDrop === 'function') { + toggleNotifDrop(); + } + return; + } + }); + + /* Escape key */ + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeDrawer(); + }); + + /* Close drawer on resize to desktop */ + window.addEventListener('resize', function () { + if (!isMobile()) closeDrawer(); + }); + + /* Close drawer on orientation change */ + window.addEventListener('orientationchange', function () { + setTimeout(function () { + if (!isMobile()) closeDrawer(); + }, 150); + }); + } + + /* ── Sync mob notif button visibility with page's notif button ── */ + function syncNotifBtn() { + const mobBtn = document.getElementById('mob-notif-btn'); + if (!mobBtn) return; + /* Look for existing notif button in sidebar */ + const sbNotif = document.querySelector('.sb-link[onclick*="Notif"], .sb-link[onclick*="notif"], #notif-btn, [data-notif]'); + if (sbNotif) { + mobBtn.style.display = 'flex'; + /* Mirror badge count */ + const badge = sbNotif.querySelector('.sb-badge'); + const dot = document.getElementById('mob-notif-dot'); + if (badge && dot) { + const observer = new MutationObserver(function () { + dot.style.display = (badge.textContent.trim() !== '0' && badge.style.display !== 'none') ? 'block' : 'none'; + }); + observer.observe(badge, { childList: true, attributes: true, attributeFilter: ['style'] }); + } + } + } + + /* ── Init ── */ + function init() { + if (window._lsMobileInited) return; + window._lsMobileInited = true; + injectMobBar(); + wireEvents(); + /* Defer notif sync until page JS has run */ + setTimeout(syncNotifBtn, 600); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/js/notifications.js b/js/notifications.js new file mode 100644 index 0000000..b660d7a --- /dev/null +++ b/js/notifications.js @@ -0,0 +1,89 @@ +/* ── Shared notification dropdown module ──────────────────────────── */ +(function() { + let _notifOpen = false; + let _sse = null; + + function renderNotifDrop(data) { + const drop = document.getElementById('notif-drop'); + const badge = document.getElementById('notif-badge'); + if (!drop || !badge) return; + if (data.unread > 0) { badge.textContent = data.unread > 9 ? '9+' : data.unread; badge.style.display = ''; } + else badge.style.display = 'none'; + drop.innerHTML = ` +
+ Уведомления + ${data.unread > 0 ? `` : ''} +
+ ${data.notifications.length ? data.notifications.map(n => ` + +
+
${LS.esc(n.message)}
${LS.fmtRelTime(n.created_at)}
+
`).join('') : '
Уведомлений нет
'}`; + } + + async function load() { + try { renderNotifDrop(await LS.getNotifications()); } catch {} + } + + function toggle() { + const drop = document.getElementById('notif-drop'); + if (!drop) return; + _notifOpen = !_notifOpen; + if (_notifOpen) { + const btn = document.getElementById('notif-btn'); + if (btn) { + const r = btn.getBoundingClientRect(); + const vh = window.innerHeight; + // Anchor bottom of dropdown to button bottom, but don't go above viewport + const bottom = vh - r.bottom; + drop.style.top = 'auto'; + drop.style.bottom = Math.max(8, bottom) + 'px'; + drop.style.left = (r.right + 8) + 'px'; + } + drop.style.display = 'block'; + load(); + } else { + drop.style.display = 'none'; + } + } + + async function clickNotif(e, id, link) { + e.preventDefault(); + await LS.markNotifRead(id).catch(() => {}); + await load(); + if (link && link !== '#') window.location.href = link; + } + + async function markAllRead() { + await LS.markAllNotifsRead().catch(() => {}); + await load(); + } + + let _inited = false; + function init() { + if (_inited) return; + _inited = true; + // Note: button already has onclick="LS.notif.toggle()" in HTML + // Close on outside click + document.addEventListener('click', e => { + const drop = document.getElementById('notif-drop'); + if (!drop || !_notifOpen) return; + if (!e.target.closest('#notif-drop') && !e.target.closest('#notif-btn')) { + _notifOpen = false; + drop.style.display = 'none'; + } + }); + + // SSE real-time + _sse = LS.connectSSE(ev => { + if (ev.type) load(); + }); + + // Initial load + load(); + } + + // Expose as LS.notif + window.LS = window.LS || {}; + LS.notif = { init, toggle, load, click: clickNotif, markAllRead }; +})(); diff --git a/js/search.js b/js/search.js new file mode 100644 index 0000000..f4e9d4e --- /dev/null +++ b/js/search.js @@ -0,0 +1,176 @@ +/* ═══════════════════════════════════════════════════════════════ + LearnSpace Global Search — /js/search.js + Include on any sidebar page after api.js. + Opens with Ctrl+K or click on search button in sidebar. + ═══════════════════════════════════════════════════════════════ */ +(function () { + if (!window.LS) return; + + const ICONS = { + lesson: 'book-open', + course: 'graduation-cap', + file: 'file-text', + question: 'help-circle', + }; + const LABELS = { + lesson: 'Уроки', + course: 'Курсы', + file: 'Файлы', + question: 'Вопросы', + }; + + let _overlay = null; + let _input = null; + let _results = null; + let _timer = null; + let _items = []; + let _activeIdx = -1; + + function esc(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>'); + } + + function build() { + if (_overlay) return; + _overlay = document.createElement('div'); + _overlay.className = 'gs-overlay'; + _overlay.innerHTML = ` +
+
+ + + ESC +
+
+
Начните вводить для поиска
+
+
`; + document.body.appendChild(_overlay); + + _input = _overlay.querySelector('.gs-input'); + _results = _overlay.querySelector('.gs-results'); + + // Close on backdrop click + _overlay.addEventListener('click', e => { if (e.target === _overlay) close(); }); + + // Event delegation for result clicks — один listener вместо N + _results.addEventListener('click', e => { + const item = e.target.closest('.gs-item'); + if (item) window.location.href = item.dataset.url; + }); + + // Input handling + _input.addEventListener('input', () => { + clearTimeout(_timer); + _timer = setTimeout(doSearch, 250); + }); + + // Keyboard nav + _input.addEventListener('keydown', e => { + if (e.key === 'Escape') { close(); return; } + if (e.key === 'ArrowDown') { e.preventDefault(); navigate(1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); navigate(-1); return; } + if (e.key === 'Enter') { + e.preventDefault(); + const active = _items[_activeIdx]; + if (active) window.location.href = active.url; + return; + } + }); + } + + async function doSearch() { + const q = _input.value.trim(); + if (q.length < 2) { + _results.innerHTML = '
Начните вводить для поиска
'; + _items = []; + _activeIdx = -1; + return; + } + + try { + const data = await LS.globalSearch(q); + _items = data.results || []; + _activeIdx = -1; + render(); + } catch { + _results.innerHTML = '
Ошибка поиска
'; + } + } + + function render() { + if (!_items.length) { + _results.innerHTML = '
Ничего не найдено
'; + return; + } + + // Group by type + const groups = {}; + for (const item of _items) { + if (!groups[item.type]) groups[item.type] = []; + groups[item.type].push(item); + } + + let html = ''; + let idx = 0; + for (const [type, items] of Object.entries(groups)) { + html += `
${LABELS[type] || type}
`; + for (const item of items) { + const iconCls = `gs-icon-${type}`; + const iconName = ICONS[type] || 'bookmark'; + html += `
+
+ +
+
+
${esc(item.title)}
+ ${item.subtitle ? `
${esc(item.subtitle)}
` : ''} +
+ +
`; + idx++; + } + } + _results.innerHTML = html; + + // Lucide icons + if (window.lucide) lucide.createIcons({ nodes: _results.querySelectorAll('[data-lucide]') }); + } + + function navigate(dir) { + const total = _items.length; + if (!total) return; + const prev = _results.querySelector('.gs-item.active'); + if (prev) prev.classList.remove('active'); + _activeIdx = (_activeIdx + dir + total) % total; + const next = _results.querySelector(`.gs-item[data-idx="${_activeIdx}"]`); + if (next) { next.classList.add('active'); next.scrollIntoView({ block: 'nearest' }); } + } + + function open() { + build(); + _overlay.classList.add('open'); + _input.value = ''; + _results.innerHTML = '
Начните вводить для поиска
'; + _items = []; + _activeIdx = -1; + setTimeout(() => _input.focus(), 50); + } + + function close() { + if (_overlay) _overlay.classList.remove('open'); + } + + // Global shortcut: Ctrl+K + document.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + if (_overlay?.classList.contains('open')) close(); + else open(); + } + }); + + // Expose + window.lsSearchOpen = open; + window.lsSearchClose = close; +})();