Merge feature/lab-split: modular lab.html (5180L → 3499L)

4 phases shipped, phase 5 (template lazy-mount) deferred:

1. Extract inline <style> → /css/lab.css (-856L)

2. Extract inline glue <script> → /js/labs/lab-glue.js (-825L)

3. Token purification ~106 hardcodes → CSS vars

4. Hash-router #sim/<name> deep-links, 34 sims auto-mapped

Final review: READY TO MERGE (0 blockers, 0 warnings, 3 polish notes).

Tests baseline unchanged (66/63/3), all curl endpoints 200.
This commit is contained in:
Maxim Dolgolyov
2026-05-22 23:21:05 +03:00
10 changed files with 2305 additions and 1810 deletions
+855
View File
@@ -0,0 +1,855 @@
/* ── page fill so sim canvas can go full-height ── */
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
/* ════════════════════════════════
HOME VIEW
════════════════════════════════ */
#lab-home { flex: 1; overflow-y: auto; padding: 36px 28px 80px; }
.lab-hero {
display: flex; align-items: center; gap: 20px;
margin-bottom: 36px;
}
.lab-hero-icon {
width: 64px; height: 64px; border-radius: 20px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(155,93,229,.35), rgba(6,214,224,.25));
border: 1.5px solid rgba(255,255,255,.12);
display: flex; align-items: center; justify-content: center;
}
.lab-hero-icon svg { width: 30px; height: 30px; stroke: var(--violet); stroke-width: 1.5; }
.lab-hero-title {
font-family: 'Unbounded', sans-serif; font-size: 1.55rem; font-weight: 800;
letter-spacing: -0.02em; margin-bottom: 5px;
}
.lab-hero-sub { font-size: 0.9rem; color: var(--text-2); font-weight: 500; }
/* category filter */
.lab-filters {
display: flex; gap: 6px; margin-bottom: 28px; flex-wrap: wrap;
}
.lab-filter {
padding: 6px 18px; border-radius: 99px;
border: 1.5px solid var(--border-h);
background: var(--surface); color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: all .16s;
}
.lab-filter:hover { border-color: rgba(155,93,229,.4); color: var(--violet); }
.lab-filter.active { background: var(--text); color: #fff; border-color: var(--text); }
/* sim grid */
.sim-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 20px;
}
.sim-card {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: 20px;
overflow: hidden;
cursor: pointer;
transition: border-color .18s, box-shadow .18s, transform .18s;
position: relative;
}
.sim-card:hover {
border-color: var(--violet);
box-shadow: 0 8px 32px rgba(155,93,229,.18);
transform: translateY(-2px);
}
.sim-card.soon { cursor: default; opacity: .65; }
.sim-card.soon:hover { transform: none; box-shadow: none; border-color: var(--border); }
.sim-preview {
width: 100%; height: 140px; display: block;
background: #0D0D1A;
}
.sim-body { padding: 18px 20px 20px; }
.sim-cat {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em;
padding: 3px 10px; border-radius: 99px; margin-bottom: 10px;
}
.sim-cat.math { background: rgba(155,93,229,.12); color: var(--violet); }
.sim-cat.phys { background: rgba(6,214,224,.1); color: var(--cyan); }
.sim-title {
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800;
margin-bottom: 6px; letter-spacing: -.01em;
}
.sim-desc { font-size: 0.82rem; color: var(--text-2); line-height: 1.55; }
.sim-soon-badge {
position: absolute; top: 12px; right: 12px;
background: rgba(15,23,42,.7); color: rgba(255,255,255,.55);
font-size: 0.65rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em;
padding: 3px 9px; border-radius: 99px; backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,.1);
}
/* ════════════════════════════════
SIM VIEW (graph)
════════════════════════════════ */
#lab-sim {
display: none;
flex: 1; min-height: 0;
flex-direction: column;
}
#lab-sim.open { display: flex; }
/* top bar */
.sim-topbar {
flex-shrink: 0;
display: flex; align-items: center; gap: 12px;
padding: 10px 18px;
background: var(--surface);
border-bottom: 1.5px solid var(--border);
}
.sim-back {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 99px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.sim-back:hover { border-color: var(--violet); color: var(--violet); }
.sim-back svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2.2; flex-shrink: 0; }
.sim-topbar-title {
font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800;
flex: 1;
}
.sim-zoom-btns { display: flex; gap: 4px; }
.zoom-btn {
min-width: 32px; width: auto; height: 32px; border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; font-size: .8rem; font-weight: 700;
padding: 0 9px; white-space: nowrap;
display: flex; align-items: center; justify-content: center; gap: 4px;
transition: all .15s;
}
.zoom-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.07); }
.zoom-btn svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 2.2; }
/* sim body */
.sim-body-wrap {
flex: 1; min-height: 0;
display: flex;
}
/* left panel */
.graph-panel {
width: 280px; flex-shrink: 0;
background: var(--surface);
border-right: 1.5px solid var(--border);
display: flex; flex-direction: column;
overflow-y: auto;
padding: 16px 14px;
gap: 6px;
}
.gp-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.62rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: .08em;
display: flex; align-items: center; gap: 8px; margin: 4px 0 8px;
}
.gp-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* function rows */
.fn-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 12px;
border: 1.5px solid var(--border);
background: rgba(15,23,42,.03);
transition: border-color .15s;
}
.fn-label {
font-family: 'Manrope', monospace; font-size: 0.85rem; font-weight: 700;
color: var(--fn-color, var(--violet)); flex-shrink: 0; letter-spacing: .01em;
}
.fn-row:focus-within { border-color: var(--fn-color, var(--violet)); }
.fn-dot {
width: 12px; height: 12px; border-radius: 50%;
flex-shrink: 0;
background: var(--fn-color, var(--violet));
box-shadow: 0 0 6px var(--fn-color, var(--violet));
}
.fn-input {
flex: 1; border: none; outline: none; background: transparent;
font-family: 'Manrope', monospace; font-size: 0.88rem; font-weight: 600;
color: var(--text); padding: 0; min-width: 0;
}
.fn-input::placeholder { color: var(--text-3); font-weight: 500; }
/* KaTeX live preview */
.fn-preview {
min-height: 20px; padding: 3px 4px 3px 36px;
font-size: 0.82rem; line-height: 1.5;
color: rgba(255,255,255,.65);
overflow: hidden; display: none;
}
.fn-preview.has-content { display: block; }
.fn-preview .katex { color: rgba(255,255,255,.8); font-size: 1em; }
.fn-err {
font-size: 0.68rem; color: var(--pink); font-weight: 600;
padding: 2px 0 0 22px; display: none;
}
.fn-err.show { display: block; }
/* presets */
.presets-wrap { display: flex; flex-wrap: wrap; gap: 5px; }
.preset-btn {
padding: 4px 11px; border-radius: 8px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
font-family: 'Manrope', monospace; font-size: 0.75rem; font-weight: 700;
cursor: pointer; transition: all .14s;
}
.preset-btn:hover {
border-color: var(--violet); color: var(--violet);
background: rgba(155,93,229,.07);
}
/* info / actions */
.gp-btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px 14px; border-radius: 12px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: all .15s; width: 100%;
}
.gp-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.06); }
.gp-btn svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; flex-shrink: 0; }
/* stereo param sliders */
.stereo-sl-row { margin-bottom: 6px; }
.stereo-sl-row label { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-2); margin-bottom: 2px; }
.stereo-sl-row label span { color: var(--violet); font-weight: 700; }
.stereo-sl-row input[type=range] { width: 100%; accent-color: var(--violet); }
.stereo-fig-btn.active, .stereo-toggle.active, .stereo-sect-btn.active, .stereo-sect-type.active {
border-color: var(--violet) !important; color: var(--violet) !important; background: rgba(155,93,229,.1) !important;
}
/* stereo panel redesign */
.stereo-panel {
width: 230px; flex-shrink: 0;
background: var(--surface);
border-right: 1.5px solid var(--border);
display: flex; flex-direction: column;
overflow-y: auto; padding: 10px 10px;
}
.stereo-fig-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 3px;
margin-bottom: 4px;
}
.st-fig-btn {
display: flex; align-items: center; gap: 5px;
padding: 6px 8px; border-radius: 9px;
border: 1.5px solid var(--border);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.68rem; font-weight: 700;
cursor: pointer; transition: all .13s; white-space: nowrap; overflow: hidden;
}
.st-fig-btn svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2; flex-shrink: 0; fill: none; }
.st-fig-btn:hover { border-color: rgba(155,93,229,.4); color: var(--violet); background: rgba(155,93,229,.06); }
.st-fig-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); }
.st-fig-btn-wide { grid-column: span 2; }
.st-tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin-bottom: 4px; }
.st-tool-btn {
display: flex; align-items: center; gap: 5px;
padding: 6px 8px; border-radius: 9px;
border: 1.5px solid var(--border);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.68rem; font-weight: 700;
cursor: pointer; transition: all .13s; white-space: nowrap; overflow: hidden;
}
.st-tool-btn svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; flex-shrink: 0; fill: none; }
.st-tool-btn:hover { border-color: rgba(155,93,229,.4); color: var(--violet); background: rgba(155,93,229,.06); }
.st-tool-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); }
.st-tool-btn-wide { grid-column: span 2; }
.st-action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin-bottom: 2px; }
.st-action-btn {
padding: 5px 6px; border-radius: 8px;
border: 1px solid var(--border);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.65rem; font-weight: 600;
cursor: pointer; transition: all .13s; text-align: center;
}
.st-action-btn:hover { border-color: rgba(239,71,111,.4); color: #ef476f; background: rgba(239,71,111,.06); }
.st-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 2px; cursor: pointer; transition: background .13s; border-radius: 7px;
}
.st-toggle-row:hover { background: rgba(255,255,255,.03); }
.st-toggle-label {
font-size: 0.7rem; font-weight: 600; color: var(--text-2);
display: flex; align-items: center; gap: 5px;
}
.st-toggle-label svg { width: 11px; height: 11px; stroke: currentColor; stroke-width: 2; opacity: .6; fill: none; }
.st-toggle {
width: 26px; height: 14px; border-radius: 7px;
background: rgba(255,255,255,.1); border: 1.5px solid var(--border-h);
position: relative; transition: background .15s; flex-shrink: 0; cursor: pointer;
}
.st-toggle::after {
content: ''; position: absolute; left: 2px; top: 50%;
transform: translateY(-50%);
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255,255,255,.4); transition: all .15s;
}
.st-toggle.on { background: var(--violet); border-color: var(--violet); }
.st-toggle.on::after { left: calc(100% - 10px); background: #fff; }
.st-n-ctrl {
display: flex; align-items: center; gap: 5px;
border: 1.5px solid var(--border); border-radius: 9px;
padding: 4px 6px;
}
.st-n-btn {
width: 20px; height: 20px; border-radius: 5px;
border: 1px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .12s;
}
.st-n-btn svg { width: 11px; height: 11px; stroke: currentColor; }
.st-n-btn:hover { background: rgba(155,93,229,.1); }
.st-n-val { font-size: 0.75rem; font-weight: 700; color: var(--text); min-width: 14px; text-align: center; }
.st-n-label { font-size: 0.68rem; color: var(--text-3); flex: 1; }
#stereo-unfold-btn.active, #stereo-measure-btn.active,
#stereo-point-btn.active, #stereo-connect-btn.active,
#stereo-mark-tick-btn.active, #stereo-mark-par-btn.active,
#stereo-derive-mid-btn.active, #stereo-derive-fc-btn.active, #stereo-derive-alt-btn.active, #stereo-derive-cen-btn.active,
#stereo-angle-edge-btn.active, #stereo-angle-lp-btn.active, #stereo-angle-dih-btn.active, #stereo-angle-pp-btn.active, #stereo-angle-skew-btn.active {
border-color: var(--violet) !important; color: var(--violet) !important; background: rgba(155,93,229,.12) !important;
}
.gp-preset-group { margin-bottom: 8px; }
.gp-preset-label {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--text-3);
margin: 6px 0 4px;
}
/* canvas area */
.graph-canvas-outer {
flex: 1; min-width: 0; min-height: 0;
display: flex; flex-direction: column;
}
.graph-canvas-wrap {
flex: 1; min-height: 0; position: relative;
}
#graph-canvas { display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/* info bar */
.graph-info-bar {
flex-shrink: 0;
display: flex; align-items: center; gap: 20px;
padding: 8px 18px;
background: #0D0D1A;
border-top: 1px solid rgba(255,255,255,.07);
font-family: 'Manrope', monospace; font-size: 0.8rem;
color: rgba(255,255,255,.4);
min-height: 36px;
}
.info-coord { display: flex; align-items: center; gap: 6px; }
.info-coord .ic-label { color: rgba(255,255,255,.25); }
.info-coord .ic-val { color: rgba(255,255,255,.7); font-weight: 700; min-width: 52px; }
.info-fn-val { display: flex; align-items: center; gap: 6px; }
.info-fn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.info-fn-val .ic-val { color: rgba(255,255,255,.7); font-weight: 700; min-width: 60px; }
.info-hint { margin-left: auto; font-size: 0.72rem; color: rgba(255,255,255,.2); }
/* ════════════════════════════════
PROJECTILE SIM
════════════════════════════════ */
.sim-proj-wrap {
flex: 1; min-height: 0;
display: flex; flex-direction: column; overflow: hidden;
}
/* left panel (shared base with .graph-panel) */
.proj-panel {
width: 260px; flex-shrink: 0;
background: var(--surface);
border-right: 1.5px solid var(--border);
display: flex; flex-direction: column;
overflow-y: auto; padding: 16px 14px; gap: 6px;
}
/* canvas */
.proj-canvas-outer {
flex: 1; min-width: 0; position: relative;
}
.proj-canvas-outer canvas {
display: block; position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
}
/* sliders */
.param-block { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.param-header { display: flex; justify-content: space-between; align-items: baseline; }
.param-name { font-size: 0.8rem; font-weight: 700; color: var(--text-2); }
.param-val {
font-family: 'Manrope', monospace; font-size: 0.82rem; font-weight: 800;
color: var(--violet); min-width: 70px; text-align: right;
}
.param-slider {
-webkit-appearance: none; appearance: none;
width: 100%; height: 4px; border-radius: 4px;
background: var(--border-h); outline: none; cursor: pointer;
}
.param-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--violet); box-shadow: 0 0 6px rgba(155,93,229,.5);
cursor: pointer;
}
.param-slider::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%; border: none;
background: var(--violet); cursor: pointer;
}
/* preset chips for projectile */
.proj-preset-chip {
padding: 5px 10px; border-radius: 8px; font-size: 0.75rem; font-weight: 700;
border: 1.5px solid var(--border-h); background: transparent;
color: var(--text-2); cursor: pointer; transition: all .15s; white-space: nowrap;
}
.proj-preset-chip:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.06); }
/* stats bar */
.proj-stats-bar {
flex-shrink: 0; display: flex; align-items: stretch;
background: #0D0D1A; border-top: 1px solid rgba(255,255,255,.07);
min-height: 48px;
}
.pstat {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 2px; padding: 6px 8px;
border-right: 1px solid rgba(255,255,255,.06);
}
.pstat:last-child { border-right: none; }
.pstat-label { font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: rgba(255,255,255,.3); }
.pstat-val { font-family: 'Manrope', monospace; font-size: 0.9rem; font-weight: 800; color: rgba(255,255,255,.85); }
#csbar-v4, #csbar-v6 { font-size: 0.60rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* play button highlight */
.zoom-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); }
/* ── launch button ── */
.proj-launch-btn {
width: 100%; padding: 14px 18px;
border-radius: 16px; border: none;
background: linear-gradient(135deg, #7c3aed 0%, #9B5DE5 45%, #F15BB5 100%);
color: #fff;
font-family: 'Unbounded', sans-serif; font-size: 0.8rem; font-weight: 800;
letter-spacing: .04em; text-transform: uppercase;
cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 10px;
box-shadow: 0 4px 24px rgba(155,93,229,.45), 0 0 0 0 rgba(155,93,229,.3);
transition: transform .15s, box-shadow .15s;
position: relative; overflow: hidden;
}
.proj-launch-btn::before {
content: ''; position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,.15) 0%, transparent 60%);
pointer-events: none;
}
.proj-launch-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(155,93,229,.6), 0 0 0 3px rgba(155,93,229,.15);
}
.proj-launch-btn:active { transform: translateY(0); }
.proj-launch-btn.paused {
background: linear-gradient(135deg, #0891b2 0%, #06D6E0 100%);
box-shadow: 0 4px 24px rgba(6,214,224,.35);
}
.proj-launch-btn.done {
background: linear-gradient(135deg, #15803d 0%, #22c55e 100%);
box-shadow: 0 4px 24px rgba(34,197,94,.35);
}
.proj-reset-btn {
width: 100%; padding: 8px;
border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 700;
cursor: pointer; transition: all .15s;
display: flex; align-items: center; justify-content: center; gap: 7px;
}
.proj-reset-btn:hover { border-color: var(--violet); color: var(--violet); }
/* speed slider — cyan thumb */
#sl-speed::-webkit-slider-thumb {
background: var(--cyan);
box-shadow: 0 0 6px rgba(6,214,224,.5);
}
#sl-speed::-moz-range-thumb { background: var(--cyan); }
/* magnetic canvas */
#mag-canvas {
display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
cursor: crosshair;
}
/* mode buttons */
.mag-mode-btn {
flex: 1; padding: 8px 6px; border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 700;
cursor: pointer; transition: all .15s;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.mag-mode-btn:hover { border-color: var(--violet); color: var(--text); }
.mag-mode-btn.active {
background: rgba(155,93,229,.15); border-color: var(--violet); color: #fff;
}
/* triangle canvas */
#tri-canvas {
display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}
/* molecular physics canvases */
#gas-canvas, #brownian-canvas, #states-canvas, #diffusion-canvas, #reactions-canvas {
display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}
/* chemistry sim-cat badge */
.sim-cat.chem { background: rgba(52,211,153,.1); color: #34d399; }
/* biology sim-cat badge */
.sim-cat.bio { background: rgba(34,211,153,.1); color: #22d399; }
/* phase dot navigator */
.cd-phase-nav { display:flex; align-items:center; justify-content:center; gap:6px; padding:6px 0; flex-wrap:wrap; }
.cd-phase-dot { width:8px; height:8px; border-radius:50%; background:var(--border-h,rgba(255,255,255,.15)); cursor:pointer; transition:background .2s,transform .2s; flex-shrink:0; }
.cd-phase-dot.active { background:#22d399; transform:scale(1.5); }
/* ── triangle panel components ── */
.tri-layer-row {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 10px;
border: 1.5px solid var(--border);
background: rgba(15,23,42,.03);
cursor: pointer; user-select: none;
transition: border-color .15s, background .15s;
}
.tri-layer-row:hover { background: rgba(155,93,229,.05); border-color: rgba(155,93,229,.3); }
.tri-layer-row.active { background: rgba(155,93,229,.08); border-color: rgba(155,93,229,.4); }
.tri-dot {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
}
.tri-layer-name {
font-size: 0.78rem; font-weight: 700; color: var(--text); flex: 1;
}
.tri-layer-hint {
font-size: 0.68rem; font-weight: 700; opacity: 0.8;
}
.tri-toggle {
width: 28px; height: 16px; border-radius: 99px;
background: var(--border-h); flex-shrink: 0;
position: relative; transition: background .2s;
}
.tri-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; border-radius: 50%;
background: #fff; transition: transform .2s;
}
.tri-layer-row.active .tri-toggle { background: var(--violet); }
.tri-layer-row.active .tri-toggle::after { transform: translateX(12px); }
.tri-stats-grid {
display: grid; grid-template-columns: 26px 1fr; gap: 3px 8px;
padding: 0 4px;
}
.tri-stat-k {
font-family: 'Manrope', monospace; font-size: 0.78rem; font-weight: 800;
display: flex; align-items: center;
}
.tri-stat-v {
font-family: 'Manrope', monospace; font-size: 0.82rem; font-weight: 700;
color: var(--text); padding: 2px 6px;
border-radius: 6px; background: rgba(15,23,42,.08);
}
/* ── trig circle buttons ── */
.trig-fn-btn {
padding: 6px 14px; border-radius: 8px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.10);
color: #aaa; font-size: 13px; font-weight: 700; cursor: pointer;
font-family: 'Manrope', sans-serif; transition: .15s;
}
.trig-fn-btn:hover { background: rgba(var(--fc-rgb,155,93,229),0.15); border-color: var(--fc,var(--violet)); color: var(--fc,var(--violet)); }
.trig-fn-btn.active { background: rgba(var(--fc-rgb,155,93,229),0.18); border-color: var(--fc,var(--violet)); color: var(--fc,var(--violet)); box-shadow: 0 0 8px rgba(var(--fc-rgb,155,93,229),0.3); }
/* ── responsive ── */
@media (max-width: 768px) {
#lab-home { padding: 20px 16px 60px; }
.sim-grid { grid-template-columns: 1fr 1fr; gap: 14px; }
.graph-panel { width: 220px; }
/* Touch-friendly button targets (min 44px) */
.zoom-btn { width: auto; min-width: 44px; height: 44px; padding: 0 12px; border-radius: 12px; }
.sim-back { padding: 10px 16px; min-height: 44px; }
.theory-toggle-btn { width: 44px; height: 44px; border-radius: 12px; }
}
@media (max-width: 540px) {
.sim-grid { grid-template-columns: 1fr; }
.sim-body-wrap { flex-direction: column; }
.graph-panel { width: 100%; height: auto; border-right: none; border-bottom: 1.5px solid var(--border); max-height: 220px; }
}
/* Circuit tool buttons */
.circ-tool-btn.active {
background: rgba(155,93,229,0.25) !important;
border-color: var(--violet) !important;
color: #c4b5fd !important;
}
.circ-top-btn.active { background: rgba(155,93,229,0.35) !important; color: #c4b5fd !important; }
/* Flask selection buttons */
.flask-metal-btn.active, .flask-acid-btn.active {
background: rgba(75,205,155,0.20) !important;
border-color: #4BCD9B !important;
color: #7BF5A4 !important;
}
/* Reaction mode buttons */
.reac-mode-btn.active {
background: rgba(6,214,224,0.18) !important;
border-color: var(--cyan) !important;
color: var(--cyan) !important;
}
/* Newton law/scene buttons */
.nlaw-btn.active {
background: rgba(6,214,224,0.18) !important;
border-color: var(--cyan) !important;
color: var(--cyan) !important;
}
.nscene-btn.active {
background: rgba(241,91,181,0.15) !important;
border-color: var(--pink) !important;
color: var(--pink) !important;
}
/* ── wave mode buttons ── */
.wave-mode-btn {
flex: 1; padding: 7px 4px; border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 700;
cursor: pointer; transition: all .15s; text-align: center;
}
.wave-mode-btn:hover { border-color: var(--violet); color: var(--text); }
.wave-mode-btn.active {
background: rgba(155,93,229,.15) !important;
border-color: var(--violet) !important; color: #fff !important;
}
.wave-n-btn.active {
background: rgba(255,209,102,.15) !important;
border-color: #FFD166 !important; color: #FFD166 !important; /* amber — wave harmonic, intentional palette */
}
/* ── theory panel (overlay right sidebar) ── */
#lab-sim { position: relative; }
.theory-panel {
position: absolute; right: 0; top: 0; bottom: 0; z-index: 55;
width: 320px;
background: var(--surface);
border-left: 1.5px solid var(--border);
transform: translateX(100%);
transition: transform 0.25s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
box-shadow: -4px 0 24px rgba(0,0,0,0.12);
}
.theory-panel.open { transform: translateX(0); }
.theory-panel-inner {
padding: 20px 16px; overflow-y: auto; flex: 1;
display: flex; flex-direction: column; gap: 16px;
}
.tp-title {
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: .06em;
display: flex; align-items: center; gap: 8px;
}
.tp-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.tp-section { margin-bottom: 4px; }
.tp-section-head {
font-size: 0.76rem; font-weight: 800; color: var(--violet);
margin-bottom: 6px; display: flex; align-items: center; gap: 6px;
}
.tp-formula {
background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.12);
border-radius: 10px; padding: 10px 12px; margin-bottom: 8px;
font-size: 0.88rem; text-align: center;
}
.tp-formula .katex { font-size: 1em; }
.tp-text {
font-size: 0.78rem; color: var(--text-2); line-height: 1.6; margin-bottom: 8px;
}
.tp-var-list { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
.tp-var {
font-size: 0.74rem; color: var(--text-2); display: flex; gap: 6px;
padding: 3px 0; border-bottom: 1px dashed rgba(255,255,255,0.04);
}
.tp-var b { color: var(--text); font-weight: 700; min-width: 24px; }
.theory-toggle-btn {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
width: 32px; height: 32px; border-radius: 10px;
border: 1.5px solid var(--border-h); background: transparent; color: var(--text-2);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s; z-index: 2;
}
.theory-toggle-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.07); }
.theory-toggle-btn svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 2.2; }
@media (max-width: 768px) {
.theory-panel.open { width: 100%; position: absolute; right: 0; top: 0; bottom: 0; z-index: 60; }
}
/* ── embed mode (loaded in iframe, no sidebar/topbar) ── */
.embed-mode { height: 100vh; }
.embed-mode .sb-content { height: 100vh; margin-left: 0 !important; }
.embed-mode #lab-sim { flex: 1; }
.embed-mode .sim-body-wrap { height: 100vh; }
.embed-mode .graph-panel { max-height: 100vh; }
/* ════════════════════════════════
GEOMETRY SIM STYLES
════════════════════════════════ */
.geo-panel {
width: 210px; flex-shrink: 0;
background: var(--surface);
border-right: 1.5px solid var(--border);
display: flex; flex-direction: column;
overflow-y: auto; padding: 12px 10px; gap: 4px;
}
.geo-tool-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
margin-bottom: 4px;
}
.geo-tool-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 9px; border-radius: 10px;
border: 1.5px solid var(--border);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.73rem; font-weight: 700;
cursor: pointer; transition: all .14s; white-space: nowrap;
}
.geo-tool-btn svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2.2; flex-shrink: 0; }
.geo-tool-btn:hover { border-color: rgba(155,93,229,.4); color: var(--violet); background: rgba(155,93,229,.06); }
.geo-tool-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); }
.geo-tool-wide {
grid-column: span 2;
}
.geo-ngon-ctrl {
display: flex; align-items: center; justify-content: center; gap: 6px;
border: 1.5px solid var(--border); border-radius: 10px;
padding: 4px 6px;
}
.geo-ngon-btn {
width: 22px; height: 22px; border-radius: 6px;
border: 1px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .12s;
}
.geo-ngon-btn svg { width: 12px; height: 12px; stroke: currentColor; }
.geo-ngon-btn:hover { background: rgba(155,93,229,.1); }
#geo-ngon-n {
font-size: 0.78rem; font-weight: 700; color: var(--text);
min-width: 18px; text-align: center;
}
.geo-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 4px; border-radius: 8px; cursor: pointer;
transition: background .13s;
}
.geo-toggle-row:hover { background: rgba(255,255,255,.04); }
.geo-toggle-label {
font-size: 0.73rem; font-weight: 600; color: var(--text-2);
display: flex; align-items: center; gap: 6px;
}
.geo-toggle-label svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; opacity: .7; }
.geo-toggle {
width: 28px; height: 16px; border-radius: 8px;
background: rgba(255,255,255,.1); border: 1.5px solid var(--border-h);
position: relative; transition: background .15s; flex-shrink: 0;
}
.geo-toggle::after {
content: ''; position: absolute; left: 2px; top: 50%;
transform: translateY(-50%);
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,.4); transition: all .15s;
}
.geo-toggle.on { background: var(--violet); border-color: var(--violet); }
.geo-toggle.on::after { left: calc(100% - 11px); background: #fff; }
.geo-info-box {
background: rgba(155,93,229,.07); border: 1px solid rgba(155,93,229,.15);
border-radius: 10px; padding: 8px 10px; margin-top: 4px;
font-size: 0.72rem; line-height: 1.55; color: var(--text-2);
}
.geo-info-box .geo-info-key { color: var(--violet); font-weight: 700; }
.geo-stat-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.7rem; padding: 2px 0;
border-bottom: 1px dashed rgba(255,255,255,.05);
color: var(--text-2);
}
.geo-stat-row:last-child { border: none; }
.geo-stat-row b { color: var(--text); font-weight: 700; }
.geo-canvas-outer {
flex: 1; min-width: 0; position: relative; background: #0a0718;
}
.geo-canvas-outer canvas {
display: block; position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
}
.geo-hint-bar {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
background: rgba(10,7,24,.82); border: 1px solid rgba(255,255,255,.1);
border-radius: 20px; padding: 4px 14px;
font-size: 0.7rem; color: rgba(255,255,255,.5);
pointer-events: none; white-space: nowrap;
backdrop-filter: blur(6px);
}
.geo-del-confirm {
display: none; position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
gap: 8px; align-items: center; white-space: nowrap;
background: rgba(18,10,32,.96); border: 1px solid rgba(155,93,229,.35);
border-radius: 10px; padding: 8px 12px; z-index: 20;
backdrop-filter: blur(8px); box-shadow: 0 4px 20px rgba(0,0,0,.6);
font-size: 0.72rem; color: rgba(255,255,255,.8);
}
.geo-del-confirm.visible { display: flex; }
.geo-del-confirm span { margin-right: 2px; }
.geo-del-btn {
padding: 3px 9px; border-radius: 6px; border: 1px solid;
font-size: 0.7rem; cursor: pointer; font-family: inherit;
transition: background .15s;
}
.geo-del-btn-soft { border-color: rgba(74,222,128,.4); color: #4ADE80; background: rgba(74,222,128,.08); }
.geo-del-btn-soft:hover { background: rgba(74,222,128,.18); }
.geo-del-btn-hard { border-color: rgba(248,113,113,.4); color: #f87171; background: rgba(248,113,113,.08); }
.geo-del-btn-hard:hover { background: rgba(248,113,113,.18); }
.geo-del-btn-cancel{ border-color: rgba(255,255,255,.15); color: rgba(255,255,255,.5); background: transparent; }
.geo-del-btn-cancel:hover{ background: rgba(255,255,255,.06); }
+888
View File
@@ -0,0 +1,888 @@
'use strict';
const { user, isTeacher, isAdmin } = LS.initPage();
window._simQuizAllowed = true; // default; overridden after permission fetch for students
LS.showBoardIfAllowed();
/* ════════════════════════════════
SIM CATALOGUE (defined after P_* consts below)
════════════════════════════════ */
let _catFilter = 'all';
var _disabledSimIds = new Set();
let _simModuleDisabled = false;
function filterSims(cat, btn) {
_catFilter = cat;
document.querySelectorAll('.lab-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderSims();
}
function renderSims() {
const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter);
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
document.getElementById('sim-grid').innerHTML = list.map(s => `
<div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}>
${s.preview}
<div class="sim-body">
<div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<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> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div>
<div class="sim-title">${s.title}</div>
<div class="sim-desc">${s.desc}</div>
</div>
${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''}
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
/* ════════════════════════════════
CARD PREVIEW SVGs
════════════════════════════════ */
function _grid(fg='rgba(255,255,255,0.06)') {
return `<g stroke="${fg}" stroke-width="1">
<line x1="45" y1="0" x2="45" y2="140"/><line x1="90" y1="0" x2="90" y2="140"/>
<line x1="135" y1="0" x2="135" y2="140"/><line x1="180" y1="0" x2="180" y2="140"/>
<line x1="225" y1="0" x2="225" y2="140"/>
<line x1="0" y1="35" x2="270" y2="35"/><line x1="0" y1="70" x2="270" y2="70"/>
<line x1="0" y1="105" x2="270" y2="105"/>
</g>`;
}
function _axes() {
return `<line x1="0" y1="70" x2="262" y2="70" stroke="rgba(255,255,255,0.32)" stroke-width="1.5"/>
<line x1="135" y1="140" x2="135" y2="6" stroke="rgba(255,255,255,0.32)" stroke-width="1.5"/>
<polygon points="265,70 258,67 258,73" fill="rgba(255,255,255,0.32)"/>
<polygon points="135,4 132,11 138,11" fill="rgba(255,255,255,0.32)"/>`;
}
function _svg(body) {
return `<svg class="sim-preview" viewBox="0 0 270 140" xmlns="http://www.w3.org/2000/svg">
<rect width="270" height="140" fill="#0D0D1A"/>${body}</svg>`;
}
/* 1 — Graph */
const P_GRAPH = _svg(`${_grid()}${_axes()}
<path d="M 15,132 Q 135,20 255,132" stroke="#9B5DE5" stroke-width="2.5" fill="none"/>
<path d="M 0,70 C 34,30 56,30 90,70 C 124,110 146,110 180,70 C 214,30 236,30 270,70"
stroke="#06D6E0" stroke-width="2" fill="none" opacity="0.75"/>`);
/* 2 — Transform: three shifted/scaled sines */
const P_TRANSFORM = _svg(`${_grid()}${_axes()}
<path d="M 0,70 C 34,30 56,30 90,70 C 124,110 146,110 180,70 C 214,30 236,30 270,70"
stroke="#9B5DE5" stroke-width="2" fill="none" opacity="0.9"/>
<path d="M 0,53 C 22,24 42,24 67,53 C 92,82 112,82 135,53 C 158,24 178,24 202,53 C 227,82 248,82 270,53"
stroke="#06D6E0" stroke-width="2" fill="none" opacity="0.65"/>
<path d="M 0,85 C 45,36 80,36 135,85 C 190,134 225,134 270,85"
stroke="#F15BB5" stroke-width="2" fill="none" opacity="0.55"/>`);
/* 3 — Triangle geometry */
const P_TRIANGLE = _svg(`${_grid('rgba(255,255,255,0.04)')}
<polygon points="60,115 210,115 135,25" fill="rgba(155,93,229,0.1)" stroke="#9B5DE5" stroke-width="2"/>
<line x1="60" y1="115" x2="173" y2="70" stroke="rgba(6,214,224,0.5)" stroke-width="1.3" stroke-dasharray="4,3"/>
<line x1="210" y1="115" x2="98" y2="70" stroke="rgba(6,214,224,0.5)" stroke-width="1.3" stroke-dasharray="4,3"/>
<line x1="135" y1="25" x2="135" y2="115" stroke="rgba(6,214,224,0.5)" stroke-width="1.3" stroke-dasharray="4,3"/>
<circle cx="135" cy="78" r="3" fill="#06D6E0"/>
<rect x="131" y="111" width="8" height="8" fill="none" stroke="rgba(255,255,255,0.4)" stroke-width="1.2"/>`);
/* 4 — Inscribed/circumscribed circles */
const P_CIRCLES = _svg(`${_grid('rgba(255,255,255,0.04)')}
<polygon points="80,118 190,118 135,22" fill="rgba(155,93,229,0.08)" stroke="#9B5DE5" stroke-width="1.8"/>
<circle cx="135" cy="85" r="33" fill="none" stroke="#06D6E0" stroke-width="1.8" stroke-dasharray="5,3" opacity="0.7"/>
<circle cx="135" cy="55" r="52" fill="none" stroke="#F15BB5" stroke-width="1.5" stroke-dasharray="5,3" opacity="0.5"/>
<circle cx="135" cy="85" r="3" fill="#06D6E0" opacity="0.8"/>
<circle cx="135" cy="55" r="3" fill="#F15BB5" opacity="0.8"/>`);
/* 5 — Quadratic roots: parabola crossing x-axis */
const P_QUADRATIC = _svg(`${_grid()}${_axes()}
<path d="M 55,125 Q 135,8 215,125" stroke="#9B5DE5" stroke-width="2.5" fill="none"/>
<circle cx="85" cy="70" r="5" fill="#F15BB5" stroke="#fff" stroke-width="1.5"/>
<circle cx="185" cy="70" r="5" fill="#F15BB5" stroke="#fff" stroke-width="1.5"/>
<line x1="85" y1="68" x2="85" y2="125" stroke="rgba(241,91,181,0.35)" stroke-width="1" stroke-dasharray="3,3"/>
<line x1="185" y1="68" x2="185" y2="125" stroke="rgba(241,91,181,0.35)" stroke-width="1" stroke-dasharray="3,3"/>
<text x="135" y="136" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">D = b²− 4ac</text>`);
/* 6 — 3D geometry: isometric cube */
const P_3D = _svg(`${_grid('rgba(255,255,255,0.04)')}
<polygon points="135,20 210,58 210,115 135,77" fill="rgba(155,93,229,0.15)" stroke="#9B5DE5" stroke-width="1.8"/>
<polygon points="135,20 60,58 60,115 135,77" fill="rgba(155,93,229,0.08)" stroke="#9B5DE5" stroke-width="1.8"/>
<polygon points="60,58 135,20 210,58 135,96" fill="rgba(155,93,229,0.22)" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="135" y1="77" x2="135" y2="96" stroke="#9B5DE5" stroke-width="1.8"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">V = a³</text>`);
/* 7 — Probability: histogram bars */
const P_PROB = _svg(`${_grid()}
<line x1="30" y1="15" x2="30" y2="118" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"/>
<line x1="28" y1="118" x2="255" y2="118" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"/>
<rect x="38" y="90" width="24" height="28" fill="rgba(155,93,229,0.6)" rx="2"/>
<rect x="68" y="72" width="24" height="46" fill="rgba(155,93,229,0.7)" rx="2"/>
<rect x="98" y="44" width="24" height="74" fill="rgba(155,93,229,0.85)" rx="2"/>
<rect x="128" y="32" width="24" height="86" fill="#9B5DE5" rx="2"/>
<rect x="158" y="50" width="24" height="68" fill="rgba(155,93,229,0.8)" rx="2"/>
<rect x="188" y="76" width="24" height="42" fill="rgba(155,93,229,0.65)" rx="2"/>
<rect x="218" y="96" width="24" height="22" fill="rgba(155,93,229,0.5)" rx="2"/>`);
/* 8 — Normal distribution: bell curve */
const P_NORMAL = _svg(`${_grid()}
<line x1="10" y1="118" x2="260" y2="118" stroke="rgba(255,255,255,0.32)" stroke-width="1.5"/>
<path d="M 10,116 C 50,115 80,110 100,90 C 115,72 125,35 135,22 C 145,35 155,72 170,90 C 190,110 220,115 260,116"
stroke="#9B5DE5" stroke-width="2.5" fill="none"/>
<path d="M 100,90 C 115,72 125,35 135,22 C 145,35 155,72 170,90 L 170,118 L 100,118 Z"
fill="rgba(155,93,229,0.15)"/>
<line x1="135" y1="22" x2="135" y2="118" stroke="rgba(255,255,255,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">μ = 0, σ = 1</text>`);
/* 8b — Trig circle */
const P_TRIGCIRCLE = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="30" y1="70" x2="240" y2="70" stroke="rgba(255,255,255,0.25)" stroke-width="1.2"/>
<line x1="135" y1="8" x2="135" y2="132" stroke="rgba(255,255,255,0.25)" stroke-width="1.2"/>
<circle cx="135" cy="70" r="52" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1.8"/>
<line x1="135" y1="70" x2="172" y2="33" stroke="rgba(255,255,255,0.45)" stroke-width="1.3"/>
<line x1="135" y1="70" x2="172" y2="70" stroke="#06D6E0" stroke-width="2.5"/>
<line x1="172" y1="70" x2="172" y2="33" stroke="#EF476F" stroke-width="2.5"/>
<circle cx="172" cy="33" r="5" fill="#9B5DE5"/>
<path d="M 148,70 A 13,13 0 0,0 144,60" stroke="rgba(155,93,229,0.6)" stroke-width="1.5" fill="none"/>
<text x="135" y="136" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">sin · cos · tg · ctg</text>`);
/* 9 — Projectile motion */
const P_PROJECTILE = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="15" y1="118" x2="255" y2="118" stroke="rgba(255,255,255,0.32)" stroke-width="1.5"/>
<path d="M 20,118 Q 135,18 250,118" stroke="#06D6E0" stroke-width="2.5" fill="none"/>
<circle cx="20" cy="118" r="5" fill="#06D6E0"/>
<line x1="20" y1="118" x2="52" y2="80" stroke="rgba(6,214,224,0.8)" stroke-width="1.5"
marker-end="url(#arr)"/>
<defs><marker id="arr" markerWidth="6" markerHeight="6" refX="3" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="#06D6E0"/>
</marker></defs>
<line x1="135" y1="18" x2="135" y2="118" stroke="rgba(255,255,255,0.15)" stroke-width="1" stroke-dasharray="3,3"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">x = v₀cos(α)·t</text>`);
/* 10 — Pendulum */
const P_PENDULUM = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="135" y1="15" x2="165" y2="95" stroke="rgba(255,255,255,0.5)" stroke-width="2"/>
<circle cx="165" cy="100" r="12" fill="rgba(6,214,224,0.25)" stroke="#06D6E0" stroke-width="2"/>
<line x1="135" y1="15" x2="95" y2="95" stroke="rgba(255,255,255,0.2)" stroke-width="1.5" stroke-dasharray="4,3"/>
<circle cx="95" cy="100" r="12" fill="none" stroke="rgba(6,214,224,0.3)" stroke-width="1.5" stroke-dasharray="3,3"/>
<path d="M 110,60 A 55,55 0 0 1 160,60" fill="none" stroke="rgba(6,214,224,0.4)" stroke-width="1.2" stroke-dasharray="3,3"/>
<circle cx="135" cy="15" r="4" fill="rgba(255,255,255,0.5)"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">T = 2π√(l/g)</text>`);
/* 11 — Collision */
const P_COLLISION = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="15" y1="70" x2="255" y2="70" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>
<circle cx="70" cy="70" r="28" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="2"/>
<text x="70" y="75" font-size="11" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">m₁</text>
<line x1="100" y1="70" x2="120" y2="70" stroke="#06D6E0" stroke-width="2" marker-end="url(#a2)"/>
<circle cx="195" cy="70" r="20" fill="rgba(241,91,181,0.15)" stroke="#F15BB5" stroke-width="2"/>
<text x="195" y="75" font-size="11" fill="#F15BB5" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">m₂</text>
<line x1="175" y1="70" x2="155" y2="70" stroke="#F15BB5" stroke-width="2" marker-end="url(#a3)"/>
<defs>
<marker id="a2" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#06D6E0"/></marker>
<marker id="a3" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#F15BB5"/></marker>
</defs>`);
/* 13 — Electric circuit */
const P_CIRCUIT = _svg(`${_grid('rgba(255,255,255,0.04)')}
<rect x="30" y="25" width="210" height="90" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="1.5" rx="4"/>
<line x1="30" y1="70" x2="70" y2="70" stroke="#06D6E0" stroke-width="2"/>
<rect x="70" y="58" width="36" height="24" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="1.8" rx="3"/>
<text x="88" y="74" font-size="10" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">R₁</text>
<line x1="106" y1="70" x2="130" y2="70" stroke="#06D6E0" stroke-width="2"/>
<rect x="130" y="58" width="36" height="24" fill="rgba(6,214,224,0.15)" stroke="#06D6E0" stroke-width="1.8" rx="3"/>
<text x="148" y="74" font-size="10" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">R₂</text>
<line x1="166" y1="70" x2="190" y2="70" stroke="#06D6E0" stroke-width="2"/>
<rect x="190" y="56" width="18" height="28" fill="rgba(241,91,181,0.15)" stroke="#F15BB5" stroke-width="1.8" rx="3"/>
<line x1="208" y1="70" x2="240" y2="70" stroke="#06D6E0" stroke-width="2"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">I = U/R</text>`);
/* 14 — Magnetic field */
const P_MAGNETIC = _svg(`
<rect width="270" height="140" fill="#05050F"/>
${_grid('rgba(155,93,229,0.06)')}
<defs>
<radialGradient id="mg1" cx="38%" cy="50%"><stop offset="0%" stop-color="#06D6E0" stop-opacity=".55"/><stop offset="100%" stop-color="#06D6E0" stop-opacity="0"/></radialGradient>
<radialGradient id="mg2" cx="62%" cy="50%"><stop offset="0%" stop-color="#F15BB5" stop-opacity=".55"/><stop offset="100%" stop-color="#F15BB5" stop-opacity="0"/></radialGradient>
</defs>
<rect width="270" height="140" fill="url(#mg1)" opacity=".7"/>
<rect width="270" height="140" fill="url(#mg2)" opacity=".7"/>
<ellipse cx="95" cy="70" rx="45" ry="45" fill="none" stroke="#06D6E0" stroke-width="1.4" stroke-dasharray="5,3" opacity=".6"/>
<ellipse cx="95" cy="70" rx="70" ry="60" fill="none" stroke="#06D6E0" stroke-width="1" stroke-dasharray="4,4" opacity=".3"/>
<ellipse cx="175" cy="70" rx="45" ry="45" fill="none" stroke="#F15BB5" stroke-width="1.4" stroke-dasharray="5,3" opacity=".6"/>
<ellipse cx="175" cy="70" rx="70" ry="60" fill="none" stroke="#F15BB5" stroke-width="1" stroke-dasharray="4,4" opacity=".3"/>
<path d="M95,30 C160,30 110,70 175,70" stroke="rgba(255,255,255,0.25)" stroke-width="1.2" fill="none"/>
<path d="M95,110 C160,110 110,70 175,70" stroke="rgba(255,255,255,0.25)" stroke-width="1.2" fill="none"/>
<circle cx="95" cy="70" r="11" fill="rgba(5,5,20,0.9)" stroke="#06D6E0" stroke-width="2.2"/>
<circle cx="95" cy="70" r="4" fill="#06D6E0"/>
<circle cx="175" cy="70" r="11" fill="rgba(5,5,20,0.9)" stroke="#F15BB5" stroke-width="2.2"/>
<line x1="170" y1="65" x2="180" y2="75" stroke="#F15BB5" stroke-width="2"/>
<line x1="180" y1="65" x2="170" y2="75" stroke="#F15BB5" stroke-width="2"/>
<text x="135" y="132" font-size="9" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">B = μ₀I / 2πr</text>`);
/* 14 — Electric field lines */
const P_FIELD = _svg(`${_grid('rgba(255,255,255,0.04)')}
<circle cx="135" cy="70" r="10" fill="rgba(155,93,229,0.3)" stroke="#9B5DE5" stroke-width="2"/>
<text x="135" y="74" font-size="10" fill="#9B5DE5" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="800">+</text>
<g stroke="#9B5DE5" stroke-width="1.3" fill="none" opacity="0.6">
<path d="M135,60 L135,20"/><path d="M135,80 L135,120"/>
<path d="M125,63 L95,38"/><path d="M145,63 L175,38"/>
<path d="M125,77 L95,102"/><path d="M145,77 L175,102"/>
<path d="M122,70 L80,70"/><path d="M148,70 L190,70"/>
<path d="M125,64 L102,42"/><path d="M145,64 L168,42"/>
<path d="M125,76 L102,98"/><path d="M145,76 L168,98"/>
</g>
<circle cx="135" cy="20" r="2" fill="#9B5DE5" opacity="0.5"/>
<circle cx="135" cy="120" r="2" fill="#9B5DE5" opacity="0.5"/>
<circle cx="80" cy="70" r="2" fill="#9B5DE5" opacity="0.5"/>
<circle cx="190" cy="70" r="2" fill="#9B5DE5" opacity="0.5"/>`);
/* 15 — Thin lens */
const P_LENS = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="10" y1="70" x2="260" y2="70" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<path d="M 135,20 Q 155,70 135,120 Q 115,70 135,20" fill="rgba(6,214,224,0.12)" stroke="#06D6E0" stroke-width="2"/>
<line x1="30" y1="45" x2="135" y2="45" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="135" y1="45" x2="230" y2="90" stroke="#9B5DE5" stroke-width="1.8"/>
<line x1="30" y1="70" x2="230" y2="70" stroke="#06D6E0" stroke-width="1.5" stroke-dasharray="3,3" opacity="0.5"/>
<line x1="30" y1="95" x2="135" y2="95" stroke="#F15BB5" stroke-width="1.8"/>
<line x1="135" y1="95" x2="230" y2="55" stroke="#F15BB5" stroke-width="1.8"/>
<circle cx="30" cy="70" r="5" fill="#9B5DE5" opacity="0.7"/>
<line x1="30" y1="40" x2="30" y2="100" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/>`);
/* 16 — Refraction */
const P_REFRACTION = _svg(`
<rect width="270" height="70" fill="#0D0D1A"/>
<rect y="70" width="270" height="70" fill="rgba(6,214,224,0.07)"/>
<line x1="0" y1="70" x2="270" y2="70" stroke="rgba(6,214,224,0.35)" stroke-width="1.5" stroke-dasharray="6,4"/>
<line x1="135" y1="10" x2="135" y2="130" stroke="rgba(255,255,255,0.15)" stroke-width="1" stroke-dasharray="3,3"/>
<line x1="60" y1="15" x2="135" y2="70" stroke="#9B5DE5" stroke-width="2.5"/>
<polygon points="135,70 127,50 143,50" fill="#9B5DE5" opacity="0.7"/>
<line x1="135" y1="70" x2="195" y2="125" stroke="#06D6E0" stroke-width="2.5"/>
<polygon points="195,125 183,112 196,107" fill="#06D6E0" opacity="0.7"/>
<path d="M 135,50 A 22,22 0 0 0 118,70" fill="none" stroke="rgba(155,93,229,0.5)" stroke-width="1.2"/>
<path d="M 135,90 A 28,28 0 0 1 157,70" fill="none" stroke="rgba(6,214,224,0.5)" stroke-width="1.2"/>
<text x="118" y="63" font-size="9" fill="rgba(155,93,229,0.8)" font-family="Manrope,sans-serif">α</text>
<text x="152" y="87" font-size="9" fill="rgba(6,214,224,0.8)" font-family="Manrope,sans-serif">β</text>
<text x="135" y="136" font-size="9" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">n₁sinα = n₂sinβ</text>`);
/* 17 — Mirrors */
const P_MIRROR = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="10" y1="70" x2="260" y2="70" stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<path d="M 200,15 Q 184,70 200,125" fill="none" stroke="#06D6E0" stroke-width="2.5"/>
<line x1="200" y1="20" x2="210" y2="30" stroke="rgba(6,214,224,0.25)" stroke-width="1.5"/>
<line x1="200" y1="45" x2="210" y2="55" stroke="rgba(6,214,224,0.25)" stroke-width="1.5"/>
<line x1="200" y1="70" x2="210" y2="80" stroke="rgba(6,214,224,0.25)" stroke-width="1.5"/>
<line x1="200" y1="95" x2="210" y2="105" stroke="rgba(6,214,224,0.25)" stroke-width="1.5"/>
<line x1="200" y1="118" x2="210" y2="128" stroke="rgba(6,214,224,0.25)" stroke-width="1.5"/>
<circle cx="130" cy="70" r="4" fill="#06D6E0" opacity="0.8"/>
<text x="130" y="84" text-anchor="middle" font-size="9" fill="#06D6E0" font-family="Manrope,sans-serif">F</text>
<line x1="50" y1="70" x2="50" y2="30" stroke="#9B5DE5" stroke-width="2"/>
<polygon points="50,30 44,42 56,42" fill="#9B5DE5"/>
<line x1="50" y1="30" x2="200" y2="30" stroke="#06D6E0" stroke-width="1.5"/>
<line x1="200" y1="30" x2="70" y2="105" stroke="#06D6E0" stroke-width="1.5"/>
<line x1="50" y1="30" x2="200" y2="70" stroke="#7BF5A4" stroke-width="1.5"/>
<line x1="200" y1="70" x2="70" y2="105" stroke="#7BF5A4" stroke-width="1.5"/>
<line x1="70" y1="70" x2="70" y2="105" stroke="#EF476F" stroke-width="2"/>
<polygon points="70,105 64,93 76,93" fill="#EF476F"/>`);
/* 18 — Isoprocesses */
const P_ISOPROCESS = _svg(`${_grid('rgba(255,255,255,0.04)')}
<line x1="30" y1="10" x2="30" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<line x1="30" y1="125" x2="265" y2="125" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"/>
<path d="M 50,20 Q 140,60 240,110" fill="none" stroke="#EF476F" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<path d="M 50,20 Q 130,80 230,118" fill="none" stroke="#FFD166" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<line x1="50" y1="20" x2="50" y2="118" stroke="#06D6E0" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<line x1="50" y1="20" x2="230" y2="20" stroke="#7BF5A4" stroke-width="2" opacity="0.5" stroke-dasharray="4,3"/>
<path d="M 50,20 Q 120,55 220,108" fill="none" stroke="#EF476F" stroke-width="2.5"/>
<circle cx="50" cy="20" r="5" fill="#9B5DE5"/>
<circle cx="220" cy="108" r="5" fill="#EF476F"/>
<text x="240" y="113" font-size="9" fill="#EF476F" font-family="Manrope,sans-serif">2</text>
<text x="40" y="16" font-size="9" fill="#9B5DE5" font-family="Manrope,sans-serif">1</text>
<text x="255" y="128" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">V</text>
<text x="18" y="12" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">P</text>`);
/* ── Chemistry / Molecular Physics previews ── */
const P_GAS = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
<rect x="6" y="6" width="258" height="128" rx="4" fill="none" stroke="rgba(155,93,229,0.4)" stroke-width="2"/>
${[
[40,30,'#4CC9F0'],[70,80,'#7BF5A4'],[110,25,'#EF476F'],[150,60,'#FFD166'],[190,30,'#4CC9F0'],
[220,90,'#EF476F'],[55,110,'#7BF5A4'],[95,65,'#4CC9F0'],[130,110,'#EF476F'],[170,40,'#FFD166'],
[210,115,'#4CC9F0'],[240,55,'#7BF5A4'],[30,70,'#FFD166'],[80,120,'#EF476F'],[165,95,'#4CC9F0']
].map(([x,y,c])=>`<circle cx="${x}" cy="${y}" r="5" fill="${c}" opacity="0.85"/>`).join('')}
<rect x="6" y="105" width="258" height="29" rx="3" fill="rgba(0,0,0,0.55)"/>
<rect x="18" y="112" width="40" height="12" rx="2" fill="rgba(155,93,229,0.25)"/>
<rect x="18" y="112" width="14" height="12" rx="2" fill="rgba(155,93,229,0.6)"/>
<rect x="70" y="112" width="40" height="12" rx="2" fill="rgba(155,93,229,0.25)"/>
<rect x="70" y="112" width="22" height="12" rx="2" fill="#7BF5A4" opacity="0.7"/>
<rect x="122" y="112" width="40" height="12" rx="2" fill="rgba(155,93,229,0.25)"/>
<rect x="122" y="112" width="30" height="12" rx="2" fill="#EF476F" opacity="0.7"/>
<text x="202" y="121" font-size="8" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">PV=nRT</text>`);
/* ── Законы Ньютона ── */
const P_NEWTON = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
<line x1="0" y1="105" x2="270" y2="105" stroke="rgba(255,255,255,0.22)" stroke-width="2"/>
<rect x="80" y="75" width="50" height="30" rx="5" fill="rgba(6,214,224,0.18)" stroke="#06D6E0" stroke-width="2"/>
<line x1="130" y1="90" x2="175" y2="90" stroke="#EF476F" stroke-width="2.5" marker-end="url(#na)"/>
<defs><marker id="na" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#EF476F"/></marker></defs>
<text x="153" y="84" font-size="9" fill="#EF476F" font-family="Manrope,sans-serif" font-weight="700">F</text>
<line x1="105" y1="75" x2="105" y2="55" stroke="rgba(255,255,255,0.3)" stroke-width="1.2" stroke-dasharray="3,2"/>
<line x1="105" y1="55" x2="175" y2="55" stroke="rgba(255,255,255,0.18)" stroke-width="1" stroke-dasharray="3,3"/>
<circle cx="65" cy="90" r="12" fill="rgba(155,93,229,0.2)" stroke="#9B5DE5" stroke-width="1.8"/>
<text x="65" y="94" font-size="9" fill="#9B5DE5" text-anchor="middle" font-family="Manrope,sans-serif" font-weight="700">m₂</text>
<line x1="195" y1="90" x2="220" y2="90" stroke="#9B5DE5" stroke-width="2" stroke-dasharray="4,3" marker-end="url(#nb)"/>
<defs><marker id="nb" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#9B5DE5"/></marker></defs>
<text x="135" y="130" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="middle" font-family="Manrope,sans-serif">a = F/m · III законы Ньютона</text>`);
/* ── Песочница сил ── */
const P_SANDBOX = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${_grid('rgba(255,255,255,0.03)')}
<line x1="0" y1="115" x2="270" y2="115" stroke="rgba(155,93,229,0.35)" stroke-width="2"/>
<rect x="55" y="82" width="44" height="33" rx="6" fill="rgba(239,71,111,0.22)" stroke="#EF476F" stroke-width="1.8"/>
<text x="77" y="103" font-size="8" fill="#fff" text-anchor="middle" font-family="monospace" font-weight="700">5кг</text>
<circle cx="180" cy="88" r="18" fill="rgba(76,201,240,0.18)" stroke="#4CC9F0" stroke-width="1.8"/>
<text x="180" y="92" font-size="8" fill="#fff" text-anchor="middle" font-family="monospace" font-weight="700">8кг</text>
<line x1="99" y1="95" x2="140" y2="95" stroke="#FFD166" stroke-width="2.2" marker-end="url(#sa)"/>
<line x1="198" y1="88" x2="238" y2="68" stroke="#7BF5A4" stroke-width="2.2" marker-end="url(#sb)"/>
<defs>
<marker id="sa" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#FFD166"/></marker>
<marker id="sb" markerWidth="7" markerHeight="7" refX="5" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#7BF5A4"/></marker>
</defs>
<text x="120" y="87" font-size="8" fill="#FFD166" font-family="monospace">F₁</text>
<text x="225" y="63" font-size="8" fill="#7BF5A4" font-family="monospace">F₂</text>
<text x="135" y="133" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">Песочница сил · F = ma</text>`);
/* ── coming soon chem previews (simple) ── */
const P_KINETICS = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${_grid()}
<path d="M 20,120 C 60,90 100,50 140,35 S 220,28 260,26" fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round"/>
<path d="M 20,30 C 60,55 100,100 140,112 S 220,118 260,120" fill="none" stroke="#EF476F" stroke-width="2" stroke-linecap="round"/>
<circle cx="140" cy="35" r="4" fill="#34d399"/>
<circle cx="140" cy="112" r="4" fill="#EF476F"/>
<text x="20" y="18" font-size="9" fill="rgba(52,211,153,0.8)" font-family="Manrope,sans-serif">[C] продукт</text>
<text x="180" y="130" font-size="9" fill="rgba(239,71,111,0.8)" font-family="Manrope,sans-serif">[A] реагент</text>`);
const P_EQUILIBRIUM = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
<text x="135" y="30" font-size="11" fill="rgba(255,255,255,0.7)" text-anchor="middle" font-family="Manrope,sans-serif">A + B ⇌ C + D</text>
<rect x="30" y="50" width="60" height="70" rx="4" fill="rgba(155,93,229,0.15)" stroke="rgba(155,93,229,0.4)" stroke-width="1.5"/>
<rect x="100" y="75" width="70" height="45" rx="4" fill="rgba(6,214,224,0.12)" stroke="rgba(6,214,224,0.35)" stroke-width="1.5"/>
<rect x="180" y="55" width="60" height="65" rx="4" fill="rgba(241,91,181,0.12)" stroke="rgba(241,91,181,0.35)" stroke-width="1.5"/>
<text x="60" y="90" font-size="9" fill="#9B5DE5" text-anchor="middle" font-family="Manrope,sans-serif">A,B</text>
<text x="135" y="101" font-size="8" fill="#06D6E0" text-anchor="middle" font-family="Manrope,sans-serif">K</text>
<text x="210" y="91" font-size="9" fill="#F15BB5" text-anchor="middle" font-family="Manrope,sans-serif">C,D</text>`);
const P_ELECTROLYSIS = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
<rect x="20" y="30" width="230" height="90" rx="6" fill="rgba(6,214,224,0.07)" stroke="rgba(6,214,224,0.2)" stroke-width="1.5"/>
<rect x="50" y="20" width="12" height="80" rx="3" fill="#9B5DE5" opacity="0.8"/>
<rect x="208" y="20" width="12" height="80" rx="3" fill="#EF476F" opacity="0.8"/>
${[55,58,61,64,67,70].map(x=>`<circle cx="${x}" cy="${110-Math.random()*20|0}" r="2.5" fill="rgba(155,93,229,0.6)"/>`).join('')}
${[210,214,218,222,226].map(x=>`<circle cx="${x}" cy="${100-Math.random()*15|0}" r="2.5" fill="rgba(239,71,111,0.6)"/>`).join('')}
<text x="56" y="15" font-size="8" fill="#9B5DE5" text-anchor="middle" font-family="Manrope,sans-serif"></text>
<text x="214" y="15" font-size="8" fill="#EF476F" text-anchor="middle" font-family="Manrope,sans-serif">+</text>`);
const P_BOHR = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
<circle cx="135" cy="70" r="8" fill="#FFD166" opacity="0.9"/>
<ellipse cx="135" cy="70" rx="30" ry="10" fill="none" stroke="rgba(155,93,229,0.4)" stroke-width="1.5"/>
<ellipse cx="135" cy="70" rx="55" ry="18" fill="none" stroke="rgba(6,214,224,0.3)" stroke-width="1.5"/>
<ellipse cx="135" cy="70" rx="80" ry="27" fill="none" stroke="rgba(241,91,181,0.25)" stroke-width="1.5"/>
<circle cx="165" cy="70" r="4" fill="#9B5DE5"/>
<circle cx="90" cy="70" r="4" fill="#06D6E0"/>
<circle cx="215" cy="70" r="4" fill="#F15BB5"/>
<line x1="165" y1="60" x2="190" y2="35" stroke="rgba(255,214,0,0.6)" stroke-width="1.5" stroke-dasharray="3,2"/>
<circle cx="190" cy="35" r="3" fill="#FFD166" opacity="0.8"/>`);
const P_ORBITALS = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
<ellipse cx="135" cy="70" rx="60" ry="25" fill="rgba(155,93,229,0.15)" stroke="rgba(155,93,229,0.5)" stroke-width="1.5"/>
<ellipse cx="135" cy="70" rx="25" ry="60" fill="rgba(6,214,224,0.1)" stroke="rgba(6,214,224,0.4)" stroke-width="1.5"/>
<circle cx="135" cy="70" r="6" fill="#FFD166" opacity="0.9"/>
<circle cx="95" cy="70" r="5" fill="#9B5DE5" opacity="0.8"/>
<circle cx="175" cy="70" r="5" fill="#9B5DE5" opacity="0.8"/>`);
const P_PH = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${_grid()}
<path d="M 20,110 L 60,108 L 100,105 L 120,90 L 130,30 L 140,75 L 180,40 L 220,35 L 260,32"
fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round"/>
<line x1="20" y1="70" x2="260" y2="70" stroke="rgba(255,255,255,0.15)" stroke-width="1" stroke-dasharray="4,3"/>
<text x="20" y="18" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">pH</text>
<text x="240" y="130" font-size="9" fill="rgba(255,255,255,0.4)" font-family="Manrope,sans-serif">V</text>`);
const P_CHEMSANDBOX = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${_grid()}
<rect x="85" y="20" width="100" height="70" rx="8" fill="none" stroke="rgba(75,205,155,0.4)" stroke-width="1.5"/>
<rect x="88" y="55" width="94" height="32" rx="4" fill="rgba(75,205,155,0.15)"/>
<circle cx="110" cy="71" r="4" fill="rgba(255,200,60,0.5)"/>
<circle cx="130" cy="68" r="3" fill="rgba(255,255,255,0.3)"/>
<circle cx="150" cy="73" r="3.5" fill="rgba(90,200,235,0.4)"/>
<text x="135" y="105" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif" text-anchor="middle">A + B <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> C + D</text>
<rect x="40" y="115" width="28" height="18" rx="4" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>
<rect x="75" y="115" width="28" height="18" rx="4" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>
<rect x="110" y="115" width="28" height="18" rx="4" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>
<rect x="145" y="115" width="28" height="18" rx="4" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>
<rect x="180" y="115" width="28" height="18" rx="4" fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>
<circle cx="54" cy="121" r="3" fill="#78D278"/><circle cx="89" cy="121" r="3" fill="#7BF5A4"/>
<circle cx="124" cy="121" r="3" fill="#4CC9F0"/><circle cx="159" cy="121" r="3" fill="#9BB8CC"/>
<circle cx="194" cy="121" r="3" fill="#FFD166"/>`);
const P_CRYSTAL = _svg(`
<rect width="270" height="140" fill="#0D0D1A"/>
${[
[80,40],[135,40],[190,40],
[55,75],[110,75],[165,75],[220,75],
[80,110],[135,110],[190,110]
].map(([x,y],i)=>`<circle cx="${x}" cy="${y}" r="${i%2===0?7:5}" fill="${i%2===0?'#9B5DE5':'#06D6E0'}" opacity="0.8"/>`).join('')}
<line x1="80" y1="40" x2="135" y2="40" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="135" y1="40" x2="190" y2="40" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="80" y1="40" x2="55" y2="75" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="135" y1="40" x2="110" y2="75" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="190" y1="40" x2="165" y2="75" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="190" y1="40" x2="220" y2="75" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="55" y1="75" x2="80" y2="110" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="110" y1="75" x2="135" y2="110" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="165" y1="75" x2="190" y2="110" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="80" y1="110" x2="135" y2="110" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
<line x1="135" y1="110" x2="190" y2="110" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>`);
const P_CELLDIVISION = _svg(`
<rect width="270" height="140" fill="#0e0e18"/>
<ellipse cx="135" cy="70" rx="78" ry="54" fill="rgba(34,211,153,0.07)" stroke="rgba(34,211,153,0.5)" stroke-width="2"/>
<ellipse cx="135" cy="70" rx="44" ry="28" fill="rgba(155,93,229,0.08)" stroke="rgba(155,93,229,0.3)" stroke-width="1" stroke-dasharray="4,3"/>
<line x1="55" y1="70" x2="215" y2="70" stroke="rgba(255,214,0,0.35)" stroke-width="1.2" stroke-dasharray="3,2"/>
<rect x="98" y="57" width="9" height="15" rx="2" fill="#EF476F" opacity="0.9"/>
<rect x="112" y="57" width="9" height="15" rx="2" fill="#FF6B35" opacity="0.9"/>
<rect x="126" y="57" width="9" height="15" rx="2" fill="#9B5DE5" opacity="0.9"/>
<rect x="140" y="57" width="9" height="15" rx="2" fill="#FFD166" opacity="0.9"/>
<rect x="154" y="57" width="9" height="15" rx="2" fill="#EF476F" opacity="0.9"/>
<rect x="98" y="75" width="9" height="15" rx="2" fill="#EF476F" opacity="0.9"/>
<rect x="112" y="75" width="9" height="15" rx="2" fill="#FF6B35" opacity="0.9"/>
<rect x="126" y="75" width="9" height="15" rx="2" fill="#9B5DE5" opacity="0.9"/>
<rect x="140" y="75" width="9" height="15" rx="2" fill="#FFD166" opacity="0.9"/>
<rect x="154" y="75" width="9" height="15" rx="2" fill="#EF476F" opacity="0.9"/>
<line x1="135" y1="16" x2="114" y2="57" stroke="rgba(255,214,0,0.4)" stroke-width="1"/>
<line x1="135" y1="16" x2="135" y2="57" stroke="rgba(255,214,0,0.4)" stroke-width="1"/>
<line x1="135" y1="124" x2="114" y2="90" stroke="rgba(255,214,0,0.4)" stroke-width="1"/>
<line x1="135" y1="124" x2="135" y2="90" stroke="rgba(255,214,0,0.4)" stroke-width="1"/>
<circle cx="135" cy="15" r="5" fill="rgba(255,214,0,0.7)"/>
<circle cx="135" cy="125" r="5" fill="rgba(255,214,0,0.7)"/>
<text x="135" y="137" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">Метафаза · митоз</text>`);
const P_PHOTOSYNTHESIS = _svg(`
<rect width="270" height="140" fill="#0a0e14"/>
<ellipse cx="135" cy="72" rx="100" ry="48" fill="rgba(34,211,153,0.07)" stroke="rgba(34,211,153,0.45)" stroke-width="2"/>
<rect x="52" y="60" width="166" height="22" rx="7" fill="rgba(34,211,153,0.18)" stroke="rgba(34,211,153,0.5)" stroke-width="1.5"/>
<line x1="70" y1="12" x2="79" y2="59" stroke="#FFD166" stroke-width="1.8" stroke-dasharray="3,2" opacity="0.8"/>
<line x1="100" y1="8" x2="107" y2="59" stroke="#FFD166" stroke-width="1.8" stroke-dasharray="3,2" opacity="0.8"/>
<line x1="135" y1="6" x2="135" y2="59" stroke="#FFD166" stroke-width="1.8" stroke-dasharray="3,2" opacity="0.8"/>
<line x1="170" y1="8" x2="163" y2="59" stroke="#FFD166" stroke-width="1.8" stroke-dasharray="3,2" opacity="0.8"/>
<circle cx="70" cy="11" r="5" fill="#FFD166" opacity="0.9"/>
<circle cx="100" cy="7" r="5" fill="#FFD166" opacity="0.9"/>
<circle cx="135" cy="5" r="5" fill="#FFD166" opacity="0.9"/>
<circle cx="170" cy="7" r="5" fill="#FFD166" opacity="0.9"/>
<text x="52" y="98" font-size="8" fill="rgba(6,214,224,0.8)" font-family="Manrope,sans-serif">H₂O</text>
<text x="90" y="106" font-size="8" fill="rgba(255,255,255,0.45)" font-family="Manrope,sans-serif">CO₂</text>
<text x="168" y="52" font-size="8" fill="#9B5DE5" font-family="Manrope,sans-serif">ATP</text>
<text x="185" y="98" font-size="8" fill="#22d399" font-family="Manrope,sans-serif">G3P</text>
<text x="135" y="135" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">Световые реакции · цикл Кальвина</text>`);
const P_ANGRYBIRDS = _svg(`
<rect width="270" height="140" fill="#0f1923"/>
<rect x="0" y="108" width="270" height="32" fill="#3d6b47"/>
<line x1="0" y1="108" x2="270" y2="108" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
<rect x="175" y="68" width="22" height="40" fill="#b5651d" stroke="#7a3f0a" stroke-width="1.5"/>
<rect x="158" y="56" width="56" height="14" fill="#b5651d" stroke="#7a3f0a" stroke-width="1.5"/>
<rect x="168" y="40" width="18" height="18" fill="#a8d8ea" stroke="#5badd4" stroke-width="1.5"/>
<rect x="202" y="78" width="16" height="30" fill="#7a7a7a" stroke="#444" stroke-width="1.5"/>
<circle cx="232" cy="100" r="10" fill="#22c55e" stroke="#166534" stroke-width="1.5"/>
<circle cx="215" cy="99" r="10" fill="#22c55e" stroke="#166534" stroke-width="1.5"/>
<path d="M 32,102 Q 90,38 148,98" stroke="#ef476f" stroke-width="2.5" fill="none" stroke-dasharray="4,3"/>
<circle cx="32" cy="102" r="9" fill="#e63946"/>
<circle cx="36" cy="98" r="2.5" fill="#fff"/>
<circle cx="37.5" cy="97.5" r="1.1" fill="#111"/>
<line x1="28" y1="94" x2="38" y2="98" stroke="#333" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="21" cy="106" r="6.5" fill="#888" opacity="0.7"/>
<circle cx="10" cy="106" r="5.5" fill="#ffd166" opacity="0.5"/>
<line x1="18" y1="93" x2="22" y2="80" stroke="rgba(255,255,255,0.18)" stroke-width="5" stroke-linecap="round"/>
<line x1="22" y1="80" x2="26" y2="93" stroke="rgba(255,255,255,0.18)" stroke-width="5" stroke-linecap="round"/>
<text x="135" y="130" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">Физика полёта · импульс · разрушение</text>`);
const P_WAVES = _svg(`${_grid()}
<line x1="0" y1="70" x2="270" y2="70" stroke="rgba(255,255,255,0.13)" stroke-width="1" stroke-dasharray="4,3"/>
<path d="M 0,70 C 17,26 33,26 67,70 C 101,114 117,114 135,70 C 153,26 169,26 202,70 C 236,114 252,114 270,70"
stroke="#9B5DE5" stroke-width="2" fill="none" opacity="0.7"/>
<path d="M 0,70 C 22,18 44,18 90,70 C 136,122 158,122 180,70 C 202,18 224,18 270,70"
stroke="#06D6E0" stroke-width="1.5" fill="none" opacity="0.5"/>
<path d="M 0,70 C 12,10 28,8 50,40 C 72,72 88,118 112,85 C 136,52 155,18 180,50 C 205,82 240,108 270,70"
stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/>
<text x="135" y="132" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b</text>`);
/* Geometry (planimetry) preview */
const P_GEOMETRY = _svg(`${_grid('rgba(255,255,255,0.04)')}
<circle cx="135" cy="70" r="50" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.5"/>
<polygon points="85,99 185,99 135,20" fill="rgba(6,214,224,0.08)" stroke="#06D6E0" stroke-width="1.8"/>
<line x1="85" y1="99" x2="162" y2="57" stroke="rgba(241,91,181,0.45)" stroke-width="1.2" stroke-dasharray="4,3"/>
<line x1="185" y1="99" x2="109" y2="57" stroke="rgba(241,91,181,0.45)" stroke-width="1.2" stroke-dasharray="4,3"/>
<line x1="135" y1="20" x2="135" y2="99" stroke="rgba(241,91,181,0.45)" stroke-width="1.2" stroke-dasharray="4,3"/>
<circle cx="135" cy="64" r="4" fill="#06D6E0" opacity="0.9"/>
<circle cx="85" cy="99" r="4" fill="#9B5DE5"/>
<circle cx="185" cy="99" r="4" fill="#9B5DE5"/>
<circle cx="135" cy="20" r="4" fill="#9B5DE5"/>
<text x="78" y="111" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">A</text>
<text x="188" y="111" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">B</text>
<text x="131" y="16" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">C</text>`);
const SIMS = [
/* ── Математика ── */
{ id: 'graph', cat: 'math',
title: 'График функции',
desc: 'Строй графики функций y = f(x) с параметрами, зумом и курсором координат.',
preview: P_GRAPH },
{ id: 'graphtransform', cat: 'math',
title: 'Трансформации графиков',
desc: 'Наблюдай, как сдвиги, растяжения и отражения меняют вид функции y = a·f(kx+b)+c.',
preview: P_TRANSFORM },
{ id: 'geometry', cat: 'math',
title: 'Планиметрия',
desc: 'Интерактивная среда построений: точки, отрезки, прямые, окружности, многоугольники. Полноценный чертёж с привязкой и измерениями.',
preview: P_GEOMETRY },
{ id: 'triangle', cat: 'math',
title: 'Геометрия треугольника',
desc: 'Интерактивный треугольник: медианы, высоты, биссектрисы, вписанная и описанная окружности.',
preview: P_TRIANGLE },
{ id: 'quadratic', cat: 'math',
title: 'Корни квадратного уравнения',
desc: 'Задай a, b, c ползунками — смотри дискриминант и корни анимированно на числовой оси.',
preview: P_QUADRATIC },
{ id: 'stereo', cat: 'math',
title: 'Стереометрия 3D',
desc: 'Вращаемые объёмные фигуры: куб, пирамида, цилиндр, конус с формулами объёма и площади. Сечения, развёртка, вписанные/описанные сферы.',
preview: P_3D },
{ id: 'probability', cat: 'math',
title: 'Теория вероятностей',
desc: 'Подброс монеты/кубика N раз — гистограмма частот и закон больших чисел в действии.',
preview: P_PROB },
{ id: 'trigcircle', cat: 'math',
title: 'Тригонометрическая окружность',
desc: 'Единичная окружность с sin, cos, tg, ctg. Перетаскивай точку — все функции обновляются мгновенно. График синхронизирован.',
preview: P_TRIGCIRCLE },
{ id: 'normaldist', cat: 'math',
title: 'Нормальное распределение',
desc: 'Двигай μ и σ ползунками — колокол Гаусса и площадь под кривой обновляются мгновенно.',
preview: P_NORMAL },
/* ── Физика ── */
{ id: 'projectile', cat: 'phys',
title: 'Бросок тела',
desc: 'Задай начальную скорость и угол — симулируй траекторию, дальность и высоту полёта.',
preview: P_PROJECTILE },
{ id: 'pendulum', cat: 'phys',
title: 'Маятник',
desc: 'Регулируй длину и угол отклонения — изучай период колебаний и затухание.',
preview: P_PENDULUM },
{ id: 'collision', cat: 'phys',
title: 'Столкновение шаров',
desc: 'Упругий и неупругий удар двух тел: законы сохранения импульса и энергии.',
preview: P_COLLISION },
{ id: 'magnetic', cat: 'phys',
title: 'Магнитное поле токов',
desc: 'Размести провода с током — наблюдай суперпозицию полей: карта, силовые линии, вектора. Заряженная частица в поле.',
preview: P_MAGNETIC },
{ id: 'circuit', cat: 'phys',
title: 'Электрические цепи',
desc: 'Конструктор цепей из резисторов и конденсаторов. Законы Ома и Кирхгофа наглядно.',
preview: P_CIRCUIT },
{ id: 'coulomb', cat: 'phys',
title: 'Закон Кулона',
desc: 'Силовые линии и эквипотенциальные поверхности для системы точечных зарядов.',
preview: P_FIELD },
{ id: 'hydrostatics', cat: 'phys',
title: 'Гидростатика',
desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.',
preview: P_SANDBOX },
{ id: 'dynamics', cat: 'phys',
title: 'Динамика',
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
preview: P_SANDBOX },
{ id: 'thinlens', cat: 'phys',
title: 'Тонкая линза',
desc: 'Двигай объект относительно линзы — формула линзы, мнимое и действительное изображение.',
preview: P_LENS },
{ id: 'refraction', cat: 'phys',
title: 'Преломление света',
desc: 'Луч на границе двух сред: закон Снеллиуса, угол Брюстера, полное внутреннее отражение.',
preview: P_REFRACTION },
{ id: 'mirrors', cat: 'phys',
title: 'Зеркала',
desc: 'Плоское, вогнутое и выпуклое зеркало: построение изображения тремя главными лучами.',
preview: P_MIRROR },
{ id: 'isoprocess', cat: 'phys',
title: 'Изопроцессы',
desc: 'PV-диаграмма для четырёх изопроцессов идеального газа. Расчёт работы, теплоты и внутренней энергии.',
preview: P_ISOPROCESS },
/* ── Химия / Молекулярная физика ── */
{ id: 'molphys', cat: 'chem',
title: 'Молекулярная физика',
desc: 'Идеальный газ, броуновское движение, агрегатные состояния и диффузия — всё в одном модуле.',
preview: P_GAS },
{ id: 'chemistry', cat: 'chem',
title: 'Химические реакции',
desc: 'Кинетика реакций, металл + кислота в колбе, ОВР с переносом электронов, ионный обмен — всё в одном модуле.',
preview: P_KINETICS },
{ id: 'equilibrium', cat: 'chem',
title: 'Химическое равновесие',
desc: 'Прямая и обратная реакция, принцип Ле Шателье: изменяй T, P, концентрацию и наблюдай сдвиг.',
preview: P_EQUILIBRIUM },
{ id: 'electrolysis', cat: 'chem',
title: 'Электролиз',
desc: 'Катод и анод в растворе электролита: движение ионов, выделение газа, закон Фарадея.',
preview: P_ELECTROLYSIS },
/* ── Скоро: Атомная структура ── */
{ id: 'bohratom', cat: 'chem',
title: 'Атом Бора',
desc: 'Электроны на орбитах, квантование энергии, эмиссия и поглощение фотонов при переходах.',
preview: P_BOHR },
{ id: 'orbitals', cat: 'chem',
title: 'Молекулярные орбитали',
desc: 'H₂, H₂O — ковалентная связь, перекрывание орбиталей, 3D-визуализация электронных облаков.',
preview: P_ORBITALS },
/* ── Скоро: Визуальная химия ── */
{ id: 'titration', cat: 'chem',
title: 'pH и кривая титрования',
desc: 'Добавляй кислоту или щёлочь — наблюдай изменение pH, цвет раствора и кривую нейтрализации.',
preview: P_PH },
{ id: 'chemsandbox', cat: 'chem',
title: 'Химическая песочница',
desc: 'Смешивай реагенты, наблюдай реакции: осадки, газы, изменение цвета. Свободное экспериментирование.',
preview: P_CHEMSANDBOX },
{ id: 'crystal', cat: 'chem',
title: 'Кристаллическая решётка',
desc: 'NaCl, алмаз, металл — интерактивная 3D-решётка, типы связей, вращение структуры.',
preview: P_CRYSTAL },
/* ── Биология ── */
{ id: 'celldivision', cat: 'bio',
title: 'Деление клетки',
desc: 'Митоз и мейоз: анимированные фазы, хромосомы, веретено деления, ядерная оболочка.',
preview: P_CELLDIVISION },
{ id: 'photosynthesis', cat: 'bio',
title: 'Фотосинтез и дыхание',
desc: 'Световые реакции в тилакоидах, цикл Кальвина, митохондриальное дыхание — молекулярная анимация.',
preview: P_PHOTOSYNTHESIS },
/* ── Игры ── */
{ id: 'angrybirds', cat: 'game',
title: 'Angry Birds Physics',
desc: 'Запускай птиц из рогатки, разрушай блоки, побеждай свиней. Реальная физика: гравитация, ветер, импульс. 6 уровней.',
preview: P_ANGRYBIRDS },
/* ── Физика: Волны ── */
{ id: 'waves', cat: 'phys',
title: 'Волны и звук',
desc: 'Поперечные и продольные волны, суперпозиция, стоячие волны. Частота, амплитуда, фаза, гармоники.',
preview: P_WAVES },
];
var _theoryOpen = false;
function toggleTheory() {
_theoryOpen = !_theoryOpen;
document.getElementById('theory-panel').classList.toggle('open', _theoryOpen);
const btn = document.getElementById('theory-toggle');
btn.style.background = _theoryOpen ? 'rgba(155,93,229,0.15)' : '';
btn.style.borderColor = _theoryOpen ? 'var(--violet)' : '';
btn.style.color = _theoryOpen ? 'var(--violet)' : '';
}
function loadTheory(simId) {
const t = THEORY[simId];
const el = document.getElementById('theory-content');
if (!t) { el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>'; return; }
let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`;
for (const s of t.sections) {
html += '<div class="tp-section">';
if (s.head) html += `<div class="tp-section-head">${s.head}</div>`;
if (s.formula) html += `<div class="tp-formula" data-formula="${s.formula.replace(/"/g,'&quot;')}"></div>`;
if (s.text) html += `<div class="tp-text">${s.text}</div>`;
if (s.vars) html += `<div class="tp-var-list">${s.vars.map(([v,d]) => `<div class="tp-var"><b>${v}</b> — ${d}</div>`).join('')}</div>`;
html += '</div>';
}
el.innerHTML = html;
// render KaTeX formulas
el.querySelectorAll('.tp-formula[data-formula]').forEach(div => {
try { katex.render(div.dataset.formula, div, { displayMode: true, throwOnError: false }); }
catch(e) { div.textContent = div.dataset.formula; }
});
}
/* ── embed mode + auto-open from ?sim= ── */
const _qp = new URLSearchParams(location.search);
var _embedMode = _qp.get('embed') === '1';
var _autoSim = _qp.get('sim');
/* ── Sim state relay (embed mode only) ──────────────────────────────── */
// Map simId → { getState, applyState } registered by openSim handlers
const _simStateRegistry = {};
function _registerSimState(simId, getState, applyState) {
_simStateRegistry[simId] = { getState, applyState };
}
let _lastEmittedState = null;
let _stateEmitInterval = null;
function _startStateEmit(simId) {
if (_stateEmitInterval) clearInterval(_stateEmitInterval);
_lastEmittedState = null;
_stateEmitInterval = setInterval(() => {
const reg = _simStateRegistry[simId];
if (!reg) return;
try {
const state = reg.getState();
const json = JSON.stringify(state);
if (json === _lastEmittedState) return;
_lastEmittedState = json;
window.parent.postMessage({ type: 'sim_state', simId, state }, '*');
} catch {}
}, 400);
}
function _stopStateEmit() {
if (_stateEmitInterval) { clearInterval(_stateEmitInterval); _stateEmitInterval = null; }
_lastEmittedState = null;
}
// Receive apply_sim_state from parent (students)
window.addEventListener('message', e => {
if (!_embedMode) return;
const d = e.data;
if (!d || d.type !== 'apply_sim_state') return;
const reg = _simStateRegistry[_autoSim];
if (!reg) return;
try {
reg.applyState(d.state);
_lastEmittedState = JSON.stringify(d.state); // suppress echo
} catch {}
});
if (_embedMode) {
document.querySelector('.sidebar').style.display = 'none';
document.querySelector('.sb-content').style.marginLeft = '0';
document.querySelector('.app-layout').classList.add('embed-mode');
document.getElementById('lab-home').style.display = 'none';
document.getElementById('theory-toggle').style.display = 'none';
if (_autoSim) {
document.getElementById('lab-sim').classList.add('open');
document.querySelector('.sim-topbar').style.display = 'none';
// defer until all external scripts are loaded
window.addEventListener('load', () => openSim(_autoSim));
}
} else {
/* init — fetch sim settings + permissions in parallel, then render */
const _permFetch = (!isTeacher && !isAdmin)
? LS.api('/api/permissions/me').catch(() => null)
: Promise.resolve(null);
Promise.all([
LS.api('/api/settings/sims').catch(() => ({})),
_permFetch,
]).then(([cfg, permData]) => {
_simModuleDisabled = cfg.module_disabled || false;
_disabledSimIds = new Set(cfg.disabled_ids || []);
// check simulations.access for students
if (!isTeacher && !isAdmin && permData) {
const p = permData.permissions?.find(p => p.key === 'simulations.access');
if (p && p.effective === false) {
document.getElementById('sim-grid').innerHTML =
`<div style="grid-column:1/-1;padding:60px 0;text-align:center;color:var(--text-3)">
<div style="font-size:2rem;margin-bottom:12px"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<div style="font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:var(--text);margin-bottom:6px">Доступ к симуляциям закрыт</div>
<div style="font-size:.88rem">Администратор ограничил доступ к лаборатории</div>
</div>`;
return;
}
// store quiz permission for later use
const qp = permData.permissions?.find(p => p.key === 'simulations.quiz');
window._simQuizAllowed = !qp || qp.effective !== false;
} else {
window._simQuizAllowed = true;
}
if (_simModuleDisabled) {
document.getElementById('sim-grid').innerHTML =
`<div style="grid-column:1/-1;padding:60px 0;text-align:center;color:var(--text-3)">
<div style="font-size:2rem;margin-bottom:12px"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<div style="font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:var(--text);margin-bottom:6px">Модуль симуляций отключён</div>
<div style="font-size:.88rem">Администратор временно отключил лабораторию</div>
</div>`;
} else {
renderSims();
if (_autoSim) openSim(_autoSim);
// hash-router: activate sim from URL fragment after catalogue renders
else _activateFromHash();
}
});
lucide.createIcons();
LS.notif.init();
}
/* ─── Hash router for sim deep-links ─────────────────────────────────────
URL pattern: /lab#sim/<name>
<name> matches SIMS[i].id (e.g. 'projectile', 'graph', 'chemsandbox').
F5 restores sim. Browser back/forward switches between sims.
Click on sim-card updates URL via wrapped openSim.
──────────────────────────────────────────────────────────────────────── */
// Build valid-id set from SIMS catalogue (filters out "coming soon" entries)
const _SIM_HASH_MAP = {};
SIMS.forEach(function(s) { if (s.id) { _SIM_HASH_MAP[s.id] = s.id; } });
var _routerNavigating = false;
function _activateFromHash() {
var m = (location.hash || '').match(/^#sim\/([\w-]+)/);
if (!m) return false;
var simName = m[1];
if (!_SIM_HASH_MAP[simName]) {
// eslint-disable-next-line no-console
window.console && window.console.warn('lab-router: unknown sim', simName);
return false;
}
openSim(simName);
return true;
}
// Intercept openSim to push URL hash on user-initiated navigation
var _origOpenSim = openSim;
openSim = function(id) {
_origOpenSim(id);
if (!_routerNavigating && !_embedMode) {
var baseId = id.includes(':') ? id.split(':')[0] : id;
if (_SIM_HASH_MAP[baseId]) {
_routerNavigating = true;
location.hash = '#sim/' + baseId;
// use setTimeout so hashchange fires after flag is set
setTimeout(function() { _routerNavigating = false; }, 0);
}
}
};
// Intercept closeSim to clear hash when returning to home grid
var _origCloseSim = closeSim;
closeSim = function() {
_origCloseSim();
if (!_embedMode) {
_routerNavigating = true;
history.pushState(null, '', location.pathname + location.search);
setTimeout(function() { _routerNavigating = false; }, 0);
}
};
// Browser back/forward navigation
window.addEventListener('hashchange', function() {
if (_routerNavigating) return;
var hasHash = _activateFromHash();
if (!hasHash && document.getElementById('lab-sim').classList.contains('open')) {
_origCloseSim();
}
});
+129 -1810
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
# Feature Context: Lab.html Split
## Current State
- lab.html: 5180L total
- Lines 10-866: inline `<style>` block (856L)
- Lines 868-4303: HTML body with 39 sim-panels
- Lines 4304-4326: external script tags (engine modules)
- Lines 4327-5152: inline `<script>` block (825L glue)
- Lines 5153+: more script tags
- 39 engine modules already extracted in `frontend/js/labs/*.js`
- `lab-init.js` (543L) is the orchestrator
- 265 hardcoded brand colors throughout
- 1017 inline `style=` attributes
## Key Discoveries
- Each sim has a `<div id="sim-X" class="sim-proj-wrap" style="display:none">` pattern
- Sim activation likely via `sim-switcher` element + JS that toggles display
- Lab has its own large CSS scope that doesn't conflict with ls.css (verify)
## Cross-Phase Dependencies
- **Phase 1** (extract style) — independent
- **Phase 2** (extract glue) — independent of Phase 1
- **Phase 3** (token purification) — can run after Phase 1 (CSS file becomes purification target)
- **Phase 4** (hash-router) — needs Phase 2 (router code in lab-glue.js easier to extend than inline)
- **Phase 5** (template lazy) — needs Phase 4 (router triggers template activation)
## Temporary Workarounds
(пусто — заполняется implementer'ом)
## Implementation Notes
### Что НЕ трогаем
- `frontend/js/labs/*.js` engine-классы (CollisionSim, ProjectileSim и т.д.) — они работают
- `frontend/js/labs/lab-init.js` — orchestrator, может расширяться, но не переписываться целиком
- `<canvas>` элементы и их id — engine-классы binds к ним по id
### Безопасные паттерны (из прошлой работы)
- Extract `<style>` → external file: добавить `<link rel="stylesheet">` в head, скопировать содержимое в .css, удалить inline. Проверить визуал curl-200 + spot-check styles.
- Extract `<script>` → external file: добавить `<script src=>` ПОСЛЕ engine-modules, переместить glue. Watch for global-leak (если inline relies on top-level vars).
- Token purification: replace `#9B5DE5``var(--violet)`, keep tints (`rgba(...)`), keep curated palettes.
+75
View File
@@ -0,0 +1,75 @@
# Feature: Lab.html Split
**Branch:** `feature/lab-split`
**Base branch:** `master`
**Created:** 2026-05-22
**Status:** 🟡 In Progress
**Strategy:** Incremental
**Mode:** Automated
**Execution:** Direct
## Summary
Расщепить `frontend/lab.html` (5180L монолит UI-shell) на модульную структуру: extract inline CSS + inline JS-glue, token purification (265 hardcodes → vars), hash-router для deep-links, optional `<template>` lazy-mount.
**Discovery:** симуляции уже extracted в `frontend/js/labs/*.js` (39 engine-классов). lab.html — это HTML shell с 856L inline CSS + 3435L DOM + 825L inline glue-JS.
**Цели:**
- lab.html structure clearer (CSS / JS вынесены, HTML только DOM)
- Хардкодов ≤ 30 (от 265)
- Deep-link `#sim/projectile` работает
- Все 39 симуляций без регрессий
- Pre-commit hook проходит на каждой фазе
## Build & Test Commands
- **Start:** `cd backend && npm start` (vanilla JS — нет билда)
- **Test:** `cd backend && npm test` (66 tests / 63 pass / 3 baseline-fail)
- **Lint:** `cd backend && npm run lint:routes`
- **Smoke:** `curl -sI http://localhost:3000/lab` → 200; манульно открыть несколько sim
- **Pre-commit hook активен** — runs all of the above automatically
## Phases
- [ ] Phase 1: Extract inline `<style>``frontend/css/lab.css` [domain: frontend] → [subplan](./phase-1-extract-style.md)
- [ ] Phase 2: Extract inline glue `<script>``frontend/js/labs/lab-glue.js` [domain: frontend] → [subplan](./phase-2-extract-glue.md)
- [ ] Phase 3: Token purification (265 hardcodes) [domain: frontend] → [subplan](./phase-3-token-purify.md) (parallelizable with 1 или 2)
- [ ] Phase 4: Hash-router for sim deep-links [domain: frontend] → [subplan](./phase-4-hash-router.md)
- [ ] Phase 5: `<template>` lazy-mount (stretch) [domain: frontend] → [subplan](./phase-5-template-lazy.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Extract style | frontend | ✅ Done | (pre-commit hook) | ✅ | ✅ 46e6d82 |
| Phase 2: Extract glue | frontend | ✅ Done | (pre-commit hook) | ✅ | ✅ 92b5c39 |
| Phase 3: Token purify | frontend | ✅ Done | (pre-commit hook) | ✅ | ✅ 6792a4a |
| Phase 4: Hash-router | frontend | ✅ Done | (pre-commit hook) | ✅ | ✅ 0b9685b |
| Phase 5: Template lazy | frontend | 🟡 Deferred (post-merge) | — | — | — |
## Final Review
- [ ] Comprehensive code review
- [ ] All 39 simulations smoke-tested
- [ ] No console errors on /lab
- [ ] pre-commit hook passes
- [ ] Merged to `master`
## Acceptance Criteria (whole feature)
- lab.html без inline `<style>` блока (856L moved out)
- lab.html без inline glue `<script>` блока (825L moved out)
- Хардкодов brand colors ≤ 30 (curated palettes сохраняются)
- `#sim/projectile`, `#sim/newton` и др. — deep-link работают, F5 восстанавливает
- 39 симуляций функциональны (canvas рендерится, кнопки работают)
- Pre-commit hook чистый на каждом коммите
## Tech Stack & Conventions Reference
- vanilla JS, no bundler, Express static serve
- `window.LS.*` namespace (api.js)
- `LS.modal`, `LS.confirm`, `LS.toast`
- ls.css design tokens (--violet/--cyan/--green/--pink/--amber + --text/--text-2/--text-3 + spacing + radii)
- Lucide icons (CDN) + inline SVG `.ic`
- **No emoji в коде** (pre-commit блокирует)
- **No grep tool**, только ast-index для search
- Existing labs/*.js engine-классы НЕ трогаем — они уже extracted
+49
View File
@@ -0,0 +1,49 @@
# Phase 1: Extract inline `<style>` → `frontend/css/lab.css`
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Вынести 856L inline `<style>` блока из lab.html в отдельный `frontend/css/lab.css`. После — lab.html становится короче на ~856L, CSS можно править/рефакторить независимо.
## Tasks
- [ ] Создать `frontend/css/lab.css`
- [ ] Скопировать содержимое `<style>...</style>` из lab.html (lines 10-866) в lab.css
- [ ] Удалить inline `<style>` блок из lab.html
- [ ] Добавить `<link rel="stylesheet" href="/css/lab.css">` в `<head>` (после `/css/ls.css`)
- [ ] Verify: `curl -sI http://localhost:3000/lab` → 200
- [ ] Spot-check: открыть в браузере, sim-toolbar/panels выглядят как раньше
- [ ] Pre-commit hook passes
## Files to Modify/Create
- `frontend/css/lab.css` — NEW (~856L)
- `frontend/lab.html` — удалить `<style>` блок, добавить `<link>` (net 855L)
## Acceptance Criteria
- lab.html без `<style>` блока (только `<link>` к /css/lab.css)
- `/lab` отвечает 200
- Визуально lab выглядит идентично pre-Phase-1
- Pre-commit hook чистый
## Notes
- Если в CSS есть `@import` / `url(...)` paths — проверить что они всё ещё валидны от нового origin (/css/lab.css base)
- CSP в server.js: разрешает `'self'` для styles, нет проблем
- Watch for: CSS-variables defined inline могут оказаться нужны другим inline blocks → проверить нет ли таких dependencies
## Review Checklist
- [ ] lab.css не пустой и содержит весь CSS из inline блока
- [ ] lab.html не содержит `<style>` блок (только `<link>`)
- [ ] No emoji в коде (pre-commit проверит)
- [ ] Server возвращает 200
- [ ] Spot-check: открыть /lab, sim-grid и sim-toolbar отображаются нормально
## Handoff to Next Phase
<!-- Implementer заполнит: какой именно баг был при extract (если был), какие inline-style overrides остались (Phase 3 будет с ними работать). -->
+68
View File
@@ -0,0 +1,68 @@
# Phase 2: Extract inline `<script>` glue → `frontend/js/labs/lab-glue.js`
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Вынести 825L inline `<script>` блока (lines 4327-5152) из lab.html в отдельный `frontend/js/labs/lab-glue.js`. Этот блок содержит glue-код (sim-switcher logic, init helpers, event wiring). lab-init.js остаётся orchestrator'ом.
## Tasks
- [ ] Определить точные границы inline `<script>` блока (line 4327 start, find matching `</script>`)
- [ ] Создать `frontend/js/labs/lab-glue.js`
- [ ] Скопировать содержимое в lab-glue.js
- [ ] **Сохранить порядок загрузки**: lab-glue.js должен подгружаться ПОСЛЕ всех `labs/*.js` engine-модулей И ПЕРЕД `labs/lab-init.js` (или после — зависит от dependencies, проверить!)
- [ ] Удалить inline блок из lab.html
- [ ] Добавить `<script src="/js/labs/lab-glue.js"></script>` в правильное место
- [ ] Verify: page loads, `console.log` ошибок нет, sim-switcher работает
- [ ] Smoke: переключить 3-4 разных sim, проверить что render запускается
## Files to Modify/Create
- `frontend/js/labs/lab-glue.js` — NEW (~825L)
- `frontend/lab.html` — удалить inline `<script>` блок, добавить `<script src>` тег (net 823L)
## Acceptance Criteria
- lab.html без большого inline `<script>` блока на lines 4327-5152
- `/lab` отвечает 200
- No `ReferenceError` / `is not defined` в console (load-order правильный)
- Sim-switcher переключает sims корректно
- 5 любых симуляций инициализируются и рендерятся
## Notes
### Load-order анализ
Перед extract — проверить какие globals использует inline glue:
- Если использует `CollisionSim` (из engine-modules) → нужно загружаться ПОСЛЕ engine-modules
- Если использует `Lucide` (CDN) → после Lucide
- Если других inline-vars нет — безопасно вынести
### Watch for
- Inline `<script>` без `defer` атрибута выполняется sync — после переноса в external может выполниться раньше DOM ready. Возможно нужен `DOMContentLoaded` wrapper, либо `defer` атрибут.
- `window.xxx = ...` глобальные exports должны остаться (onclick handlers HTML on них опираются)
### Strategy
1. Read весь inline блок
2. Identify все function/var declarations
3. Скопировать как есть в lab-glue.js
4. Add at top: `'use strict';` если ещё нет
5. Тестировать пристально
## Review Checklist
- [ ] lab-glue.js загружается в правильном порядке (после engine modules)
- [ ] No console errors на /lab
- [ ] Sim-switcher работает (тест: переключить projectile → newton → chemsandbox)
- [ ] Все onclick handlers HTML работают
- [ ] No emoji в коде
- [ ] Pre-commit hook passes
## Handoff to Next Phase
<!-- Implementer фиксирует: где находится hash-router-точка-расширения (Phase 4 будет добавлять hashchange handler сюда). -->
+62
View File
@@ -0,0 +1,62 @@
# Phase 3: Token purification — 265 hardcoded colors → vars
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Заменить 265 хардкодных brand-цветов в lab-related files на `var(--token)` где это семантически корректно. Сохранить curated palettes (subject-specific colors) и canvas-fillStyle (CSS vars не resolve'ятся в canvas context).
## Tasks
- [ ] Идентифицировать все хардкоды в:
- `frontend/lab.html` (HTML body + remaining inline styles)
- `frontend/css/lab.css` (после Phase 1)
- `frontend/js/labs/lab-glue.js` (после Phase 2)
- [ ] Заменить direct token matches:
- `#9B5DE5``var(--violet)`
- `#06D6E0``var(--cyan)`
- `#06D664``var(--green)` (also `#06D6A0` если есть)
- `#F15BB5``var(--pink)`
- `#FFB347``var(--amber)`
- `#0F172A``var(--text)`
- `#3D4F6B``var(--text-2)`
- `#56687A``var(--text-3)`
- `#EEF2FF``var(--bg)`
- [ ] KEEP (НЕ менять):
- Tinted/alpha (`rgba(155,93,229,0.12)` etc.) — CSS не имеет color-mix() без deps
- Canvas `ctx.fillStyle = "#..."` — CSS vars не работают в canvas
- Curated subject palettes (bio violet / chem green / math cyan / phys amber) если они вложены массивом
- Slightly-different shades (`#9B5DE6``#9B5DE5`) — это намеренно другой цвет
- [ ] Semantic aliases: использовать `var(--danger)` / `var(--success)` / `var(--warning)` / `var(--info)` в semantic context
## Files to Modify
- `frontend/lab.html` — заменить hardcodes
- `frontend/css/lab.css` (после Phase 1) — заменить hardcodes
- `frontend/js/labs/lab-glue.js` (после Phase 2) — заменить hardcodes если есть
## Acceptance Criteria
- Хардкодов brand-colors ≤ 30 (от 265, target 90%+ replacement)
- Визуально lab выглядит идентично
- Pre-commit hook passes
- No regression в любой sim
## Notes
- Lab.html сейчас содержит много inline `style="color: #XXX"` — multi-line replacement через ast-index search + manual replacement
- Don't over-aggressive — если color используется один раз в curated palette decoration, лучше keep
- Document log: "Заменено: N, оставлено: M (reasons listed)"
## Review Checklist
- [ ] Counts reported: before / after
- [ ] Spot-check 3 sims визуально (canvas рендеринг не поменялся)
- [ ] No emoji в коде
- [ ] Pre-commit hook passes
## Handoff to Next Phase
<!-- Implementer: какие места были tricky, есть ли паттерны которые нужно унифицировать в будущем. -->
+70
View File
@@ -0,0 +1,70 @@
# Phase 4: Hash-router для sim deep-links
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Сделать `#sim/projectile`, `#sim/newton`, `#sim/chemsandbox` etc. → открывают конкретный sim в lab. F5 на любом deep-link восстанавливает sim. Browser back/forward переключают между симуляциями. По образцу admin-redesign Phase 1 router.
## Tasks
- [ ] В `frontend/js/labs/lab-glue.js` (или новый `frontend/js/labs/lab-router.js`):
- На load: прочитать `location.hash`, parsr `#sim/<name>`, активировать соответствующий sim
- Listen `hashchange`: при изменении hash → переключить sim
- При программном переключении sim (через sim-switcher UI) → обновить hash без recursion (флаг `_routerNavigating`)
- [ ] Map sim-name → sim-id:
- `#sim/projectile` → activate `<div id="sim-proj">`
- `#sim/newton` → activate `<div id="sim-dynamics">` (или какой-там)
- Полный mapping из существующих sim-ID
- [ ] Fallback: unknown hash → ignore (показать default sim)
- [ ] Verify: F5 на `/lab#sim/projectile` восстанавливает projectile sim
## Files to Modify
- `frontend/js/labs/lab-glue.js` — добавить router code (~50-100L)
- `frontend/lab.html` — без изменений (или + 1 script tag если делаем отдельный lab-router.js)
## Acceptance Criteria
- F5 на `/lab#sim/X` восстанавливает соответствующий sim
- Browser back/forward переключают между sims
- Click на sim-switcher обновляет URL (`#sim/X` в адресной строке)
- Unknown hash (`#sim/nonexistent`) → console.warn + fallback на default
- 5 deep-link проверены вручную (projectile, newton, chemsandbox, gas, mirror)
## Notes
### Recursion guard pattern (из admin-redesign Phase 1)
```js
let _navigating = false;
function navigateTo(simId) {
_navigating = true;
location.hash = '#sim/' + simId;
setTimeout(() => { _navigating = false; }, 0);
activateSim(simId);
}
window.addEventListener('hashchange', () => {
if (_navigating) return;
const m = location.hash.match(/^#sim\/([\w-]+)/);
if (m) activateSim(m[1]);
});
```
### Reference
`frontend/js/admin/router.js` (admin-redesign Phase 1) — read for inspiration. Adapt to lab context.
## Review Checklist
- [ ] Hash deep-link работает для 5 проверенных симов
- [ ] Browser back/forward работают
- [ ] No console errors
- [ ] No infinite-loop при программной активации
- [ ] Pre-commit hook passes
## Handoff to Next Phase
<!-- Implementer: full mapping #sim/X → sim-ID для Phase 5 (template lazy). -->
+63
View File
@@ -0,0 +1,63 @@
# Phase 5: `<template>` lazy-mount (stretch goal — DEFERRED)
> **Decision (2026-05-22):** Phase 5 отложен как post-merge follow-up. Phases 1-4 уже дали lab.html 5180→3499L (-32%) и решили основные задачи (modular CSS, modular glue JS, token purification, deep-link routing). Phase 5 требует рефакторинга 38 engine классов (нужен `destroy()` для cleanup), memory-leak verification, и риск сломать любую из 38 симуляций. Лучше merge'нуть 4 чистых фазы и оценить в проде.
**Status:** 🟡 Deferred (post-merge) — see decision log below
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Уменьшить initial DOM size: вместо 39 `<div id="sim-X">` с `display:none` — обернуть каждую в `<template id="tpl-sim-X">`, активация sim → clone template + mount into placeholder. Initial page load становится легче (нет render'а скрытых canvas-ов).
**ВНИМАНИЕ:** Это stretch goal — может быть пропущен если Phase 1-4 успешны и не хочется добавлять риск.
## Tasks
- [ ] Identify все `<div id="sim-X" class="sim-proj-wrap" style="display:none">` блоки (~38 штук, скрытых; 1 default visible)
- [ ] Обернуть каждый в `<template id="tpl-sim-X">...</template>`
- [ ] Создать mount point `<div id="sim-mount"></div>` (или использовать existing #sim-grid)
- [ ] В lab-glue.js / lab-router.js:
- `activateSim(name)` → clone `<template id="tpl-sim-X">` → replace content of `#sim-mount`
- Initialize sim engine (CollisionSim, ProjectileSim, etc.) **после** mount'а
- Cleanup previous sim engine (stop animations, remove listeners) перед switching
- [ ] Test: переключить 5 sims подряд, no memory leak, animations stop когда sim de-activated
## Files to Modify
- `frontend/lab.html` — wrap 38+ `<div id="sim-X">` в `<template>` (большое change but mechanical)
- `frontend/js/labs/lab-glue.js` — add template-clone activation logic
## Acceptance Criteria
- Initial DOM size уменьшен (verify через DevTools — count nodes)
- Все 39 sims активируются и работают
- No memory leak при переключении (verify через DevTools Performance / Memory tab — heap не растёт неограниченно)
- Sim engines properly cleanup previous instance
## Risks (HIGH)
- **Sim engines binds to specific canvas IDs by JS** (e.g. `document.getElementById('canvas-proj')`). После template clone — `getElementById` может вернуть element до момента когда engine ищет его. Time it carefully.
- **Animations не stop'аются** автоматически при template removal. Engine классы должны иметь `destroy()` метод — проверить.
- **Initial sim** (тот что default visible) — изначально mount'ed, не template. Обработать особо.
## Notes
Если в течение фазы выясняется что engine классы плохо cleanup'ятся (нет `destroy()`, requestAnimationFrame продолжает работать), MARK phase as 🟡 partial и оставить как known limitation. Не нужно рефакторить 30 engine классов.
## Mitigation: opt-out
Если эта фаза создаёт реальный риск — **пропустить**. lab.html без Phase 5 будет ~4000L (после Phase 1 убрали 856L style + 825L glue), что уже сильно лучше 5180L. Phase 5 опционален.
## Review Checklist
- [ ] All 39 sims активируются успешно
- [ ] No memory leak при переключении (Heap snapshot до и после — diff не растёт)
- [ ] Browser DevTools showsменьше nodes на initial load
- [ ] No console errors
## Handoff to Next Phase
<!-- Финальная фаза. Implementer записывает: что осталось как known-limitation, какие engine классы не имеют destroy(). -->