# 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: `` - Или Lucide: `mountain` icon #### Попап ```html
``` #### Логика ```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 не запрашивал тайлы вне региона