# 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-агент*