Files
Learn_System/frontend/red-book-biomes.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

885 lines
42 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Биомы — Красная книга РБ</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-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); }
.sb-content { padding: 0; overflow: hidden; }
/* ── 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; }
.biome-main { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* Topbar */
.bio-topbar {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding: 12px 20px; border-bottom: 1px solid var(--rb-border);
background: var(--rb-surface); flex-shrink: 0; z-index: 10;
}
.bio-topbar h1 { font-family: 'Unbounded', sans-serif; font-size: 15px; font-weight: 700; margin: 0; }
.biome-tabs { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; }
.biome-tab {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 10px; cursor: pointer;
font-size: 12px; font-weight: 600; border: 1px solid var(--rb-border);
color: var(--rb-muted); transition: all .2s;
}
.biome-tab:hover { border-color: var(--rb-accent); color: var(--rb-text); }
.biome-tab.active { border-color: var(--rb-accent); background: rgba(74,222,128,.12); color: var(--rb-accent); }
.biome-tab .tab-icon { font-size: 16px; }
/* Canvas + overlay */
.bio-body { flex: 1; position: relative; overflow: hidden; }
#bio-canvas { width: 100%; height: 100%; display: block; }
/* Species orbs HUD */
.bio-orbs {
position: absolute; right: 0; top: 0; bottom: 0; width: 280px;
background: linear-gradient(to left, rgba(10,26,13,.95), transparent);
display: flex; flex-direction: column; justify-content: center;
padding: 20px 20px 20px 40px; gap: 10px; overflow-y: auto;
pointer-events: none;
}
.bio-orbs.visible { pointer-events: auto; }
.orb-card {
background: rgba(17,29,19,.9); border: 1px solid var(--rb-border);
border-radius: 12px; padding: 12px; cursor: pointer;
transition: all .2s; display: flex; align-items: center; gap: 10px;
}
.orb-card:hover { border-color: var(--rb-accent); transform: translateX(-4px); }
.orb-icon { font-size: 24px; flex-shrink: 0; }
.orb-info { flex: 1; min-width: 0; }
.orb-name { font-size: 12px; font-weight: 700; color: #fff; }
.orb-lat { font-size: 10px; color: var(--rb-muted); font-style: italic; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.orb-cat { font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 5px; }
.cat-CR { background: rgba(239,68,68,.2); color: #ef4444; }
.cat-EN { background: rgba(249,115,22,.2); color: #f97316; }
.cat-VU { background: rgba(234,179,8,.2); color: #eab308; }
.cat-NT { background: rgba(34,197,94,.2); color: #22c55e; }
.cat-LC { background: rgba(59,130,246,.2); color: #3b82f6; }
/* Biome description overlay */
.bio-info-overlay {
position: absolute; left: 20px; bottom: 24px;
background: rgba(17,29,19,.92); border: 1px solid var(--rb-border);
border-radius: 14px; padding: 16px 20px; max-width: 360px;
backdrop-filter: blur(8px);
}
.bio-info-overlay h3 { font-family: 'Unbounded', sans-serif; font-size: 16px; margin: 0 0 6px; color: var(--rb-accent); }
.bio-info-overlay p { font-size: 12px; color: var(--rb-muted); margin: 0 0 10px; line-height: 1.6; }
.bio-species-count { font-size: 12px; font-weight: 700; color: var(--rb-text); }
/* Species popup */
.bio-popup {
position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
background: var(--rb-surface); border: 1px solid var(--rb-border);
border-radius: 16px; padding: 24px; max-width: 320px; width: 90%;
z-index: 20; display: none; animation: popIn .2s both;
}
@keyframes popIn { from { opacity:0; transform:translate(-50%,-50%) scale(.9); } to { opacity:1; transform:translate(-50%,-50%) scale(1); } }
.bio-popup.open { display: block; }
.popup-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.popup-icon { font-size: 40px; }
.popup-name { font-weight: 800; font-size: 15px; color: #fff; }
.popup-lat { font-style: italic; font-size: 11px; color: var(--rb-muted); }
.popup-desc { font-size: 12px; color: #c8e6ce; line-height: 1.7; margin-bottom: 14px; }
.popup-fact { background: rgba(74,222,128,.08); border-left: 3px solid var(--rb-accent); padding: 10px 12px; border-radius: 0 8px 8px 0; font-size: 12px; color: var(--rb-accent); margin-bottom: 14px; }
.popup-btns { display: flex; gap: 8px; }
.popup-btn-primary { flex: 1; background: var(--rb-accent); color: #0a1a0d; font-weight: 700; font-size: 12px; padding: 9px; border: none; border-radius: 8px; cursor: pointer; }
.popup-btn-close { background: transparent; border: 1px solid var(--rb-border); color: var(--rb-muted); font-size: 12px; padding: 9px 14px; border-radius: 8px; cursor: pointer; }
/* ── Mobile ── */
@media (max-width: 768px) {
.sb-content { overflow: auto; }
.bio-info-overlay { max-width: calc(100vw - 80px); left: 10px; bottom: 10px; padding: 12px 14px; }
.bio-info-overlay h3 { font-size: 14px; }
.bio-popup { max-width: 90vw; padding: 18px; }
.bio-scene-wrap { min-height: 45vw; }
}
@media (max-width: 480px) {
.bio-info-overlay { bottom: 8px; left: 8px; }
.popup-icon { font-size: 30px; }
.popup-btns { flex-direction: column; }
.popup-btn-primary, .popup-btn-close { width: 100%; text-align: center; }
}
</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"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link active"><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">
<div class="biome-main">
<div class="bio-topbar">
<a href="/red-book.html" style="color:var(--rb-muted);text-decoration:none;font-size:12px;border:1px solid var(--rb-border);padding:6px 12px;border-radius:8px;"><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="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg> Биомы Беларуси</h1>
<div class="biome-tabs" id="biome-tabs"></div>
<button id="sound-btn" onclick="toggleSound()" title="Звуки биома" style="display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук</button>
<button id="daynight-btn" onclick="toggleDayNight()" title="День/Ночь" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg> День</button>
<button id="weather-btn" onclick="toggleRain()" title="Дождь" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><path d="M4 14.9A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.24"/><path d="M8 19v1M8 14v1M16 19v1M16 14v1M12 21v1M12 16v1"/></svg> Дождь</button>
</div>
<div class="bio-body">
<canvas id="bio-canvas"></canvas>
<!-- Species list sidebar -->
<div class="bio-orbs visible" id="bio-orbs"></div>
<!-- Biome info -->
<div class="bio-info-overlay" id="bio-info">
<h3 id="bio-name">Загрузка...</h3>
<p id="bio-desc">Выберите биом выше для исследования.</p>
<div class="bio-species-count" id="bio-species-count"></div>
</div>
<!-- Species popup -->
<div class="bio-popup" id="bio-popup">
<div class="popup-header">
<span class="popup-icon" id="pp-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>
<div>
<div class="popup-name" id="pp-name"></div>
<div class="popup-lat" id="pp-lat"></div>
<span class="orb-cat" id="pp-cat"></span>
</div>
</div>
<div class="popup-fact" id="pp-fact"></div>
<div class="popup-desc" id="pp-desc"></div>
<div class="popup-btns">
<button class="popup-btn-primary" id="pp-collect" onclick="collectFromPopup()"><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 9.9-1"/></svg> Открыть</button>
<button class="popup-btn-close" onclick="closePopup()">Закрыть</button>
</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>
/* ══════════════════════════════════════════════════════════════════════════
Biome definitions
══════════════════════════════════════════════════════════════════════════ */
const BIOMES = [
{ id: 1, name: 'Широколиственный лес', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>', type: 'forest',
skyColor: 0x071a09, fogColor: 0x0d2e10, fogDensity: 0.025,
groundColor: 0x071209, ambientColor: 0x1a4a20, sunColor: 0x88ffaa },
{ id: 2, name: 'Хвойный лес', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg>', type: 'conifer',
skyColor: 0x060f0a, fogColor: 0x0a1a0d, fogDensity: 0.03,
groundColor: 0x050d07, ambientColor: 0x0d3015, sunColor: 0x66cc88 },
{ id: 3, name: 'Болото', 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>', type: 'wetland',
skyColor: 0x050e10, fogColor: 0x0a1e22, fogDensity: 0.04,
groundColor: 0x071510, ambientColor: 0x103020, sunColor: 0x44aacc },
{ id: 4, name: 'Река и озеро', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M2 6c.6.5 1.2 1 2.5 1C7 7 7 5 9.5 5c2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 12c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1M2 18c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 2.6 0 2.4 2 5 2 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></svg>', type: 'river',
skyColor: 0x060d14, fogColor: 0x0a1a2e, fogDensity: 0.02,
groundColor: 0x050d1a, ambientColor: 0x0a2040, sunColor: 0x4488ff },
{ id: 5, name: 'Луг и поле', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4z"/></svg>', type: 'meadow',
skyColor: 0x0a100a, fogColor: 0x101a10, fogDensity: 0.015,
groundColor: 0x071407, ambientColor: 0x1a3010, sunColor: 0xaadd44 },
];
let habitats = [];
let currentBiomeIdx = 0;
let speciesList = [];
let currentSpecies = null;
let orbMeshes = [];
/* ══════════════════════════════════════════════════════════════════════════
Three.js
══════════════════════════════════════════════════════════════════════════ */
const canvas = document.getElementById('bio-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setClearColor(0x071209);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 300);
camera.position.set(0, 3, 22);
camera.lookAt(0, 1, 0);
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();
const ambient = new THREE.AmbientLight(0x1a4a20, 1.5);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0x88ffaa, 1.2);
sun.position.set(8, 12, 5);
scene.add(sun);
// Ground
const groundMat = new THREE.MeshLambertMaterial({ color: 0x071209 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(100, 100), groundMat);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// Particles (fireflies/spores)
const PC = 400;
const pPos = new Float32Array(PC * 3);
const pPhase = new Float32Array(PC);
for (let i = 0; i < PC; i++) {
pPos[i*3] = (Math.random() - 0.5) * 40;
pPos[i*3+1] = Math.random() * 8;
pPos[i*3+2] = (Math.random() - 0.5) * 30 - 5;
pPhase[i] = Math.random() * Math.PI * 2;
}
const pgeo = new THREE.BufferGeometry();
pgeo.setAttribute('position', new THREE.BufferAttribute(pPos.slice(), 3));
const pmat = new THREE.PointsMaterial({ color: 0x88ffaa, size: 0.1, transparent: true, opacity: 0.5 });
const particles = new THREE.Points(pgeo, pmat);
scene.add(particles);
// Trees group (replaced per biome)
let treesGroup = new THREE.Group();
scene.add(treesGroup);
// Species orb group
let orbGroup = new THREE.Group();
scene.add(orbGroup);
/* Build biome scene */
function buildBiomeScene(biome) {
renderer.setClearColor(biome.skyColor);
scene.fog = new THREE.FogExp2(biome.fogColor, biome.fogDensity);
groundMat.color.setHex(biome.groundColor);
ambient.color.setHex(biome.ambientColor);
sun.color.setHex(biome.sunColor);
// Remove old trees
while (treesGroup.children.length) treesGroup.remove(treesGroup.children[0]);
if (biome.type === 'forest' || biome.type === 'conifer') buildForest(biome);
else if (biome.type === 'wetland') buildWetland(biome);
else if (biome.type === 'river') buildRiver(biome);
else if (biome.type === 'meadow') buildMeadow(biome);
// Particle color
const pColors = { forest:'#88ffaa', conifer:'#44cc77', wetland:'#aaddcc', river:'#4488ff', meadow:'#aadd55' };
pmat.color.setStyle(pColors[biome.type] || '#88ffaa');
}
function buildForest(biome) {
const isConifer = biome.type === 'conifer';
const treeColor = isConifer ? 0x0d2e12 : 0x1a4a1a;
const trunkCol = isConifer ? 0x3b1e08 : 0x5c3010;
for (let i = 0; i < 60; i++) {
const angle = Math.random() * Math.PI * 2;
const r = 4 + Math.random() * 20;
const sc = 0.5 + Math.random() * 1.5;
const g = new THREE.Group();
const th = (0.8 + Math.random() * 0.5) * sc;
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.08*sc, 0.12*sc, th, 6),
new THREE.MeshLambertMaterial({ color: trunkCol })
);
trunk.position.y = th/2;
g.add(trunk);
const levels = isConifer ? 4 : 2;
for (let l = 0; l < levels; l++) {
const cr = isConifer ? (0.5 - l*0.1)*sc : (0.6 - l*0.15)*sc;
const ch = (0.6 - l*0.05)*sc;
const geo = isConifer ? new THREE.ConeGeometry(cr, ch, 7) : new THREE.SphereGeometry(cr, 8, 6);
const col = new THREE.Color(treeColor).offsetHSL(0, 0, l * 0.04);
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: col }));
mesh.position.y = th + (isConifer ? ch*0.4 + l*ch*0.5 : cr*0.7 + l*cr*0.3);
g.add(mesh);
}
g.position.set(Math.cos(angle)*r, 0, Math.sin(angle)*r - 3);
treesGroup.add(g);
}
}
function buildWetland(biome) {
groundMat.color.setHex(0x061410);
// Reeds
for (let i = 0; i < 80; i++) {
const x = (Math.random()-0.5)*30, z = Math.random()*15-12;
const h = 1 + Math.random()*2;
const reed = new THREE.Mesh(
new THREE.CylinderGeometry(0.04, 0.04, h, 4),
new THREE.MeshLambertMaterial({ color: 0x5a7a2a })
);
reed.position.set(x, h/2, z);
reed.rotation.z = (Math.random()-0.5)*0.2;
treesGroup.add(reed);
}
// Water plane
const water = new THREE.Mesh(
new THREE.PlaneGeometry(50, 15),
new THREE.MeshLambertMaterial({ color: 0x0d2e3a, transparent: true, opacity: 0.7 })
);
water.rotation.x = -Math.PI/2;
water.position.set(0, 0.01, 0);
treesGroup.add(water);
}
function buildRiver(biome) {
// Water
const water = new THREE.Mesh(
new THREE.PlaneGeometry(8, 50),
new THREE.MeshLambertMaterial({ color: 0x0a2040, transparent: true, opacity: 0.8 })
);
water.rotation.x = -Math.PI/2;
water.position.set(0, 0.01, 0);
treesGroup.add(water);
// Banks with trees
for (let i = 0; i < 30; i++) {
const side = Math.random() > 0.5 ? 1 : -1;
const x = side * (5 + Math.random() * 10);
const z = (Math.random()-0.5)*30;
const sc = 0.5 + Math.random();
const g = new THREE.Group();
const h = 1.2*sc;
g.add(new THREE.Mesh(new THREE.CylinderGeometry(0.1*sc, 0.15*sc, h, 6),
new THREE.MeshLambertMaterial({ color: 0x4a2a10 })));
const crown = new THREE.Mesh(new THREE.SphereGeometry(0.7*sc, 8, 6),
new THREE.MeshLambertMaterial({ color: 0x1a5520 }));
crown.position.y = h + 0.5*sc;
g.children[0].position.y = h/2;
g.add(crown);
g.position.set(x, 0, z);
treesGroup.add(g);
}
}
function buildMeadow(biome) {
groundMat.color.setHex(0x0d1f08);
// Grass tufts
for (let i = 0; i < 120; i++) {
const x = (Math.random()-0.5)*40, z = (Math.random()-0.5)*30;
const h = 0.2 + Math.random()*0.5;
const blades = 4;
for (let b = 0; b < blades; b++) {
const blade = new THREE.Mesh(
new THREE.CylinderGeometry(0.01, 0.02, h, 3),
new THREE.MeshLambertMaterial({ color: 0x2a5a10 })
);
blade.position.set(x + (Math.random()-0.5)*0.3, h/2, z + (Math.random()-0.5)*0.3);
blade.rotation.z = (Math.random()-0.5)*0.5;
treesGroup.add(blade);
}
}
// Flowers
for (let i = 0; i < 30; i++) {
const x = (Math.random()-0.5)*30, z = (Math.random()-0.5)*20;
const colors = [0xff4488, 0xffcc22, 0xaa44ff, 0xff6644];
const flower = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 6, 6),
new THREE.MeshLambertMaterial({ color: colors[Math.floor(Math.random()*colors.length)] })
);
flower.position.set(x, 0.5+Math.random()*0.3, z);
treesGroup.add(flower);
}
}
/* Species orbs in 3D */
function buildSpeciesOrbs(species) {
while (orbGroup.children.length) orbGroup.remove(orbGroup.children[0]);
orbMeshes = [];
const CAT_COL = { CR: 0xef4444, EN: 0xf97316, VU: 0xeab308, NT: 0x22c55e, LC: 0x3b82f6 };
species.forEach((sp, i) => {
const angle = (i / species.length) * Math.PI * 2;
const r = 5 + Math.random() * 4;
const h = 1 + Math.random() * 5;
const color = CAT_COL[sp.category] || 0x22c55e;
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.35, 12, 12),
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.5, roughness: 0.3, metalness: 0.1 })
);
const cx = Math.cos(angle) * r;
const cz = Math.sin(angle) * r - 2;
orb.position.set(cx, h, cz);
orb.userData = {
sp, idx: i, phase: Math.random() * Math.PI * 2,
// Gentle orbit path
orbit: { cx, cz, r: 0.8 + Math.random() * 0.5, t: Math.random() * Math.PI * 2 },
};
orbGroup.add(orb);
orbMeshes.push(orb);
});
}
/* Raycasting for orb click */
const ray2d = new THREE.Vector2();
const rayc = new THREE.Raycaster();
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
ray2d.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
ray2d.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
rayc.setFromCamera(ray2d, camera);
const hits = rayc.intersectObjects(orbMeshes);
if (hits.length) openSpeciesPopup(hits[0].object.userData.sp);
});
function openSpeciesPopup(sp) {
currentSpecies = sp;
document.getElementById('pp-icon').innerHTML = sp.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>';
document.getElementById('pp-name').textContent = sp.name_ru;
document.getElementById('pp-lat').textContent = sp.name_lat || '';
const catEl = document.getElementById('pp-cat');
catEl.textContent = sp.category;
catEl.className = `orb-cat cat-${sp.category}`;
document.getElementById('pp-fact').textContent = sp.interesting_fact || '';
document.getElementById('pp-desc').textContent = sp.description ? sp.description.slice(0, 180) + '…' : '';
document.getElementById('bio-popup').classList.add('open');
}
function closePopup() {
document.getElementById('bio-popup').classList.remove('open');
currentSpecies = null;
}
async function collectFromPopup() {
if (!currentSpecies) return;
const res = await LS.post(`/api/red-book/species/${currentSpecies.id}/collect`, { method: 'biome' }).catch(() => null);
if (res) {
const btn = document.getElementById('pp-collect');
btn.innerHTML = `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> +${res.xp_earned||20} XP`;
btn.disabled = true;
}
}
/* ══════════════════════════════════════════════════════════════════════════
Biome tabs
══════════════════════════════════════════════════════════════════════════ */
function renderTabs() {
const el = document.getElementById('biome-tabs');
el.innerHTML = BIOMES.map((b, i) =>
`<div class="biome-tab ${i === currentBiomeIdx ? 'active' : ''}" onclick="selectBiome(${i})">
<span class="tab-icon">${b.icon}</span>${b.name}
</div>`
).join('');
}
async function selectBiome(idx) {
currentBiomeIdx = idx;
renderTabs();
const biome = BIOMES[idx];
buildBiomeScene(biome);
// Ambient sound
if (soundEnabled) playBiomeSound(biome.type);
// Re-apply night if active
if (isNight) {
ambient.intensity = 0.3; sun.intensity = 0.1;
renderer.setClearColor(0x030810);
scene.fog = new THREE.FogExp2(0x050c14, biome.fogDensity * 1.5);
}
// Re-apply rain if active
if (rainEnabled) initRain();
// Load species for this habitat
const habitat = habitats.find(h => h.type === biome.type);
let species = [];
if (habitat) {
const data = await LS.get(`/api/red-book/biome/${habitat.id}`).catch(() => []);
species = Array.isArray(data) ? data : [];
}
speciesList = species;
// Update info overlay
document.getElementById('bio-name').textContent = biome.name;
document.getElementById('bio-desc').textContent = habitat?.description || 'Биом Беларуси';
document.getElementById('bio-species-count').textContent = `${species.length} видов`;
// Build 3D orbs
buildSpeciesOrbs(species);
// Build HTML orb list
const orbsEl = document.getElementById('bio-orbs');
orbsEl.innerHTML = species.length ? species.map(sp =>
`<div class="orb-card" onclick='openSpeciesPopup(${JSON.stringify(sp).replace(/'/g, "&#39;")})'>
<span class="orb-icon">${sp.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>
<div class="orb-info">
<div class="orb-name">${sp.name_ru}</div>
<div class="orb-lat">${sp.name_lat || ''}</div>
</div>
<span class="orb-cat cat-${sp.category}">${sp.category}</span>
</div>`
).join('')
: '<div style="color:var(--rb-muted);font-size:12px;padding:10px">Нет данных о видах этого биома</div>';
}
/* ══════════════════════════════════════════════════════════════════════════
Render loop
══════════════════════════════════════════════════════════════════════════ */
/* ── Day/Night cycle ── */
let isNight = false;
function toggleDayNight() {
isNight = !isNight;
const btn = document.getElementById('daynight-btn');
const biome = BIOMES[currentBiomeIdx];
if (isNight) {
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg> Ночь';
btn.style.borderColor = '#818cf8';
btn.style.color = '#818cf8';
// Dim lights
ambient.intensity = 0.3;
sun.intensity = 0.1;
sun.color.setHex(0x112244);
// Darken sky and fog
renderer.setClearColor(0x030810);
scene.fog = new THREE.FogExp2(0x050c14, biome.fogDensity * 1.5);
// Brighter particles (stars/fireflies)
pmat.size = 0.18;
pmat.opacity = 0.85;
pmat.color.setStyle('#aaccff');
} else {
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg> День';
btn.style.borderColor = 'var(--rb-border)';
btn.style.color = 'var(--rb-muted)';
// Restore biome lighting
ambient.intensity = 1.5;
sun.intensity = 1.2;
buildBiomeScene(biome); // resets all colors
if (soundEnabled) playBiomeSound(biome.type);
}
}
/* ── Rain system ── */
let rainEnabled = false;
let rainGeo, rainMat, rainMesh;
function toggleRain() {
rainEnabled = !rainEnabled;
const btn = document.getElementById('weather-btn');
if (rainEnabled) {
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 14.9A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.24"/><path d="M16 14v6a2 2 0 0 1-4 0"/></svg> Ливень';
btn.style.borderColor = '#60a5fa';
btn.style.color = '#60a5fa';
initRain();
} else {
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><path d="M4 14.9A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.24"/><path d="M8 19v1M8 14v1M16 19v1M16 14v1M12 21v1M12 16v1"/></svg> Дождь';
btn.style.borderColor = 'var(--rb-border)';
btn.style.color = 'var(--rb-muted)';
if (rainMesh) { scene.remove(rainMesh); rainMesh = null; }
// Reduce fog
const biome = BIOMES[currentBiomeIdx];
scene.fog = new THREE.FogExp2(biome.fogColor, biome.fogDensity);
}
}
function initRain() {
if (rainMesh) scene.remove(rainMesh);
const RC = 1500;
const rPos = new Float32Array(RC * 3);
for (let i = 0; i < RC; i++) {
rPos[i*3] = (Math.random() - 0.5) * 50;
rPos[i*3+1] = Math.random() * 20;
rPos[i*3+2] = (Math.random() - 0.5) * 40 - 5;
}
rainGeo = new THREE.BufferGeometry();
rainGeo.setAttribute('position', new THREE.BufferAttribute(rPos, 3));
rainMat = new THREE.PointsMaterial({ color: 0x88aaff, size: 0.06, transparent: true, opacity: 0.5 });
rainMesh = new THREE.Points(rainGeo, rainMat);
scene.add(rainMesh);
// Increase fog for rain atmosphere
const biome = BIOMES[currentBiomeIdx];
scene.fog = new THREE.FogExp2(biome.fogColor, biome.fogDensity * 2.5);
}
function updateRain() {
if (!rainEnabled || !rainMesh) return;
const pos = rainGeo.attributes.position;
for (let i = 0; i < 1500; i++) {
pos.array[i*3+1] -= 0.18; // fall down
pos.array[i*3] -= 0.03; // slight wind
if (pos.array[i*3+1] < 0) {
pos.array[i*3+1] = 20;
pos.array[i*3] = (Math.random() - 0.5) * 50;
}
}
pos.needsUpdate = true;
}
let t = 0, camTheta = 0;
canvas.addEventListener('mousedown', e => { _drag = true; _px = e.clientX; });
window.addEventListener('mouseup', () => { _drag = false; });
let _drag = false, _px = 0;
window.addEventListener('mousemove', e => {
if (!_drag) return;
camTheta -= (e.clientX - _px) * 0.005;
_px = e.clientX;
});
function animate() {
requestAnimationFrame(animate);
t += 0.008;
if (!_drag) camTheta += 0.002;
camera.position.x = 22 * Math.sin(camTheta);
camera.position.z = 22 * Math.cos(camTheta);
camera.lookAt(0, 1, 0);
// Animate particles
const pos = pgeo.attributes.position;
for (let i = 0; i < PC; i++) {
pos.array[i*3+1] += Math.sin(t*1.5 + pPhase[i]) * 0.003;
pos.array[i*3] += Math.cos(t + pPhase[i]*0.7) * 0.002;
}
pos.needsUpdate = true;
pmat.opacity = 0.35 + Math.sin(t*2)*0.15;
// Animate orbs
orbMeshes.forEach(orb => {
orb.position.y += Math.sin(t*1.5 + orb.userData.phase) * 0.005;
orb.material.emissiveIntensity = 0.4 + Math.sin(t*2 + orb.userData.phase)*0.25;
// Animate orbit path
if (orb.userData.orbit) {
orb.userData.orbit.t = (orb.userData.orbit.t || 0) + 0.003;
const ot = orb.userData.orbit.t;
orb.position.x = orb.userData.orbit.cx + Math.cos(ot) * orb.userData.orbit.r;
orb.position.z = orb.userData.orbit.cz + Math.sin(ot) * orb.userData.orbit.r;
}
});
updateRain();
renderer.render(scene, camera);
}
animate();
/* ══════════════════════════════════════════════════════════════════════════
Boot
══════════════════════════════════════════════════════════════════════════ */
function toggleSidebar() {
document.getElementById('app').classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', document.getElementById('app').classList.contains('sb-collapsed') ? '1' : '0');
lucide.createIcons();
setTimeout(resize, 300);
}
/* ══════════════════════════════════════════════════════════════════════════
Ambient Sound Engine (Web Audio API synthesis — no files needed)
══════════════════════════════════════════════════════════════════════════ */
let audioCtx = null;
let soundEnabled = false;
let activeNodes = [];
// Biome sound profiles: { wind, waterFreq, birdRate, frogRate, insectFreq }
const SOUND_PROFILES = {
forest: { windFreq: 300, windGain: 0.06, birdRate: 2.5, waterFreq: 0, frogRate: 0, insectFreq: 0 },
conifer: { windFreq: 200, windGain: 0.09, birdRate: 1.0, waterFreq: 0, frogRate: 0, insectFreq: 0 },
wetland: { windFreq: 400, windGain: 0.04, birdRate: 0.5, waterFreq: 800, frogRate: 0.8, insectFreq: 4000 },
river: { windFreq: 500, windGain: 0.03, birdRate: 1.5, waterFreq: 1200, frogRate: 0, insectFreq: 0 },
meadow: { windFreq: 600, windGain: 0.05, birdRate: 3.0, waterFreq: 0, frogRate: 0, insectFreq: 2800 },
};
function ensureAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
}
function stopAllSound() {
activeNodes.forEach(n => { try { n.stop?.(); n.disconnect?.(); } catch {} });
activeNodes = [];
}
function makeNoise(ctx) {
const bufSize = ctx.sampleRate * 2;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
src.loop = true;
return src;
}
function playBiomeSound(biomeType) {
if (!soundEnabled) return;
ensureAudio();
stopAllSound();
const ctx = audioCtx;
const prof = SOUND_PROFILES[biomeType] || SOUND_PROFILES.forest;
// Wind: filtered white noise
if (prof.windGain > 0) {
const noise = makeNoise(ctx);
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = prof.windFreq;
filter.Q.value = 0.5;
const gain = ctx.createGain();
gain.gain.value = prof.windGain;
// slow LFO on gain for natural breathing
const lfo = ctx.createOscillator();
lfo.frequency.value = 0.15;
const lfoGain = ctx.createGain();
lfoGain.gain.value = prof.windGain * 0.4;
lfo.connect(lfoGain);
lfoGain.connect(gain.gain);
noise.connect(filter); filter.connect(gain); gain.connect(ctx.destination);
noise.start(); lfo.start();
activeNodes.push(noise, lfo);
}
// Water: higher-pitched filtered noise
if (prof.waterFreq > 0) {
const noise = makeNoise(ctx);
const filter = ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = prof.waterFreq;
const gain = ctx.createGain();
gain.gain.value = 0.07;
noise.connect(filter); filter.connect(gain); gain.connect(ctx.destination);
noise.start();
activeNodes.push(noise);
}
// Insects: high-pitched drone
if (prof.insectFreq > 0) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = prof.insectFreq;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = prof.insectFreq + 200;
const gain = ctx.createGain();
gain.gain.value = 0.02;
osc.connect(filter); filter.connect(gain); gain.connect(ctx.destination);
osc.start();
activeNodes.push(osc);
}
// Birds: periodic chirps
if (prof.birdRate > 0) {
function chirp() {
if (!soundEnabled) return;
const t = ctx.currentTime;
const freq = 1800 + Math.random() * 1200;
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, t);
osc.frequency.linearRampToValueAtTime(freq * 1.3, t + 0.05);
osc.frequency.linearRampToValueAtTime(freq, t + 0.1);
const env = ctx.createGain();
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(0.08, t + 0.02);
env.gain.linearRampToValueAtTime(0, t + 0.12);
osc.connect(env); env.connect(ctx.destination);
osc.start(t); osc.stop(t + 0.15);
const next = (1 / prof.birdRate) + Math.random() * 2;
setTimeout(chirp, next * 1000);
}
setTimeout(chirp, 500 + Math.random() * 2000);
}
// Frogs: low pulse
if (prof.frogRate > 0) {
function croak() {
if (!soundEnabled) return;
const t = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = 180 + Math.random() * 40;
const env = ctx.createGain();
env.gain.setValueAtTime(0, t);
env.gain.linearRampToValueAtTime(0.06, t + 0.03);
env.gain.linearRampToValueAtTime(0, t + 0.2);
osc.connect(env); env.connect(ctx.destination);
osc.start(t); osc.stop(t + 0.25);
const next = (1 / prof.frogRate) + Math.random() * 1.5;
setTimeout(croak, next * 1000);
}
setTimeout(croak, Math.random() * 1500);
}
}
function toggleSound() {
ensureAudio();
soundEnabled = !soundEnabled;
const btn = document.getElementById('sound-btn');
if (soundEnabled) {
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> Звук';
btn.style.borderColor = 'var(--rb-accent)';
btn.style.color = 'var(--rb-accent)';
const biome = BIOMES[currentBiomeIdx];
playBiomeSound(biome.type);
} else {
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук';
btn.style.borderColor = 'var(--rb-border)';
btn.style.color = 'var(--rb-muted)';
stopAllSound();
}
}
async function init() {
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';
}
habitats = await LS.get('/api/red-book/habitats').catch(() => []);
renderTabs();
selectBiome(0);
}
init();
</script>
<script src="/js/mobile.js"></script>
</body>
</html>