331 lines
11 KiB
Markdown
331 lines
11 KiB
Markdown
# 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 не запрашивал тайлы вне региона
|