Files
wiki/tasks/enduro-trails/DEV_TASK_TERRAIN_JS.md
2026-05-13 00:50:01 +03:00

11 KiB
Raw Blame History

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:
# Через ssh2 — скопировать /home/slin/enduro-trails/prototype/static/app.js 
# в /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.js
  • 1.2 Добавить в конец app.js следующий код:
// ═══════════════════════════════════════════
// 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:
# 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 Проверить что функции на месте:
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-агент