3898080f04
Причина бага «из админа конструктор симуляций редиректит на дашборд»: у sim-builder.html свой пейдж-гейт, который при feature_sim_builder=false уводил на /dashboard НЕЗАВИСИМО от роли (мой прошлый admin-override был только в hideDisabledFeatures, а этот гейт его не знал). Тот же недочёт нашёлся ещё у 3 страниц с собственным фича-редиректом (на /403): collection.html, knowledge-map.html, red-book.html. Во все 4 добавил обход для админа (админ управляет модулями → видит и открывает всё, даже отключённое) — согласно правилу admin-override. Поведение для ученика/учителя не изменилось. node --check инлайна всех 4 страниц — OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1513 lines
86 KiB
HTML
1513 lines
86 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Красная книга РБ — LearnSpace</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;700;900&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||
<link rel="stylesheet" href="/css/ls.css"/>
|
||
<style>
|
||
/* ── RB tokens ──────────────────────────────────────────────────────── */
|
||
: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;
|
||
--sb-width: 240px;
|
||
}
|
||
|
||
/* ── Layout override for dark RB theme ─────────────────────────────── */
|
||
body { background: var(--rb-bg); color: var(--rb-text); font-family: 'Manrope', sans-serif; }
|
||
.app-layout { background: var(--rb-bg); }
|
||
.sb-content { padding: 0; }
|
||
|
||
/* ── 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; }
|
||
/* brand */
|
||
.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; }
|
||
/* section label */
|
||
.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; }
|
||
/* divider */
|
||
.rb-sb-divider { border: none; border-top: 1px solid var(--rb-border); margin: 8px 4px 8px; }
|
||
/* back link */
|
||
.rb-back-link { opacity: 0.65; font-size: 0.82rem !important; }
|
||
.rb-back-link:hover { opacity: 1 !important; }
|
||
|
||
/* ── Hero ────────────────────────────────────────────────────────────── */
|
||
.rb-hero {
|
||
position: relative;
|
||
height: 100vh;
|
||
min-height: 600px;
|
||
overflow: hidden;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
#hero-canvas {
|
||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||
}
|
||
.hero-overlay {
|
||
position: absolute; inset: 0;
|
||
background: linear-gradient(to bottom, transparent 30%, var(--rb-bg) 100%);
|
||
pointer-events: none;
|
||
}
|
||
.hero-content {
|
||
position: relative; z-index: 2;
|
||
text-align: center; padding: 0 24px;
|
||
}
|
||
.hero-badge {
|
||
display: inline-block;
|
||
background: rgba(239,68,68,.15);
|
||
border: 1px solid rgba(239,68,68,.4);
|
||
color: #fca5a5;
|
||
font-size: 11px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase;
|
||
padding: 5px 14px; border-radius: 20px; margin-bottom: 20px;
|
||
}
|
||
.hero-title {
|
||
font-family: 'Unbounded', sans-serif;
|
||
font-size: clamp(28px, 5vw, 64px); font-weight: 900; line-height: 1.1;
|
||
color: #fff; margin: 0 0 16px;
|
||
}
|
||
.hero-title span { color: var(--rb-accent); }
|
||
.hero-subtitle {
|
||
font-size: 16px; color: var(--rb-muted); margin: 0 0 36px;
|
||
max-width: 500px; margin-left: auto; margin-right: auto;
|
||
}
|
||
.hero-btns { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||
.btn-rb-primary {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
background: var(--rb-accent); color: #0a1a0d;
|
||
font-weight: 700; font-size: 14px; padding: 12px 24px;
|
||
border: none; border-radius: 12px; cursor: pointer;
|
||
text-decoration: none; transition: all .2s;
|
||
}
|
||
.btn-rb-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(74,222,128,.3); }
|
||
.btn-rb-outline {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
background: transparent; color: var(--rb-accent);
|
||
font-weight: 600; font-size: 14px; padding: 12px 24px;
|
||
border: 1.5px solid var(--rb-accent); border-radius: 12px; cursor: pointer;
|
||
text-decoration: none; transition: all .2s;
|
||
}
|
||
.btn-rb-outline:hover { background: rgba(74,222,128,.1); }
|
||
.hero-scroll {
|
||
position: absolute; bottom: 32px; left: 50%; transform: translateX(-50%);
|
||
z-index: 3; text-align: center; cursor: pointer;
|
||
}
|
||
.hero-scroll-arrow {
|
||
width: 24px; height: 24px; border-right: 2px solid var(--rb-accent);
|
||
border-bottom: 2px solid var(--rb-accent); transform: rotate(45deg);
|
||
margin: 0 auto; animation: scrollBounce 1.5s infinite;
|
||
}
|
||
@keyframes scrollBounce {
|
||
0%,100% { transform: rotate(45deg) translateY(0); }
|
||
50% { transform: rotate(45deg) translateY(6px); }
|
||
}
|
||
|
||
/* ── Main content ────────────────────────────────────────────────────── */
|
||
.rb-main { padding: 40px 32px 80px; max-width: 1400px; margin: 0 auto; }
|
||
|
||
/* ── Stats bar ───────────────────────────────────────────────────────── */
|
||
.stats-bar {
|
||
display: flex; gap: 16px; flex-wrap: wrap;
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
border-radius: 16px; padding: 20px 28px; margin-bottom: 32px;
|
||
align-items: center;
|
||
}
|
||
.stat-chip {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 13px; font-weight: 600;
|
||
}
|
||
.stat-dot {
|
||
width: 10px; height: 10px; border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.stat-chip .n { font-size: 20px; font-weight: 700; }
|
||
.sep { width: 1px; height: 32px; background: var(--rb-border); }
|
||
.collection-progress { margin-left: auto; min-width: 200px; }
|
||
.progress-label { font-size: 12px; color: var(--rb-muted); margin-bottom: 6px; display: flex; justify-content: space-between; }
|
||
.progress-bar-wrap { height: 6px; background: var(--rb-border); border-radius: 3px; overflow: hidden; }
|
||
.progress-bar-fill { height: 100%; background: linear-gradient(90deg, #22c55e, #4ade80); border-radius: 3px; transition: width .6s; }
|
||
|
||
/* ── Two-column layout: map + gallery ───────────────────────────────── */
|
||
.rb-layout { display: grid; grid-template-columns: 320px 1fr; gap: 28px; align-items: start; }
|
||
@media (max-width: 900px) { .rb-layout { grid-template-columns: 1fr; } }
|
||
|
||
/* ── Map panel ───────────────────────────────────────────────────────── */
|
||
.map-panel {
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
border-radius: 16px; padding: 20px; position: sticky; top: 24px;
|
||
}
|
||
.map-panel h3 { font-size: 14px; color: var(--rb-muted); margin: 0 0 16px; font-weight: 600; letter-spacing: .5px; text-transform: uppercase; }
|
||
.by-map {
|
||
width: 100%; aspect-ratio: 1.2;
|
||
position: relative;
|
||
}
|
||
.by-map svg { width: 100%; height: 100%; }
|
||
.region-path {
|
||
fill: #1e3523; stroke: #2d5238; stroke-width: 1;
|
||
cursor: pointer; transition: fill .2s;
|
||
}
|
||
.region-path:hover, .region-path.active { fill: #2d6a3f; }
|
||
.region-path.selected { fill: #166534; stroke: var(--rb-accent); stroke-width: 1.5; }
|
||
.region-tooltip {
|
||
position: absolute; background: #0d2210; border: 1px solid var(--rb-border);
|
||
color: var(--rb-text); font-size: 12px; padding: 8px 12px; border-radius: 8px;
|
||
pointer-events: none; white-space: nowrap; z-index: 10;
|
||
opacity: 0; transition: opacity .15s;
|
||
}
|
||
.map-legend { display: flex; gap: 12px; margin-top: 14px; flex-wrap: wrap; }
|
||
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--rb-muted); }
|
||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
|
||
/* ── Filters ─────────────────────────────────────────────────────────── */
|
||
.filters-bar {
|
||
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px; align-items: center;
|
||
}
|
||
.filter-chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
color: var(--rb-muted); font-size: 12px; font-weight: 600;
|
||
padding: 6px 14px; border-radius: 20px; cursor: pointer;
|
||
transition: all .15s; white-space: nowrap;
|
||
}
|
||
.filter-chip:hover { border-color: var(--rb-accent); color: var(--rb-text); }
|
||
.filter-chip.active { background: rgba(74,222,128,.15); border-color: var(--rb-accent); color: var(--rb-accent); }
|
||
.filter-chip .chip-icon { font-size: 14px; }
|
||
.cat-chip { font-weight: 700; }
|
||
.cat-chip.CR { color: var(--rb-cr); border-color: rgba(239,68,68,.3); }
|
||
.cat-chip.CR.active { background: rgba(239,68,68,.15); }
|
||
.cat-chip.EN { color: var(--rb-en); border-color: rgba(249,115,22,.3); }
|
||
.cat-chip.EN.active { background: rgba(249,115,22,.15); }
|
||
.cat-chip.VU { color: var(--rb-vu); border-color: rgba(234,179,8,.3); }
|
||
.cat-chip.VU.active { background: rgba(234,179,8,.15); }
|
||
.cat-chip.NT { color: var(--rb-nt); border-color: rgba(34,197,94,.3); }
|
||
.cat-chip.NT.active { background: rgba(34,197,94,.15); }
|
||
.search-wrap { position: relative; flex: 1; min-width: 180px; }
|
||
.search-wrap input {
|
||
width: 100%; background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
color: var(--rb-text); font-size: 13px; padding: 8px 12px 8px 36px;
|
||
border-radius: 20px; outline: none; font-family: inherit;
|
||
}
|
||
.search-wrap input:focus { border-color: var(--rb-accent); }
|
||
.search-wrap input::placeholder { color: var(--rb-muted); }
|
||
.search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--rb-muted); font-size: 14px; }
|
||
|
||
/* ── Species grid ────────────────────────────────────────────────────── */
|
||
.species-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.species-card {
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
border-radius: 14px; overflow: hidden; cursor: pointer;
|
||
transition: all .2s; position: relative;
|
||
animation: cardIn .4s both;
|
||
animation-delay: calc(var(--i, 0) * 40ms);
|
||
}
|
||
@keyframes cardIn {
|
||
from { opacity: 0; transform: translateY(16px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.species-card:hover { transform: translateY(-4px); border-color: var(--rb-accent); box-shadow: 0 8px 24px rgba(0,0,0,.4); }
|
||
.species-card.locked { opacity: .7; }
|
||
.card-photo {
|
||
width: 100%; aspect-ratio: 1;
|
||
background: #1a2e1d;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 48px; position: relative; overflow: hidden;
|
||
}
|
||
.card-photo img { width: 100%; height: 100%; object-fit: cover; }
|
||
.card-photo .card-icon { font-size: 48px; }
|
||
.card-lock {
|
||
position: absolute; inset: 0;
|
||
background: rgba(0,0,0,.55);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 24px;
|
||
}
|
||
.card-body { padding: 12px; }
|
||
.card-name { font-size: 13px; font-weight: 700; color: var(--rb-text); margin: 0 0 2px; line-height: 1.3; }
|
||
.card-lat { font-size: 10px; color: var(--rb-muted); font-style: italic; margin: 0 0 8px; }
|
||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||
.cat-badge {
|
||
font-size: 10px; font-weight: 800; letter-spacing: .5px;
|
||
padding: 2px 8px; border-radius: 8px;
|
||
}
|
||
.cat-badge.CR { background: rgba(239,68,68,.2); color: var(--rb-cr); }
|
||
.cat-badge.EN { background: rgba(249,115,22,.2); color: var(--rb-en); }
|
||
.cat-badge.VU { background: rgba(234,179,8,.2); color: var(--rb-vu); }
|
||
.cat-badge.NT { background: rgba(34,197,94,.2); color: var(--rb-nt); }
|
||
.cat-badge.LC { background: rgba(59,130,246,.2); color: var(--rb-lc); }
|
||
.card-group-icon { font-size: 16px; }
|
||
|
||
/* ── Modal ───────────────────────────────────────────────────────────── */
|
||
.rb-modal-backdrop {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,.8);
|
||
z-index: 1000; display: none; align-items: center; justify-content: center;
|
||
padding: 20px; backdrop-filter: blur(4px);
|
||
}
|
||
.rb-modal-backdrop.open { display: flex; }
|
||
.rb-modal {
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
border-radius: 20px; width: 100%; max-width: 820px; max-height: 90vh;
|
||
overflow: hidden; display: flex; flex-direction: column;
|
||
animation: modalIn .25s both;
|
||
}
|
||
@keyframes modalIn {
|
||
from { opacity: 0; transform: scale(.95); }
|
||
to { opacity: 1; transform: scale(1); }
|
||
}
|
||
.modal-top {
|
||
display: grid; grid-template-columns: 280px 1fr;
|
||
min-height: 280px;
|
||
}
|
||
@media (max-width: 600px) { .modal-top { grid-template-columns: 1fr; } }
|
||
.modal-visual {
|
||
background: #0d2210; position: relative;
|
||
display: flex; align-items: center; justify-content: center;
|
||
min-height: 200px;
|
||
}
|
||
.modal-visual canvas { width: 100%; height: 100%; position: absolute; inset: 0; }
|
||
.modal-visual .modal-icon { font-size: 72px; position: relative; z-index: 1; }
|
||
.modal-header { padding: 24px 24px 16px; }
|
||
.modal-cat-badge {
|
||
display: inline-block; font-size: 11px; font-weight: 800;
|
||
letter-spacing: 1px; padding: 3px 12px; border-radius: 10px; margin-bottom: 10px;
|
||
}
|
||
.modal-name { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 800; margin: 0 0 4px; color: #fff; }
|
||
.modal-be { font-size: 13px; color: var(--rb-muted); font-style: italic; margin: 0 0 4px; }
|
||
.modal-lat { font-size: 12px; color: var(--rb-muted); font-style: italic; margin: 0 0 14px; }
|
||
.modal-meta { display: flex; gap: 12px; flex-wrap: wrap; }
|
||
.meta-pill {
|
||
display: flex; align-items: center; gap: 5px;
|
||
background: rgba(255,255,255,.05); border: 1px solid var(--rb-border);
|
||
font-size: 11px; color: var(--rb-muted); padding: 4px 10px; border-radius: 8px;
|
||
}
|
||
.modal-body { flex: 1; overflow-y: auto; padding: 0 24px 24px; }
|
||
.modal-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--rb-border); margin-bottom: 20px; padding-top: 16px; }
|
||
.modal-tab {
|
||
padding: 8px 16px; font-size: 13px; font-weight: 600; color: var(--rb-muted);
|
||
cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||
transition: all .15s;
|
||
}
|
||
.modal-tab.active { color: var(--rb-accent); border-color: var(--rb-accent); }
|
||
.modal-tab-content { display: none; }
|
||
.modal-tab-content.active { display: block; }
|
||
.modal-desc { font-size: 14px; line-height: 1.8; color: #c8e6ce; }
|
||
.fact-box {
|
||
background: rgba(74,222,128,.08); border-left: 3px solid var(--rb-accent);
|
||
padding: 12px 16px; border-radius: 0 8px 8px 0; margin-top: 16px;
|
||
}
|
||
.fact-box p { margin: 0; font-size: 13px; color: var(--rb-accent); line-height: 1.6; }
|
||
.threats-list { list-style: none; padding: 0; margin: 0; }
|
||
.threats-list li {
|
||
display: flex; align-items: flex-start; gap: 10px;
|
||
padding: 10px 0; border-bottom: 1px solid var(--rb-border); font-size: 13px; color: #c8e6ce;
|
||
}
|
||
.threats-list li:last-child { border-bottom: none; }
|
||
.threat-icon { color: var(--rb-cr); flex-shrink: 0; margin-top: 1px; }
|
||
.regions-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||
.region-badge {
|
||
background: rgba(74,222,128,.1); border: 1px solid var(--rb-border);
|
||
color: var(--rb-accent); font-size: 11px; font-weight: 700;
|
||
padding: 4px 12px; border-radius: 8px;
|
||
}
|
||
.food-web-section { margin-top: 16px; }
|
||
.fw-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--rb-muted); margin: 0 0 8px; }
|
||
.fw-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.fw-chip {
|
||
display: flex; align-items: center; gap: 6px;
|
||
background: rgba(255,255,255,.05); border: 1px solid var(--rb-border);
|
||
font-size: 12px; padding: 4px 10px; border-radius: 8px; cursor: pointer;
|
||
}
|
||
.fw-chip:hover { border-color: var(--rb-accent); color: var(--rb-accent); }
|
||
.trend-chart { width: 100%; height: 120px; position: relative; margin-top: 16px; }
|
||
.trend-svg { width: 100%; height: 100%; }
|
||
.modal-footer {
|
||
padding: 16px 24px;
|
||
border-top: 1px solid var(--rb-border);
|
||
display: flex; gap: 12px; align-items: center;
|
||
}
|
||
.btn-collect {
|
||
flex: 1; background: var(--rb-accent); color: #0a1a0d;
|
||
font-weight: 700; font-size: 14px; padding: 12px;
|
||
border: none; border-radius: 10px; cursor: pointer; transition: all .2s;
|
||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||
}
|
||
.btn-collect:hover { filter: brightness(1.1); }
|
||
.btn-collect.already { background: var(--rb-border); color: var(--rb-muted); cursor: default; }
|
||
.btn-close-modal {
|
||
background: transparent; border: 1px solid var(--rb-border);
|
||
color: var(--rb-muted); padding: 12px 20px; border-radius: 10px;
|
||
cursor: pointer; font-size: 13px; font-weight: 600;
|
||
}
|
||
.btn-close-modal:hover { border-color: var(--rb-muted); color: var(--rb-text); }
|
||
.modal-xp-badge {
|
||
background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3);
|
||
color: #fde047; font-size: 12px; font-weight: 700; padding: 6px 14px; border-radius: 8px;
|
||
}
|
||
|
||
/* ── Toast notifications ─────────────────────────────────────────────── */
|
||
#rb-toasts {
|
||
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
||
display: flex; flex-direction: column; gap: 10px; pointer-events: none;
|
||
}
|
||
.rb-toast {
|
||
display: flex; align-items: center; gap: 10px;
|
||
background: #0d2210; border: 1px solid var(--rb-border);
|
||
color: var(--rb-text); font-size: 13px; font-weight: 600;
|
||
padding: 12px 18px; border-radius: 12px;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
||
pointer-events: auto;
|
||
animation: toastIn .3s both;
|
||
}
|
||
.rb-toast.success { border-color: rgba(74,222,128,.4); }
|
||
.rb-toast.error { border-color: rgba(239,68,68,.4); color: #fca5a5; }
|
||
.rb-toast.info { border-color: rgba(59,130,246,.4); }
|
||
.rb-toast.out { animation: toastOut .3s both; }
|
||
@keyframes toastIn { from { opacity:0; transform:translateX(20px); } to { opacity:1; transform:translateX(0); } }
|
||
@keyframes toastOut { from { opacity:1; transform:translateX(0); } to { opacity:0; transform:translateX(20px); } }
|
||
|
||
/* ── Empty state ─────────────────────────────────────────────────────── */
|
||
.rb-empty {
|
||
grid-column: 1/-1; text-align: center; padding: 60px 20px;
|
||
color: var(--rb-muted);
|
||
}
|
||
.rb-empty .empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||
.rb-empty p { font-size: 14px; }
|
||
|
||
/* ── Quests section ──────────────────────────────────────────────────── */
|
||
.quests-section { margin-bottom: 28px; }
|
||
.quests-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||
.quests-header h3 { font-family: 'Unbounded', sans-serif; font-size: 15px; font-weight: 700; margin: 0; }
|
||
.quests-header .q-sub { font-size: 11px; color: var(--rb-muted); margin-left: 8px; }
|
||
.quests-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 4px; }
|
||
.quest-card {
|
||
flex-shrink: 0; width: 240px;
|
||
background: var(--rb-surface); border: 1px solid var(--rb-border);
|
||
border-radius: 14px; padding: 16px; cursor: pointer;
|
||
transition: all .2s; position: relative; overflow: hidden;
|
||
}
|
||
.quest-card:hover { border-color: var(--rb-accent); transform: translateY(-2px); }
|
||
.quest-card.completed { border-color: rgba(74,222,128,.35); opacity: .8; }
|
||
.quest-card::before {
|
||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||
background: linear-gradient(90deg, var(--qcolor, #4ade80), transparent);
|
||
}
|
||
.quest-icon { font-size: 24px; margin-bottom: 8px; display: block; }
|
||
.quest-title { font-size: 13px; font-weight: 700; color: #fff; margin: 0 0 4px; }
|
||
.quest-desc { font-size: 11px; color: var(--rb-muted); margin: 0 0 12px; line-height: 1.5; }
|
||
.quest-progress-bar { height: 4px; background: var(--rb-border); border-radius: 2px; overflow: hidden; margin-bottom: 6px; }
|
||
.quest-progress-fill { height: 100%; border-radius: 2px; transition: width .5s; }
|
||
.quest-progress-text { display: flex; justify-content: space-between; font-size: 10px; color: var(--rb-muted); }
|
||
.quest-xp-badge { display: inline-block; background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3); color: #fde047; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
|
||
.quest-done-badge { display: inline-block; background: rgba(74,222,128,.15); border: 1px solid rgba(74,222,128,.3); color: var(--rb-accent); font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; }
|
||
|
||
/* Quest drawer */
|
||
.quest-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 800; display: none; }
|
||
.quest-drawer-backdrop.open { display: block; }
|
||
.quest-drawer {
|
||
position: fixed; right: 0; top: 0; bottom: 0; width: 380px;
|
||
background: var(--rb-surface); border-left: 1px solid var(--rb-border);
|
||
z-index: 801; overflow-y: auto; padding: 28px 24px;
|
||
transform: translateX(100%); transition: transform .25s;
|
||
}
|
||
.quest-drawer-backdrop.open .quest-drawer { transform: translateX(0); }
|
||
.quest-drawer h3 { font-family: 'Unbounded', sans-serif; font-size: 16px; font-weight: 800; margin: 0 0 6px; }
|
||
.quest-drawer p { font-size: 13px; color: var(--rb-muted); margin: 0 0 20px; line-height: 1.6; }
|
||
.quest-species-list { display: flex; flex-direction: column; gap: 8px; }
|
||
.qsp-row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
background: rgba(255,255,255,.03); border: 1px solid var(--rb-border);
|
||
border-radius: 10px; padding: 10px 12px; cursor: pointer; transition: border-color .15s;
|
||
}
|
||
.qsp-row:hover { border-color: var(--rb-accent); }
|
||
.qsp-row.done { opacity: .55; }
|
||
.qsp-icon { font-size: 20px; }
|
||
.qsp-name { font-size: 13px; font-weight: 600; flex: 1; }
|
||
.qsp-check { color: var(--rb-accent); font-size: 16px; }
|
||
.qsp-lock { color: var(--rb-muted); font-size: 14px; }
|
||
.drawer-close-btn {
|
||
background: transparent; border: 1px solid var(--rb-border);
|
||
color: var(--rb-muted); padding: 8px 18px; border-radius: 8px;
|
||
cursor: pointer; font-size: 12px; font-weight: 600; margin-top: 20px;
|
||
}
|
||
.drawer-start-btn {
|
||
background: var(--rb-accent); color: #0a1a0d;
|
||
font-weight: 700; font-size: 13px; padding: 10px 20px;
|
||
border: none; border-radius: 10px; cursor: pointer; margin-top: 16px; width: 100%;
|
||
}
|
||
|
||
/* ── Daily card ──────────────────────────────────────────────────────── */
|
||
.daily-card {
|
||
background: linear-gradient(135deg, #0d2210, #1a3a20);
|
||
border: 1px solid var(--rb-border); border-radius: 16px;
|
||
padding: 20px 24px; margin-bottom: 28px;
|
||
display: flex; gap: 20px; align-items: center; cursor: pointer;
|
||
transition: all .2s;
|
||
}
|
||
.daily-card:hover { border-color: var(--rb-accent); }
|
||
.daily-badge-wrap { flex-shrink: 0; }
|
||
.daily-badge {
|
||
background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3);
|
||
color: #fde047; font-size: 10px; font-weight: 800; letter-spacing: 1.5px;
|
||
text-transform: uppercase; padding: 4px 10px; border-radius: 8px; display: inline-block; margin-bottom: 8px;
|
||
}
|
||
.daily-icon { font-size: 40px; display: block; }
|
||
.daily-info { flex: 1; min-width: 0; }
|
||
.daily-info h4 { margin: 0 0 4px; font-size: 15px; font-weight: 700; color: #fff; }
|
||
.daily-info p { margin: 0; font-size: 12px; color: var(--rb-muted); line-height: 1.5; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||
.daily-xp { flex-shrink: 0; background: rgba(234,179,8,.15); border: 1px solid rgba(234,179,8,.3); color: #fde047; font-weight: 700; font-size: 13px; padding: 8px 16px; border-radius: 10px; }
|
||
|
||
/* ── Sightings feed ──────────────────────────────────────────────────── */
|
||
.sight-item {
|
||
background: rgba(255,255,255,.03); border: 1px solid var(--rb-border);
|
||
border-radius: 10px; padding: 12px;
|
||
}
|
||
.sight-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||
.sight-user { font-size: 12px; font-weight: 700; color: var(--rb-text); }
|
||
.sight-date { font-size: 11px; color: var(--rb-muted); margin-left: auto; }
|
||
.sight-region { font-size: 11px; background: rgba(74,222,128,.1); color: var(--rb-accent); padding: 2px 8px; border-radius: 6px; }
|
||
.sight-desc { font-size: 13px; color: #c8e6ce; line-height: 1.5; }
|
||
.sight-verified { font-size: 10px; background: rgba(59,130,246,.15); color: #60a5fa; padding: 2px 8px; border-radius: 6px; }
|
||
|
||
/* ── Section header ──────────────────────────────────────────────────── */
|
||
.section-header {
|
||
display: flex; align-items: center; gap: 12px; margin-bottom: 20px;
|
||
}
|
||
.section-header h2 { font-family: 'Unbounded', sans-serif; font-size: 18px; font-weight: 700; margin: 0; }
|
||
.section-header .count { background: var(--rb-border); color: var(--rb-muted); font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 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()" title="Свернуть меню"><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 active"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
|
||
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
|
||
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" 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"><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">
|
||
|
||
<!-- HERO -->
|
||
<section class="rb-hero">
|
||
<canvas id="hero-canvas"></canvas>
|
||
<div class="hero-overlay"></div>
|
||
<div class="hero-content">
|
||
<div class="hero-badge">Республика Беларусь · Официальный список</div>
|
||
<h1 class="hero-title">Красная книга<br><span>Беларуси</span></h1>
|
||
<p class="hero-subtitle"><span id="hero-threatened">—</span> видов под угрозой. Исследуйте, открывайте, защищайте.</p>
|
||
<div class="hero-btns">
|
||
<a href="#gallery" class="btn-rb-primary" onclick="scrollToGallery()"><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> Исследовать виды</a>
|
||
</div>
|
||
</div>
|
||
<div class="hero-scroll" onclick="scrollToGallery()">
|
||
<div class="hero-scroll-arrow"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- MAIN -->
|
||
<div class="rb-main" id="gallery">
|
||
|
||
<!-- Daily species -->
|
||
<div class="daily-card" id="daily-card" onclick="openDailySpecies()">
|
||
<div class="daily-badge-wrap">
|
||
<span class="daily-badge">Вид дня</span>
|
||
<span class="daily-icon" id="daily-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="daily-info">
|
||
<h4 id="daily-name">Загрузка...</h4>
|
||
<p id="daily-fact"></p>
|
||
</div>
|
||
<div class="daily-xp">+20 XP</div>
|
||
</div>
|
||
|
||
<!-- Quests -->
|
||
<div class="quests-section" id="quests-section" style="display:none">
|
||
<div class="quests-header">
|
||
<div>
|
||
<h3><svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> Квесты<span class="q-sub" id="quests-sub"></span></h3>
|
||
</div>
|
||
<a href="javascript:void(0)" onclick="openQuestDrawer(null)" style="font-size:12px;color:var(--rb-muted);text-decoration:none;">Все квесты <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></a>
|
||
</div>
|
||
<div class="quests-row" id="quests-row">
|
||
<!-- filled by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats -->
|
||
<div class="stats-bar">
|
||
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-cr)"></div><span class="n" id="stat-cr">—</span> CR</div>
|
||
<div class="sep"></div>
|
||
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-en)"></div><span class="n" id="stat-en">—</span> EN</div>
|
||
<div class="sep"></div>
|
||
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-vu)"></div><span class="n" id="stat-vu">—</span> VU</div>
|
||
<div class="sep"></div>
|
||
<div class="stat-chip"><div class="stat-dot" style="background:var(--rb-nt)"></div><span class="n" id="stat-nt">—</span> NT</div>
|
||
<div class="sep"></div>
|
||
<div class="stat-chip" style="color:var(--rb-muted)">Всего <span class="n" id="stat-total">—</span></div>
|
||
<div class="collection-progress" id="collection-progress-wrap">
|
||
<div class="progress-label"><span>Ваша коллекция</span><span id="progress-text">0 / 0</span></div>
|
||
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="progress-fill" style="width:0%"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Two-column layout -->
|
||
<div class="rb-layout">
|
||
|
||
<!-- MAP PANEL -->
|
||
<div class="map-panel">
|
||
<h3>Ареал по регионам</h3>
|
||
<div class="by-map">
|
||
<!-- Simplified SVG map of Belarus regions -->
|
||
<svg viewBox="0 0 300 260" xmlns="http://www.w3.org/2000/svg" id="belarus-svg">
|
||
<!-- Витебская -->
|
||
<path class="region-path" data-region="vitebsk" data-name="Витебская область"
|
||
d="M60,20 L180,20 L200,30 L200,90 L160,100 L140,95 L100,100 L60,90 Z"/>
|
||
<!-- Минская -->
|
||
<path class="region-path" data-region="minsk" data-name="Минская область"
|
||
d="M60,90 L100,100 L140,95 L160,100 L170,130 L150,155 L100,160 L60,150 L50,120 Z"/>
|
||
<!-- Гродненская -->
|
||
<path class="region-path" data-region="grodno" data-name="Гродненская область"
|
||
d="M10,80 L60,90 L50,120 L60,150 L30,170 L10,150 L5,110 Z"/>
|
||
<!-- Брестская -->
|
||
<path class="region-path" data-region="brest" data-name="Брестская область"
|
||
d="M10,150 L30,170 L60,150 L100,160 L90,210 L60,230 L20,220 L5,180 Z"/>
|
||
<!-- Гомельская -->
|
||
<path class="region-path" data-region="gomel" data-name="Гомельская область"
|
||
d="M100,160 L150,155 L200,165 L240,190 L230,240 L160,250 L90,240 L90,210 Z"/>
|
||
<!-- Могилёвская -->
|
||
<path class="region-path" data-region="mogilev" data-name="Могилёвская область"
|
||
d="M160,100 L200,90 L250,100 L260,140 L240,190 L200,165 L150,155 L170,130 Z"/>
|
||
</svg>
|
||
<div class="region-tooltip" id="region-tooltip"></div>
|
||
</div>
|
||
<div class="map-legend">
|
||
<div class="legend-item"><div class="legend-dot" style="background:#2d6a3f"></div>Выбранный</div>
|
||
<div class="legend-item"><div class="legend-dot" style="background:#1e3523"></div>Без фильтра</div>
|
||
</div>
|
||
<!-- Region species count labels filled by JS -->
|
||
<div id="region-stats" style="margin-top:16px; display:flex; flex-direction:column; gap:6px;"></div>
|
||
</div>
|
||
|
||
<!-- GALLERY -->
|
||
<div>
|
||
<!-- Groups filter -->
|
||
<div class="filters-bar" id="groups-filter"></div>
|
||
|
||
<!-- Category + search filter -->
|
||
<div class="filters-bar">
|
||
<div class="filter-chip cat-chip CR" data-cat="CR" onclick="toggleCat('CR')">CR</div>
|
||
<div class="filter-chip cat-chip EN" data-cat="EN" onclick="toggleCat('EN')">EN</div>
|
||
<div class="filter-chip cat-chip VU" data-cat="VU" onclick="toggleCat('VU')">VU</div>
|
||
<div class="filter-chip cat-chip NT" data-cat="NT" onclick="toggleCat('NT')">NT</div>
|
||
<div class="search-wrap">
|
||
<span class="search-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||
<input type="text" id="search-input" placeholder="Поиск по названию..." oninput="debouncedSearch()"/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-header">
|
||
<h2>Виды</h2>
|
||
<span class="count" id="species-count">—</span>
|
||
<button onclick="openRandomSpecies()" style="margin-left:auto;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 14px;border-radius:20px;cursor:pointer;transition:all .15s;" onmouseover="this.style.borderColor='var(--rb-accent)';this.style.color='var(--rb-accent)'" onmouseout="this.style.borderColor='var(--rb-border)';this.style.color='var(--rb-muted)'"><svg class="ic" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5"/><path d="M16 8h.01M8 8h.01M8 16h.01M16 16h.01M12 12h.01"/></svg> Случайный вид</button>
|
||
</div>
|
||
|
||
<div class="species-grid" id="species-grid">
|
||
<!-- filled by JS -->
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- TOASTS -->
|
||
<div id="rb-toasts"></div>
|
||
|
||
<!-- QUEST DRAWER -->
|
||
<div class="quest-drawer-backdrop" id="quest-drawer-bd" onclick="closeQuestDrawer(event)">
|
||
<div class="quest-drawer" id="quest-drawer">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
|
||
<span id="qd-icon" style="font-size:32px"><svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg></span>
|
||
<span id="qd-status-badge"></span>
|
||
</div>
|
||
<h3 id="qd-title">—</h3>
|
||
<p id="qd-desc"></p>
|
||
<div style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;" id="qd-meta"></div>
|
||
<div id="quest-species-list" class="quest-species-list"></div>
|
||
<button class="drawer-start-btn" id="qd-start-btn" onclick="startCurrentQuest()" style="display:none"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Начать квест</button>
|
||
<button class="drawer-close-btn" onclick="closeQuestDrawer()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MODAL -->
|
||
<div class="rb-modal-backdrop" id="modal-backdrop" onclick="closeModalOnBackdrop(event)">
|
||
<div class="rb-modal" id="rb-modal">
|
||
<div class="modal-top">
|
||
<div class="modal-visual" id="modal-visual">
|
||
<span class="modal-icon" id="modal-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="modal-header">
|
||
<div class="modal-cat-badge" id="modal-cat-badge">VU</div>
|
||
<h2 class="modal-name" id="modal-name">—</h2>
|
||
<p class="modal-be" id="modal-be"></p>
|
||
<p class="modal-lat" id="modal-lat"></p>
|
||
<div class="modal-meta" id="modal-meta"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="modal-tabs">
|
||
<div class="modal-tab active" onclick="switchTab('desc')">Описание</div>
|
||
<div class="modal-tab" onclick="switchTab('threats')">Угрозы</div>
|
||
<div class="modal-tab" onclick="switchTab('area')">Ареал</div>
|
||
<div class="modal-tab" onclick="switchTab('web')">Пищевая сеть</div>
|
||
<div class="modal-tab" onclick="switchTab('trend')">Динамика</div>
|
||
<div class="modal-tab" onclick="switchTab('sightings')"><svg class="ic" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Наблюдения</div>
|
||
</div>
|
||
<div class="modal-tab-content active" id="tab-desc">
|
||
<p class="modal-desc" id="modal-desc"></p>
|
||
<div class="fact-box"><p id="modal-fact"></p></div>
|
||
</div>
|
||
<div class="modal-tab-content" id="tab-threats">
|
||
<ul class="threats-list" id="modal-threats"></ul>
|
||
<div style="margin-top:16px; font-size:13px; color:var(--rb-muted);" id="modal-conservation"></div>
|
||
<div style="margin-top:12px; font-size:13px; color:var(--rb-muted);" id="modal-where"></div>
|
||
</div>
|
||
<div class="modal-tab-content" id="tab-area">
|
||
<p style="font-size:13px; color:var(--rb-muted); margin-bottom:12px;">Регионы присутствия:</p>
|
||
<div class="regions-grid" id="modal-regions"></div>
|
||
</div>
|
||
<div class="modal-tab-content" id="tab-web">
|
||
<div class="food-web-section" id="modal-fw-prey"></div>
|
||
<div class="food-web-section" id="modal-fw-pred" style="margin-top:16px;"></div>
|
||
</div>
|
||
<div class="modal-tab-content" id="tab-trend">
|
||
<div class="trend-chart">
|
||
<svg class="trend-svg" id="trend-svg" viewBox="0 0 400 120" preserveAspectRatio="none"></svg>
|
||
</div>
|
||
<p style="font-size:11px; color:var(--rb-muted); margin-top:8px;" id="trend-source"></p>
|
||
</div>
|
||
<div class="modal-tab-content" id="tab-sightings">
|
||
<!-- Sighting form -->
|
||
<div style="background:rgba(74,222,128,.06);border:1px solid var(--rb-border);border-radius:12px;padding:16px;margin-bottom:16px;">
|
||
<p style="font-size:13px;font-weight:700;margin:0 0 10px;color:#fff;">Добавить наблюдение</p>
|
||
<select id="sight-region" style="width:100%;background:var(--rb-surface);border:1px solid var(--rb-border);color:var(--rb-text);font-size:13px;padding:8px 12px;border-radius:8px;outline:none;margin-bottom:8px;font-family:inherit;">
|
||
<option value="">— Регион (необязательно) —</option>
|
||
<option value="vitebsk">Витебская</option>
|
||
<option value="minsk">Минская</option>
|
||
<option value="grodno">Гродненская</option>
|
||
<option value="brest">Брестская</option>
|
||
<option value="gomel">Гомельская</option>
|
||
<option value="mogilev">Могилёвская</option>
|
||
</select>
|
||
<textarea id="sight-desc" placeholder="Описание наблюдения..." style="width:100%;background:var(--rb-surface);border:1px solid var(--rb-border);color:var(--rb-text);font-size:13px;padding:8px 12px;border-radius:8px;outline:none;resize:vertical;min-height:60px;font-family:inherit;box-sizing:border-box;margin-bottom:8px;"></textarea>
|
||
<button onclick="submitSighting()" style="background:var(--rb-accent);color:#0a1a0d;font-weight:700;font-size:13px;padding:8px 18px;border:none;border-radius:8px;cursor:pointer;">Отправить</button>
|
||
</div>
|
||
<!-- Sightings feed -->
|
||
<div id="sightings-feed" style="display:flex;flex-direction:column;gap:10px;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-collect" id="btn-collect" onclick="collectCurrent()"><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> Открыть (+XP)</button>
|
||
<span class="modal-xp-badge" id="modal-xp-badge">+30 XP</span>
|
||
<button class="btn-close-modal" onclick="closeModal()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</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 src="/js/sidebar.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
<script>
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
State
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
let state = {
|
||
groups: [], species: [], mapData: [],
|
||
filters: { group_id: null, category: null, region: null, q: '' },
|
||
current: null, dailySpecies: null,
|
||
collectedIds: new Set(),
|
||
};
|
||
|
||
const REGION_NAMES = {
|
||
vitebsk: 'Витебская', minsk: 'Минская', grodno: 'Гродненская',
|
||
brest: 'Брестская', gomel: 'Гомельская', mogilev: 'Могилёвская',
|
||
};
|
||
const XP_MAP = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Init
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
async function init() {
|
||
lucide.createIcons();
|
||
const feats = await LS.loadFeatures().catch(() => ({}));
|
||
if (feats.red_book === false && LS.getUser()?.role !== 'admin') { window.location.replace('/403'); return; }
|
||
LS.hideDisabledFeatures?.();
|
||
|
||
// Auth (sidebar)
|
||
const user = LS.getUser?.() || null;
|
||
if (user) {
|
||
document.getElementById('nav-user').textContent = user.name?.split(' ')[0] || 'Профиль';
|
||
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
|
||
}
|
||
|
||
// Sidebar collapse
|
||
if (localStorage.getItem('ls_sb_collapsed') === '1') {
|
||
document.getElementById('app').classList.add('sb-collapsed');
|
||
}
|
||
|
||
// Load data in parallel
|
||
const [groups, statsRes, mapData, daily] = await Promise.all([
|
||
LS.get('/api/red-book/groups').catch(() => []),
|
||
LS.get('/api/red-book/stats').catch(() => ({})),
|
||
LS.get('/api/red-book/map-data').catch(() => []),
|
||
LS.get('/api/red-book/daily').catch(() => null),
|
||
]);
|
||
state.groups = groups;
|
||
state.mapData = mapData;
|
||
state.dailySpecies = daily;
|
||
|
||
// Stats bar
|
||
document.getElementById('stat-cr').textContent = statsRes.cr || 0;
|
||
document.getElementById('stat-en').textContent = statsRes.en || 0;
|
||
document.getElementById('stat-vu').textContent = statsRes.vu || 0;
|
||
document.getElementById('hero-threatened').textContent = (statsRes.cr || 0) + (statsRes.en || 0) + (statsRes.vu || 0);
|
||
document.getElementById('stat-nt').textContent = statsRes.nt || 0;
|
||
document.getElementById('stat-total').textContent = statsRes.total || 0;
|
||
|
||
const collected = statsRes.collected || 0;
|
||
const total = statsRes.total || 1;
|
||
document.getElementById('progress-text').textContent = `${collected} / ${total}`;
|
||
document.getElementById('progress-fill').style.width = `${Math.round(collected/total*100)}%`;
|
||
|
||
// Daily card
|
||
if (daily) {
|
||
document.getElementById('daily-icon').innerHTML = daily.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('daily-name').textContent = daily.name_ru;
|
||
document.getElementById('daily-fact').textContent = daily.interesting_fact || daily.description?.slice(0,100)+'…';
|
||
}
|
||
|
||
// Groups filter
|
||
renderGroupsFilter();
|
||
|
||
// Map
|
||
renderMap();
|
||
|
||
// Load species
|
||
await loadSpecies();
|
||
|
||
// Load quests after species so state.collectedIds is ready
|
||
loadQuests();
|
||
|
||
// Three.js hero
|
||
initHero();
|
||
|
||
// Deep-link: ?species=ID
|
||
const deepSpecies = new URLSearchParams(location.search).get('species');
|
||
if (deepSpecies) openSpecies(Number(deepSpecies));
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Groups filter
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function renderGroupsFilter() {
|
||
const el = document.getElementById('groups-filter');
|
||
el.innerHTML = `<div class="filter-chip ${!state.filters.group_id?'active':''}" onclick="setGroup(null)">Все группы</div>`;
|
||
state.groups.forEach(g => {
|
||
el.innerHTML += `<div class="filter-chip ${state.filters.group_id===g.id?'active':''}" onclick="setGroup(${g.id})">
|
||
<span class="chip-icon">${g.icon}</span><span class="sb-lbl">${g.name_ru}</span>
|
||
<span style="opacity:.5">${g.n}</span>
|
||
</div>`;
|
||
});
|
||
}
|
||
function setGroup(id) {
|
||
state.filters.group_id = id;
|
||
renderGroupsFilter();
|
||
loadSpecies();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Category filter
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function toggleCat(cat) {
|
||
state.filters.category = state.filters.category === cat ? null : cat;
|
||
document.querySelectorAll('.cat-chip').forEach(el => {
|
||
el.classList.toggle('active', el.dataset.cat === state.filters.category);
|
||
});
|
||
loadSpecies();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Search
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
let searchTimer;
|
||
function debouncedSearch() {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => {
|
||
state.filters.q = document.getElementById('search-input').value.trim();
|
||
loadSpecies();
|
||
}, 300);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Load & render species
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
async function loadSpecies() {
|
||
const p = new URLSearchParams();
|
||
if (state.filters.group_id) p.set('group_id', state.filters.group_id);
|
||
if (state.filters.category) p.set('category', state.filters.category);
|
||
if (state.filters.region) p.set('region', state.filters.region);
|
||
if (state.filters.q) p.set('q', state.filters.q);
|
||
p.set('limit', '100');
|
||
|
||
const data = await LS.get(`/api/red-book/species?${p}`).catch(() => ({ species: [] }));
|
||
state.species = data.species || [];
|
||
|
||
// update collection set
|
||
state.collectedIds = new Set(state.species.filter(s => s.collected).map(s => s.id));
|
||
|
||
renderGrid();
|
||
renderQuestCards();
|
||
}
|
||
|
||
function renderGrid() {
|
||
const grid = document.getElementById('species-grid');
|
||
const sp = state.species;
|
||
document.getElementById('species-count').textContent = sp.length;
|
||
|
||
if (!sp.length) {
|
||
grid.innerHTML = `<div class="rb-empty"><div class="empty-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></div><p>Ничего не найдено. Измените фильтры.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
grid.innerHTML = sp.map((s, i) => {
|
||
const locked = !s.collected;
|
||
return `<div class="species-card ${locked?'locked':''}" style="--i:${i%20}" onclick="openSpecies(${s.id})">
|
||
<div class="card-photo">
|
||
${s.photo_url ? `<img src="${s.photo_url}" alt="${s.name_ru}" loading="lazy"/>` : `<span class="card-icon">${s.group_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>`}
|
||
${locked ? '<div class="card-lock"><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>
|
||
<div class="card-body">
|
||
<div class="card-name">${s.name_ru}</div>
|
||
<div class="card-lat">${s.name_lat || ''}</div>
|
||
<div class="card-footer">
|
||
<span class="cat-badge ${s.category}">${s.category}</span>
|
||
<span class="card-group-icon">${s.group_icon||''}</span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Map
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function renderMap() {
|
||
const regionMap = {};
|
||
state.mapData.forEach(r => regionMap[r.region_code] = r);
|
||
|
||
document.querySelectorAll('.region-path').forEach(path => {
|
||
const code = path.dataset.region;
|
||
const info = regionMap[code] || { total: 0, cr: 0, en: 0 };
|
||
path.dataset.total = info.total;
|
||
path.dataset.cr = info.cr;
|
||
path.dataset.en = info.en;
|
||
|
||
// Color intensity by total species
|
||
const intensity = Math.min(info.total / 30, 1);
|
||
const g = Math.round(26 + intensity * 60);
|
||
path.style.fill = `rgb(13,${g},16)`;
|
||
|
||
const tooltip = document.getElementById('region-tooltip');
|
||
path.addEventListener('mouseenter', e => {
|
||
tooltip.style.opacity = '1';
|
||
tooltip.innerHTML = `<b>${path.dataset.name}</b><br>Видов: ${info.total} · CR:${info.cr} EN:${info.en}`;
|
||
});
|
||
path.addEventListener('mousemove', e => {
|
||
const rect = path.closest('.by-map').getBoundingClientRect();
|
||
tooltip.style.left = (e.clientX - rect.left + 8) + 'px';
|
||
tooltip.style.top = (e.clientY - rect.top - 30) + 'px';
|
||
});
|
||
path.addEventListener('mouseleave', () => tooltip.style.opacity = '0');
|
||
path.addEventListener('click', () => toggleRegion(code, path));
|
||
});
|
||
|
||
// Region stats sidebar
|
||
const statsEl = document.getElementById('region-stats');
|
||
statsEl.innerHTML = state.mapData.map(r =>
|
||
`<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--rb-muted)">
|
||
<span>${REGION_NAMES[r.region_code]||r.region_code}</span>
|
||
<span style="font-weight:700;color:var(--rb-text)">${r.total}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
function toggleRegion(code, path) {
|
||
if (state.filters.region === code) {
|
||
state.filters.region = null;
|
||
document.querySelectorAll('.region-path').forEach(p => p.classList.remove('selected'));
|
||
} else {
|
||
state.filters.region = code;
|
||
document.querySelectorAll('.region-path').forEach(p => p.classList.remove('selected'));
|
||
path.classList.add('selected');
|
||
}
|
||
loadSpecies();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Open species modal
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
async function openSpecies(id) {
|
||
const sp = await LS.get(`/api/red-book/species/${id}`).catch(() => null);
|
||
if (!sp) return;
|
||
state.current = sp;
|
||
|
||
// visual
|
||
document.getElementById('modal-icon').innerHTML = sp.group_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>';
|
||
|
||
// category badge
|
||
const badge = document.getElementById('modal-cat-badge');
|
||
const catColors = { CR:'#ef4444', EN:'#f97316', VU:'#eab308', NT:'#22c55e', LC:'#3b82f6' };
|
||
badge.textContent = `${sp.category} · КК РБ ${sp.by_category}`;
|
||
badge.style.background = (catColors[sp.category] || '#22c55e') + '22';
|
||
badge.style.color = catColors[sp.category] || '#22c55e';
|
||
badge.style.border = `1px solid ${catColors[sp.category] || '#22c55e'}55`;
|
||
|
||
document.getElementById('modal-name').textContent = sp.name_ru;
|
||
document.getElementById('modal-be').textContent = sp.name_be || '';
|
||
document.getElementById('modal-lat').textContent = sp.name_lat || '';
|
||
|
||
const MONTH_ABBR = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
||
const seasons = sp.season_active ? JSON.parse(sp.season_active || '[]') : [];
|
||
const seasonHtml = seasons.length
|
||
? `<div class="meta-pill" title="Сезон активности"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> ${seasons.map(m => MONTH_ABBR[parseInt(m)-1]).join(' · ')}</div>`
|
||
: '';
|
||
|
||
document.getElementById('modal-meta').innerHTML = [
|
||
sp.habitat_name ? `<div class="meta-pill">${sp.habitat_type === 'forest' ? '<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>' : sp.habitat_type === 'wetland' ? '<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>' : sp.habitat_type === 'river' ? '<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>' : '<svg class="ic" viewBox="0 0 24 24"><path d="M7 20h10"/><path d="M10 20c5.5-2.5.8-6.4 3-10"/><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/><path d="M14.1 6a7 7 0 0 1 1.5 4.7c-1.7 1.3-3 1.4-4 1.1a3 3 0 0 1-1.8-1.9c.8-2 2.3-3.3 4.3-3.9z"/></svg>'} ${sp.habitat_name}</div>` : '',
|
||
sp.group_name ? `<div class="meta-pill">${sp.group_icon} ${sp.group_name}</div>` : '',
|
||
sp.biomass_kg ? `<div class="meta-pill"><svg class="ic" viewBox="0 0 24 24"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10M12 3v18M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg> ${sp.biomass_kg >= 1 ? sp.biomass_kg + ' кг' : (sp.biomass_kg*1000).toFixed(0) + ' г'}</div>` : '',
|
||
seasonHtml,
|
||
].join('');
|
||
|
||
// tabs content
|
||
document.getElementById('modal-desc').textContent = sp.description || '';
|
||
document.getElementById('modal-fact').textContent = sp.interesting_fact || '';
|
||
|
||
// threats
|
||
const threats = sp.threats || [];
|
||
document.getElementById('modal-threats').innerHTML = threats.map(t =>
|
||
`<li><span class="threat-icon"><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></span>${t}</li>`
|
||
).join('') || '<li><span class="threat-icon">—</span>Данные отсутствуют</li>';
|
||
document.getElementById('modal-conservation').innerHTML = sp.conservation ? '<svg class="ic" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> ' + sp.conservation : '';
|
||
document.getElementById('modal-where').innerHTML = sp.where_to_see ? '<svg class="ic" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> ' + sp.where_to_see : '';
|
||
|
||
// regions
|
||
document.getElementById('modal-regions').innerHTML = (sp.regions || []).map(r =>
|
||
`<span class="region-badge">${REGION_NAMES[r] || r}</span>`
|
||
).join('') || '<span style="color:var(--rb-muted)">Данные отсутствуют</span>';
|
||
|
||
// food web
|
||
const preyEl = document.getElementById('modal-fw-prey');
|
||
if (sp.prey?.length) {
|
||
preyEl.innerHTML = `<p class="fw-label">Охотится на</p><div class="fw-list">${sp.prey.map(p => `<div class="fw-chip" onclick="closeModal();openSpecies(${p.id})">${p.group_icon||''}${p.name_ru}</div>`).join('')}</div>`;
|
||
} else { preyEl.innerHTML = ''; }
|
||
const predEl = document.getElementById('modal-fw-pred');
|
||
if (sp.predators?.length) {
|
||
predEl.innerHTML = `<p class="fw-label">Хищники</p><div class="fw-list">${sp.predators.map(p => `<div class="fw-chip" onclick="closeModal();openSpecies(${p.id})">${p.group_icon||''}${p.name_ru}</div>`).join('')}</div>`;
|
||
} else { predEl.innerHTML = ''; }
|
||
|
||
// trend chart
|
||
renderTrendChart(sp.population_trend || sp.population_data || []);
|
||
|
||
// collect button
|
||
const btnCollect = document.getElementById('btn-collect');
|
||
const xp = XP_MAP[sp.category] || 20;
|
||
document.getElementById('modal-xp-badge').textContent = `+${xp} XP`;
|
||
if (sp.collected || state.collectedIds.has(sp.id)) {
|
||
btnCollect.className = 'btn-collect already';
|
||
btnCollect.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Уже в коллекции';
|
||
btnCollect.disabled = true;
|
||
} else {
|
||
btnCollect.className = 'btn-collect';
|
||
btnCollect.innerHTML = `<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> Открыть (+${xp} XP)`;
|
||
btnCollect.disabled = false;
|
||
}
|
||
|
||
// Switch to first tab
|
||
switchTab('desc');
|
||
document.getElementById('modal-backdrop').classList.add('open');
|
||
}
|
||
|
||
function renderTrendChart(data) {
|
||
const svg = document.getElementById('trend-svg');
|
||
const pts = Array.isArray(data) ? data : [];
|
||
if (!pts.length) { svg.innerHTML = '<text x="200" y="60" text-anchor="middle" fill="#6b9a74" font-size="12">Нет данных</text>'; return; }
|
||
const years = pts.map(p => p.year || p.year);
|
||
const counts = pts.map(p => p.count || p.count_estimate || 0);
|
||
const minY = Math.min(...counts), maxY = Math.max(...counts) || 1;
|
||
const minX = Math.min(...years), maxX = Math.max(...years) || minX + 1;
|
||
const W = 400, H = 100, PAD = 20;
|
||
|
||
const px = y => PAD + (y - minX) / (maxX - minX) * (W - PAD*2);
|
||
const py = c => (H - PAD) - (c - minY) / (maxY - minY) * (H - PAD*2);
|
||
|
||
const polyline = pts.map((p,i) => `${px(p.year||years[i])},${py(p.count||p.count_estimate||0)}`).join(' ');
|
||
const fill = pts.map((p,i) => `${px(p.year||years[i])},${py(p.count||p.count_estimate||0)}`).join(' ')
|
||
+ ` ${px(maxX)},${H-PAD} ${px(minX)},${H-PAD}`;
|
||
|
||
const color = counts[counts.length-1] > counts[0] ? '#4ade80' : '#ef4444';
|
||
svg.innerHTML = `
|
||
<polygon points="${fill}" fill="${color}22"/>
|
||
<polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="2"/>
|
||
${pts.map((p,i) => `<circle cx="${px(p.year||years[i])}" cy="${py(p.count||p.count_estimate||0)}" r="3" fill="${color}"/>
|
||
<text x="${px(p.year||years[i])}" y="${H}" fill="#6b9a74" font-size="9" text-anchor="middle">${p.year||years[i]}</text>
|
||
`).join('')}
|
||
`;
|
||
document.getElementById('trend-source').textContent = pts[pts.length-1]?.source ? `Источник: ${pts[pts.length-1].source}` : '';
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Toast
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function showToast(msg, type = 'success', duration = 3000) {
|
||
const wrap = document.getElementById('rb-toasts');
|
||
const t = document.createElement('div');
|
||
t.className = `rb-toast ${type}`;
|
||
t.innerHTML = msg;
|
||
wrap.appendChild(t);
|
||
setTimeout(() => {
|
||
t.classList.add('out');
|
||
t.addEventListener('animationend', () => t.remove());
|
||
}, duration);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Random species
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function openRandomSpecies() {
|
||
const sp = state.species;
|
||
if (!sp.length) return;
|
||
const s = sp[Math.floor(Math.random() * sp.length)];
|
||
openSpecies(s.id);
|
||
}
|
||
|
||
async function collectCurrent() {
|
||
if (!state.current) return;
|
||
const sp = state.current;
|
||
if (sp.collected || state.collectedIds.has(sp.id)) return;
|
||
|
||
const token = LS.getToken?.() || localStorage.getItem('ls_token');
|
||
if (!token) { showToast('Войдите в систему, чтобы открывать виды', 'error'); return; }
|
||
|
||
const res = await LS.post(`/api/red-book/species/${sp.id}/collect`, { method: 'explore' }).catch(e => ({ error: e.message }));
|
||
if (res.error && !res.collected) { showToast(res.error, 'error'); return; }
|
||
|
||
state.collectedIds.add(sp.id);
|
||
sp.collected = true;
|
||
const btn = document.getElementById('btn-collect');
|
||
btn.className = 'btn-collect already';
|
||
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;
|
||
showToast(`<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> ${sp.name_ru} добавлен в коллекцию! +${res.xp_earned || 20} XP`, 'success');
|
||
// Server-side completed quests
|
||
(res.completed_quests || []).forEach(q => {
|
||
showToast(`<svg class="ic" viewBox="0 0 24 24"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></svg> Квест выполнен: «${q.title}»! +${q.xp_reward} XP`, 'success', 5000);
|
||
});
|
||
if (res.completed_quests?.length) await loadQuests();
|
||
else checkQuestProgress();
|
||
// Update grid card
|
||
loadSpecies();
|
||
}
|
||
|
||
function openDailySpecies() {
|
||
if (state.dailySpecies?.id) openSpecies(state.dailySpecies.id);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Quests
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
let questState = { quests: [], drawerQuest: null };
|
||
const QUEST_ICONS = ['<svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg>','<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>','<svg class="ic" viewBox="0 0 24 24"><path d="M16 7h.01"/><path d="M3.4 18H12a8 8 0 0 0 8-8V7a4 4 0 0 0-7.28-2.3L2 20"/><path d="m20 7 2 .5-2 .5"/><path d="M10 18v3M14 17.75v3.25"/><path d="M7 18a6 6 0 0 0 3.84-10.61"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M12 22c-4 0-8-2-10-6 0-4 2-8 5-10l2 4 2-2 1 3 2-4c3 2 5 6 5 10-2 4-6 5-7 5z"/></svg>','<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>','<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>','<svg class="ic" viewBox="0 0 24 24"><path d="M4.5 13.5C4 18.538 7.582 21 12 21s8-2.462 8-5.5"/><path d="M4.5 13.5c-1.62-2.808-.16-6.83 3.5-8.5M19.5 13.5c1.62-2.808.16-6.83-3.5-8.5"/><circle cx="8" cy="8" r="2"/><circle cx="16" cy="8" r="2"/></svg>','<svg class="ic" viewBox="0 0 24 24"><path d="M17 9c-2 1-3 3-3 5v4M7 9c2 1 3 3 3 5v4M5 9C2 9 2 7 2 6s1-3 3-3h14c2 0 3 2 3 3s0 3-3 3M7 21h10"/></svg>'];
|
||
const QUEST_COLORS = ['#4ade80','#f97316','#3b82f6','#a855f7','#22d3ee','#eab308'];
|
||
|
||
async function loadQuests() {
|
||
const quests = await LS.get('/api/red-book/quests').catch(() => []);
|
||
questState.quests = quests;
|
||
|
||
const section = document.getElementById('quests-section');
|
||
if (!quests.length) { section.style.display = 'none'; return; }
|
||
section.style.display = 'block';
|
||
|
||
const active = quests.filter(q => q.user_status === 'active').length;
|
||
const completed = quests.filter(q => q.user_status === 'completed').length;
|
||
document.getElementById('quests-sub').textContent = ` · ${completed}/${quests.length} выполнено`;
|
||
|
||
renderQuestCards();
|
||
}
|
||
|
||
function renderQuestCards() {
|
||
const row = document.getElementById('quests-row');
|
||
const qs = questState.quests;
|
||
row.innerHTML = qs.map((q, i) => {
|
||
const ids = q.species_ids || [];
|
||
const collectedInQ = ids.filter(id => state.collectedIds.has(id)).length;
|
||
const pct = ids.length ? Math.round(collectedInQ / ids.length * 100) : 0;
|
||
const color = QUEST_COLORS[i % QUEST_COLORS.length];
|
||
const icon = QUEST_ICONS[i % QUEST_ICONS.length];
|
||
const done = q.user_status === 'completed';
|
||
return `<div class="quest-card ${done?'completed':''}" style="--qcolor:${color}" onclick="openQuestDrawer(${q.id})">
|
||
<span class="quest-icon">${icon}</span>
|
||
<div class="quest-title">${q.title}</div>
|
||
<div class="quest-desc">${q.description}</div>
|
||
<div class="quest-progress-bar"><div class="quest-progress-fill" style="width:${pct}%;background:${color}"></div></div>
|
||
<div class="quest-progress-text">
|
||
<span>${collectedInQ} / ${ids.length} видов</span>
|
||
${done ? '<span class="quest-done-badge"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выполнен</span>' : `<span class="quest-xp-badge">+${q.xp_reward} XP</span>`}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function openQuestDrawer(questId) {
|
||
const q = questId ? questState.quests.find(q => q.id === questId) : questState.quests[0];
|
||
if (!q) return;
|
||
questState.drawerQuest = q;
|
||
|
||
const ids = q.species_ids || [];
|
||
const i = questState.quests.indexOf(q);
|
||
const color = QUEST_COLORS[i % QUEST_COLORS.length];
|
||
const icon = QUEST_ICONS[i % QUEST_ICONS.length];
|
||
|
||
document.getElementById('qd-icon').innerHTML = icon;
|
||
document.getElementById('qd-title').textContent = q.title;
|
||
document.getElementById('qd-desc').textContent = q.description;
|
||
|
||
const done = q.user_status === 'completed';
|
||
const statusBadge = document.getElementById('qd-status-badge');
|
||
statusBadge.innerHTML = done
|
||
? '<span style="background:rgba(74,222,128,.15);border:1px solid rgba(74,222,128,.3);color:#4ade80;font-size:11px;font-weight:700;padding:3px 10px;border-radius:8px"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выполнен</span>'
|
||
: q.user_status === 'active'
|
||
? '<span style="background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);color:#60a5fa;font-size:11px;font-weight:700;padding:3px 10px;border-radius:8px">В прогрессе</span>'
|
||
: '';
|
||
|
||
document.getElementById('qd-meta').innerHTML = `
|
||
<div style="background:rgba(234,179,8,.15);border:1px solid rgba(234,179,8,.3);color:#fde047;font-size:11px;font-weight:700;padding:4px 12px;border-radius:8px">+${q.xp_reward} XP</div>
|
||
<div style="background:rgba(255,255,255,.05);border:1px solid var(--rb-border);font-size:11px;color:var(--rb-muted);padding:4px 12px;border-radius:8px">${ids.length} видов</div>
|
||
`;
|
||
|
||
const startBtn = document.getElementById('qd-start-btn');
|
||
startBtn.style.display = (q.user_status === 'locked' || q.user_status === 'unknown') ? 'block' : 'none';
|
||
|
||
// Species list
|
||
const listEl = document.getElementById('quest-species-list');
|
||
if (ids.length) {
|
||
// Try to match with known species
|
||
const speciesMap = {};
|
||
state.species.forEach(s => speciesMap[s.id] = s);
|
||
listEl.innerHTML = ids.map(id => {
|
||
const sp = speciesMap[id];
|
||
const done = state.collectedIds.has(id);
|
||
return `<div class="qsp-row ${done?'done':''}" onclick="closeQuestDrawer();openSpecies(${id})">
|
||
<span class="qsp-icon">${sp?.group_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="qsp-name">${sp?.name_ru || `Вид #${id}`}</span>
|
||
${done ? '<span class="qsp-check"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : '<span class="qsp-lock"><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></span>'}
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
listEl.innerHTML = '<p style="color:var(--rb-muted);font-size:13px">Нет видов в квесте</p>';
|
||
}
|
||
|
||
document.getElementById('quest-drawer-bd').classList.add('open');
|
||
}
|
||
|
||
function closeQuestDrawer(e) {
|
||
if (e && e.target !== document.getElementById('quest-drawer-bd')) return;
|
||
document.getElementById('quest-drawer-bd').classList.remove('open');
|
||
questState.drawerQuest = null;
|
||
}
|
||
|
||
async function startCurrentQuest() {
|
||
const q = questState.drawerQuest;
|
||
if (!q) return;
|
||
await LS.post(`/api/red-book/quests/${q.id}/start`, {}).catch(() => {});
|
||
q.user_status = 'active';
|
||
document.getElementById('qd-start-btn').style.display = 'none';
|
||
const _badge = document.getElementById('qd-status-badge');
|
||
if (_badge) _badge.innerHTML = '<span style="background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);color:#60a5fa;font-size:11px;font-weight:700;padding:3px 10px;border-radius:8px">В прогрессе</span>';
|
||
renderQuestCards();
|
||
showToast('<svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> Квест начат!', 'success');
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Sightings
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
const REGION_LABELS = {
|
||
vitebsk: 'Витебская', minsk: 'Минская', grodno: 'Гродненская',
|
||
brest: 'Брестская', gomel: 'Гомельская', mogilev: 'Могилёвская',
|
||
};
|
||
|
||
async function loadSightings(speciesId) {
|
||
const feed = document.getElementById('sightings-feed');
|
||
feed.innerHTML = '<p style="color:var(--rb-muted);font-size:13px">Загрузка...</p>';
|
||
const list = await LS.get(`/api/red-book/sightings?species_id=${speciesId}`).catch(() => []);
|
||
if (!list.length) {
|
||
feed.innerHTML = '<p style="color:var(--rb-muted);font-size:13px">Наблюдений пока нет. Будьте первым!</p>';
|
||
return;
|
||
}
|
||
const fmt = dt => new Date(dt).toLocaleDateString('ru', { day:'2-digit', month:'short', year:'numeric' });
|
||
feed.innerHTML = list.map(s => `
|
||
<div class="sight-item">
|
||
<div class="sight-header">
|
||
<span class="sight-user"><svg class="ic" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> ${s.user_name}</span>
|
||
${s.region_code ? `<span class="sight-region">${REGION_LABELS[s.region_code] || s.region_code}</span>` : ''}
|
||
${s.confirmed_by_teacher ? '<span class="sight-verified"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Подтверждено</span>' : ''}
|
||
<span class="sight-date">${fmt(s.created_at)}</span>
|
||
</div>
|
||
${s.description ? `<div class="sight-desc">${s.description}</div>` : ''}
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function submitSighting() {
|
||
if (!state.current) return;
|
||
const token = LS.getToken?.() || localStorage.getItem('ls_token');
|
||
if (!token) { showToast('Войдите в систему', 'error'); return; }
|
||
|
||
const region = document.getElementById('sight-region').value;
|
||
const desc = document.getElementById('sight-desc').value.trim();
|
||
const res = await LS.post('/api/red-book/sightings', {
|
||
species_id: state.current.id,
|
||
region_code: region,
|
||
description: desc,
|
||
}).catch(e => ({ error: e.message }));
|
||
|
||
if (res.error) { showToast(res.error, 'error'); return; }
|
||
document.getElementById('sight-desc').value = '';
|
||
document.getElementById('sight-region').value = '';
|
||
showToast('<svg class="ic" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Наблюдение добавлено!', 'success');
|
||
loadSightings(state.current.id);
|
||
}
|
||
|
||
function checkQuestProgress() {
|
||
questState.quests.forEach(q => {
|
||
if (q.user_status !== 'active') return;
|
||
const ids = q.species_ids || [];
|
||
const done = ids.every(id => state.collectedIds.has(id));
|
||
if (done && q.user_status !== 'completed') {
|
||
q.user_status = 'completed';
|
||
showToast(`<svg class="ic" viewBox="0 0 24 24"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> Квест выполнен: «${q.title}»! +${q.xp_reward} XP`, 'success', 5000);
|
||
}
|
||
});
|
||
renderQuestCards();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Modal tabs
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function switchTab(name) {
|
||
document.querySelectorAll('.modal-tab').forEach((t,i) => {
|
||
const tabNames = ['desc','threats','area','web','trend','sightings'];
|
||
t.classList.toggle('active', tabNames[i] === name);
|
||
});
|
||
document.querySelectorAll('.modal-tab-content').forEach(c => {
|
||
c.classList.toggle('active', c.id === 'tab-'+name);
|
||
});
|
||
if (name === 'sightings' && state.current) loadSightings(state.current.id);
|
||
}
|
||
function closeModal() {
|
||
document.getElementById('modal-backdrop').classList.remove('open');
|
||
state.current = null;
|
||
}
|
||
function closeModalOnBackdrop(e) {
|
||
if (e.target === document.getElementById('modal-backdrop')) closeModal();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Scroll helper
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function scrollToGallery() {
|
||
document.getElementById('gallery').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Sidebar toggle
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
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();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════════════
|
||
Three.js Hero Forest Scene
|
||
══════════════════════════════════════════════════════════════════════════ */
|
||
function initHero() {
|
||
const canvas = document.getElementById('hero-canvas');
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.setClearColor(0x0a1a0d);
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.fog = new THREE.FogExp2(0x071209, 0.035);
|
||
|
||
const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 200);
|
||
camera.position.set(0, 2, 18);
|
||
|
||
// Resize
|
||
function onResize() {
|
||
const w = canvas.parentElement.clientWidth, h = canvas.parentElement.clientHeight;
|
||
renderer.setSize(w, h, false);
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
window.addEventListener('resize', onResize);
|
||
onResize();
|
||
|
||
// Lighting
|
||
const ambient = new THREE.AmbientLight(0x0d2e10, 2);
|
||
scene.add(ambient);
|
||
const sun = new THREE.DirectionalLight(0x88ffaa, 1.5);
|
||
sun.position.set(5, 10, 5);
|
||
scene.add(sun);
|
||
const fill = new THREE.PointLight(0x0066ff, 0.3, 50);
|
||
fill.position.set(-10, 5, 0);
|
||
scene.add(fill);
|
||
|
||
// Ground
|
||
const groundGeo = new THREE.PlaneGeometry(80, 80, 1, 1);
|
||
const groundMat = new THREE.MeshLambertMaterial({ color: 0x071a09 });
|
||
const ground = new THREE.Mesh(groundGeo, groundMat);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
scene.add(ground);
|
||
|
||
// Trees
|
||
function makeTree(x, z, scale) {
|
||
const g = new THREE.Group();
|
||
const trunkH = (0.8 + Math.random() * 0.4) * scale;
|
||
const trunk = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.06*scale, 0.1*scale, trunkH, 6),
|
||
new THREE.MeshLambertMaterial({ color: 0x3b2010 })
|
||
);
|
||
trunk.position.y = trunkH / 2;
|
||
g.add(trunk);
|
||
const levels = 3 + Math.floor(Math.random() * 2);
|
||
for (let i = 0; i < levels; i++) {
|
||
const r = (0.7 - i * 0.15) * scale;
|
||
const h = (0.7 - i * 0.08) * scale;
|
||
const col = new THREE.Color().setHSL(0.33, 0.6, 0.1 + i * 0.04);
|
||
const cone = new THREE.Mesh(
|
||
new THREE.ConeGeometry(r, h, 7),
|
||
new THREE.MeshLambertMaterial({ color: col })
|
||
);
|
||
cone.position.y = trunkH + h * 0.4 + i * h * 0.55;
|
||
g.add(cone);
|
||
}
|
||
g.position.set(x, 0, z);
|
||
g.rotation.y = Math.random() * Math.PI * 2;
|
||
return g;
|
||
}
|
||
|
||
for (let i = 0; i < 80; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const radius = 6 + Math.random() * 25;
|
||
const scale = 0.6 + Math.random() * 1.4;
|
||
scene.add(makeTree(Math.cos(angle) * radius, Math.sin(angle) * radius, scale));
|
||
}
|
||
|
||
// Firefly particles
|
||
const COUNT = 300;
|
||
const positions = new Float32Array(COUNT * 3);
|
||
const phases = new Float32Array(COUNT);
|
||
for (let i = 0; i < COUNT; i++) {
|
||
positions[i*3] = (Math.random() - 0.5) * 40;
|
||
positions[i*3+1] = Math.random() * 8;
|
||
positions[i*3+2] = (Math.random() - 0.5) * 40;
|
||
phases[i] = Math.random() * Math.PI * 2;
|
||
}
|
||
const pgeo = new THREE.BufferGeometry();
|
||
pgeo.setAttribute('position', new THREE.BufferAttribute(positions.slice(), 3));
|
||
const pmat = new THREE.PointsMaterial({ color: 0x88ffaa, size: 0.12, transparent: true, opacity: 0.6 });
|
||
const particles = new THREE.Points(pgeo, pmat);
|
||
scene.add(particles);
|
||
|
||
// Animate
|
||
let t = 0;
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
t += 0.008;
|
||
// Slow camera drift
|
||
camera.position.x = Math.sin(t * 0.15) * 2;
|
||
camera.position.y = 2 + Math.sin(t * 0.1) * 0.3;
|
||
camera.lookAt(0, 3, 0);
|
||
|
||
// Firefly flicker + drift
|
||
const pos = pgeo.attributes.position;
|
||
for (let i = 0; i < COUNT; i++) {
|
||
pos.array[i*3+1] += Math.sin(t * 2 + phases[i]) * 0.003;
|
||
pos.array[i*3] += Math.cos(t + phases[i] * 0.5) * 0.002;
|
||
}
|
||
pos.needsUpdate = true;
|
||
pmat.opacity = 0.4 + Math.sin(t * 3) * 0.2;
|
||
|
||
renderer.render(scene, camera);
|
||
}
|
||
animate();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|