auto-sync: 2026-05-09 20:40:01

This commit is contained in:
Stream
2026-05-09 20:40:01 +03:00
parent d0ec715210
commit 92fd3e6778
6 changed files with 633 additions and 2 deletions

View File

@@ -0,0 +1,330 @@
# DEV TASK: Рельеф (Terrain Layer)
**Статус:** Ready for dev
**BRD:** `BRD_TERRAIN.md`
**Проект:** Enduro Trails
**Фаза:** 5.4 (после 5.3, перед 6)
---
## Задачи
### 1. Скачать SRTM данные
**Регион:** ЦФО + Чувашия (примерно 45°N-60°N, 35°E-50°E)
**Способы:**
- NASA Earthdata (нужен аккаунт): https://e4ftl01.cr.usgs.gov/MEASURES/SRTMGL1.003/
- AWS Open Data (публичный, без регистрации): `s3://elevation-tiles-prod/skadi/{N|S}{lat}/{N|S}{lat}{E|W}{lon}.hgt.gz`
- Или EarthExplorer (USGS)
**Формат:** `.hgt.gz` → распаковать в `.hgt`
**Пример скрипта:**
```python
# download_srtm.py
import requests
import os
TILES = [
# ЦФО + Чувашия — список 1°×1° тайлов
(55, 37), (55, 38), (55, 39), (55, 40), # Москва и север
(54, 37), (54, 38), (54, 39), (54, 40), # Тула, Рязань
(53, 38), (53, 39), (53, 40), (53, 41), # Курск, Воронеж
(52, 38), (52, 39), (52, 40), (52, 41), # Белгород
(56, 37), (56, 38), (56, 39), (56, 40), # Ярославль, Кострома
(57, 37), (57, 38), (57, 39), (57, 40), # Вологда
(58, 37), (58, 38), (58, 39), (58, 40), # Архангельск, Коми
(59, 38), (59, 39), (59, 40), (59, 41), # Сыктывкар
(60, 40), (60, 41), (60, 42), # Ухта
(54, 42), (54, 43), (54, 44), (54, 45), # Самара, Ульяновск
(53, 42), (53, 43), (53, 44), (53, 45), # Пенза, Саратов
(52, 42), (52, 43), (52, 44), (52, 45), # Волгоград
(51, 38), (51, 39), (51, 40), (51, 41), # Ростов
(50, 38), (50, 39), (50, 40), (50, 41), # Краснодар (юг)
(55, 47), (55, 48), (55, 49), (55, 50), # Чувашия, Марий Эл
(54, 47), (54, 48), (54, 49), (54, 50), # Татарстан
(56, 47), (56, 48), (56, 49), (56, 50), # Киров
]
BASE_URL = "https://s3.amazonaws.com/elevation-tiles-prod/skadi"
for lat, lon in TILES:
ns = 'N' if lat >= 0 else 'S'
ew = 'E' if lon >= 0 else 'W'
fname = f"{ns}{abs(lat):02d}{ew}{abs(lon):03d}.hgt.gz"
url = f"{BASE_URL}/{ns}{abs(lat):02d}/{fname}"
# download + gunzip
```
**Куда:** `/home/slin/enduro-trails/data/srtm/`
---
### 2. Сгенерировать тайлы
#### 2.1 Цветной рельеф (hypsometric)
**Ramp файл** (`hypso_ramp.txt`):
```
0 45 80 22
50 90 138 58
150 168 198 108
300 212 184 90
500 196 148 32
800 139 90 43
1200 107 68 35
2000 255 255 255
nv 0 0 0 0
```
**Команды GDAL:**
```bash
# Собрать все .hgt в один VRT
gdalbuildvrt srtm.vrt *.hgt
# Цветной рельеф
gdaldem color-relief srtm.vrt hypso_ramp.txt hypso.tif
# Тайлы (gdal2tiles.py или rio-tiler)
# Вариант A: gdal2tiles.py
gdal2tiles.py -z 5-15 --processes=4 hypso.tif terrain_hypso/
# Вариант B: rio-tiler (если GDAL2Tiles медленный)
# Использовать rio-cogeo + rio-tiler для генерации тайлов
```
**Куда:** `/home/slin/enduro-trails/data/terrain/hypso/{z}/{x}/{y}.png`
#### 2.2 Теневой рельеф (hillshade)
```bash
# Hillshade
gdaldem hillshade -az 315 -alt 45 -z 1 srtm.vrt hillshade.tif
# Тайлы
gdal2tiles.py -z 10-15 --processes=4 hillshade.tif terrain_hillshade/
```
**Куда:** `/home/slin/enduro-trails/data/terrain/hillshade/{z}/{x}/{y}.png`
**Важно:** Проверить, что тайлы в формате PNG (не JPEG, нужна прозрачность/opacity контроль в MapLibre).
---
### 3. Настроить nginx
Добавить в конфиг nginx (на сервере `82.22.50.71`):
```nginx
location /enduro/terrain/ {
alias /home/slin/enduro-trails/data/terrain/;
expires 1y;
add_header Cache-Control "public, immutable";
}
```
Перезагрузить nginx.
---
### 4. Frontend: MapLibre layers
В `style.json` (или программно в `app.js`):
```javascript
// Sources
map.addSource('terrain-hypso', {
type: 'raster',
tiles: ['https://openclaw.mva154.duckdns.org/enduro/terrain/hypso/{z}/{x}/{y}.png'],
tileSize: 256,
minzoom: 5,
maxzoom: 15,
bounds: [35, 45, 50, 60] // [west, south, east, north]
});
map.addSource('terrain-hillshade', {
type: 'raster',
tiles: ['https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/{z}/{x}/{y}.png'],
tileSize: 256,
minzoom: 10,
maxzoom: 15,
bounds: [35, 45, 50, 60]
});
// Layers (paint.opacity контролируется через toggle)
map.addLayer({
id: 'terrain-hypso-layer',
type: 'raster',
source: 'terrain-hypso',
layout: { visibility: 'none' }, // по умолчанию выключен
paint: { 'raster-opacity': 0.45 }
});
map.addLayer({
id: 'terrain-hillshade-layer',
type: 'raster',
source: 'terrain-hillshade',
layout: { visibility: 'none' },
paint: { 'raster-opacity': 0.35 }
});
```
**Z-Index:** `terrain-hypso-layer` должен быть ниже дорог, выше background. `terrain-hillshade-layer` — поверх hypso, под дорогами.
---
### 5. Frontend: UI (toolbar + popup)
#### Кнопка в toolbar
- Позиция: после кнопки слоёв (`#layer-toggle`), перед линейкой
- HTML: `<button id="terrain-toggle" class="map-button" title="Рельеф">🏔️</button>`
- Или Lucide: `mountain` icon
#### Попап
```html
<div id="terrain-popup" class="terrain-popup hidden">
<div class="terrain-popup-header">🏔️ Рельеф</div>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-hypso-check">
<span>Цветной рельеф</span>
</label>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-hillshade-check">
<span>Теневой рельеф</span>
<small id="hillshade-hint" class="hint">Доступно при приближении</small>
</label>
</div>
```
#### Логика
```javascript
const terrainState = {
hypso: localStorage.getItem('terrain_hypso') === 'true',
hillshade: localStorage.getItem('terrain_hillshade') === 'true'
};
function updateTerrainLayers() {
const zoom = map.getZoom();
// Hypso — всегда если включено
map.setLayoutProperty('terrain-hypso-layer', 'visibility',
terrainState.hypso ? 'visible' : 'none');
// Hillshade — только если включено И зум >= 10
const hillshadeVisible = terrainState.hillshade && zoom >= 10;
map.setLayoutProperty('terrain-hillshade-layer', 'visibility',
hillshadeVisible ? 'visible' : 'none');
// UI: подсветка кнопки если хоть что-то включено
const btn = document.getElementById('terrain-toggle');
btn.classList.toggle('active', terrainState.hypso || terrainState.hillshade);
// UI: hint для hillshade
const hillshadeCheck = document.getElementById('terrain-hillshade-check');
const hint = document.getElementById('hillshade-hint');
if (zoom < 10) {
hillshadeCheck.disabled = true;
hint.style.display = 'block';
} else {
hillshadeCheck.disabled = false;
hint.style.display = 'none';
}
}
// События
map.on('zoom', updateTerrainLayers);
document.getElementById('terrain-hypso-check').addEventListener('change', (e) => {
terrainState.hypso = e.target.checked;
localStorage.setItem('terrain_hypso', terrainState.hypso);
updateTerrainLayers();
});
document.getElementById('terrain-hillshade-check').addEventListener('change', (e) => {
terrainState.hillshade = e.target.checked;
localStorage.setItem('terrain_hillshade', terrainState.hillshade);
updateTerrainLayers();
});
// Toggle popup
document.getElementById('terrain-toggle').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('terrain-popup').classList.toggle('hidden');
});
// Закрыть по тапу вне
map.on('click', () => {
document.getElementById('terrain-popup').classList.add('hidden');
});
```
#### CSS (добавить в app.css)
```css
.terrain-popup {
position: absolute;
bottom: 70px;
right: 12px;
background: var(--bg-primary);
border-radius: 12px;
padding: 12px 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
z-index: 1000;
min-width: 180px;
}
.terrain-popup.hidden { display: none; }
.terrain-popup-header {
font-weight: 600;
margin-bottom: 10px;
font-size: 15px;
}
.terrain-checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
cursor: pointer;
}
.terrain-checkbox input { margin: 0; }
.terrain-checkbox .hint {
display: none;
color: var(--text-muted);
font-size: 11px;
margin-left: auto;
}
#terrain-toggle.active {
background: var(--accent);
color: white;
}
```
---
### 6. Деплой
1. Сгенерировать тайлы на сервере (или локально + SFTP)
2. Скопировать тайлы в `/home/slin/enduro-trails/data/terrain/`
3. Обновить nginx конфиг, перезагрузить
4. Деплой фронтенда: `deploy_static.js` + `docker cp` после рестарта
5. Проверить: открыть карту, включить рельеф, проверить тайлы в Network tab
---
## Проверка (тест-кейсы)
| # | Действие | Ожидаемый результат |
|---|----------|---------------------|
| 1 | Открыть карту, зум 6, нажать 🏔️, включить «Цветной» | Виден цветной рельеф (зелёный→жёлтый→коричневый) |
| 2 | Зум 12, включить «Теневой» | Появляются тени, овраги объёмные |
| 3 | Зум 8, «Теневой» disabled | Чекбокс серый, hint «Доступно при приближении» |
| 4 | Перезагрузить страницу | Состояние чекбоксов сохранено |
| 5 | Выключить оба слоя | Кнопка 🏔️ не подсвечена |
| 6 | Мобильный: тап вне попапа | Попап закрывается |
---
## Примечания
- **GDAL:** Если нет в контейнере — установить `apt-get install gdal-bin` (или собрать отдельно, контейнер на Debian)
- **Время генерации:** ~30-60 мин на все тайлы (зависит от CPU)
- **Место:** ~150 MB тайлов + ~100 MB .hgt = ~250 MB суммарно
- **Рамп цветов:** Можно скорректировать после теста — в BRD предварительная версия
- **Bounds:** Указать bounds в source, чтобы MapLibre не запрашивал тайлы вне региона

View File

@@ -97,6 +97,7 @@ docker restart prototype-enduro-trails-1
| F-14 | "Разведка" | Грунтовки вокруг точки, статистика по типам, POI | ⏳ Бэклог | 4 |
| F-15 | "Народные треки" | OSM Traces, Wikiloc, Komoot, 4x4travel | ⏳ Бэклог | 8 |
| F-16 | Тёмная тема + редизайн | Две темы (авто/светлая/тёмная), SunCalc, мобильный UI, drag-and-drop точек, расстояние по маршруту | ✅ Готово | 5 |
| F-29 | Рельеф (terrain) | Цветной рельеф (все зумы) + теневой (зум 10+). SRTM 30м, кнопка 🏔️ в toolbar с чекбоксами | 📋 BRD готов | 5.4 |
| F-22 | Линейка UX | Расстояние сегмента под маркером, крестик удаления, зелёный Старт, панель fit-content, toast, toggle скрыть/показать, deleteRuler | ✅ Готово | 5.2 |
| F-23 | Метки UX | Починен баг удаления через попап (popup.remove() перед marker.remove()) | ✅ Готово | 5.2 |
| F-24 | Поиск точек маршрута | Inline Nominatim поиск в каждом wl-item, убран верхний search bar | ✅ Готово | 5.2 |
@@ -203,10 +204,18 @@ docker restart prototype-enduro-trails-1
- `streaming.mode: "off"` в Telegram канале — убраны дублированные сообщения
- `send_voice.sh` — убрана отправка через `openclaw message send`, только генерация OGG + `MEDIA:` директива
### Фаза 6 — SRTM рельеф
### 📋 Фаза 5.4 — Рельеф на карте
**BRD:** `BRD_TERRAIN.md`
**DEV TASK:** `DEV_TASK_TERRAIN.md`
- F-29 Рельеф (terrain) — цветной (все зумы) + теневой (зум 10+)
- SRTM 30м, GDAL, растровые тайлы PNG
- Кнопка 🏔️ в toolbar с чекбоксами, localStorage
- nginx статика `/enduro/terrain/`
### ⏳ Фаза 6 — SRTM рельеф (продвинутый)
- F-12 «Горка» — макс набор высоты, мин дистанция
- Профиль высот на маршруте
- SRTM DEM 30м данные (Public Domain)
- SRTM данные уже есть после фазы 5.4
### ⏳ Фаза 7 — PWA + офлайн
- F-17 Service Worker, офлайн MBTiles, GPS-трекинг в реальном времени
@@ -252,6 +261,8 @@ docker restart prototype-enduro-trails-1
| `BRD_PHASE3.md` | Бизнес-требования Фазы 3 (согласовано) |
| `BRD_PHASE3.1.md` | Бизнес-требования Фазы 3.1 (на согласовании) |
| `BRD_PHASE5.md` | Бизнес-требования Фазы 5 (✅ реализовано) |
| `BRD_TERRAIN.md` | Бизнес-требования Фазы 5.4 — Рельеф |
| `DEV_TASK_TERRAIN.md` | ТЗ для Dev-агента Фаза 5.4 |
| `TEST_CASES_PHASE3.md` | 56 тест-кейсов |
| `DEV_TASK_PHASE3.md` | ТЗ для Dev-агента Фаза 3 |
| `DEV_TASK_PHASE5.md` | ТЗ для Dev-агента Фаза 5 |

View File

@@ -0,0 +1,85 @@
const { Client } = require('ssh2');
const conn = new Client();
conn.on('ready', () => {
console.log('SSH connected');
const downloadScript = [
'#!/bin/bash',
'set -e',
'SRTM_DIR="/home/slin/enduro-trails/data/srtm"',
'mkdir -p "$SRTM_DIR"',
'BASE_URL="https://s3.amazonaws.com/elevation-tiles-prod/skadi"',
'TILES=(',
' "N55E037" "N55E038" "N55E039" "N55E040"',
' "N54E037" "N54E038" "N54E039" "N54E040"',
' "N53E038" "N53E039" "N53E040" "N53E041"',
' "N52E038" "N52E039" "N52E040" "N52E041"',
' "N56E037" "N56E038" "N56E039" "N56E040"',
' "N57E037" "N57E038" "N57E039" "N57E040"',
' "N58E037" "N58E038" "N58E039" "N58E040"',
' "N59E038" "N59E039" "N59E040" "N59E041"',
' "N60E040" "N60E041" "N60E042"',
' "N54E042" "N54E043" "N54E044" "N54E045"',
' "N53E042" "N53E043" "N53E044" "N53E045"',
' "N52E042" "N52E043" "N52E044" "N52E045"',
' "N51E038" "N51E039" "N51E040" "N51E041"',
' "N50E038" "N50E039" "N50E040" "N50E041"',
' "N55E047" "N55E048" "N55E049" "N55E050"',
' "N54E047" "N54E048" "N54E049" "N54E050"',
' "N56E047" "N56E048" "N56E049" "N56E050"',
')',
'echo "Downloading ${TILES[@]} SRTM tiles..."',
'cd "$SRTM_DIR"',
'for tile in "${TILES[@]}"; do',
' lat=${tile:1:2}',
' fname="${tile}.hgt.gz"',
' url="${BASE_URL}/${lat}/${fname}"',
' if [ -f "${tile}.hgt" ]; then',
' echo "SKIP ${tile} (already have .hgt)"',
' continue',
' fi',
' if [ -f "${fname}" ]; then',
' echo "SKIP ${tile} (already downloaded)"',
' continue',
' fi',
' echo "DOWNLOAD ${tile}..."',
' wget --timeout=60 -q "$url" -O "${fname}"',
' if [ -s "${fname}" ]; then',
' echo "EXTRACT ${tile}..."',
' gunzip -f "${fname}"',
' echo "OK ${tile}"',
' else',
' echo "FAIL ${tile} (empty or error)"',
' rm -f "${fname}"',
' fi',
'done',
'echo ""',
'echo "Download complete. Files:"',
'ls -la *.hgt 2>/dev/null | wc -l',
'echo " .hgt files"',
].join('\n');
conn.exec(downloadScript, (err, stream) => {
if (err) { console.error('Exec error:', err); conn.end(); return; }
stream.on('data', d => process.stdout.write(d));
stream.stderr.on('data', d => process.stderr.write(d));
stream.on('close', (code) => {
console.log('\nDownload script exited:', code);
conn.end();
});
});
});
conn.on('error', (err) => {
console.error('SSH error:', err.message);
process.exit(1);
});
conn.connect({
host: '82.22.50.71',
username: 'slin',
password: 'motoZ@yaz2010',
readyTimeout: 30000
});

View File

@@ -0,0 +1,91 @@
const { Client } = require('ssh2');
const TILES = [
'N55E037','N55E038','N55E039','N55E040',
'N54E037','N54E038','N54E039','N54E040',
'N53E038','N53E039','N53E040','N53E041',
'N52E038','N52E039','N52E040','N52E041',
'N56E037','N56E038','N56E039','N56E040',
'N57E037','N57E038','N57E039','N57E040',
'N58E037','N58E038','N58E039','N58E040',
'N59E038','N59E039','N59E040','N59E041',
'N60E040','N60E041','N60E042',
'N54E042','N54E043','N54E044','N54E045',
'N53E042','N53E043','N53E044','N53E045',
'N52E042','N52E043','N52E044','N52E045',
'N51E038','N51E039','N51E040','N51E041',
'N50E038','N50E039','N50E040','N50E041',
'N55E047','N55E048','N55E049','N55E050',
'N54E047','N54E048','N54E049','N54E050',
'N56E047','N56E048','N56E049','N56E050',
];
const conn = new Client();
conn.on('ready', () => {
console.log('SSH connected. Downloading SRTM tiles...');
let completed = 0;
let failed = 0;
function downloadNext(idx) {
if (idx >= TILES.length) {
console.log(`\nAll done. Completed: ${completed}, Failed: ${failed}`);
conn.end();
return;
}
const tile = TILES[idx];
const lat = tile.substring(1, 3);
const url = `https://s3.amazonaws.com/elevation-tiles-prod/skadi/${lat}/${tile}.hgt.gz`;
const remoteHgt = `/home/slin/enduro-trails/data/srtm/${tile}.hgt`;
const remoteGz = `/home/slin/enduro-trails/data/srtm/${tile}.hgt.gz`;
// Check if already exists
conn.exec(`test -f ${remoteHgt} && echo EXISTS || echo MISSING`, (err, stream) => {
if (err) { failed++; downloadNext(idx + 1); return; }
let out = '';
stream.on('data', d => out += d);
stream.on('close', () => {
if (out.trim() === 'EXISTS') {
completed++;
console.log(`[${idx+1}/${TILES.length}] SKIP ${tile} (exists)`);
downloadNext(idx + 1);
return;
}
// Download
conn.exec(`wget --timeout=60 -q "${url}" -O ${remoteGz} && test -s ${remoteGz} && gunzip -f ${remoteGz} && echo OK || (rm -f ${remoteGz}; echo FAIL)`, (err, stream) => {
if (err) { failed++; downloadNext(idx + 1); return; }
let out2 = '';
stream.on('data', d => out2 += d);
stream.on('close', () => {
const result = out2.trim();
if (result === 'OK') {
completed++;
console.log(`[${idx+1}/${TILES.length}] OK ${tile}`);
} else {
failed++;
console.log(`[${idx+1}/${TILES.length}] FAIL ${tile}`);
}
downloadNext(idx + 1);
});
});
});
});
}
downloadNext(0);
});
conn.on('error', (err) => {
console.error('SSH error:', err.message);
process.exit(1);
});
conn.connect({
host: '82.22.50.71',
username: 'slin',
password: 'motoZ@yaz2010',
readyTimeout: 30000
});

View File

@@ -0,0 +1,94 @@
const { Client } = require('ssh2');
const TILES = [
'N55E037','N55E038','N55E039','N55E040',
'N54E037','N54E038','N54E039','N54E040',
'N53E038','N53E039','N53E040','N53E041',
'N52E038','N52E039','N52E040','N52E041',
'N56E037','N56E038','N56E039','N56E040',
'N57E037','N57E038','N57E039','N57E040',
'N58E037','N58E038','N58E039','N58E040',
'N59E038','N59E039','N59E040','N59E041',
'N60E040','N60E041','N60E042',
'N54E042','N54E043','N54E044','N54E045',
'N53E042','N53E043','N53E044','N53E045',
'N52E042','N52E043','N52E044','N52E045',
'N51E038','N51E039','N51E040','N51E041',
'N50E038','N50E039','N50E040','N50E041',
'N55E047','N55E048','N55E049','N55E050',
'N54E047','N54E048','N54E049','N54E050',
'N56E047','N56E048','N56E049','N56E050',
];
const conn = new Client();
conn.on('ready', () => {
console.log('SSH connected. Downloading SRTM tiles sequentially...');
let completed = 0;
let failed = 0;
let skipped = 0;
function downloadNext(idx) {
if (idx >= TILES.length) {
console.log(`\n=== DONE === Completed: ${completed}, Skipped: ${skipped}, Failed: ${failed}`);
conn.end();
return;
}
const tile = TILES[idx];
const lat = tile.substring(1, 3);
const url = `https://s3.amazonaws.com/elevation-tiles-prod/skadi/${lat}/${tile}.hgt.gz`;
const remoteHgt = `/home/slin/enduro-trails/data/srtm/${tile}.hgt`;
const remoteGz = `/home/slin/enduro-trails/data/srtm/${tile}.hgt.gz`;
// Check if already exists
conn.exec(`test -f ${remoteHgt} && echo EXISTS || echo MISSING`, (err, stream) => {
if (err) { failed++; downloadNext(idx + 1); return; }
let out = '';
stream.on('data', d => out += d);
stream.on('close', () => {
if (out.trim() === 'EXISTS') {
skipped++;
console.log(`[${idx+1}/${TILES.length}] SKIP ${tile} (exists)`);
downloadNext(idx + 1);
return;
}
// Download with wget (no -q to see errors)
const cmd = `cd /home/slin/enduro-trails/data/srtm && wget --timeout=60 "${url}" -O ${remoteGz} 2>&1 && test -s ${remoteGz} && gunzip -f ${remoteGz} && echo OK || (rm -f ${remoteGz}; echo FAIL)`;
conn.exec(cmd, (err, stream) => {
if (err) { failed++; downloadNext(idx + 1); return; }
let out2 = '';
stream.on('data', d => out2 += d);
stream.on('close', () => {
const result = out2.trim().split('\n').pop();
if (result === 'OK') {
completed++;
console.log(`[${idx+1}/${TILES.length}] OK ${tile}`);
} else {
failed++;
console.log(`[${idx+1}/${TILES.length}] FAIL ${tile}`);
}
downloadNext(idx + 1);
});
});
});
});
}
downloadNext(0);
});
conn.on('error', (err) => {
console.error('SSH error:', err.message);
process.exit(1);
});
conn.connect({
host: '82.22.50.71',
username: 'slin',
password: 'motoZ@yaz2010',
readyTimeout: 30000
});

View File

@@ -0,0 +1,20 @@
# Dev Report: Рельеф (Terrain Layer)
Дата: 2026-05-09
Статус: IN PROGRESS
## Задача
Реализовать фичу "Рельеф" (Terrain Layer) для Enduro Trails:
1. Скачать SRTM данные для ЦФО + Чувашия
2. Сгенерировать растровые тайлы PNG (hypsometric + hillshade)
3. Настроить nginx для статики
4. Frontend: MapLibre raster sources + layers
5. UI: кнопка 🏔️ в toolbar с попапом и чекбоксами
6. Деплой
## Сделано
- [ ] Прочитаны BRD, DEV_TASK, PROJECT
- [ ] Изучена текущая структура фронтенда (index.html, app.js, app.css)
- [ ] Найдены layer toggle controls (map-controls-r div, layerState в app.js)
## Следующий шаг
Создать отчёт и начать с SSH на сервер для скачивания SRTM + генерации тайлов.