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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

2081 lines
95 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Молекулярный конструктор — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
body { overflow: hidden; }
.sb-sub-link { padding-left: 28px !important; font-size: 0.76rem !important; opacity: .75; }
.sb-sub-link:hover { opacity: 1; }
.sb-content { padding: 0 !important; overflow: hidden; display: flex; flex-direction: column; background: #0d0d1a; }
/* ── 3D mode button ── */
.tool-btn.mode-3d-active { background: rgba(6,214,224,.2) !important; border-color: #06D6E0 !important; color: #06D6E0 !important; }
/* ── Toolbar ── */
.bio-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; flex-shrink: 0;
background: rgba(15,15,30,0.95);
border-bottom: 1px solid rgba(155,93,229,0.15);
backdrop-filter: blur(10px);
flex-wrap: wrap;
}
.bio-title {
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 700;
color: var(--violet); letter-spacing: .04em; white-space: nowrap; margin-right: 8px;
}
.el-btn {
width: 38px; height: 38px; border-radius: 10px; border: 2px solid transparent;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 800;
cursor: pointer; transition: all .15s; position: relative; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.el-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,.4); }
.el-btn.active { border-color: #fff !important; box-shadow: 0 0 0 3px rgba(255,255,255,.18) !important; }
.tool-sep { width: 1px; height: 28px; background: rgba(255,255,255,.12); margin: 0 4px; flex-shrink: 0; }
.tool-btn {
height: 34px; padding: 0 12px; border-radius: 8px; border: 1.5px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06); color: #ccc;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600;
cursor: pointer; transition: all .15s; white-space: nowrap; display: flex; align-items: center; gap: 5px;
}
.tool-btn:hover { background: rgba(255,255,255,.12); color: #fff; }
.tool-btn.active { background: rgba(155,93,229,.25); border-color: var(--violet); color: #c084fc; }
.tool-btn svg { width: 14px; height: 14px; }
/* ── Main body ── */
.bio-body { flex: 1; display: flex; overflow: hidden; min-height: 0; height: 0; }
/* ── Canvas ── */
.bio-canvas-wrap { flex: 1; position: relative; overflow: hidden; cursor: crosshair; min-width: 0; }
#mol-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Zoom controls */
.zoom-ctrl {
position: absolute; bottom: 16px; left: 16px; display: flex; flex-direction: column; gap: 4px;
}
.zoom-btn {
width: 32px; height: 32px; border-radius: 8px;
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12);
color: #aaa; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.zoom-btn:hover { background: rgba(255,255,255,.16); color: #fff; }
/* Hint */
.bio-hint {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,.7); color: #999; font-size: 0.72rem; padding: 5px 14px;
border-radius: 999px; pointer-events: none; white-space: nowrap;
border: 1px solid rgba(255,255,255,.06);
}
/* ── Info panel ── */
.bio-panel {
width: 290px; flex-shrink: 0;
background: rgba(8,8,20,0.9);
border-left: 1px solid rgba(155,93,229,.15);
display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
.bp-section { padding: 16px; border-bottom: 1px solid rgba(255,255,255,.06); }
.bp-label { font-size: 0.68rem; font-weight: 700; color: #666; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 8px; }
.bp-formula {
font-family: 'Unbounded', sans-serif; font-size: 1.6rem; font-weight: 800;
color: #fff; min-height: 44px; word-break: break-all;
}
.bp-formula.empty { color: #333; }
.bp-mol-name { font-size: 0.92rem; font-weight: 700; color: var(--violet); margin-bottom: 4px; }
.bp-mol-desc { font-size: 0.76rem; color: #888; line-height: 1.5; }
.bp-issue { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 6px; background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.2); font-size: 0.75rem; color: #f87171; margin-bottom: 4px; }
.bp-ok { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 6px; background: rgba(74,222,128,.1); border: 1px solid rgba(74,222,128,.2); font-size: 0.75rem; color: #4ade80; }
.bp-btn {
width: 100%; padding: 9px; border-radius: 10px; border: none; cursor: pointer;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
transition: all .15s; margin-bottom: 6px;
}
.bp-btn-primary { background: linear-gradient(135deg,#9B5DE5,#c084fc); color: #fff; }
.bp-btn-primary:hover { filter: brightness(1.1); }
.bp-btn-secondary { background: rgba(255,255,255,.06); color: #aaa; border: 1.5px solid rgba(255,255,255,.1); }
.bp-btn-secondary:hover { background: rgba(255,255,255,.1); color: #fff; }
.bp-btn:disabled { opacity: .4; cursor: not-allowed; }
/* Challenges in panel */
.chal-progress {
padding: 10px 12px 6px; border-bottom: 1px solid rgba(255,255,255,.05);
}
.chal-progress-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.72rem; color: #888; margin-bottom: 6px;
}
.chal-progress-row strong { color: #c084fc; }
.chal-progress-bar {
height: 4px; border-radius: 999px;
background: rgba(255,255,255,.08); overflow: hidden;
}
.chal-progress-fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, #9B5DE5, #c084fc);
transition: width .4s ease;
}
.chal-type-row { display: flex; gap: 6px; padding: 8px 12px 4px; flex-wrap: wrap; }
.chal-type-chip {
font-size: 0.66rem; font-weight: 700; padding: 2px 8px; border-radius: 999px;
border: 1px solid transparent; cursor: pointer; transition: all .15s;
background: rgba(255,255,255,.05); color: #666;
}
.chal-type-chip.active { background: rgba(155,93,229,.2); border-color: var(--violet); color: #c084fc; }
.chal-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: 8px; background: rgba(255,255,255,.04); margin-bottom: 4px;
border: 1.5px solid transparent; cursor: pointer; transition: all .15s;
}
.chal-item:hover { background: rgba(155,93,229,.1); border-color: rgba(155,93,229,.2); }
.chal-item.done { opacity: .5; }
.chal-item.active-chal { background: rgba(155,93,229,.18); border-color: var(--violet); }
.chal-icon { width: 28px; height: 28px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.chal-info { flex: 1; min-width: 0; }
.chal-title { font-size: 0.78rem; font-weight: 700; color: #ddd; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chal-xp { font-size: 0.68rem; color: #888; }
.diff-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
/* Choice buttons for identify/formula challenges */
.chal-choice-btn {
width: 100%; padding: 8px 12px; border-radius: 8px; text-align: left;
border: 1.5px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04);
color: #ccc; font-family: 'Manrope', sans-serif; font-size: 0.8rem;
cursor: pointer; transition: all .15s; margin-bottom: 4px; display: block;
}
.chal-choice-btn:hover:not(:disabled) { border-color: rgba(155,93,229,.4); background: rgba(155,93,229,.1); color: #ddd; }
.chal-choice-btn.correct { border-color: #4ade80 !important; background: rgba(74,222,128,.1) !important; color: #4ade80 !important; }
.chal-choice-btn.wrong { border-color: #ef4444 !important; background: rgba(239,68,68,.1) !important; color: #ef4444 !important; }
.chal-choice-btn:disabled { cursor: default; }
/* Saved panel */
.saved-item {
display: flex; align-items: center; gap: 8px; padding: 7px 10px;
border-radius: 8px; background: rgba(255,255,255,.04); margin-bottom: 4px;
border: 1px solid rgba(255,255,255,.06); cursor: pointer; transition: all .15s;
}
.saved-item:hover { background: rgba(6,214,224,.08); border-color: rgba(6,214,224,.15); }
.saved-formula { font-size: 0.78rem; font-weight: 700; color: #06D6E0; font-family: monospace; }
.saved-name { font-size: 0.7rem; color: #888; }
/* Tab bar inside panel */
.panel-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,.06); flex-shrink: 0; }
.panel-tab { flex: 1; padding: 10px 4px; font-size: 0.72rem; font-weight: 700; color: #666; background: none; border: none; cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; text-align: center; }
.panel-tab.active { color: var(--violet); border-bottom-color: var(--violet); }
.panel-pane { display: none; padding: 12px; flex: 1 1 0; overflow-y: auto; min-height: 0; }
.panel-pane.active { display: block; }
/* ── Ring dropdown ── */
.ring-menu-wrap { position: relative; }
.ring-menu {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 300;
background: rgba(10,10,24,.98); border: 1px solid rgba(155,93,229,.3);
border-radius: 10px; padding: 6px; min-width: 168px;
box-shadow: 0 8px 28px rgba(0,0,0,.6); display: none;
}
.ring-menu.open { display: block; }
.ring-opt {
display: flex; align-items: center; gap: 7px; padding: 7px 10px;
border-radius: 7px; cursor: pointer; transition: all .15s;
color: #ccc; font-size: 0.77rem; font-weight: 600; white-space: nowrap;
}
.ring-opt:hover { background: rgba(155,93,229,.18); color: #c084fc; }
.ring-sub { font-size: 0.69rem; color: #555; margin-left: auto; }
/* ── Atom count badge ── */
.el-cnt {
position: absolute; bottom: -3px; right: -3px;
background: #7c3aed; color: #fff; font-size: 8px; font-weight: 800;
border-radius: 4px; min-width: 12px; padding: 0 2px; text-align: center;
line-height: 13px; pointer-events: none; display: none;
}
.el-btn.has-cnt .el-cnt { display: block; }
/* ── Tool btn disabled ── */
.tool-btn:disabled { opacity: .35; cursor: default; pointer-events: none; }
.tool-btn.icon-only { padding: 0 9px; }
/* ── Live mol stats panel ── */
.bp-stats-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 8px;
}
.bp-stat-chip {
display: flex; flex-direction: column;
padding: 7px 9px; border-radius: 9px;
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.07);
transition: border-color .15s;
}
.bp-stat-chip:hover { border-color: rgba(155,93,229,.35); }
.bp-stat-lbl { font-size: 0.58rem; font-weight: 700; color: #555; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 2px; }
.bp-stat-val { font-size: 0.84rem; font-weight: 700; color: #ddd; }
.bp-stat-val.good { color: #4ade80; }
.bp-stat-val.warn { color: #facc15; }
.bp-stat-val.bad { color: #f87171; }
.bp-stat-val.cyan { color: #06D6E0; }
.bp-stat-val.violet{ color: #c084fc; }
.bp-fg-wrap {
margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px;
}
.bp-fg-tag {
font-size: 0.66rem; font-weight: 700; padding: 2px 8px;
border-radius: 999px; border: 1px solid; cursor: default;
}
/* ── Match challenge ── */
.match-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-bottom: 8px; }
.match-col-hdr { font-size: 0.65rem; font-weight: 700; color: #555; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; text-align: center; }
.match-it {
padding: 6px 7px; border-radius: 7px; border: 1.5px solid rgba(255,255,255,.1);
background: rgba(255,255,255,.04); color: #ccc; font-size: 0.74rem; font-weight: 600;
cursor: pointer; transition: all .15s; text-align: center; word-break: break-word;
display: block; width: 100%;
}
.match-it:hover:not(.matched):not(.selected) { border-color: rgba(155,93,229,.5); background: rgba(155,93,229,.12); }
.match-it.selected { border-color: #06D6E0 !important; background: rgba(6,214,224,.15) !important; color: #06D6E0 !important; }
.match-it.matched { border-color: #4ade80 !important; background: rgba(74,222,128,.08) !important; color: #4ade80 !important; cursor: default; }
.match-it.wrong { border-color: #ef4444 !important; background: rgba(239,68,68,.12) !important; }
/* ── Balance challenge ── */
.balance-eq { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; padding: 8px 0 12px; }
.bal-coef {
width: 34px; text-align: center; padding: 4px 0; border-radius: 6px;
background: rgba(255,255,255,.07); border: 1.5px solid rgba(255,255,255,.15);
color: #fff; font-size: 0.86rem; font-weight: 700; -moz-appearance: textfield;
}
.bal-coef::-webkit-outer-spin-button, .bal-coef::-webkit-inner-spin-button { -webkit-appearance: none; }
.bal-coef:focus { outline: none; border-color: #9B5DE5; background: rgba(155,93,229,.14); }
.bal-formula { font-size: 0.88rem; color: #06D6E0; font-weight: 700; }
.bal-op { font-size: 1rem; color: #666; }
.bal-arrow { font-size: 0.9rem; color: #888; padding: 0 2px; }
/* ── Mobile ── */
@media (max-width: 768px) {
body { overflow: auto; }
.sb-content { overflow: auto; flex-direction: column; }
/* Toolbar: two rows — elements then tools */
.bio-toolbar {
padding: 6px 8px; flex-wrap: wrap; gap: 4px;
position: sticky; top: 56px; z-index: 20;
background: rgba(15,15,30,0.97); backdrop-filter: blur(10px);
}
.bio-title { display: none; }
.tool-sep { display: none; }
/* Element palette: horizontal scroll, full width first row */
#el-palette {
flex-wrap: nowrap !important; overflow-x: auto; -webkit-overflow-scrolling: touch;
width: 100%; order: -1; gap: 3px !important;
scrollbar-width: none; padding-bottom: 2px;
}
#el-palette::-webkit-scrollbar { display: none; }
.el-btn { width: 32px; height: 32px; font-size: 0.72rem; flex-shrink: 0; }
/* Tool buttons: compact, scrollable row */
.tool-btn { height: 30px; padding: 0 8px; font-size: 0.72rem; flex-shrink: 0; }
.tool-btn svg { width: 12px; height: 12px; }
.tool-btn.icon-only { padding: 0 6px; }
/* Body: canvas on top, panel below — natural scroll */
.bio-body { flex-direction: column; overflow: visible; flex: none; }
.bio-canvas-wrap { height: 45vw; min-height: 220px; flex: none; }
.bio-panel {
width: 100% !important; max-height: none; height: auto;
border-left: none !important; border-top: 1px solid rgba(155,93,229,.15);
overflow-y: visible; flex: none;
}
.ring-menu-wrap { display: none; }
/* Panel tabs */
.panel-tabs { overflow-x: auto; scrollbar-width: none; flex-wrap: nowrap; }
.panel-tabs::-webkit-scrollbar { display: none; }
.panel-tab { white-space: nowrap; flex-shrink: 0; }
/* Panel sections */
.bp-section { padding: 12px; }
/* Zoom controls */
.zoom-ctrl { bottom: 8px; left: 8px; }
.zoom-btn { width: 28px; height: 28px; font-size: 14px; }
.bio-hint { display: none; }
}
@media (max-width: 480px) {
.bio-canvas-wrap { height: 220px; min-height: 180px; }
.el-btn { width: 28px; height: 28px; font-size: 0.66rem; }
.tool-btn { height: 26px; padding: 0 6px; font-size: 0.66rem; }
}
</style>
</head>
<body>
<div class="app-layout" id="app">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="btn-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link active"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/biochem-library" class="sb-link sb-sub-link"><i data-lucide="library" class="sb-icon"></i><span class="sb-lbl">↳ Библиотека</span></a>
<a href="/biochem-reactions" class="sb-link sb-sub-link"><i data-lucide="arrow-right-left" class="sb-icon"></i><span class="sb-lbl">↳ Реакции</span></a>
<a href="/biochem-properties" class="sb-link sb-sub-link"><i data-lucide="table-properties" class="sb-icon"></i><span class="sb-lbl">↳ Свойства</span></a>
<a href="/biochem-pathways" class="sb-link sb-sub-link"><i data-lucide="route" class="sb-icon"></i><span class="sb-lbl">↳ Пути</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding:4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="LS.notif.toggle()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<!-- ── Toolbar ── -->
<div class="bio-toolbar">
<span class="bio-title">Биохимия</span>
<div class="tool-sep"></div>
<!-- Element palette (filled by JS) -->
<div id="el-palette" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center"></div>
<div class="tool-sep"></div>
<!-- Tools -->
<button class="tool-btn active" id="tool-add" onclick="setTool('add')" title="Добавить атом / нарисовать связь">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/></svg>Добавить
</button>
<button class="tool-btn" id="tool-move" onclick="setTool('move')" title="Перемещать атомы">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M12 12h.01"/></svg>Двигать
</button>
<button class="tool-btn" id="tool-erase" onclick="setTool('erase')" title="Удалить атом/связь">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16l10-10 7 7-2.5 2.5"/><path d="M6.0001 10L14 18"/></svg>Стереть
</button>
<div class="tool-sep"></div>
<button class="tool-btn" onclick="clearAll()" title="Очистить холст">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>Очистить
</button>
<button class="tool-btn" onclick="centerView()" title="По центру">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3M21 8V5a2 2 0 00-2-2h-3M3 16v3a2 2 0 002 2h3M16 21h3a2 2 0 002-2v-3"/></svg>Центр
</button>
<button class="tool-btn" id="btn-3d" onclick="toggle3D()" title="3D вращение">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>3D
</button>
<button class="tool-btn" id="btn-vdw" onclick="toggleVDW()" title="Space-fill (VDW радиусы)" style="display:none"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> VDW</button>
<div class="tool-sep"></div>
<button class="tool-btn icon-only" id="btn-undo" onclick="undo()" disabled title="Отменить (Ctrl+Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>
</button>
<button class="tool-btn icon-only" id="btn-redo" onclick="redo()" disabled title="Повторить (Ctrl+Y)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 019-9 9 9 0 016 2.3L21 13"/></svg>
</button>
<div class="ring-menu-wrap">
<button class="tool-btn" id="btn-rings" onclick="toggleRingMenu()" title="Вставить кольцо">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5"/></svg>Кольца
</button>
<div class="ring-menu" id="ring-menu">
<div class="ring-opt" onclick="insertRing('benzene')">⬡ Бензол <span class="ring-sub">C₆H₆</span></div>
<div class="ring-opt" onclick="insertRing('cyclohexane')">⬡ Циклогексан <span class="ring-sub">C₆</span></div>
<div class="ring-opt" onclick="insertRing('cyclopentane')">⬠ Циклопентан <span class="ring-sub">C₅</span></div>
<div class="ring-opt" onclick="insertRing('naphthalene')">⬡⬡ Нафталин <span class="ring-sub">C₁₀H₈</span></div>
</div>
</div>
</div>
<!-- ── Body ── -->
<div class="bio-body">
<!-- Canvas -->
<div class="bio-canvas-wrap" id="canvas-wrap">
<canvas id="mol-canvas"></canvas>
<div class="zoom-ctrl">
<button class="zoom-btn" onclick="zoomBy(1.2)" title="Приблизить">+</button>
<button class="zoom-btn" onclick="zoomBy(0.8)" title="Отдалить"></button>
</div>
<div class="bio-hint" id="bio-hint">Кликни на холст, чтобы добавить атом · Перетащи от атома, чтобы создать связь · Клик по связи меняет порядок</div>
</div>
<!-- Info panel -->
<div class="bio-panel">
<div class="panel-tabs">
<button class="panel-tab active" onclick="switchPanel('editor')">Редактор</button>
<button class="panel-tab" onclick="switchPanel('challenges')">Задания</button>
<button class="panel-tab" onclick="switchPanel('saved')">Сохранённые</button>
</div>
<!-- Editor pane -->
<div class="panel-pane active" id="pane-editor">
<div class="bp-section" style="border-bottom:none;padding-bottom:0">
<div class="bp-label">Формула</div>
<div class="bp-formula empty" id="bp-formula"></div>
</div>
<!-- Live mol stats -->
<div class="bp-section" id="bp-mol-stats" style="display:none;padding-top:8px;padding-bottom:8px">
<div class="bp-label">Свойства</div>
<div class="bp-stats-grid" id="bp-stats-grid"></div>
<div class="bp-fg-wrap" id="bp-fg-wrap" style="display:none"></div>
</div>
<div class="bp-section" id="bp-mol-info" style="display:none">
<div class="bp-mol-name" id="bp-mol-name"></div>
<div class="bp-mol-desc" id="bp-mol-desc"></div>
</div>
<div class="bp-section" id="bp-issues-wrap">
<div id="bp-issues"></div>
</div>
<div class="bp-section">
<button class="bp-btn bp-btn-primary" id="bp-save-btn" onclick="saveCurrentMolecule()" disabled>
Сохранить молекулу
</button>
<button class="bp-btn bp-btn-secondary" onclick="loadFromLibrary()">
Загрузить из библиотеки
</button>
</div>
<div class="bp-section" id="bp-active-challenge" style="display:none">
<div class="bp-label" id="bp-chal-type-label">Текущее задание</div>
<div id="bp-chal-text" style="font-size:0.8rem;color:#ddd;margin-bottom:8px"></div>
<!-- identify/formula: molecule name hint -->
<div id="bp-chal-mol-hint" style="display:none;font-size:1rem;font-weight:700;color:#06D6E0;margin-bottom:10px;text-align:center"></div>
<!-- identify/formula: multiple choice buttons -->
<div id="bp-chal-choices" style="display:none;margin-bottom:6px"></div>
<!-- match challenge: two columns -->
<div id="bp-chal-match" style="display:none;margin-bottom:6px"></div>
<!-- balance challenge: equation with inputs -->
<div id="bp-chal-balance" style="display:none;margin-bottom:6px"></div>
<!-- build: submit button -->
<button class="bp-btn bp-btn-primary" id="bp-submit-build" onclick="submitChallenge()">Проверить решение</button>
<button class="bp-btn bp-btn-secondary" onclick="cancelChallenge()">Отмена</button>
</div>
</div>
<!-- Challenges pane -->
<div class="panel-pane" id="pane-challenges" style="padding:0">
<div class="chal-progress">
<div class="chal-progress-row">
<span>Прогресс: <strong id="chal-done-n">0</strong>/<span id="chal-total-n">0</span></span>
<span id="chal-xp-total" style="color:#facc15">0 XP</span>
</div>
<div class="chal-progress-bar"><div class="chal-progress-fill" id="chal-progress-fill" style="width:0%"></div></div>
</div>
<div class="chal-type-row">
<button class="chal-type-chip active" data-ctype="" onclick="setChalFilter(this,'')">Все</button>
<button class="chal-type-chip" data-ctype="build" onclick="setChalFilter(this,'build')"><svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Построить</button>
<button class="chal-type-chip" data-ctype="identify" onclick="setChalFilter(this,'identify')"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Узнать</button>
<button class="chal-type-chip" data-ctype="formula" onclick="setChalFilter(this,'formula')"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Формула</button>
<button class="chal-type-chip" data-ctype="balance" onclick="setChalFilter(this,'balance')"><svg class="ic" viewBox="0 0 24 24"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10M12 3v18M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg> Уравнение</button>
<button class="chal-type-chip" data-ctype="match" onclick="setChalFilter(this,'match')"><svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Совместить</button>
<button class="chal-type-chip" data-ctype="classify" onclick="setChalFilter(this,'classify')"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg> Класс</button>
<button class="chal-type-chip" data-ctype="complete" onclick="setChalFilter(this,'complete')"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Завершить</button>
</div>
<div style="padding:0 12px 12px">
<div id="challenges-list" style="color:#666;font-size:0.82rem">Загрузка…</div>
</div>
</div>
<!-- Saved pane -->
<div class="panel-pane" id="pane-saved">
<div id="saved-list" style="color:#666;font-size:0.82rem">Загрузка…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Library modal -->
<div class="modal-overlay" id="lib-modal" onclick="if(event.target===this)closeLibModal()" style="display:none;z-index:1000">
<div class="modal-box" style="max-width:600px;background:#0f0f1e;border:1px solid rgba(155,93,229,.2)">
<div class="modal-title" style="color:#ddd">Библиотека молекул</div>
<div style="display:flex;gap:8px;margin-bottom:16px">
<input type="text" id="lib-search" placeholder="Поиск по названию или формуле…" oninput="filterLibrary(this.value)"
style="flex:1;padding:8px 12px;border-radius:8px;background:rgba(255,255,255,.06);border:1.5px solid rgba(255,255,255,.1);color:#ddd;font-family:'Manrope',sans-serif;font-size:0.84rem">
<select id="lib-cat" onchange="filterLibrary()" style="padding:8px 12px;border-radius:8px;background:rgba(255,255,255,.06);border:1.5px solid rgba(255,255,255,.1);color:#ddd;font-family:'Manrope',sans-serif;font-size:0.82rem">
<option value="">Все категории</option>
<option value="inorganic">Неорганика</option>
<option value="organic">Органика</option>
<option value="biomolecule">Биомолекулы</option>
</select>
</div>
<div id="lib-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;max-height:380px;overflow-y:auto"></div>
<div style="margin-top:16px;text-align:right">
<button class="bp-btn bp-btn-secondary" style="width:auto;padding:8px 20px" onclick="closeLibModal()">Закрыть</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
'use strict';
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) location.href = '/login';
// Nav init
const nav = document.getElementById('nav-user');
const ava = document.getElementById('nav-avatar');
if (nav) nav.textContent = user?.name?.split(' ')[0] || 'Пользователь';
if (ava) ava.textContent = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
if (isAdmin) { document.getElementById('btn-admin').style.display=''; }
if (isTeacher) { document.getElementById('btn-classes').style.display=''; }
LS.showBoardIfAllowed();
// ── Element definitions (CPK colours) ──
const ELEMENTS = {
H: { name:'Водород', color:'#D4D4D4', text:'#222', radius:18, maxV:1 },
C: { name:'Углерод', color:'#555555', text:'#fff', radius:20, maxV:4 },
N: { name:'Азот', color:'#4060FF', text:'#fff', radius:20, maxV:3 },
O: { name:'Кислород', color:'#EE2020', text:'#fff', radius:20, maxV:2 },
P: { name:'Фосфор', color:'#FF8000', text:'#fff', radius:22, maxV:5 },
S: { name:'Сера', color:'#C8B400', text:'#000', radius:22, maxV:6 },
Cl: { name:'Хлор', color:'#00A860', text:'#fff', radius:22, maxV:1 },
Na: { name:'Натрий', color:'#8040C0', text:'#fff', radius:22, maxV:1 },
Ca: { name:'Кальций', color:'#707070', text:'#fff', radius:22, maxV:2 },
Mg: { name:'Магний', color:'#1E8A1E', text:'#fff', radius:22, maxV:2 },
Fe: { name:'Железо', color:'#B03010', text:'#fff', radius:22, maxV:3 },
};
// ── State ──
let atoms = [];
let bonds = [];
let nextId = 1;
let selEl = 'C'; // selected element
let tool = 'add'; // 'add' | 'move' | 'erase'
let hoveredAtomId = null;
let hoveredBondId = null;
let bondFromId = null; // dragging bond from this atom
let bondCurX = 0, bondCurY = 0;
let isDragging = false;
let dragId = null;
let dragOffX = 0, dragOffY = 0;
let isPanning = false;
let panSX = 0, panSY = 0;
let panX = 0, panY = 0;
let scale = 1;
let mouseDownW = null; // world pos at mousedown
let activeChalId = null; // challenge being attempted
let _libAll = []; // all library molecules (cached)
// ── Undo/Redo history ──
const _HISTORY_MAX = 30;
let _history = [];
let _histIdx = -1;
// ── Panel tabs ──
const _panelNames = { editor: 'pane-editor', challenges: 'pane-challenges', saved: 'pane-saved' };
function switchPanel(name) {
document.querySelectorAll('.panel-tab').forEach((b, i) => {
const panes = ['editor','challenges','saved'];
b.classList.toggle('active', panes[i] === name);
});
document.querySelectorAll('.panel-pane').forEach(p => p.classList.remove('active'));
const pane = document.getElementById('pane-' + name);
if (pane) pane.classList.add('active');
if (name === 'challenges' && !_challenges.length) loadChallenges();
if (name === 'saved') loadSaved();
// Re-sync canvas after panel content changes layout
setTimeout(resizeCanvas, 50);
}
// ── Canvas setup ──
const canvas = document.getElementById('mol-canvas');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
const wrap = document.getElementById('canvas-wrap');
const w = wrap.clientWidth, h = wrap.clientHeight;
if (w < 1 || h < 1) return; // panel hidden — skip
canvas.width = w;
canvas.height = h;
render();
}
// ── Coordinate helpers ──
function toWorld(ex, ey) {
const r = canvas.getBoundingClientRect();
return { x: (ex - r.left - panX) / scale, y: (ey - r.top - panY) / scale };
}
function toScreen(wx, wy) {
return { x: wx * scale + panX, y: wy * scale + panY };
}
// ── Hit testing ──
const HIT_R_BONUS = 6;
function atomAt(wx, wy) {
for (let i = atoms.length - 1; i >= 0; i--) {
const a = atoms[i];
const r = (ELEMENTS[a.s]?.radius || 20) + HIT_R_BONUS;
if ((wx - a.x)**2 + (wy - a.y)**2 <= r*r) return a;
}
return null;
}
function bondAt(wx, wy) {
for (const b of bonds) {
const a1 = atoms.find(a => a.id === b.from);
const a2 = atoms.find(a => a.id === b.to);
if (!a1 || !a2) continue;
if (ptSegDist(wx, wy, a1.x, a1.y, a2.x, a2.y) < 8) return b;
}
return null;
}
function ptSegDist(px, py, ax, ay, bx, by) {
const dx = bx-ax, dy = by-ay, len2 = dx*dx+dy*dy;
if (len2 === 0) return Math.hypot(px-ax, py-ay);
const t = Math.max(0, Math.min(1, ((px-ax)*dx+(py-ay)*dy)/len2));
return Math.hypot(px-(ax+t*dx), py-(ay+t*dy));
}
// ── Molecule operations ──
function addAtom(wx, wy, sym) {
const a = { id: nextId++, s: sym, x: wx, y: wy };
atoms.push(a);
return a;
}
function addBond(fromId, toId, order=1) {
const existing = bonds.find(b =>
(b.from===fromId && b.to===toId) || (b.from===toId && b.to===fromId));
if (existing) { existing.order = Math.min(existing.order + 1, 3); return existing; }
const b = { id: nextId++, from: fromId, to: toId, order };
bonds.push(b);
return b;
}
function removeAtom(id) {
atoms = atoms.filter(a => a.id !== id);
bonds = bonds.filter(b => b.from !== id && b.to !== id);
}
function removeBond(id) { bonds = bonds.filter(b => b.id !== id); }
function cycleBondOrder(id) {
const b = bonds.find(b => b.id === id);
if (!b) return;
if (b.order >= 3) removeBond(id);
else b.order++;
}
function hillFormula() {
if (!atoms.length) return '';
const cnt = {};
for (const a of atoms) cnt[a.s] = (cnt[a.s]||0) + 1;
const parts = [];
if (cnt.C) { parts.push('C' + (cnt.C>1?cnt.C:'')); delete cnt.C; }
if (cnt.H) { parts.push('H' + (cnt.H>1?cnt.H:'')); delete cnt.H; }
for (const el of Object.keys(cnt).sort()) parts.push(el+(cnt[el]>1?cnt[el]:''));
return parts.join('');
}
function getBondSum(id) {
return bonds.reduce((s,b) => s + (b.from===id||b.to===id ? b.o || b.order : 0), 0);
}
function getIssues() {
return atoms.filter(a => {
const used = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
return used > (ELEMENTS[a.s]?.maxV ?? 4);
}).map(a => {
const used = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
return { id:a.id, s:a.s, used, max: ELEMENTS[a.s]?.maxV??4 };
});
}
// ── Live molecular stats ──
const ATOMIC_MASS = {
H:1.008, C:12.011, N:14.007, O:15.999, P:30.974, S:32.06,
Cl:35.45, Na:22.990, Ca:40.078, Mg:24.305, Fe:55.845,
};
function calcMolStats() {
const wrap = document.getElementById('bp-mol-stats');
if (!atoms.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
const cnt = {};
for (const a of atoms) cnt[a.s] = (cnt[a.s]||0) + 1;
// Molar weight
let mw = 0;
for (const [el, n] of Object.entries(cnt)) mw += (ATOMIC_MASS[el]||0) * n;
// DBE: (2C + 2 + N + P H Cl) / 2
const C = cnt.C||0, H = cnt.H||0, Nv = cnt.N||0, Pv = cnt.P||0, Clv = cnt.Cl||0;
const dbe = (C || H) ? (2*C + 2 + Nv + Pv - H - Clv) / 2 : null;
const fg = _detectFG();
const molClass = _molClass(cnt, dbe, fg);
const polarity = _polarity(cnt, fg);
const chips = [];
chips.push(_chip('М.М. г/моль', mw.toFixed(2), 'cyan'));
if (dbe !== null && Number.isFinite(dbe)) {
const cls = dbe < 0 ? 'bad' : dbe === 0 ? 'good' : dbe >= 4 ? 'violet' : 'warn';
chips.push(_chip('DBE', Number.isInteger(dbe*2) && dbe === Math.round(dbe) ? dbe : dbe.toFixed(1), cls));
}
chips.push(_chip('Полярность', polarity.label, polarity.cls));
chips.push(_chip('Атомов', atoms.length, ''));
if (molClass) chips.push(
`<div class="bp-stat-chip" style="grid-column:1/-1"><span class="bp-stat-lbl">Класс</span>` +
`<span class="bp-stat-val violet">${molClass}</span></div>`
);
document.getElementById('bp-stats-grid').innerHTML = chips.join('');
const fgWrap = document.getElementById('bp-fg-wrap');
if (fg.length) {
fgWrap.innerHTML = fg.map(g =>
`<span class="bp-fg-tag" style="color:${g.color};border-color:${g.color}40">${g.label}</span>`
).join('');
fgWrap.style.display = 'flex';
} else {
fgWrap.innerHTML = ''; fgWrap.style.display = 'none';
}
}
function _chip(label, val, cls) {
return `<div class="bp-stat-chip"><span class="bp-stat-lbl">${label}</span>` +
`<span class="bp-stat-val ${cls}">${val}</span></div>`;
}
function _detectFG() {
const groups = [];
const bondsOf = id => bonds.filter(b => b.from===id || b.to===id);
const othr = (b, id) => b.from===id ? b.to : b.from;
const sym = id => atoms.find(a=>a.id===id)?.s;
const used = new Set();
// COOH: C with C=O and C-O-H
for (const a of atoms) {
if (a.s !== 'C') continue;
const myB = bondsOf(a.id);
const hasDblO = myB.some(b => b.order===2 && sym(othr(b,a.id))==='O');
const sglOs = myB.filter(b => b.order===1 && sym(othr(b,a.id))==='O');
if (hasDblO && sglOs.length) {
const oId = othr(sglOs[0], a.id);
if (bondsOf(oId).some(b => sym(othr(b,oId))==='H')) {
groups.push({ label:'COOH', color:'#f87171' }); used.add(a.id); continue;
}
}
// C=O (aldehyde or ketone)
if (hasDblO && !used.has(a.id)) {
const hN = myB.some(b => sym(othr(b,a.id))==='H');
const cN = myB.filter(b => sym(othr(b,a.id))==='C').length;
if (hN) groups.push({ label:'CHO', color:'#fb923c' });
else if(cN>=2)groups.push({ label:'C=O (кетон)', color:'#fb923c' });
else groups.push({ label:'C=O', color:'#fb923c' });
used.add(a.id);
}
}
// OH
const ohCount = atoms.filter(a => a.s==='O' &&
bondsOf(a.id).some(b => b.order===1 && sym(othr(b,a.id))==='H')).length;
if (ohCount) groups.push({ label: ohCount>1 ? `OH ×${ohCount}` : 'OH', color:'#60a5fa' });
// NH₂ / NH
for (const a of atoms) {
if (a.s !== 'N') continue;
const hCnt = bondsOf(a.id).filter(b => sym(othr(b,a.id))==='H').length;
if (hCnt >= 2) groups.push({ label:'NH₂', color:'#34d399' });
else if (hCnt === 1) groups.push({ label:'NH', color:'#34d399' });
}
// SH
if (atoms.some(a => a.s==='S' && bondsOf(a.id).some(b => sym(othr(b,a.id))==='H')))
groups.push({ label:'SH', color:'#fbbf24' });
// C=C
const enes = bonds.filter(b => b.order===2 && sym(b.from)==='C' && sym(b.to)==='C');
if (enes.length) groups.push({ label: enes.length>1 ? `C=C ×${enes.length}` : 'C=C', color:'#a78bfa' });
// C≡C
if (bonds.some(b => b.order===3 && sym(b.from)==='C' && sym(b.to)==='C'))
groups.push({ label:'C≡C', color:'#e879f9' });
// Aromatic (≥3 C=C bonds in C skeleton)
const cIds = new Set(atoms.filter(a=>a.s==='C').map(a=>a.id));
if (bonds.filter(b => b.order===2 && cIds.has(b.from) && cIds.has(b.to)).length >= 3)
groups.push({ label:'Арен', color:'#06D6E0' });
// Cl
const clCnt = atoms.filter(a=>a.s==='Cl').length;
if (clCnt) groups.push({ label: clCnt>1 ? `Cl ×${clCnt}` : 'Cl', color:'#4ade80' });
// Phosphate
for (const a of atoms) {
if (a.s!=='P') continue;
if (bondsOf(a.id).filter(b=>sym(othr(b,a.id))==='O').length >= 2) {
groups.push({ label:'Фосфат', color:'#f97316' }); break;
}
}
return groups;
}
function _molClass(cnt, dbe, fg) {
const has = label => fg.some(g => g.label.startsWith(label));
const onlyCH = Object.keys(cnt).every(el => el==='C'||el==='H');
if (has('Арен')) return 'Ароматическое';
if (has('COOH')) return 'Карбоновая кислота';
if (has('CHO')) return 'Альдегид';
if (has('C=O (кетон)')) return 'Кетон';
if (has('OH') && !cnt.N) return 'Спирт';
if (has('NH')) return 'Амин';
if (has('SH')) return 'Тиол';
if (has('Фосфат')) return 'Фосфорное соединение';
if (has('C≡C')) return 'Алкин';
if (has('C=C')) return 'Алкен';
if (dbe === 0 && onlyCH) return 'Алкан';
if (dbe === 1 && onlyCH) return 'Циклоалкан';
return null;
}
function _polarity(cnt, fg) {
if (cnt.Na||cnt.Ca||cnt.Mg||cnt.Fe) return { label:'Ионная', cls:'bad' };
if (fg.some(g=>g.label.startsWith('COOH')) || (cnt.O&&cnt.N))
return { label:'Сильно полярная', cls:'bad' };
if (cnt.O||cnt.N) return { label:'Полярная', cls:'warn' };
if (cnt.Cl||cnt.S) return { label:'Слабо полярная', cls:'warn' };
return { label:'Неполярная', cls:'good' };
}
// ── Rendering ──
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Background grid
ctx.save();
const gs = 40 * scale;
const ox = ((panX % gs) + gs) % gs;
const oy = ((panY % gs) + gs) % gs;
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = ox; x < canvas.width; x += gs) { ctx.moveTo(x,0); ctx.lineTo(x,canvas.height); }
for (let y = oy; y < canvas.height; y += gs) { ctx.moveTo(0,y); ctx.lineTo(canvas.width,y); }
ctx.stroke();
ctx.restore();
ctx.save();
ctx.translate(panX, panY);
ctx.scale(scale, scale);
// Bonds
for (const b of bonds) renderBond(b, b.id === hoveredBondId);
// Bond preview line
if (bondFromId !== null) {
const src = atoms.find(a => a.id === bondFromId);
if (src) {
ctx.save();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = '#9B5DE5';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(src.x, src.y);
ctx.lineTo(bondCurX, bondCurY);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
// Atoms
for (const a of atoms) renderAtom(a, a.id === hoveredAtomId);
ctx.restore();
}
function renderBond(b, hovered) {
const a1 = atoms.find(a => a.id === b.from);
const a2 = atoms.find(a => a.id === b.to);
if (!a1 || !a2) return;
const dx = a2.x - a1.x, dy = a2.y - a1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const nx = dx/len, ny = dy/len;
const px = -ny, py = nx;
const r1 = ELEMENTS[a1.s]?.radius || 20;
const r2 = ELEMENTS[a2.s]?.radius || 20;
const x1 = a1.x + nx*r1, y1 = a1.y + ny*r1;
const x2 = a2.x - nx*r2, y2 = a2.y - ny*r2;
ctx.strokeStyle = hovered ? '#c084fc' : '#6b7280';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
const drawLine = (ox, oy) => {
ctx.beginPath();
ctx.moveTo(x1+px*ox, y1+py*oy);
ctx.lineTo(x2+px*ox, y2+py*oy);
ctx.stroke();
};
if (b.order === 1) { drawLine(0,0); }
else if (b.order === 2) { drawLine(-4,0); drawLine(4,0); }
else { drawLine(0,0); drawLine(-5,0); drawLine(5,0); }
}
function renderAtom(a, hovered) {
const el = ELEMENTS[a.s] || { color:'#888', text:'#fff', radius:20 };
const r = el.radius;
const bondSum = bonds.reduce((s,b)=>(b.from===a.id||b.to===a.id)?s+(b.order||1):s,0);
const remaining = (el.maxV||1) - bondSum;
const overloaded = remaining < 0;
ctx.save();
if (hovered) { ctx.shadowBlur = 18; ctx.shadowColor = overloaded ? '#ef4444' : '#9B5DE5'; }
// Circle
ctx.beginPath();
ctx.arc(a.x, a.y, r, 0, Math.PI*2);
ctx.fillStyle = el.color;
ctx.fill();
ctx.strokeStyle = overloaded ? '#ef4444' : (hovered ? '#c084fc' : lighten(el.color));
ctx.lineWidth = hovered ? 2.5 : 1.8;
ctx.stroke();
ctx.shadowBlur = 0;
// Symbol
ctx.fillStyle = el.text || '#fff';
ctx.font = `bold ${r < 20 ? 11 : 13}px Manrope, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(a.s, a.x, a.y);
// Remaining valency badge
if (remaining > 0 && atoms.length > 1) {
ctx.fillStyle = '#4ade80';
ctx.font = '9px Manrope, sans-serif';
ctx.fillText(remaining, a.x + r - 2, a.y - r + 4);
} else if (overloaded) {
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 9px Manrope, sans-serif';
ctx.fillText('!', a.x + r - 2, a.y - r + 4);
}
// Free valence stubs — dashed radial lines showing open bonding slots
if (remaining > 0 && !overloaded) {
const myBonds = bonds.filter(b => b.from===a.id || b.to===a.id);
const taken = myBonds.map(b => {
const oth = atoms.find(x => x.id === (b.from===a.id ? b.to : b.from));
return oth ? Math.atan2(oth.y - a.y, oth.x - a.x) : null;
}).filter(ang => ang !== null);
ctx.save();
ctx.strokeStyle = 'rgba(74,222,128,0.5)';
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.lineCap = 'round';
const sLen = r * 0.6;
const N2P = x => ((x % (Math.PI*2)) + Math.PI*2) % (Math.PI*2);
let stubAngles;
if (!taken.length) {
stubAngles = Array.from({length: remaining}, (_, i) =>
-Math.PI/2 + (i / remaining) * Math.PI * 2);
} else {
// Find gaps between bond directions, place stubs in the largest gaps
const sorted = taken.map(N2P).sort((a, b) => a - b);
const nB = sorted.length;
const gaps = sorted.map((s, i) => {
const e = sorted[(i+1) % nB];
const size = (e > s) ? e - s : e - s + Math.PI * 2;
return { start: s, size };
}).sort((a, b) => b.size - a.size);
stubAngles = [];
let need = remaining;
for (const g of gaps) {
if (!need) break;
const k = Math.min(need, Math.max(1, Math.floor(g.size / (Math.PI / 2))));
for (let s = 0; s < k; s++) stubAngles.push(g.start + g.size * (s+1) / (k+1));
need -= k;
}
}
for (const angle of stubAngles) {
const ca = Math.cos(angle), sa = Math.sin(angle);
ctx.beginPath();
ctx.moveTo(a.x + ca*(r+1), a.y + sa*(r+1));
ctx.lineTo(a.x + ca*(r+1+sLen), a.y + sa*(r+1+sLen));
ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
}
ctx.restore();
}
function lighten(hex) {
try {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, ((n>>16)&0xff) + 50);
const g = Math.min(255, ((n>>8)&0xff) + 50);
const b = Math.min(255, (n&0xff) + 50);
return `rgb(${r},${g},${b})`;
} catch { return '#aaa'; }
}
// ── Mouse events ──
function getEvtPos(e) {
if (e.touches) return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
return { clientX: e.clientX, clientY: e.clientY };
}
canvas.addEventListener('mousedown', onMD);
canvas.addEventListener('mousemove', onMM);
canvas.addEventListener('mouseup', onMU);
canvas.addEventListener('mouseleave', () => {
if (_is3D) { _3dDrag = false; return; }
if (!isDragging && !isPanning) { hoveredAtomId=null; hoveredBondId=null; render(); }
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
if (_is3D) { scale = Math.max(0.2, Math.min(4, scale * (e.deltaY < 0 ? 1.12 : 0.9))); return; }
zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 0.9);
}, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
/* ── Touch support (mobile) ── */
function touchToMouse(type, e) {
e.preventDefault();
const t = e.touches[0] || e.changedTouches[0];
if (!t) return;
const me = new MouseEvent(type, {
clientX: t.clientX, clientY: t.clientY,
button: 0, buttons: 1,
});
canvas.dispatchEvent(me);
}
canvas.addEventListener('touchstart', e => touchToMouse('mousedown', e), { passive: false });
canvas.addEventListener('touchmove', e => touchToMouse('mousemove', e), { passive: false });
canvas.addEventListener('touchend', e => touchToMouse('mouseup', e), { passive: false });
canvas.addEventListener('touchcancel', e => touchToMouse('mouseup', e), { passive: false });
function onMD(e) {
// 3D mode: drag to rotate
if (_is3D) {
_3dDrag = true; _3dSpin = false;
_3dVelX = 0; _3dVelY = 0;
_3dLX = e.clientX; _3dLY = e.clientY;
canvas.style.cursor = 'grabbing';
return;
}
// Read-only for identify challenges (canvas shows the mystery molecule)
const activeType = activeChalId ? (_challenges.find(c=>c.id===activeChalId)?.type||'build') : 'build';
if (activeType !== 'build' && activeChalId) {
if (e.button === 1 || (e.button === 0 && e.altKey)) {
isPanning = true; panSX = e.clientX - panX; panSY = e.clientY - panY;
}
return;
}
if (e.button === 1 || (e.button === 0 && e.altKey)) {
isPanning = true;
panSX = e.clientX - panX; panSY = e.clientY - panY;
return;
}
if (e.button === 2) {
// Right-click <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> delete
const w = toWorld(e.clientX, e.clientY);
const a = atomAt(w.x, w.y);
if (a) { pushHistory(); removeAtom(a.id); updateInfo(); render(); return; }
const b = bondAt(w.x, w.y);
if (b) { pushHistory(); removeBond(b.id); updateInfo(); render(); return; }
return;
}
if (e.button !== 0) return;
const w = toWorld(e.clientX, e.clientY);
mouseDownW = w;
if (tool === 'add') {
const a = atomAt(w.x, w.y);
if (a) {
bondFromId = a.id; bondCurX = w.x; bondCurY = w.y;
} else {
pushHistory();
addAtom(w.x, w.y, selEl);
updateInfo(); render();
}
} else if (tool === 'move') {
const a = atomAt(w.x, w.y);
if (a) {
dragId = a.id; dragOffX = a.x - w.x; dragOffY = a.y - w.y; isDragging = true;
} else {
isPanning = true; panSX = e.clientX - panX; panSY = e.clientY - panY;
}
} else if (tool === 'erase') {
const a = atomAt(w.x, w.y);
if (a) { pushHistory(); removeAtom(a.id); updateInfo(); render(); return; }
const b = bondAt(w.x, w.y);
if (b) { pushHistory(); removeBond(b.id); updateInfo(); render(); return; }
}
}
function onMM(e) {
if (_is3D) {
if (_3dDrag) {
const dx = e.clientX - _3dLX;
const dy = e.clientY - _3dLY;
// EMA velocity for smooth inertia
_3dVelY = _3dVelY * 0.5 + dx * 0.009 * 0.5;
_3dVelX = _3dVelX * 0.5 + dy * 0.009 * 0.5;
_3dRotY += dx * 0.009;
_3dRotX += dy * 0.009;
_3dLX = e.clientX; _3dLY = e.clientY;
}
canvas.style.cursor = _3dDrag ? 'grabbing' : 'grab';
return;
}
const w = toWorld(e.clientX, e.clientY);
if (isPanning) { panX = e.clientX - panSX; panY = e.clientY - panSY; render(); return; }
if (bondFromId !== null) {
bondCurX = w.x; bondCurY = w.y;
hoveredAtomId = atomAt(w.x, w.y)?.id ?? null;
updateCursor(e);
render(); return;
}
if (isDragging && dragId !== null) {
const a = atoms.find(a => a.id === dragId);
if (a) { a.x = w.x + dragOffX; a.y = w.y + dragOffY; }
render(); return;
}
const ha = atomAt(w.x, w.y);
hoveredAtomId = ha?.id ?? null;
hoveredBondId = ha ? null : (bondAt(w.x, w.y)?.id ?? null);
updateCursor(e);
render();
}
function onMU(e) {
if (_is3D) {
if (_3dDrag) {
_3dDrag = false;
canvas.style.cursor = 'grab';
// let inertia decay, then resume auto-spin
clearTimeout(_3dSpinTmo);
_3dSpinTmo = setTimeout(() => { _3dSpin = true; }, 3000);
}
return;
}
const w = toWorld(e.clientX, e.clientY);
if (isPanning) { isPanning = false; return; }
if (bondFromId !== null) {
const src = bondFromId;
bondFromId = null;
const target = atomAt(w.x, w.y);
if (target && target.id !== src) {
pushHistory();
addBond(src, target.id);
} else if (!target && mouseDownW) {
const dx = w.x - mouseDownW.x, dy = w.y - mouseDownW.y;
if (Math.hypot(dx, dy) > 10) {
pushHistory();
const na = addAtom(w.x, w.y, selEl);
addBond(src, na.id);
}
}
updateInfo(); render(); return;
}
if (isDragging) { isDragging = false; dragId = null; updateInfo(); render(); return; }
// Click on bond <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> cycle order
if (mouseDownW) {
const moved = Math.hypot(w.x - mouseDownW.x, w.y - mouseDownW.y);
if (moved < 5) {
const b = bondAt(w.x, w.y);
if (b) { pushHistory(); cycleBondOrder(b.id); updateInfo(); render(); }
}
}
}
function updateCursor(e) {
const w = toWorld(e.clientX, e.clientY);
if (tool === 'erase') { canvas.style.cursor = 'cell'; return; }
if (tool === 'move') {
canvas.style.cursor = atomAt(w.x, w.y) ? 'grab' : 'default';
return;
}
if (bondFromId !== null) { canvas.style.cursor = 'crosshair'; return; }
canvas.style.cursor = atomAt(w.x, w.y) ? 'crosshair' : 'cell';
}
// ── Zoom ──
function zoomBy(factor) { zoomAt(canvas.width/2, canvas.height/2, factor); }
function zoomAt(sx, sy, factor) {
const wx = (sx - panX) / scale, wy = (sy - panY) / scale;
scale = Math.max(0.2, Math.min(4, scale * factor));
panX = sx - wx * scale; panY = sy - wy * scale;
render();
}
function centerView() {
if (!atoms.length) { panX = canvas.width/2; panY = canvas.height/2; scale = 1; render(); return; }
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for (const a of atoms) { minX=Math.min(minX,a.x); minY=Math.min(minY,a.y); maxX=Math.max(maxX,a.x); maxY=Math.max(maxY,a.y); }
const pad = 80;
scale = Math.min(4, Math.min((canvas.width-pad*2)/Math.max(maxX-minX,1), (canvas.height-pad*2)/Math.max(maxY-minY,1)));
panX = canvas.width/2 - ((minX+maxX)/2)*scale;
panY = canvas.height/2 - ((minY+maxY)/2)*scale;
render();
}
// ── UI ──
function setTool(t) {
tool = t;
['add','move','erase'].forEach(id => document.getElementById('tool-'+id).classList.toggle('active', id===t));
canvas.style.cursor = t==='erase' ? 'cell' : 'crosshair';
}
function clearAll() {
pushHistory();
atoms = []; bonds = []; nextId = 1;
bondFromId = null; hoveredAtomId = null; hoveredBondId = null;
updateInfo(); render();
}
function switchPanel(name) {
document.querySelectorAll('.panel-tab').forEach((b,i) => b.classList.toggle('active', ['editor','challenges','saved'][i]===name));
document.querySelectorAll('.panel-pane').forEach(p => p.classList.remove('active'));
document.getElementById('pane-'+name).classList.add('active');
if (name==='challenges') loadChallenges();
if (name==='saved') loadSaved();
}
function updateInfo() {
updateAtomCounts();
const formula = hillFormula();
const fEl = document.getElementById('bp-formula');
fEl.textContent = formula || '—';
fEl.classList.toggle('empty', !formula);
document.getElementById('bp-save-btn').disabled = !formula;
// Issues
const issues = getIssues();
const issDiv = document.getElementById('bp-issues');
if (issues.length) {
issDiv.innerHTML = issues.map(i => `<div class="bp-issue"><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ${i.s}: ${i.used}/${i.max} связей</div>`).join('');
} else if (formula) {
issDiv.innerHTML = '<div class="bp-ok"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Валентность в норме</div>';
} else {
issDiv.innerHTML = '';
}
// Lookup known molecule
const info = document.getElementById('bp-mol-info');
if (formula && !issues.length) {
LS.biochemValidate(
atoms.map(a=>({id:a.id,s:a.s})),
bonds.map(b=>({f:b.from,t:b.to,o:b.order}))
).then(r => {
if (r.known) {
info.style.display = '';
document.getElementById('bp-mol-name').textContent = r.known.name_ru;
document.getElementById('bp-mol-desc').textContent = r.known.description;
} else { info.style.display = 'none'; }
}).catch(() => { info.style.display = 'none'; });
} else { info.style.display = 'none'; }
calcMolStats();
}
// ── Build element palette ──
const EL_SHORTCUTS = { H:'H', C:'C', N:'N', O:'O', P:'P', S:'S' };
for (const [sym, el] of Object.entries(ELEMENTS)) {
const btn = document.createElement('button');
btn.className = 'el-btn' + (sym==='C' ? ' active' : '');
btn.id = 'el-btn-' + sym;
const shortcut = EL_SHORTCUTS[sym] ? ` [${sym}]` : '';
btn.title = el.name + ' (' + sym + ')' + shortcut;
btn.innerHTML = `${sym}<span class="el-cnt" id="el-cnt-${sym}"></span>`;
btn.style.background = el.color;
btn.style.color = el.text;
btn.style.borderColor = sym==='C' ? '#fff' : 'transparent';
btn.onclick = () => selectElement(sym);
document.getElementById('el-palette').appendChild(btn);
}
function selectElement(sym) {
if (!ELEMENTS[sym]) return;
selEl = sym;
document.querySelectorAll('.el-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = 'transparent'; });
const btn = document.getElementById('el-btn-' + sym);
if (btn) { btn.classList.add('active'); btn.style.borderColor = '#fff'; }
setTool('add');
}
function updateAtomCounts() {
const cnt = {};
for (const a of atoms) cnt[a.s] = (cnt[a.s]||0) + 1;
for (const sym of Object.keys(ELEMENTS)) {
const badge = document.getElementById('el-cnt-' + sym);
const btn = document.getElementById('el-btn-' + sym);
if (!badge || !btn) continue;
const n = cnt[sym] || 0;
badge.textContent = n > 0 ? n : '';
btn.classList.toggle('has-cnt', n > 0);
}
}
// ── Save ──
async function saveCurrentMolecule() {
if (!atoms.length) return;
const issues = getIssues();
if (issues.length) { LS.toast('Сначала исправь ошибки валентности', 'error'); return; }
const name = hillFormula();
try {
await LS.biochemSave(
atoms.map(a=>({id:a.id,s:a.s,x:Math.round(a.x),y:Math.round(a.y)})),
bonds.map(b=>({f:b.from,t:b.to,o:b.order})),
name
);
LS.toast('Молекула сохранена!', 'success');
} catch(e) { LS.toast('Ошибка: '+e.message, 'error'); }
}
// ── Library ──
async function loadFromLibrary() {
if (!_libAll.length) _libAll = await LS.biochemGetMolecules();
document.getElementById('lib-modal').style.display = '';
filterLibrary();
}
function closeLibModal() { document.getElementById('lib-modal').style.display = 'none'; }
function filterLibrary() {
const q = (document.getElementById('lib-search').value||'').toLowerCase();
const cat = document.getElementById('lib-cat').value;
const filtered = _libAll.filter(m =>
(!q || m.name_ru.toLowerCase().includes(q) || m.formula.toLowerCase().includes(q)) &&
(!cat || m.category===cat)
);
const grid = document.getElementById('lib-grid');
if (!filtered.length) { grid.innerHTML = '<div style="color:#666;font-size:0.82rem;grid-column:1/-1">Ничего не найдено</div>'; return; }
grid.innerHTML = filtered.map(m => `
<div onclick="loadMolecule(${m.id})" style="padding:10px;border-radius:10px;background:rgba(255,255,255,.04);border:1.5px solid rgba(255,255,255,.08);cursor:pointer;transition:all .15s"
onmouseover="this.style.borderColor='rgba(155,93,229,.4)'" onmouseout="this.style.borderColor='rgba(255,255,255,.08)'">
<div style="font-family:monospace;font-size:0.9rem;color:#06D6E0;font-weight:700">${m.formula}</div>
<div style="font-size:0.74rem;color:#bbb;margin-top:2px">${m.name_ru}</div>
<div style="font-size:0.68rem;color:#555;margin-top:2px">${['','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg><svg class="ic" viewBox="0 0 24 24"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'][m.difficulty]||''}</div>
</div>`).join('');
}
async function loadMolecule(id) {
try {
const m = await LS.biochemGetMolecule(id);
pushHistory();
atoms = (m.atoms_json||[]).map(a=>({...a, id: a.id}));
bonds = (m.bonds_json||[]).map(b=>({id:nextId++, from:b.f, to:b.t, order:b.o}));
nextId = Math.max(nextId, ...atoms.map(a=>a.id), 0) + 1;
closeLibModal();
centerView();
updateInfo();
} catch(e) { LS.toast('Ошибка загрузки: '+e.message,'error'); }
}
// ── Challenges ──
let _challenges = [];
let _chalFilter = '';
function setChalFilter(btn, type) {
_chalFilter = type;
document.querySelectorAll('.chal-type-chip').forEach(b => b.classList.toggle('active', b.dataset.ctype === type));
renderChalList();
}
async function loadChallenges() {
try {
_challenges = await LS.biochemGetChallenges();
updateChalProgress();
renderChalList();
} catch { document.getElementById('challenges-list').innerHTML = '<div style="color:#666">Ошибка загрузки</div>'; }
}
function updateChalProgress() {
const done = _challenges.filter(c => c.done).length;
const total = _challenges.length;
const xp = _challenges.filter(c => c.done).reduce((s,c) => s + c.xp_reward, 0);
document.getElementById('chal-done-n').textContent = done;
document.getElementById('chal-total-n').textContent = total;
document.getElementById('chal-xp-total').textContent = xp + ' XP';
document.getElementById('chal-progress-fill').style.width = total ? (done/total*100)+'%' : '0%';
}
function renderChalList() {
const list = document.getElementById('challenges-list');
const TYPE_ICON = { build:'<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>', identify:'<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', formula:'<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', balance:'<svg class="ic" viewBox="0 0 24 24"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10M12 3v18M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg>', match:'<svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', classify:'<svg class="ic" viewBox="0 0 24 24"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg>', complete:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>' };
const DIFF_COLOR = ['','#4ade80','#facc15','#f97316'];
const filtered = _chalFilter ? _challenges.filter(c => (c.type||'build') === _chalFilter) : _challenges;
if (!filtered.length) { list.innerHTML = '<div style="color:#555;font-size:0.78rem;padding:8px 0">Нет заданий</div>'; return; }
list.innerHTML = filtered.map(c => `
<div class="chal-item ${c.done?'done':''} ${activeChalId===c.id?'active-chal':''}" onclick="startChallenge(${c.id})">
<div class="chal-icon" style="background:rgba(155,93,229,.15)">${c.done ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : (TYPE_ICON[c.type||'build']||'<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>')}</div>
<div class="chal-info">
<div class="chal-title">${c.title}</div>
<div class="chal-xp">+${c.xp_reward} XP</div>
</div>
<div class="diff-dot" style="background:${DIFF_COLOR[c.difficulty]||'#888'}"></div>
</div>`).join('');
}
function startChallenge(id) {
const c = _challenges.find(c=>c.id===id);
if (!c || c.done) return;
activeChalId = id;
const typeLabels = {
build: '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Построить молекулу',
identify: '<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Узнать молекулу',
formula: '<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Найти формулу',
balance: '<svg class="ic" viewBox="0 0 24 24"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10M12 3v18M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg> Уравнять реакцию',
match: '<svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Совместить пары',
classify: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg> Классифицировать',
complete: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Завершить реакцию',
};
document.getElementById('bp-chal-type-label').textContent = typeLabels[c.type||'build'] || 'Задание';
document.getElementById('bp-chal-text').textContent = c.description;
document.getElementById('bp-active-challenge').style.display = '';
// hide all sub-UIs first
document.getElementById('bp-chal-choices').style.display = 'none';
document.getElementById('bp-chal-mol-hint').style.display = 'none';
document.getElementById('bp-chal-match').style.display = 'none';
document.getElementById('bp-chal-balance').style.display = 'none';
document.getElementById('bp-submit-build').style.display = 'none';
const type = c.type || 'build';
if (type === 'build') {
clearAll();
document.getElementById('bp-submit-build').style.display = '';
document.getElementById('bio-hint').innerHTML = c.hint ? '<svg class="ic" viewBox="0 0 24 24"><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg> ' + c.hint : 'Построй молекулу на холсте';
switchPanel('editor');
} else if (type === 'identify') {
renderChoiceButtons(c.data_json?.choices || [], answer => submitChoiceAnswer(answer));
if (c.data_json?.mol_id) {
LS.biochemGetMolecule(c.data_json.mol_id).then(m => {
atoms = (m.atoms_json||[]).map(a=>({...a}));
bonds = (m.bonds_json||[]).map(b=>({id:nextId++, from:b.f??b.from, to:b.t??b.to, order:b.o??b.order??1}));
nextId = Math.max(nextId, ...atoms.map(a=>a.id), 0) + 1;
centerView();
});
} else { clearAll(); }
document.getElementById('bio-hint').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg> Выбери правильное название молекулы';
switchPanel('editor');
} else if (type === 'formula') {
clearAll();
const molName = c.data_json?.mol_name || c.title;
document.getElementById('bp-chal-mol-hint').textContent = molName;
document.getElementById('bp-chal-mol-hint').style.display = '';
renderChoiceButtons(c.data_json?.choices || [], answer => submitChoiceAnswer(answer));
document.getElementById('bio-hint').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg> Выбери правильную химическую формулу';
switchPanel('editor');
} else if (type === 'classify') {
clearAll();
const target = c.data_json?.target || c.target_formula || '';
document.getElementById('bp-chal-mol-hint').textContent = target;
document.getElementById('bp-chal-mol-hint').style.display = '';
renderChoiceButtons(c.data_json?.choices || [], answer => submitChoiceAnswer(answer));
document.getElementById('bio-hint').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg> Выбери класс вещества';
switchPanel('editor');
} else if (type === 'complete') {
clearAll();
const eqText = c.data_json?.equation || '';
document.getElementById('bp-chal-mol-hint').textContent = eqText;
document.getElementById('bp-chal-mol-hint').style.display = eqText ? '' : 'none';
document.getElementById('bp-chal-mol-hint').style.fontSize = '0.82rem';
document.getElementById('bp-chal-mol-hint').style.color = '#ccc';
renderChoiceButtons(c.data_json?.choices || [], answer => submitChoiceAnswer(answer));
document.getElementById('bio-hint').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 4l7.07 17 2.51-7.39L21 11.07z"/></svg> Выбери недостающий компонент реакции';
switchPanel('editor');
} else if (type === 'match') {
clearAll();
renderMatchChallenge(c.data_json?.pairs || []);
document.getElementById('bio-hint').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Совмести формулы с названиями';
switchPanel('editor');
} else if (type === 'balance') {
clearAll();
renderBalanceChallenge(c.data_json || {});
document.getElementById('bp-submit-build').style.display = '';
document.getElementById('bio-hint').innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10M12 3v18M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg> Введи коэффициенты для балансировки уравнения';
switchPanel('editor');
}
renderChalList();
LS.toast('Задание: ' + c.title, 'info');
}
function renderChoiceButtons(choices, onSelect) {
const wrap = document.getElementById('bp-chal-choices');
wrap.style.display = '';
wrap.innerHTML = choices.map(ch =>
`<button class="chal-choice-btn" onclick="handleChoiceClick(this,'${ch.replace(/'/g,"\\'")}')">
${ch}
</button>`
).join('');
wrap._onSelect = onSelect;
}
function handleChoiceClick(btn, answer) {
// Disable all buttons
const wrap = document.getElementById('bp-chal-choices');
wrap.querySelectorAll('.chal-choice-btn').forEach(b => { b.disabled = true; });
wrap._pendingAnswer = answer;
wrap._pendingBtn = btn;
if (wrap._onSelect) wrap._onSelect(answer);
}
function cancelChallenge() {
activeChalId = null;
document.getElementById('bp-active-challenge').style.display = 'none';
document.getElementById('bp-chal-choices').style.display = 'none';
document.getElementById('bp-chal-mol-hint').style.display = 'none';
document.getElementById('bp-chal-match').style.display = 'none';
document.getElementById('bp-chal-balance').style.display = 'none';
document.getElementById('bp-submit-build').style.display = '';
document.getElementById('bio-hint').textContent = 'Кликни на холст, чтобы добавить атом · Перетащи от атома, чтобы создать связь · Клик по связи меняет порядок';
renderChalList();
}
async function submitChallenge() {
if (!activeChalId) return;
const c = _challenges.find(ch=>ch.id===activeChalId);
const type = c?.type || 'build';
// Balance type: read coefficient inputs
if (type === 'balance') {
const inputs = document.querySelectorAll('#bp-chal-balance .bal-coef');
const coefficients = Array.from(inputs).map(inp => parseInt(inp.value) || 0);
try {
const r = await LS.biochemSolveChallenge(activeChalId, { coefficients });
LS.toast(`Верно! +${r.xp} XP`, 'success');
cancelChallenge(); loadChallenges();
} catch(e) {
inputs.forEach(inp => { inp.style.borderColor='#ef4444'; setTimeout(()=>inp.style.borderColor='',900); });
LS.toast('Неверно! Проверь коэффициенты', 'error');
}
return;
}
// Default build type
const issues = getIssues();
if (issues.length) { LS.toast('Исправь ошибки валентности перед проверкой', 'error'); return; }
try {
const r = await LS.biochemSolveChallenge(activeChalId, {
atoms: atoms.map(a=>({id:a.id,s:a.s})),
bonds: bonds.map(b=>({f:b.from,t:b.to,o:b.order})),
});
LS.toast(`Верно! +${r.xp} XP`, 'success');
cancelChallenge();
loadChallenges();
} catch(e) {
if (e.data?.error === 'wrong_formula') LS.toast(`Неверно. Ожидается ${e.data.expected}, получено ${e.data.submitted}`, 'error');
else LS.toast('Ошибка: ' + e.message, 'error');
}
}
async function submitChoiceAnswer(answer) {
if (!activeChalId) return;
try {
const r = await LS.biochemSolveChallenge(activeChalId, { answer });
// Mark correct button green
const wrap = document.getElementById('bp-chal-choices');
wrap.querySelectorAll('.chal-choice-btn').forEach(b => {
if (b.textContent.trim() === answer) b.classList.add('correct');
});
LS.toast(`Верно! +${r.xp} XP`, 'success');
setTimeout(() => { cancelChallenge(); loadChallenges(); }, 900);
} catch(e) {
// Mark wrong button red, re-enable others
const wrap = document.getElementById('bp-chal-choices');
wrap.querySelectorAll('.chal-choice-btn').forEach(b => {
if (b.textContent.trim() === answer) b.classList.add('wrong');
else { b.disabled = false; }
});
LS.toast('Неверно! Попробуй ещё раз', 'error');
}
}
// ── Saved ──
async function loadSaved() {
const list = document.getElementById('saved-list');
try {
const saved = await LS.biochemGetSaved();
if (!saved.length) { list.innerHTML = '<div style="color:#555;font-size:0.8rem">Сохранённых молекул пока нет</div>'; return; }
list.innerHTML = saved.map(m => `
<div class="saved-item" onclick="loadSavedMolecule(${m.id})">
<div style="flex:1;min-width:0">
<div class="saved-formula">${m.formula}</div>
<div class="saved-name">${m.mol_name||m.name||''}</div>
</div>
<button onclick="event.stopPropagation();deleteSaved(${m.id})" style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:2px 4px" title="Удалить"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>`).join('');
} catch { list.innerHTML = '<div style="color:#666">Ошибка загрузки</div>'; }
}
async function loadSavedMolecule(id) {
try {
const saved = await LS.biochemGetSaved();
const m = saved.find(s=>s.id===id);
if (!m) return;
pushHistory();
atoms = (m.atoms_json||[]).map(a=>({...a}));
bonds = (m.bonds_json||[]).map(b=>({id:nextId++, from:b.f||b.from, to:b.t||b.to, order:b.o||b.order||1}));
nextId = Math.max(nextId, ...atoms.map(a=>a.id), 0) + 1;
centerView(); updateInfo(); switchPanel('editor');
} catch(e) { LS.toast('Ошибка: '+e.message,'error'); }
}
async function deleteSaved(id) {
if (!await LS.confirm('Удалить сохранённую молекулу?')) return;
try { await LS.biochemDeleteSaved(id); loadSaved(); } catch(e) { LS.toast('Ошибка','error'); }
}
// ── 3D mode ──
let _is3D = false;
let _isVDW = false; // space-fill (VDW) sub-mode
let _3dRotX = 0.35; // initial X-tilt in radians
let _3dRotY = 0;
let _3dAnimId = null;
let _3dDrag = false;
let _3dLX = 0, _3dLY = 0;
let _3dVelX = 0; // rotation inertia
let _3dVelY = 0;
let _3dSpin = true; // auto-spin flag
let _3dSpinTmo = null; // resume-spin timeout
// VDW radii (van der Waals, canvas units)
const VDW_R = { H:38, C:56, N:52, O:50, S:72, P:70, F:40, Cl:72, Br:85,
Na:80, Ca:86, K:92, Mg:72, Fe:70 };
function toggle3D() {
_is3D = !_is3D;
document.getElementById('btn-3d').classList.toggle('mode-3d-active', _is3D);
const vdwBtn = document.getElementById('btn-vdw');
vdwBtn.style.display = _is3D ? '' : 'none';
if (!_is3D && _isVDW) { _isVDW = false; vdwBtn.classList.remove('mode-3d-active'); }
if (_is3D) {
centerView();
_start3D();
} else {
_stop3D();
render();
}
}
function toggleVDW() {
_isVDW = !_isVDW;
document.getElementById('btn-vdw').classList.toggle('mode-3d-active', _isVDW);
}
function _start3D() {
_stop3D();
function frame() {
if (!_is3D) return;
if (!_3dDrag) {
if (_3dSpin && Math.abs(_3dVelX) < 0.001 && Math.abs(_3dVelY) < 0.001) {
// auto-spin when no inertia
_3dRotY += 0.013;
} else {
// apply inertia with decay
_3dRotX += _3dVelX;
_3dRotY += _3dVelY;
_3dVelX *= 0.92;
_3dVelY *= 0.92;
}
}
render3D();
_3dAnimId = requestAnimationFrame(frame);
}
_3dAnimId = requestAnimationFrame(frame);
}
function _stop3D() {
if (_3dAnimId) { cancelAnimationFrame(_3dAnimId); _3dAnimId = null; }
}
function _proj3D(x, y, z) {
const cy = Math.cos(_3dRotY), sy = Math.sin(_3dRotY);
const x1 = x * cy + z * sy;
const z1 = -x * sy + z * cy;
const cx = Math.cos(_3dRotX), sx2 = Math.sin(_3dRotX);
const y2 = y * cx - z1 * sx2;
const z2 = y * sx2 + z1 * cx;
const fov = 800;
const sc = fov / (fov + z2);
return { sx: x1 * sc, sy: y2 * sc, sz: z2, sc };
}
function _hexRgb(hex) {
hex = hex.replace('#','');
if (hex.length === 3) hex = hex.split('').map(c=>c+c).join('');
const n = parseInt(hex,16);
return [(n>>16)&255,(n>>8)&255,n&255];
}
function render3D() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#07070f';
ctx.fillRect(0,0,W,H);
if (!atoms.length) return;
// molecule center (world coords)
let cx=0, cy=0;
for (const a of atoms) { cx+=a.x; cy+=a.y; }
cx/=atoms.length; cy/=atoms.length;
const s3 = scale * 1.15;
// project all atoms
const proj = atoms.map(a => {
const p = _proj3D((a.x-cx)*s3, (a.y-cy)*s3, (a.z||0)*s3);
return { a, sx: p.sx+W/2, sy: p.sy+H/2, sz: p.sz, sc: p.sc };
});
proj.sort((a,b) => b.sz - a.sz); // back<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>front
const pm = {};
for (const p of proj) pm[p.a.id] = p;
// bonds (hidden in VDW mode)
if (!_isVDW) {
for (const b of bonds) {
const p1 = pm[b.from], p2 = pm[b.to];
if (!p1||!p2) continue;
const avgSc = (p1.sc+p2.sc)/2;
ctx.strokeStyle = `rgba(180,180,200,${0.35+avgSc*0.55})`;
ctx.lineWidth = Math.max(1.2, 3.5*avgSc);
ctx.lineCap = 'round';
const order = b.order||1;
if (order===1) {
ctx.beginPath(); ctx.moveTo(p1.sx,p1.sy); ctx.lineTo(p2.sx,p2.sy); ctx.stroke();
} else {
const dx=p2.sx-p1.sx, dy=p2.sy-p1.sy, len=Math.hypot(dx,dy)||1;
const ox=-dy/len*3.5*avgSc, oy=dx/len*3.5*avgSc;
for (let o=-(order-1); o<=(order-1); o+=2) {
ctx.beginPath();
ctx.moveTo(p1.sx+ox*o, p1.sy+oy*o);
ctx.lineTo(p2.sx+ox*o, p2.sy+oy*o);
ctx.stroke();
}
}
}
}
// atoms (back<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>front already)
for (const p of proj) {
const { a, sx, sy, sc } = p;
const el = ELEMENTS[a.s]||{color:'#888',text:'#fff',radius:20};
const r = _isVDW
? Math.max(4, (VDW_R[a.s] || 56) * sc * 0.85)
: Math.max(3, el.radius * sc * 1.25);
const [r0,g0,b0] = _hexRgb(el.color);
// sphere gradient: highlight top-left, dark bottom-right
const grd = ctx.createRadialGradient(sx-r*0.32,sy-r*0.38,r*0.06, sx,sy,r);
grd.addColorStop(0, `rgb(${Math.min(255,r0+110)},${Math.min(255,g0+110)},${Math.min(255,b0+110)})`);
grd.addColorStop(0.42, el.color);
grd.addColorStop(1, `rgb(${Math.round(r0*0.2)},${Math.round(g0*0.2)},${Math.round(b0*0.2)})`);
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI*2);
ctx.fillStyle = grd;
ctx.fill();
// label (hide H when tiny, hidden in VDW mode)
if (!_isVDW && (a.s !== 'H' || r > 13)) {
ctx.fillStyle = el.text||'#fff';
ctx.font = `bold ${Math.max(8,Math.round(r*0.72))}px Manrope,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowBlur = 0;
ctx.fillText(a.s, sx, sy);
}
}
}
// ── Init ──
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
panX = canvas.width / 2;
panY = canvas.height / 2;
// Auto-load molecule from URL ?mol=ID
const _urlMol = new URLSearchParams(location.search).get('mol');
if (_urlMol) {
LS.biochemGetMolecule(_urlMol).then(m => {
atoms = (m.atoms_json||[]).map(a => ({...a}));
bonds = (m.bonds_json||[]).map(b => ({ id: nextId++, from: b.f, to: b.t, order: b.o }));
nextId = Math.max(nextId, ...atoms.map(a => a.id), 0) + 1;
history.replaceState({}, '', '/biochem');
centerView(); updateInfo();
}).catch(() => {});
}
// ── History (undo/redo) ──
function pushHistory() {
_history = _history.slice(0, _histIdx + 1);
_history.push({
atoms: JSON.parse(JSON.stringify(atoms)),
bonds: JSON.parse(JSON.stringify(bonds)),
nextId,
});
if (_history.length > _HISTORY_MAX) _history.shift();
_histIdx = _history.length - 1;
_updateUndoButtons();
}
function undo() {
if (_histIdx <= 0) return;
_histIdx--;
const s = _history[_histIdx];
atoms = JSON.parse(JSON.stringify(s.atoms));
bonds = JSON.parse(JSON.stringify(s.bonds));
nextId = s.nextId;
updateInfo(); render(); _updateUndoButtons();
}
function redo() {
if (_histIdx >= _history.length - 1) return;
_histIdx++;
const s = _history[_histIdx];
atoms = JSON.parse(JSON.stringify(s.atoms));
bonds = JSON.parse(JSON.stringify(s.bonds));
nextId = s.nextId;
updateInfo(); render(); _updateUndoButtons();
}
function _updateUndoButtons() {
document.getElementById('btn-undo').disabled = _histIdx <= 0;
document.getElementById('btn-redo').disabled = _histIdx >= _history.length - 1;
}
// ── Ring templates ──
const RING_TEMPLATES = {
benzene: {
atoms: [{s:'C',x:0,y:-55},{s:'C',x:47.6,y:-27.5},{s:'C',x:47.6,y:27.5},
{s:'C',x:0,y:55},{s:'C',x:-47.6,y:27.5},{s:'C',x:-47.6,y:-27.5}],
bonds: [[0,1,2],[1,2,1],[2,3,2],[3,4,1],[4,5,2],[5,0,1]],
},
cyclohexane: {
atoms: [{s:'C',x:0,y:-55},{s:'C',x:47.6,y:-27.5},{s:'C',x:47.6,y:27.5},
{s:'C',x:0,y:55},{s:'C',x:-47.6,y:27.5},{s:'C',x:-47.6,y:-27.5}],
bonds: [[0,1,1],[1,2,1],[2,3,1],[3,4,1],[4,5,1],[5,0,1]],
},
cyclopentane: {
atoms: [{s:'C',x:0,y:-50},{s:'C',x:47.6,y:-15.5},{s:'C',x:29.4,y:40.5},
{s:'C',x:-29.4,y:40.5},{s:'C',x:-47.6,y:-15.5}],
bonds: [[0,1,1],[1,2,1],[2,3,1],[3,4,1],[4,0,1]],
},
naphthalene: {
atoms: [
{s:'C',x:0,y:27.5},{s:'C',x:0,y:-27.5},
{s:'C',x:-47.6,y:-55},{s:'C',x:-95.2,y:-27.5},{s:'C',x:-95.2,y:27.5},{s:'C',x:-47.6,y:55},
{s:'C',x:47.6,y:-55},{s:'C',x:95.2,y:-27.5},{s:'C',x:95.2,y:27.5},{s:'C',x:47.6,y:55},
],
bonds: [
[0,1,1],[1,2,2],[2,3,1],[3,4,2],[4,5,1],[5,0,2],
[1,6,2],[6,7,1],[7,8,2],[8,9,1],[9,0,2],
],
},
};
function insertRing(key) {
const t = RING_TEMPLATES[key];
if (!t) return;
pushHistory();
// Place to the right of existing atoms (or canvas center)
const offX = atoms.length ? Math.max(...atoms.map(a=>a.x)) + 90 : 0;
const idMap = {};
t.atoms.forEach((a, i) => {
const id = nextId++;
idMap[i] = id;
atoms.push({ id, s: a.s, x: a.x + offX, y: a.y });
});
t.bonds.forEach(([f, to, order]) => {
bonds.push({ id: nextId++, from: idMap[f], to: idMap[to], order });
});
centerView();
updateInfo();
closeRingMenu();
}
function toggleRingMenu() {
document.getElementById('ring-menu').classList.toggle('open');
}
function closeRingMenu() {
document.getElementById('ring-menu').classList.remove('open');
}
// Close ring menu on outside click
document.addEventListener('click', e => {
if (!e.target.closest('.ring-menu-wrap')) closeRingMenu();
});
// ── Match challenge ──
let _matchSelected = null; // { side: 'left'|'right', value: string }
let _matchPairs = []; // completed pairs so far
function renderMatchChallenge(pairs) {
_matchSelected = null;
_matchPairs = [];
const wrap = document.getElementById('bp-chal-match');
wrap.style.display = '';
// Shuffle right side
const rights = [...pairs.map(p=>p.right)].sort(() => Math.random()-.5);
wrap.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px">
<div class="match-col-hdr">Формула</div><div class="match-col-hdr">Название</div>
${pairs.map((p,i) => `
<button class="match-it" id="ml-${i}" data-side="left" data-val="${escHtml(p.left)}" onclick="matchClick(this)">${p.left}</button>
<button class="match-it" id="mr-${i}" data-side="right" data-val="${escHtml(rights[i])}" onclick="matchClick(this)">${rights[i]}</button>
`).join('')}
</div>
<button class="bp-btn bp-btn-primary" id="match-submit-btn" onclick="submitMatchAnswer()" style="display:none;margin-top:6px">Проверить</button>`;
wrap._pairs = pairs;
wrap._rights = rights;
}
function escHtml(s) { return String(s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function matchClick(btn) {
if (btn.classList.contains('matched')) return;
const side = btn.dataset.side;
const val = btn.dataset.val;
if (!_matchSelected) {
// First click — select
document.querySelectorAll('.match-it.selected').forEach(b=>b.classList.remove('selected'));
btn.classList.add('selected');
_matchSelected = { side, val, btn };
} else if (_matchSelected.side === side) {
// Same side — reselect
document.querySelectorAll('.match-it.selected').forEach(b=>b.classList.remove('selected'));
btn.classList.add('selected');
_matchSelected = { side, val, btn };
} else {
// Opposite side — attempt pair
const left = side === 'right' ? _matchSelected.val : val;
const right = side === 'right' ? val : _matchSelected.val;
const wrap = document.getElementById('bp-chal-match');
const correctRight = (wrap._pairs||[]).find(p=>p.left===left)?.right;
if (right === correctRight) {
btn.classList.add('matched');
_matchSelected.btn.classList.remove('selected');
_matchSelected.btn.classList.add('matched');
_matchPairs.push({ left, right });
_matchSelected = null;
// Check if all done
if (_matchPairs.length === (wrap._pairs||[]).length) {
document.getElementById('match-submit-btn').style.display = '';
}
} else {
// Flash wrong
btn.classList.add('wrong');
_matchSelected.btn.classList.add('wrong');
setTimeout(() => {
btn.classList.remove('wrong','selected');
_matchSelected?.btn.classList.remove('wrong','selected');
_matchSelected = null;
}, 700);
}
}
}
async function submitMatchAnswer() {
if (!activeChalId) return;
try {
const r = await LS.biochemSolveChallenge(activeChalId, { pairs: _matchPairs });
LS.toast(`Верно! +${r.xp} XP`, 'success');
cancelChallenge(); loadChallenges();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// ── Balance challenge ──
function renderBalanceChallenge(data) {
const wrap = document.getElementById('bp-chal-balance');
wrap.style.display = '';
const reactants = data.reactants || [];
const products = data.products || [];
const count = reactants.length + products.length;
let html = '<div class="balance-eq">';
reactants.forEach((f,i) => {
if (i > 0) html += '<span class="bal-op">+</span>';
html += `<input type="number" class="bal-coef" min="1" max="99" value="1" placeholder="?">`;
html += `<span class="bal-formula">${escHtml(f)}</span>`;
});
html += '<span class="bal-arrow"><svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></span>';
products.forEach((f,i) => {
if (i > 0) html += '<span class="bal-op">+</span>';
html += `<input type="number" class="bal-coef" min="1" max="99" value="1" placeholder="?">`;
html += `<span class="bal-formula">${escHtml(f)}</span>`;
});
html += '</div>';
wrap.innerHTML = html;
}
// Keyboard shortcuts
window.addEventListener('keydown', e => {
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
if (hoveredAtomId) { pushHistory(); removeAtom(hoveredAtomId); hoveredAtomId=null; updateInfo(); render(); }
else if (hoveredBondId) { pushHistory(); removeBond(hoveredBondId); hoveredBondId=null; updateInfo(); render(); }
}
if (e.key === 'Escape') { cancelChallenge(); bondFromId=null; closeRingMenu(); render(); }
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase()==='z') { e.preventDefault(); undo(); }
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase()==='z') || (e.ctrlKey && e.key.toLowerCase()==='y')) { e.preventDefault(); redo(); }
// Element shortcuts
if (!e.ctrlKey && !e.altKey) {
const EL_KEY = { h:'H', c:'C', n:'N', o:'O', p:'P', s:'S' };
if (EL_KEY[e.key.toLowerCase()]) selectElement(EL_KEY[e.key.toLowerCase()]);
if (e.key.toLowerCase()==='a') setTool('add');
if (e.key.toLowerCase()==='m') setTool('move');
if (e.key.toLowerCase()==='e') setTool('erase');
}
});
if (window.lucide) lucide.createIcons();
LS.notif?.init();
LS.hideDisabledFeatures?.();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>