Files
wiki/tasks/enduro-trails/DEV_TASK_TERRAIN.md
2026-05-09 20:40:01 +03:00

11 KiB
Raw Blame History

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

Пример скрипта:

# 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:

# Собрать все .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)

# 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):

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):

// 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

Попап

<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>

Логика

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)

.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 не запрашивал тайлы вне региона