358b761eb2
Проблема: динамическая вставка через 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>
2047 lines
94 KiB
HTML
2047 lines
94 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|