Files
Learn_System/frontend/red-book-ecosystem.html
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

808 lines
41 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Экосистемы — Красная книга РБ</title>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;900&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<style>
:root {
--rb-bg: #0a1a0d; --rb-surface: #111d13; --rb-border: #1e3523;
--rb-cr: #ef4444; --rb-en: #f97316; --rb-vu: #eab308; --rb-nt: #22c55e; --rb-lc: #3b82f6;
--rb-accent: #4ade80; --rb-text: #e2f5e8; --rb-muted: #6b9a74;
}
body { background: var(--rb-bg); color: var(--rb-text); font-family: 'Manrope', sans-serif; margin: 0; }
.app-layout { background: var(--rb-bg); }
/* ── RB Sidebar ─────────────────────────────────────────────────────── */
.app-layout > .sidebar { background: var(--rb-surface) !important; border-right: 1px solid var(--rb-border) !important; }
.sb-brand { border-bottom: 1px solid var(--rb-border); padding: 16px 12px 12px; }
.sb-link, a.sb-link, button.sb-link { color: var(--rb-muted) !important; }
.sb-link:hover { background: rgba(74,222,128,.08) !important; color: var(--rb-text) !important; }
.sb-link.active { background: rgba(74,222,128,.14) !important; color: var(--rb-accent) !important; font-weight: 700; }
.sb-link.active::before { background: var(--rb-accent) !important; }
.sb-toggle { color: var(--rb-muted) !important; border-color: var(--rb-border) !important; background: transparent !important; }
.sb-toggle:hover { background: rgba(74,222,128,.12) !important; color: var(--rb-accent) !important; border-color: var(--rb-accent) !important; }
.nav-user-chip { border-color: var(--rb-border) !important; }
.nav-user-chip:hover { background: rgba(74,222,128,.08) !important; }
.nav-avatar { background: linear-gradient(135deg, #166534, #15803d) !important; }
.nav-user-name { color: var(--rb-muted) !important; }
.rb-brand-link { display: flex; align-items: center; gap: 9px; text-decoration: none; flex: 1; min-width: 0; overflow: hidden; }
.rb-brand-icon { font-size: 24px; line-height: 1; flex-shrink: 0; }
.rb-brand-text { font-family: 'Unbounded', sans-serif; font-size: 10px; font-weight: 700; color: var(--rb-accent); line-height: 1.4; white-space: nowrap; }
.rb-sb-section { font-size: 9px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; color: var(--rb-muted); padding: 14px 12px 4px; margin: 0; opacity: 0.55; }
.app-layout.sb-collapsed .rb-sb-section { display: none; }
.rb-sb-divider { border: none; border-top: 1px solid var(--rb-border); margin: 8px 4px; }
.rb-back-link { opacity: 0.65; font-size: 0.82rem !important; }
.rb-back-link:hover { opacity: 1 !important; }
.eco-main { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* Top bar */
.eco-topbar {
display: flex; align-items: center; gap: 16px;
padding: 14px 24px; border-bottom: 1px solid var(--rb-border);
background: var(--rb-surface); flex-shrink: 0;
}
.eco-topbar h1 { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight: 700; margin: 0; }
.eco-back {
display: flex; align-items: center; gap: 6px;
color: var(--rb-muted); font-size: 13px; text-decoration: none;
background: transparent; border: 1px solid var(--rb-border); padding: 6px 14px;
border-radius: 8px; cursor: pointer; transition: all .15s;
}
.eco-back:hover { color: var(--rb-accent); border-color: var(--rb-accent); }
/* Body: canvas + panel */
.eco-body { flex: 1; display: flex; overflow: hidden; min-width: 0; }
#eco-canvas { flex: 1; display: block; min-width: 0; width: 0; }
/* Side panel */
.eco-panel {
width: 260px; flex-shrink: 0; background: var(--rb-surface);
border-left: 1px solid var(--rb-border); overflow-y: auto;
display: flex; flex-direction: column; max-height: 100%;
}
.panel-section { padding: 18px; border-bottom: 1px solid var(--rb-border); }
.panel-section h3 { font-size: 12px; font-weight: 700; text-transform: uppercase;
letter-spacing: 1px; color: var(--rb-muted); margin: 0 0 12px; }
/* Simulation controls */
.sim-slider-wrap { margin-bottom: 12px; }
.sim-slider-label { display: flex; justify-content: space-between; font-size: 12px; color: var(--rb-muted); margin-bottom: 4px; }
.sim-slider { width: 100%; accent-color: var(--rb-cr); cursor: pointer; }
.btn-simulate {
width: 100%; background: var(--rb-accent); color: #0a1a0d;
font-weight: 700; font-size: 13px; padding: 10px;
border: none; border-radius: 10px; cursor: pointer; transition: all .2s;
}
.btn-simulate:hover { filter: brightness(1.1); }
.btn-simulate.running { background: var(--rb-cr); color: #fff; }
.btn-reset {
width: 100%; margin-top: 8px; background: transparent;
border: 1px solid var(--rb-border); color: var(--rb-muted);
font-size: 12px; padding: 8px; border-radius: 8px; cursor: pointer;
}
.btn-reset:hover { border-color: var(--rb-muted); color: var(--rb-text); }
/* Scenario chips */
.scenario-list { display: flex; flex-direction: column; gap: 6px; }
.scenario-chip {
padding: 10px 12px; background: rgba(255,255,255,.04);
border: 1px solid var(--rb-border); border-radius: 8px;
cursor: pointer; font-size: 12px; transition: all .15s;
}
.scenario-chip:hover { border-color: var(--rb-accent); color: var(--rb-accent); }
.scenario-chip.active { border-color: var(--rb-accent); background: rgba(74,222,128,.1); color: var(--rb-accent); }
.scenario-chip strong { display: block; margin-bottom: 2px; font-size: 13px; }
/* Info card */
.node-info {
padding: 16px 18px; background: rgba(74,222,128,.05);
border-bottom: 1px solid var(--rb-border);
}
.node-info-name { font-weight: 700; font-size: 15px; color: #fff; margin-bottom: 4px; }
.node-info-lat { font-style: italic; font-size: 11px; color: var(--rb-muted); margin-bottom: 8px; }
.node-info-stat { font-size: 12px; color: var(--rb-muted); }
/* Legend */
.legend { display: flex; flex-wrap: wrap; gap: 8px; }
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--rb-muted); }
.legend-circle { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
/* Result box */
.sim-result {
padding: 14px 18px; background: rgba(239,68,68,.08);
border-bottom: 1px solid var(--rb-border); font-size: 12px; color: #fca5a5;
display: none; line-height: 1.6;
}
.sim-result.show { display: block; }
.sim-result strong { display: block; color: var(--rb-cr); margin-bottom: 6px; font-size: 13px; }
/* ── Mobile ── */
@media (max-width: 768px) {
main.sb-content { overflow: auto; width: 100% !important; flex: 1; }
.eco-layout { flex-direction: column !important; }
.eco-sidebar { width: 100% !important; max-height: 45vh; border-right: none !important; border-bottom: 1px solid var(--rb-border); overflow-y: auto; }
.eco-canvas { min-height: 55vw; }
.scenario-chips { flex-wrap: wrap; }
}
@media (max-width: 480px) {
.eco-canvas { min-height: 260px; }
.scenario-chip { font-size: 11px; padding: 6px 10px; }
}
</style>
</head>
<body>
<div class="app-layout" id="app">
<!-- Red Book sidebar -->
<nav class="sidebar">
<div class="sb-brand">
<a href="/red-book.html" class="rb-brand-link">
<span class="rb-brand-icon"><svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg></span>
<span class="sb-lbl rb-brand-text">Красная<br>книга РБ</span>
</a>
<button class="sb-toggle" onclick="toggleSidebar()"><i data-lucide="panel-left-close"></i></button>
</div>
<nav class="sb-nav">
<p class="rb-sb-section">РАЗДЕЛЫ</p>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
<a href="/red-book-ecosystem.html" class="sb-link active"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
<a href="/red-book-games.html" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Игры</span></a>
<hr class="rb-sb-divider">
<a href="/dashboard.html" class="sb-link rb-back-link"><i data-lucide="chevron-left" class="sb-icon"></i><span class="sb-lbl">Назад</span></a>
<a href="/profile.html" class="sb-link"><i data-lucide="user" class="sb-icon"></i><span class="sb-lbl">Профиль</span></a>
</nav>
<div class="sb-footer">
<div class="nav-user-chip" onclick="location.href='/profile.html'">
<div class="nav-avatar" id="nav-avatar">LS</div>
<span class="nav-user-name" id="nav-user"></span>
</div>
</div>
</nav>
<main class="sb-content" style="overflow:hidden; padding:0; min-width:0; width:0; flex:1;">
<div class="eco-main" style="width:100%;">
<!-- Topbar -->
<div class="eco-topbar">
<a href="/red-book.html" class="eco-back"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg> Пищевые сети · Симулятор экосистем</h1>
<span style="margin-left:auto;font-size:12px;color:var(--rb-muted)" id="node-count"></span>
<button onclick="toggleEnergyFlow()" id="btn-energy" title="Анимация потока энергии" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;padding:6px 12px;border-radius:8px;cursor:pointer;transition:all .2s"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Поток</button>
<button onclick="saveSnapshot()" title="Сохранить снимок" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;padding:6px 12px;border-radius:8px;cursor:pointer;transition:all .2s"><svg class="ic" viewBox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg> Снимок</button>
</div>
<!-- Body -->
<div class="eco-body">
<canvas id="eco-canvas"></canvas>
<div class="eco-panel">
<!-- Selected node info -->
<div class="node-info" id="node-info" style="display:none">
<div class="node-info-name" id="ni-name"></div>
<div class="node-info-lat" id="ni-lat"></div>
<div class="node-info-stat" id="ni-stat"></div>
</div>
<!-- Simulation result -->
<div class="sim-result" id="sim-result">
<strong><svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Результат симуляции</strong>
<span id="sim-result-text"></span>
</div>
<!-- Simulation -->
<div class="panel-section">
<h3>Симулятор воздействия</h3>
<div class="sim-slider-wrap" id="slider-wrap" style="display:none">
<div class="sim-slider-label">
<span id="slider-label">Браконьерство</span>
<span id="slider-val">0%</span>
</div>
<input type="range" class="sim-slider" id="pressure-slider" min="0" max="100" value="0"
oninput="updateSlider(this.value)"/>
</div>
<button class="btn-simulate" id="btn-simulate" onclick="runSimulation()"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить симуляцию</button>
<button class="btn-reset" onclick="resetSimulation()"><svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg> Сбросить</button>
</div>
<!-- Scenarios -->
<div class="panel-section">
<h3>Сценарии</h3>
<div class="scenario-list">
<div class="scenario-chip" onclick="applyScenario('wolf', this)">
<strong>Исчезновение рыси</strong>Что если хищник исчезнет?
</div>
<div class="scenario-chip" onclick="applyScenario('wetland', this)">
<strong>Осушение болот</strong>Потеря 70% болотных видов
</div>
<div class="scenario-chip" onclick="applyScenario('poaching', this)">
<strong>Браконьерство</strong>Давление на крупных млекопитающих
</div>
<div class="scenario-chip" onclick="applyScenario('restore', this)">
<strong>Восстановление бобра</strong>Положительный эффект
</div>
</div>
</div>
<!-- Legend -->
<div class="panel-section">
<h3>Легенда</h3>
<div class="legend">
<div class="legend-item"><div class="legend-circle" style="background:var(--rb-cr)"></div>CR</div>
<div class="legend-item"><div class="legend-circle" style="background:var(--rb-en)"></div>EN</div>
<div class="legend-item"><div class="legend-circle" style="background:var(--rb-vu)"></div>VU</div>
<div class="legend-item"><div class="legend-circle" style="background:var(--rb-nt)"></div>NT</div>
<div class="legend-item"><div class="legend-circle" style="background:var(--rb-lc)"></div>LC</div>
</div>
<p style="font-size:11px;color:var(--rb-muted);margin-top:10px;">
Размер узла = биомасса. Стрелки = «хищник <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> жертва».<br>
Нажмите на узел для выбора.
</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js"></script>
<script src="/js/api.js"></script>
<script>
/* ══════════════════════════════════════════════════════════════════════════
State & constants
══════════════════════════════════════════════════════════════════════════ */
const CAT_COLOR = { CR: 0xef4444, EN: 0xf97316, VU: 0xeab308, NT: 0x22c55e, LC: 0x3b82f6 };
const CAT_HEX = { CR: '#ef4444', EN: '#f97316', VU: '#eab308', NT: '#22c55e', LC: '#3b82f6' };
let graph = { nodes: [], links: [] };
let nodeObjects = {};
let lineObjects = [];
let selectedNode = null;
let simRunning = false;
let originalScales = {};
/* ══════════════════════════════════════════════════════════════════════════
Three.js setup
══════════════════════════════════════════════════════════════════════════ */
const canvas = document.getElementById('eco-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setClearColor(0x0a1a0d);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x071209, 0.012);
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500);
camera.position.set(0, 0, 80);
// Resize
function resize() {
const w = canvas.parentElement.clientWidth, h = canvas.parentElement.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
// Lights
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dir = new THREE.DirectionalLight(0x88ffcc, 1);
dir.position.set(10, 20, 10);
scene.add(dir);
// Mouse for orbit + click
let isDragging = false, prevMouse = { x: 0, y: 0 }, mouseDownPos = { x: 0, y: 0 };
let cameraTheta = 0, cameraPhi = 0, cameraRadius = 80;
canvas.addEventListener('mousedown', e => {
isDragging = true;
prevMouse = mouseDownPos = { x: e.clientX, y: e.clientY };
});
window.addEventListener('mouseup', () => { isDragging = false; });
window.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
cameraTheta -= dx * 0.005;
cameraPhi = Math.max(-1.2, Math.min(1.2, cameraPhi - dy * 0.005));
prevMouse = { x: e.clientX, y: e.clientY };
updateCamera();
});
canvas.addEventListener('wheel', e => {
cameraRadius = Math.max(20, Math.min(200, cameraRadius + e.deltaY * 0.05));
updateCamera();
}, { passive: true });
function updateCamera() {
camera.position.set(
cameraRadius * Math.sin(cameraTheta) * Math.cos(cameraPhi),
cameraRadius * Math.sin(cameraPhi),
cameraRadius * Math.cos(cameraTheta) * Math.cos(cameraPhi)
);
camera.lookAt(0, 0, 0);
}
// Raycaster for node click
const raycaster = new THREE.Raycaster();
raycaster.params.Points.threshold = 1;
const mouse2d = new THREE.Vector2();
canvas.addEventListener('click', e => {
// Ignore if user dragged (orbiting), not a true click
const ddx = e.clientX - mouseDownPos.x, ddy = e.clientY - mouseDownPos.y;
if (Math.sqrt(ddx * ddx + ddy * ddy) > 6) return;
const rect = canvas.getBoundingClientRect();
mouse2d.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse2d.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse2d, camera);
const meshes = Object.values(nodeObjects).map(n => n.mesh);
const hits = raycaster.intersectObjects(meshes);
if (hits.length) {
selectNode(hits[0].object.userData.id);
} else {
deselectNode();
}
});
/* ══════════════════════════════════════════════════════════════════════════
Load graph data
══════════════════════════════════════════════════════════════════════════ */
async function loadGraph() {
const data = await LS.get('/api/red-book/food-web').catch(() => ({ nodes: [], links: [] }));
graph = data;
document.getElementById('node-count').textContent = `${graph.nodes.length} видов · ${graph.links.length} связей`;
buildGraph();
}
function buildGraph() {
// Clear old
Object.values(nodeObjects).forEach(n => scene.remove(n.mesh, n.label));
lineObjects.forEach(l => scene.remove(l));
nodeObjects = {};
lineObjects = [];
// Layout: force-directed simulation (simplified 2D projected to 3D sphere)
const N = graph.nodes.length;
const positions = {};
// Biome cluster centers (staggered on sphere surface)
const BIOME_CENTERS = {
forest: new THREE.Vector3(25, 10, 10),
conifer: new THREE.Vector3(-25, 10, 10),
wetland: new THREE.Vector3(0, -25, 10),
river: new THREE.Vector3(10, 10, -25),
meadow: new THREE.Vector3(-10, -10, 25),
};
// Initialise near biome cluster centers
graph.nodes.forEach((node, i) => {
const center = BIOME_CENTERS[node.habitat_type] || null;
if (center) {
positions[node.id] = center.clone().add(new THREE.Vector3(
(Math.random() - 0.5) * 12,
(Math.random() - 0.5) * 12,
(Math.random() - 0.5) * 12,
));
} else {
const theta = (i / N) * Math.PI * 2;
const phi = (Math.random() - 0.5) * Math.PI;
const r = 30;
positions[node.id] = new THREE.Vector3(
r * Math.cos(theta) * Math.cos(phi),
r * Math.sin(phi),
r * Math.sin(theta) * Math.cos(phi)
);
}
});
// Simple force iterations
for (let iter = 0; iter < 80; iter++) {
// Repulsion
for (let i = 0; i < graph.nodes.length; i++) {
for (let j = i + 1; j < graph.nodes.length; j++) {
const a = graph.nodes[i], b = graph.nodes[j];
const diff = positions[a.id].clone().sub(positions[b.id]);
const dist = Math.max(diff.length(), 0.1);
const force = diff.normalize().multiplyScalar(200 / (dist * dist));
positions[a.id].add(force);
positions[b.id].sub(force);
}
}
// Attraction along links
graph.links.forEach(link => {
const a = positions[link.source], b = positions[link.target];
if (!a || !b) return;
const diff = b.clone().sub(a);
const dist = diff.length();
const force = diff.normalize().multiplyScalar((dist - 15) * 0.05 * (link.strength || 0.5));
a.add(force);
b.sub(force);
});
// Biome cluster attraction (gentle)
graph.nodes.forEach(node => {
const center = BIOME_CENTERS[node.habitat_type];
if (!center) return;
const p = positions[node.id];
const diff = center.clone().sub(p);
p.add(diff.multiplyScalar(0.02));
});
// Damping toward sphere
graph.nodes.forEach(node => {
const p = positions[node.id];
const len = p.length();
if (len > 0) p.multiplyScalar(30 / len * 0.05 + 0.95);
});
}
// Create meshes
graph.nodes.forEach(node => {
const radius = Math.max(0.6, Math.min(3, 0.5 + Math.log(1 + (node.biomass_kg || 1)) * 0.3));
const color = CAT_COLOR[node.category] || 0x22c55e;
const geo = new THREE.SphereGeometry(radius, 16, 16);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.3, metalness: 0.2, emissive: color, emissiveIntensity: 0.2 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(positions[node.id]);
mesh.userData = { id: node.id, node };
scene.add(mesh);
nodeObjects[node.id] = { mesh, radius, origColor: color };
originalScales[node.id] = 1;
});
// Create arrow lines
graph.links.forEach(link => {
const a = positions[link.source], b = positions[link.target];
if (!a || !b) return;
const pts = [a, b];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: 0x2d5238, transparent: true, opacity: 0.5 });
const line = new THREE.Line(geo, mat);
line.userData = { source: link.source, target: link.target };
scene.add(line);
lineObjects.push(line);
});
}
/* ══════════════════════════════════════════════════════════════════════════
Select node
══════════════════════════════════════════════════════════════════════════ */
function selectNode(id) {
selectedNode = id;
const node = graph.nodes.find(n => n.id === id);
if (!node) return;
// Highlight connected links
lineObjects.forEach(l => {
const connected = l.userData.source === id || l.userData.target === id;
l.material.opacity = connected ? 0.9 : 0.2;
l.material.color.setHex(connected ? 0x4ade80 : 0x2d5238);
});
// Show info panel
const preys = graph.links.filter(l => l.source === id).map(l => graph.nodes.find(n => n.id === l.target)?.name_ru).filter(Boolean);
const predators = graph.links.filter(l => l.target === id).map(l => graph.nodes.find(n => n.id === l.source)?.name_ru).filter(Boolean);
document.getElementById('node-info').style.display = 'block';
document.getElementById('ni-name').innerHTML = `${node.icon || '<svg class="ic" viewBox="0 0 24 24"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>'} ${node.name_ru}`;
document.getElementById('ni-lat').textContent = node.name_lat || '';
document.getElementById('ni-stat').innerHTML =
`<span style="color:${CAT_HEX[node.category]||'#22c55e'};font-weight:700">${node.category}</span>` +
(node.description ? `<br><span style="color:#a8d5b0;font-size:11px;line-height:1.5">${node.description.slice(0, 100)}…</span>` : '') +
(preys.length ? `<br><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg> Охотится: ${preys.slice(0,3).join(', ')}` : '') +
(predators.length? `<br><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="currentColor" stroke="none"/></svg> Хищники: ${predators.slice(0,3).join(', ')}` : '');
// Show slider
document.getElementById('slider-wrap').style.display = 'block';
document.getElementById('slider-label').textContent = `Давление на «${node.name_ru.split(' ')[0]}»`;
}
function deselectNode() {
selectedNode = null;
lineObjects.forEach(l => { l.material.opacity = 0.5; l.material.color.setHex(0x2d5238); });
document.getElementById('node-info').style.display = 'none';
document.getElementById('slider-wrap').style.display = 'none';
}
function updateSlider(v) {
document.getElementById('slider-val').textContent = v + '%';
}
/* ══════════════════════════════════════════════════════════════════════════
Lotka-Volterra multi-step simulation
══════════════════════════════════════════════════════════════════════════ */
let growMode = false; // true = restore scenario (population grows)
function runLotkaVolterra(startId, pressure, grow) {
// Population vector: each species starts at 1.0
const pop = {};
graph.nodes.forEach(n => pop[n.id] = 1.0);
// Apply initial perturbation
pop[startId] = grow ? 1.0 + pressure * 0.8 : Math.max(0.05, 1.0 - pressure * 0.85);
// Build adjacency: links are predator -> prey
// predators[id] = [{ id, strength }], prey[id] = [{ id, strength }]
const predators = {}, preys = {};
graph.nodes.forEach(n => { predators[n.id] = []; preys[n.id] = []; });
graph.links.forEach(l => {
preys[l.source].push({ id: l.target, s: l.strength || 0.5 });
predators[l.target].push({ id: l.source, s: l.strength || 0.5 });
});
// Iterate simplified LV equations: dp/dt = p * (gain - loss)
const dt = 0.15;
const STEPS = 25;
for (let step = 0; step < STEPS; step++) {
const delta = {};
graph.nodes.forEach(n => {
if (n.id === startId) return; // fixed
// gain = from prey being available (predator perspective)
// loss = from predators eating you
let gain = 0, loss = 0;
preys[n.id].forEach(({ id: pid, s }) => { gain += s * pop[pid] * 0.3; });
predators[n.id].forEach(({ id: hid, s }) => { loss += s * pop[hid] * 0.4; });
// natural return force toward 1.0
const returnForce = (1.0 - pop[n.id]) * 0.15;
delta[n.id] = pop[n.id] * (gain - loss) * dt + returnForce * dt;
});
graph.nodes.forEach(n => {
if (n.id !== startId) {
pop[n.id] = Math.max(0.05, Math.min(3.0, pop[n.id] + (delta[n.id] || 0)));
}
});
}
return pop;
}
function runSimulation() {
if (!selectedNode) { LS.toast('Выберите вид на графе для симуляции', 'warn'); return; }
simRunning = true;
const pressure = parseInt(document.getElementById('pressure-slider').value) / 100;
if (pressure === 0) {
LS.toast('Установите ненулевое давление на слайдере', 'warn');
simRunning = false;
return;
}
const btn = document.getElementById('btn-simulate');
btn.className = 'btn-simulate running';
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M5 22h14M5 2h14M17 22v-4.17a2 2 0 0 0-.59-1.41L12 12l-4.41 4.42A2 2 0 0 0 7 17.83V22M7 2v4.17a2 2 0 0 0 .59 1.41L12 12l4.41-4.42A2 2 0 0 0 17 6.17V2"/></svg> Симуляция...';
// Run Lotka-Volterra simulation
const lvPop = runLotkaVolterra(selectedNode, pressure, growMode);
// Apply direct effect instantly
const selObj = nodeObjects[selectedNode];
if (selObj) {
const targetScale = growMode ? 1 + pressure * 0.8 : Math.max(0.1, 1 - pressure * 0.85);
animateScale(selectedNode, targetScale, 600);
selObj.mesh.material.emissiveIntensity = 0.7;
selObj.mesh.material.emissive.setHex(growMode ? 0x4ade80 : 0xef4444);
}
// Animate all other nodes with LV results (staggered)
const sorted = graph.nodes
.filter(n => n.id !== selectedNode)
.sort((a, b) => Math.abs(lvPop[b.id] - 1) - Math.abs(lvPop[a.id] - 1));
sorted.forEach((n, i) => {
setTimeout(() => {
const pop = lvPop[n.id];
const obj = nodeObjects[n.id];
if (!obj) return;
animateScale(n.id, Math.max(0.1, Math.min(2.5, pop)), 500);
const delta = pop - 1;
if (delta > 0.05) {
obj.mesh.material.emissive.setHex(0x4ade80);
obj.mesh.material.emissiveIntensity = Math.min(0.7, delta * 0.8);
} else if (delta < -0.05) {
obj.mesh.material.emissive.setHex(0xef4444);
obj.mesh.material.emissiveIntensity = Math.min(0.7, Math.abs(delta) * 0.8);
}
}, 200 + i * 20);
});
// Show result
setTimeout(() => {
const selNode = graph.nodes.find(n => n.id === selectedNode);
const collapsed = sorted.filter(n => lvPop[n.id] < 0.5).map(n => n.name_ru).slice(0, 3);
const boomed = sorted.filter(n => lvPop[n.id] > 1.4).map(n => n.name_ru).slice(0, 3);
const resultText = document.getElementById('sim-result-text');
if (!resultText.textContent) {
let txt = growMode
? `Восстановление «${selNode?.name_ru}» (+${Math.round(pressure*80)}%)`
: `Снижение «${selNode?.name_ru}» на ${Math.round(pressure*85)}%`;
if (boomed.length) txt += `. Рост: ${boomed.join(', ')}`;
if (collapsed.length) txt += `. Под угрозой: ${collapsed.join(', ')}`;
txt += `. Симуляция по модели Лотки–Вольтерра.`;
resultText.textContent = txt;
}
document.getElementById('sim-result').classList.add('show');
btn.className = 'btn-simulate';
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Запустить снова';
simRunning = false;
}, 800);
}
function animateScale(id, targetScale, duration) {
const obj = nodeObjects[id];
if (!obj) return;
const start = obj.mesh.scale.x;
const startTime = performance.now();
function step(now) {
const t = Math.min((now - startTime) / duration, 1);
const e = 1 - Math.pow(1 - t, 3);
obj.mesh.scale.setScalar(start + (targetScale - start) * e);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function resetSimulation() {
growMode = false;
document.getElementById('pressure-slider').value = 0;
document.getElementById('slider-val').textContent = '0%';
document.getElementById('sim-result').classList.remove('show');
document.getElementById('sim-result-text').textContent = '';
graph.nodes.forEach(n => {
const obj = nodeObjects[n.id];
if (!obj) return;
animateScale(n.id, 1, 500);
obj.mesh.material.emissive.setHex(CAT_COLOR[n.category] || 0x22c55e);
obj.mesh.material.emissiveIntensity = 0.2;
});
// Note: does NOT touch scenario-chip active classes — managed by applyScenario
}
/* Scenarios */
function applyScenario(name, chip) {
// Update active chip
document.querySelectorAll('.scenario-chip').forEach(c => c.classList.remove('active'));
chip?.classList.add('active');
deselectNode();
resetSimulation();
const scenarios = {
wolf: { target: 'Рысь', pressure: 80, grow: false, msg: 'Исчезновение рыси <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> +40% популяция косули <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> перевыпас <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> деградация леса' },
wetland: { target: 'болот', pressure: 70, grow: false, msg: 'Осушение болот <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> гибель 70% болотных видов <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> цепная реакция по всей пищевой сети' },
poaching: { target: 'Зубр', pressure: 60, grow: false, msg: 'Браконьерство <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> снижение зубра <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> зарастание опушек <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> потеря биоразнообразия' },
restore: { target: 'бобёр', pressure: 45, grow: true, msg: 'Восстановление бобра <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> создание прудов <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> +30% водных видов <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> стабилизация экосистемы' },
};
const s = scenarios[name];
if (!s) return;
const nodeId = graph.nodes.find(n => n.name_ru.toLowerCase().includes(s.target.toLowerCase()))?.id;
if (!nodeId) { console.warn('Scenario target not found:', s.target); return; }
growMode = s.grow;
selectNode(nodeId);
document.getElementById('pressure-slider').value = s.pressure;
updateSlider(s.pressure);
document.getElementById('sim-result-text').textContent = s.msg;
setTimeout(() => runSimulation(), 350);
}
/* ══════════════════════════════════════════════════════════════════════════
Energy flow particles
══════════════════════════════════════════════════════════════════════════ */
let energyFlowEnabled = false;
const flowParticles = []; // { mesh, linkIdx, t }
function toggleEnergyFlow() {
energyFlowEnabled = !energyFlowEnabled;
const btn = document.getElementById('btn-energy');
if (energyFlowEnabled) {
btn.style.borderColor = 'var(--rb-accent)';
btn.style.color = 'var(--rb-accent)';
initFlowParticles();
} else {
btn.style.borderColor = 'var(--rb-border)';
btn.style.color = 'var(--rb-muted)';
flowParticles.forEach(p => scene.remove(p.mesh));
flowParticles.length = 0;
}
}
function initFlowParticles() {
// Remove old
flowParticles.forEach(p => scene.remove(p.mesh));
flowParticles.length = 0;
// Create one particle per link (3 particles per link staggered)
graph.links.forEach((link, i) => {
for (let k = 0; k < 2; k++) {
const geo = new THREE.SphereGeometry(0.2, 6, 6);
const mat = new THREE.MeshBasicMaterial({ color: 0x88ffaa, transparent: true, opacity: 0.8 });
const mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
flowParticles.push({ mesh, linkIdx: i, t: k * 0.5 });
}
});
}
function updateFlowParticles(dt) {
if (!energyFlowEnabled) return;
flowParticles.forEach(p => {
p.t = (p.t + dt * 0.4) % 1;
const link = graph.links[p.linkIdx];
if (!link) return;
const src = nodeObjects[link.source]?.mesh.position;
const tgt = nodeObjects[link.target]?.mesh.position;
if (!src || !tgt) return;
p.mesh.position.lerpVectors(src, tgt, p.t);
// Fade at ends
const fade = Math.sin(p.t * Math.PI);
p.mesh.material.opacity = fade * 0.9;
});
}
/* ══════════════════════════════════════════════════════════════════════════
Snapshot
══════════════════════════════════════════════════════════════════════════ */
function saveSnapshot() {
// Render one frame to canvas
renderer.render(scene, camera);
const url = renderer.domElement.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = `food-web-${Date.now()}.png`;
a.click();
}
/* ══════════════════════════════════════════════════════════════════════════
Render loop
══════════════════════════════════════════════════════════════════════════ */
let t = 0;
let lastTime = 0;
function animate(now = 0) {
requestAnimationFrame(animate);
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
t += 0.01;
// Gentle auto-rotate when not interacting
if (!isDragging) {
cameraTheta += 0.001;
updateCamera();
}
// Pulse emissive on CR nodes
graph.nodes.forEach(n => {
const obj = nodeObjects[n.id];
if (obj && n.category === 'CR') {
obj.mesh.material.emissiveIntensity = 0.2 + Math.sin(t * 3 + n.id) * 0.15;
}
});
// Energy flow
updateFlowParticles(dt);
renderer.render(scene, camera);
}
animate();
/* ══════════════════════════════════════════════════════════════════════════
Sidebar
══════════════════════════════════════════════════════════════════════════ */
function toggleSidebar() {
const app = document.getElementById('app');
app.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', app.classList.contains('sb-collapsed') ? '1' : '0');
lucide.createIcons();
setTimeout(resize, 300);
}
/* ══════════════════════════════════════════════════════════════════════════
Boot
══════════════════════════════════════════════════════════════════════════ */
LS.hideDisabledFeatures?.();
lucide.createIcons();
if (localStorage.getItem('ls_sb_collapsed') === '1') document.getElementById('app').classList.add('sb-collapsed');
const user = LS.getUser?.();
if (user) {
document.getElementById('nav-user').textContent = user.name?.split(' ')[0] || '—';
document.getElementById('nav-avatar').textContent = (user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
}
resize(); // init canvas size so raycaster works correctly
loadGraph();
</script>
<script src="/js/mobile.js"></script>
</body>
</html>