297 lines
11 KiB
Markdown
297 lines
11 KiB
Markdown
# DEV TASK: Terrain JS-логика (баг — функции отсутствуют в app.js)
|
||
|
||
**Статус:** Ready for dev
|
||
**Проект:** enduro-trails
|
||
**Фаза:** 5.4 (фикс)
|
||
|
||
---
|
||
|
||
## Цель
|
||
|
||
> Добавить JS-функции `toggleTerrainPopup()` и `onTerrainCheckbox()` в app.js. Кнопка рельеф должна открывать попап, чекбоксы — включать/выключать слои гипсометрии и отмывки на карте.
|
||
|
||
## Архитектура
|
||
|
||
HTML-разметка (кнопка `#terrain-toggle`, попап `#terrain-popup`, чекбоксы) и CSS уже на месте. Нужно только JS:
|
||
- `toggleTerrainPopup()` — показать/скрыть попап, toggle класс `.active` на кнопке
|
||
- `onTerrainCheckbox()` — добавить/удалить raster source+layer для hypso и hillshade
|
||
- `updateHillshadeAvailability()` — disable чекбокс hillshade на зуме < 10
|
||
- Персистентность через localStorage
|
||
|
||
## Стек
|
||
|
||
- MapLibre GL JS (уже подключён, доступен как `window._map`)
|
||
- Тайлы terrain: `https://openclaw.mva154.duckdns.org/enduro/terrain/hypso/{z}/{x}/{y}.png` и `.../hillshade/{z}/{x}/{y}.png`
|
||
- Формат тайлов: TMS (нужен `scheme: 'tms'` в source)
|
||
- Bounds: `[35, 45, 55, 62]`
|
||
|
||
---
|
||
|
||
## Инфраструктура
|
||
|
||
| Параметр | Значение |
|
||
|----------|----------|
|
||
| Сервер | `slin@82.22.50.71` (пароль: `motoZ@yaz2010`) |
|
||
| Контейнер | `prototype-enduro-trails-1` |
|
||
| Файл | `/home/slin/enduro-trails/prototype/static/app.js` (2625 строк) |
|
||
| URL | `https://openclaw.mva154.duckdns.org/enduro/` |
|
||
| Деплой | SSH → docker cp (БЕЗ рестарта!) |
|
||
|
||
---
|
||
|
||
## Файловая карта
|
||
|
||
| Действие | Файл | Ответственность |
|
||
|----------|------|-----------------|
|
||
| Изменить | `static/app.js` (добавить в конец) | Terrain JS-логика |
|
||
|
||
---
|
||
|
||
## Задачи
|
||
|
||
### Task 1: Добавить terrain JS-функции в app.js
|
||
|
||
**Файлы:**
|
||
- Изменить: `/home/slin/enduro-trails/prototype/static/app.js` — добавить в конец файла
|
||
|
||
**Шаги:**
|
||
|
||
- [ ] **1.1** Скачать актуальный app.js с сервера в workspace:
|
||
|
||
```bash
|
||
# Через ssh2 — скопировать /home/slin/enduro-trails/prototype/static/app.js
|
||
# в /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.js
|
||
```
|
||
|
||
- [ ] **1.2** Добавить в конец app.js следующий код:
|
||
|
||
```javascript
|
||
// ═══════════════════════════════════════════
|
||
// TERRAIN LAYERS (Phase 5.4)
|
||
// ═══════════════════════════════════════════
|
||
|
||
const TERRAIN_BASE_URL = window.location.pathname.replace(/\/[^/]*$/, '') + '/terrain';
|
||
|
||
function toggleTerrainPopup() {
|
||
const popup = document.getElementById('terrain-popup');
|
||
const btn = document.getElementById('terrain-toggle');
|
||
if (!popup || !btn) return;
|
||
|
||
const isVisible = popup.style.display !== 'none';
|
||
popup.style.display = isVisible ? 'none' : 'block';
|
||
btn.classList.toggle('active', !isVisible);
|
||
|
||
// Close on outside click
|
||
if (!isVisible) {
|
||
setTimeout(() => {
|
||
document.addEventListener('click', closeTerrainOnOutside);
|
||
}, 10);
|
||
} else {
|
||
document.removeEventListener('click', closeTerrainOnOutside);
|
||
}
|
||
}
|
||
|
||
function closeTerrainOnOutside(e) {
|
||
const popup = document.getElementById('terrain-popup');
|
||
const btn = document.getElementById('terrain-toggle');
|
||
if (!popup.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
||
popup.style.display = 'none';
|
||
btn.classList.remove('active');
|
||
document.removeEventListener('click', closeTerrainOnOutside);
|
||
}
|
||
}
|
||
|
||
function onTerrainCheckbox() {
|
||
const map = window._map;
|
||
if (!map) return;
|
||
|
||
const hypsoChecked = document.getElementById('terrain-hypso-cb').checked;
|
||
const hillshadeChecked = document.getElementById('terrain-hillshade-cb').checked;
|
||
|
||
// Save state
|
||
localStorage.setItem('terrain-hypso', hypsoChecked ? '1' : '0');
|
||
localStorage.setItem('terrain-hillshade', hillshadeChecked ? '1' : '0');
|
||
|
||
// Update button active state
|
||
const btn = document.getElementById('terrain-toggle');
|
||
btn.classList.toggle('active', hypsoChecked || hillshadeChecked);
|
||
|
||
// Apply layers
|
||
applyTerrainLayer('terrain-hypso', TERRAIN_BASE_URL + '/hypso/{z}/{x}/{y}.png', hypsoChecked, 0.55, 5, 15);
|
||
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15);
|
||
}
|
||
|
||
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
|
||
const map = window._map;
|
||
if (!map) return;
|
||
|
||
const sourceId = id + '-source';
|
||
|
||
if (enabled) {
|
||
// Add source if not exists
|
||
if (!map.getSource(sourceId)) {
|
||
map.addSource(sourceId, {
|
||
type: 'raster',
|
||
tiles: [tileUrl],
|
||
tileSize: 256,
|
||
scheme: 'tms',
|
||
bounds: [35, 45, 55, 62],
|
||
minzoom: minzoom,
|
||
maxzoom: maxzoom
|
||
});
|
||
}
|
||
// Add layer if not exists
|
||
if (!map.getLayer(id)) {
|
||
// Insert before first road/trail layer for correct z-order
|
||
const firstTrailLayer = map.getStyle().layers.find(l =>
|
||
l.id.startsWith('trails-') || l.id.startsWith('poi-')
|
||
);
|
||
map.addLayer({
|
||
id: id,
|
||
type: 'raster',
|
||
source: sourceId,
|
||
paint: {
|
||
'raster-opacity': opacity
|
||
},
|
||
minzoom: minzoom,
|
||
maxzoom: maxzoom
|
||
}, firstTrailLayer ? firstTrailLayer.id : undefined);
|
||
}
|
||
} else {
|
||
// Remove layer and source
|
||
if (map.getLayer(id)) map.removeLayer(id);
|
||
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
||
}
|
||
}
|
||
|
||
function updateHillshadeAvailability() {
|
||
const map = window._map;
|
||
if (!map) return;
|
||
|
||
const zoom = map.getZoom();
|
||
const cb = document.getElementById('terrain-hillshade-cb');
|
||
const hint = document.getElementById('terrain-hillshade-hint');
|
||
const label = cb ? cb.closest('.terrain-checkbox') : null;
|
||
|
||
if (zoom < 10) {
|
||
if (cb) cb.disabled = true;
|
||
if (label) label.classList.add('disabled');
|
||
if (hint) hint.style.display = 'inline';
|
||
} else {
|
||
if (cb) cb.disabled = false;
|
||
if (label) label.classList.remove('disabled');
|
||
if (hint) hint.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function restoreTerrainState() {
|
||
const hypso = localStorage.getItem('terrain-hypso') === '1';
|
||
const hillshade = localStorage.getItem('terrain-hillshade') === '1';
|
||
|
||
const hypsoCb = document.getElementById('terrain-hypso-cb');
|
||
const hillshadeCb = document.getElementById('terrain-hillshade-cb');
|
||
|
||
if (hypsoCb) hypsoCb.checked = hypso;
|
||
if (hillshadeCb) hillshadeCb.checked = hillshade;
|
||
|
||
if (hypso || hillshade) {
|
||
onTerrainCheckbox();
|
||
}
|
||
|
||
// Update button active state
|
||
const btn = document.getElementById('terrain-toggle');
|
||
if (btn) btn.classList.toggle('active', hypso || hillshade);
|
||
}
|
||
|
||
// Hook into map load and zoom changes
|
||
(function initTerrain() {
|
||
const map = window._map;
|
||
if (map) {
|
||
map.on('zoomend', updateHillshadeAvailability);
|
||
map.on('style.load', () => {
|
||
// Re-apply terrain after style change (theme switch)
|
||
setTimeout(restoreTerrainState, 100);
|
||
});
|
||
// Initial state
|
||
updateHillshadeAvailability();
|
||
restoreTerrainState();
|
||
} else {
|
||
// Map not ready yet, wait
|
||
const interval = setInterval(() => {
|
||
if (window._map) {
|
||
clearInterval(interval);
|
||
window._map.on('zoomend', updateHillshadeAvailability);
|
||
window._map.on('style.load', () => {
|
||
setTimeout(restoreTerrainState, 100);
|
||
});
|
||
updateHillshadeAvailability();
|
||
restoreTerrainState();
|
||
}
|
||
}, 500);
|
||
}
|
||
})();
|
||
```
|
||
|
||
- [ ] **1.3** Загрузить на сервер и docker cp:
|
||
|
||
```bash
|
||
# SFTP upload app.js → /home/slin/enduro-trails/prototype/static/app.js
|
||
# Затем:
|
||
docker cp /home/slin/enduro-trails/prototype/static/app.js prototype-enduro-trails-1:/app/static/app.js
|
||
```
|
||
|
||
⚠️ НЕ рестартовать контейнер!
|
||
|
||
- [ ] **1.4** Проверить что функции на месте:
|
||
|
||
```bash
|
||
docker exec prototype-enduro-trails-1 grep -c "toggleTerrainPopup\|onTerrainCheckbox\|applyTerrainLayer" /app/static/app.js
|
||
# Ожидаемый результат: >= 3
|
||
```
|
||
|
||
**Критерий готовности:** Функции `toggleTerrainPopup`, `onTerrainCheckbox`, `applyTerrainLayer`, `updateHillshadeAvailability`, `restoreTerrainState` присутствуют в app.js на сервере.
|
||
|
||
---
|
||
|
||
## Проверка (Acceptance)
|
||
|
||
| # | Проверка | Команда / Действие | Ожидаемый результат |
|
||
|---|----------|-------------------|---------------------|
|
||
| 1 | Функции в app.js | `grep -c toggleTerrainPopup` | >= 1 |
|
||
| 2 | Кнопка открывает попап | Клик по 🏔️ | Попап виден |
|
||
| 3 | Чекбокс hypso | Включить «Гипсометрия» | Цветной слой на карте |
|
||
| 4 | Чекбокс hillshade | Включить «Отмывка» (зум >= 10) | Тени рельефа |
|
||
| 5 | Hillshade disabled | Зум < 10 | Чекбокс неактивен, hint «Зум 10+» |
|
||
| 6 | Попап закрывается | Клик вне попапа | Попап скрыт |
|
||
| 7 | Персистентность | Перезагрузить страницу | Состояние чекбоксов сохранено |
|
||
| 8 | Смена темы | Переключить тему | Terrain слои остаются |
|
||
|
||
---
|
||
|
||
## Ограничения и контекст
|
||
|
||
- ⚠️ docker cp БЕЗ рестарта — рестарт перезапишет статику
|
||
- ⚠️ SSH через Node.js ssh2 модуль
|
||
- ⚠️ Тайлы в формате TMS — обязателен `scheme: 'tms'` в source
|
||
- ⚠️ `bounds: [35, 45, 55, 62]` — без этого MapLibre запрашивает тайлы за пределами региона
|
||
- ⚠️ Terrain слои должны быть ПОД дорогами/POI (beforeId = первый trails/poi layer)
|
||
- ⚠️ После `map.setStyle()` (смена темы) все кастомные source/layer слетают — `restoreTerrainState()` вызывается на `style.load`
|
||
- ⚠️ Hillshade тайлы существуют только для зумов 10-15
|
||
- 🚫 НЕ трогать index.html и app.css — они уже корректны
|
||
|
||
---
|
||
|
||
## Деплой-чеклист
|
||
|
||
- [ ] app.js скачан с сервера (актуальная версия)
|
||
- [ ] Terrain-код добавлен в конец
|
||
- [ ] Загружен обратно на сервер
|
||
- [ ] docker cp выполнен
|
||
- [ ] `grep toggleTerrainPopup` — найдено
|
||
- [ ] Кнопка рельеф работает (попап открывается)
|
||
- [ ] Гипсометрия включается (цветной слой виден)
|
||
|
||
---
|
||
|
||
*Создано: 2026-05-12 | Автор ТЗ: Стрим | Исполнитель: Dev-агент*
|