Files
Maxim Dolgolyov 358b761eb2 fix(biochem): статичный subnav без мигания + редизайн
Проблема: динамическая вставка через JS вызывала мигание (nav
появлялся через ~100ms после первого пейнта).

Решение: nav — статичный HTML в каждой странице, CSS — в <head>.
Активная вкладка проставлена в HTML (class bsn-active) — нет JS,
нет мигания, работает с первого байта.

Редизайн .biochem-subnav:
- frosted glass (backdrop-filter blur 14px, rgba 0.92)
- активная вкладка: фиолетовый фон-пилюля + нижняя линия 2.5px
- hover: мягкий фиолетовый фон
- mobile <560px: только иконки (bsn-label display:none)
- overflow-x auto + scrollbar-width:none — горизонтальная прокрутка без полосы
- biochem-nav.js сведён к no-op комментарию

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 08:54:38 +03:00

2047 lines
94 KiB
HTML
Raw Permalink 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; }
/* ── Shimmer skeleton ── */
@keyframes bc-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.bc-sk {
background: linear-gradient(90deg,
rgba(255,255,255,0.04) 0%,
rgba(255,255,255,0.10) 50%,
rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: bc-shimmer 1.6s infinite;
border-radius: 8px;
}
.bc-sk-line { height: 11px; margin: 5px 0; }
.bc-sk-line.sm { width: 55%; }
.bc-sk-line.md { width: 80%; }
.bc-sk-saved { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid rgba(255,255,255,.05); }
.bc-sk-saved .bc-sk-fi { flex: 1; }
/* ── 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; }
}
/* ── Biochem subnav ─────────────────────────────────────────────── */
.biochem-subnav {
display: flex; align-items: center; gap: 2px;
padding: 0 16px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-bottom: 1.5px solid rgba(15,23,42,0.07);
flex-shrink: 0; overflow-x: auto;
scrollbar-width: none; position: relative;
}
.biochem-subnav::-webkit-scrollbar { display: none; }
.biochem-subnav::after {
content: ''; position: absolute; bottom: -1px; left: 0; right: 0;
height: 1.5px; background: rgba(15,23,42,0.07);
}
.bsn-tab {
display: inline-flex; align-items: center; gap: 7px;
padding: 11px 14px; border-radius: 9px; margin: 5px 1px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
color: var(--text-3, #56687A); text-decoration: none; white-space: nowrap;
transition: background .15s, color .15s; position: relative;
}
.bsn-tab svg { stroke: currentColor; width: 15px; height: 15px; flex-shrink: 0; fill: none; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.bsn-tab:hover { background: rgba(155,93,229,0.08); color: #9B5DE5; }
.bsn-active {
background: rgba(155,93,229,0.10); color: #7c3aed; font-weight: 700;
}
.bsn-active::after {
content: ''; position: absolute; bottom: -5px; left: 14px; right: 14px;
height: 2.5px; border-radius: 99px; background: #9B5DE5;
}
@media (max-width: 560px) {
.biochem-subnav { padding: 0 6px; }
.bsn-tab { padding: 10px 10px; gap: 0; }
.bsn-tab .bsn-label { display: none; }
}
</style>
</head>
<body>
<div class="app-layout" id="app">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<nav class="biochem-subnav" aria-label="Разделы биохимии">
<a class="bsn-tab bsn-active" href="/biochem" aria-current="page"><svg viewBox="0 0 24 24"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3M7.5 15h9"/></svg><span class="bsn-label">Редактор</span></a><a class="bsn-tab" href="/biochem-library"><svg viewBox="0 0 24 24"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg><span class="bsn-label">Библиотека</span></a><a class="bsn-tab" href="/biochem-reactions"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg><span class="bsn-label">Реакции</span></a><a class="bsn-tab" href="/biochem-properties"><svg viewBox="0 0 24 24"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 10h2a5 5 0 0 0 0-10h-2m-6 5h6"/></svg><span class="bsn-label">Свойства</span></a><a class="bsn-tab" href="/biochem-pathways"><svg viewBox="0 0 24 24"><path d="M6 18h8M3 22h18M8 22V12l4-10 4 10v10M10 9h4"/></svg><span class="bsn-label">Пути</span></a>
</nav>
<!-- ── 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>
<button class="tool-btn" id="btn-charge" onclick="toggleCharges()" title="Частичные заряды δ+/δ− и диполь"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2v20M2 12h20"/></svg> δ±</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>
<!-- SMILES import -->
<div style="display:flex;gap:6px;margin-top:8px">
<input type="text" id="smiles-in" placeholder="SMILES, напр. CCO" spellcheck="false"
onkeydown="if(event.key==='Enter')importSmiles()"
style="flex:1;min-width:0;padding:7px 10px;border-radius:8px;background:rgba(255,255,255,.06);border:1.5px solid rgba(255,255,255,.12);color:#ddd;font:600 .78rem monospace">
<button class="bp-btn bp-btn-secondary" style="width:auto;margin:0;padding:0 14px" onclick="importSmiles()">Импорт</button>
</div>
<!-- Export -->
<div style="display:flex;gap:6px;margin-top:6px">
<button class="bp-btn bp-btn-secondary" style="margin:0;flex:1" onclick="exportPNG()">PNG</button>
<button class="bp-btn bp-btn-secondary" style="margin:0;flex:1" onclick="exportJSON()">JSON</button>
</div>
</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="/js/biochem-core.js"></script>
<script src="/js/sidebar.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] || 'Пользователь';
LS.renderNavAvatar(ava, user);
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.order||b.o||1) : 0), 0);
}
function getIssues() {
// Единая проверка валентности из ядра (с подсказками, Фаза 2.4)
return BIO.valency(atoms, bonds);
}
// ── Live molecular stats ──
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;
// Полный химический анализ из общего ядра (масса, DBE, геометрия, диполь, группы)
const an = BIO.analyze(atoms, bonds);
_chargeMap = an.charges || null;
_dipoleVec = (an.polarity && an.polarity.vector) || null;
const dbe = an.dbe;
const fg = an.groups;
const molClass = _molClass(cnt, dbe, fg);
const chips = [];
chips.push(_chip('М.М. г/моль', an.mass.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('Полярность', an.polarity.label, an.polarity.cls));
if (an.dipole != null) chips.push(_chip('Дипольный момент', an.dipole.toFixed(2) + ' D', an.dipole < 0.18 ? 'good' : an.dipole >= 1.5 ? 'bad' : 'warn'));
chips.push(_chip('Атомов', atoms.length, ''));
// Geometry (VSEPR) — for the central atom
if (bonds.length) {
const g = an.geometry;
if (g.shape) chips.push(_chip('Геометрия', g.shape, 'violet'));
if (g.hybridization) chips.push(_chip('Гибридизация', g.hybridization, 'cyan'));
if (g.angle != null) chips.push(_chip('Угол связи', g.angle + '°', ''));
}
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 _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;
}
// ── 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 = (_showCharges && _chargeMap) ? BIO.chargeColor(_chargeMap[a.id]) : 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.msg}</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();
if (_is3D) _build3D();
}
// ── 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'); }
}
// ── SMILES import ──
function importSmiles() {
const inp = document.getElementById('smiles-in');
const smi = (inp.value || '').trim();
if (!smi) return;
const parsed = BIO.parseSmiles(smi);
if (!parsed || !parsed.atoms.length) {
LS.toast('Не удалось разобрать SMILES (поддержан верхний регистр: CCO, C1=CC=CC=C1)', 'error');
return;
}
pushHistory();
// переносим в редактор (bonds в формате {from,to,order})
const idMap = {};
atoms = parsed.atoms.map(a => { const nid = nextId++; idMap[a.id] = nid; return { id: nid, s: a.s, x: a.x, y: a.y }; });
bonds = parsed.bonds.map(b => ({ id: nextId++, from: idMap[b.f], to: idMap[b.t], order: b.o }));
inp.value = '';
if (_is3D) _build3D();
centerView(); updateInfo();
LS.toast(`Импортировано: ${BIO.hillFormula(atoms)}`, 'success');
}
// ── Export ──
function exportPNG() {
if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = (hillFormula() || 'molecule') + (_is3D ? '-3d' : '') + '.png';
a.click();
}
function exportJSON() {
if (!atoms.length) { LS.toast('Пустой холст', 'info'); return; }
BIO.download((hillFormula() || 'molecule') + '.json',
BIO.toJSON(atoms, bonds.map(b => ({ f: b.from, t: b.to, o: b.order })), hillFormula()),
'application/json');
}
// ── 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() {
const cl = document.getElementById('challenges-list');
cl.innerHTML = bcSkSaved(5);
try {
_challenges = await LS.biochemGetChallenges();
updateChalProgress();
renderChalList();
} catch { cl.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 if (e.data?.error === 'wrong_structure') LS.toast('Формула верна, но структура (связность) не та — проверь, как соединены атомы', 'error');
else if (e.data?.error === 'valency_error') LS.toast('Есть ошибки валентности', '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 ──
function bcSkSaved(n = 4) {
return Array.from({length: n}, () => `
<div class="bc-sk-saved">
<div class="bc-sk-fi">
<div class="bc-sk bc-sk-line md"></div>
<div class="bc-sk bc-sk-line sm"></div>
</div>
<div class="bc-sk" style="width:18px;height:18px;border-radius:4px"></div>
</div>`).join('');
}
async function loadSaved() {
const list = document.getElementById('saved-list');
list.innerHTML = bcSkSaved(4);
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
// Real 3D geometry (VSEPR), rebuilt whenever the molecule changes in 3D mode
let _atoms3d = [];
function _build3D() {
if (window.BIO) _atoms3d = BIO.vsepr(atoms, bonds).atoms3d;
else _atoms3d = atoms.map(a => ({ id:a.id, s:a.s, x:a.x, y:a.y, z:0 }));
}
// Fit scale to the 3D molecule extent so it fills the view nicely
function _fit3D() {
if (!_atoms3d.length) { scale = 1; return; }
let ext = 1;
for (const a of _atoms3d) ext = Math.max(ext, Math.hypot(a.x, a.y, a.z));
const view = Math.min(canvas.width, canvas.height) * 0.40;
scale = Math.max(0.3, Math.min(4, view / (ext * 1.6 + 30)));
}
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) {
canvas.style.cursor = 'grab';
_build3D();
_fit3D();
_start3D();
} else {
_stop3D();
canvas.style.cursor = 'crosshair';
render();
}
}
function toggleVDW() {
_isVDW = !_isVDW;
document.getElementById('btn-vdw').classList.toggle('mode-3d-active', _isVDW);
}
// ── Partial-charge heatmap (δ+/δ−) + dipole arrow ──
let _showCharges = false;
let _chargeMap = null; // { atomId: partialCharge }
let _dipoleVec = null; // [x,y,z]
function toggleCharges() {
_showCharges = !_showCharges;
document.getElementById('btn-charge').classList.toggle('mode-3d-active', _showCharges);
if (_is3D) render3D(); else render();
}
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 render3D() {
const W = canvas.width, H = canvas.height;
if (!_atoms3d.length && atoms.length) _build3D();
// 3D coords are in canvas units (~real geometry); scale to fit the view
BIO.render3D(ctx, _atoms3d, bonds, {
rotX: _3dRotX, rotY: _3dRotY, scale: scale * 1.6, W, H,
}, {
vdw: _isVDW, bg: '#07070f',
charges: _showCharges ? _chargeMap : null,
dipoleVec: _showCharges ? _dipoleVec : null,
});
}
// ── 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 || [];
wrap._reactants = reactants;
wrap._products = products;
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="?" oninput="updateBalanceFeedback()">`;
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="?" oninput="updateBalanceFeedback()">`;
html += `<span class="bal-formula">${escHtml(f)}</span>`;
});
html += '</div>';
// живая поэлементная проверка сохранения атомов
html += '<div id="bal-feedback" style="display:flex;flex-wrap:wrap;gap:5px;margin-top:2px"></div>';
wrap.innerHTML = html;
updateBalanceFeedback();
}
// Живой счётчик атомов слева/справа по каждому элементу (через BIO.parseFormula)
function updateBalanceFeedback() {
const wrap = document.getElementById('bp-chal-balance');
const fb = document.getElementById('bal-feedback');
if (!wrap || !fb || !window.BIO) return;
const reactants = wrap._reactants || [], products = wrap._products || [];
const coefs = Array.from(wrap.querySelectorAll('.bal-coef')).map(i => parseInt(i.value) || 0);
const sideCounts = (formulas, offset) => {
const tot = {};
formulas.forEach((f, i) => {
const k = coefs[offset + i] || 0;
const c = BIO.parseFormula(f);
for (const el in c) tot[el] = (tot[el] || 0) + c[el] * k;
});
return tot;
};
const L = sideCounts(reactants, 0);
const R = sideCounts(products, reactants.length);
const els = [...new Set([...Object.keys(L), ...Object.keys(R)])].sort();
let allOk = els.length > 0;
fb.innerHTML = els.map(el => {
const l = L[el] || 0, r = R[el] || 0, ok = l === r;
if (!ok) allOk = false;
const col = ok ? '#4ade80' : '#f87171';
return `<span style="font-size:.66rem;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid ${col}40;color:${col}">${el}: ${l}=${r} ${ok ? '✓' : '✗'}</span>`;
}).join('') + (allOk ? '<span style="font-size:.66rem;font-weight:700;padding:2px 7px;border-radius:999px;background:rgba(74,222,128,.14);color:#4ade80">сбалансировано</span>' : '');
}
// 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(); if (_is3D) toggle3D(); 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.loadFeatures?.().then(feats => {
if (feats?.biochem === false) window.location.replace('/403');
else LS.hideDisabledFeatures?.();
}).catch(() => LS.hideDisabledFeatures?.());
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/biochem-nav.js"></script>
</body>
</html>