# Dev Task: Enduro Trails — Фаза 4 «Продвинутый роутинг» **Приоритет:** HIGH **Проект:** enduro-trails **BRD:** `BRD_PHASE4.md` --- ## Контекст Фаза 3 реализована: роутинг A→B с альтернативами, статистика, GPX, флажки. Фаза 4 — 3 новых режима роутинга: Разведка, Связка, Красивый маршрут. ## Деплой SSH из контейнера НЕ работает. Используй Node.js ssh2: ```js const { Client } = require('/tmp/node_modules/ssh2'); ``` Шаблон деплоя: `/tmp/deploy_app2.js` (уже работает). Для статики (app.js, app.css, index.html) — SFTP upload в `/home/slin/enduro-trails/prototype/static/`, потом `docker cp` в контейнер + `docker restart`. --- ## Задача 1: «Разведка» (F-14) — ПЕРВАЯ ### Бэкенд: новый endpoint POST `/api/recon` **Запрос:** ```json { "lon": 37.6, "lat": 55.7, "radius_km": 20 } ``` **Ответ:** ```json { "center": { "lon": 37.6, "lat": 55.7 }, "radius_km": 20, "trails": { "total_count": 124, "total_km": 380, "lev12_count": 45, "lev12_km": 120, "lev345_count": 68, "lev345_km": 210, "path_count": 11, "path_km": 50 }, "poi": { "natural=water": 3, "tourism=viewpoint": 2, "historic=ruins": 1, "ford=yes": 12, "natural=peak": 0, "natural=cave_entrance": 0 } } ``` **SQL логика:** ```python import math lat_rad = math.radians(lat) delta_lat = radius_km / 111.0 delta_lon = radius_km / (111.0 * math.cos(lat_rad)) # Trails — агрегация по типам cur.execute(""" SELECT highway_type, track_type, SUM(length_m) as total_m, COUNT(*) as cnt FROM trails WHERE min_lon <= ? AND max_lon >= ? AND min_lat <= ? AND max_lat >= ? AND length_m >= 100 GROUP BY highway_type, track_type """, (lon + delta_lon, lon - delta_lon, lat + delta_lat, lat - delta_lat)) # POI — подсчёт по типам cur.execute(""" SELECT poi_type, COUNT(*) as cnt FROM poi WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ? GROUP BY poi_type """, (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat)) ``` Агрегация trail типов: - `lev12`: highway_type='track' AND track_type IN ('grade1','grade2') - `lev345`: highway_type='track' AND (track_type IN ('grade3','grade4','grade5') OR track_type IS NULL) - `path`: highway_type IN ('path','bridleway') ### Фронт: кнопка 📍 и UI **Кнопка в `#map-controls-br`:** ```html ``` ⚠️ Конфликт: текущая кнопка геолокации тоже 📍. Заменить геолокацию на иконку 🎯. **Логика:** 1. Нажал 📍 → `reconMode = true`, курсор crosshair, деактивировать другие режимы 2. Клик на карту → POST `/api/recon`, нарисовать круг + показать попап 3. Круг: GeoJSON Polygon (64 точки) с полупрозрачной заливкой `#ff6600`, opacity 0.1 4. Попап: статистика 5. Кнопки радиуса [20] [50] [100] — перезапрос API 6. Выход: повторный клик на 📍 или другой режим **Попап (HTML div):** ```html
``` **Круг на карте (GeoJSON Polygon):** ```js function makeCircleGeoJSON(lon, lat, radiusKm) { const coords = []; for (let i = 0; i <= 64; i++) { const a = (2 * Math.PI * i) / 64; const dlat = (radiusKm / 111) * Math.cos(a); const dlon = (radiusKm / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(a); coords.push([lon + dlon, lat + dlat]); } return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] } }; } ``` --- ## Задача 2: «Связка» (F-13) — ВТОРАЯ ### UI **Кнопка:** ```html ``` **Логика:** переиспользует POST `/api/route` с `alternatives=3`. 1. Нажал 🔗 → `linkMode = true`, курсор crosshair 2. Клик → маркер «1️⃣» (оранжевый) 3. Клик → маркер «2️⃣» (оранжевый) 4. Автоматический POST `/api/route` с этими двумя точками 5. Карточки маршрутов (как «Дикий путь») **Отличия от «Дикого пути»:** - Заголовок панели: «🔗 Связка» - Маркеры: оранжевые с цифрами 1️⃣ 2️⃣ (не зелёный/красный A/B) - В карточке акцент: «грунтовая связка N% грунта» - Нет промежуточных точек (не нужно для связки) --- ## Задача 3: «Красивый маршрут» (F-11) — ПОСЛЕДНЕЙ ### Бэкенд: новый endpoint POST `/api/scenic` **Запрос:** ```json { "lon": 37.6, "lat": 55.7, "target_km": 150 } ``` **Ответ:** ```json { "routes": [ { "name": "Северный маршрут", "waypoints": [ {"lon": 37.6, "lat": 55.7, "label": "Старт"}, {"lon": 36.5, "lat": 56.1, "label": "💧 Озеро Сенеж"}, {"lon": 36.8, "lat": 56.3, "label": "👁 Смотровая"}, {"lon": 37.6, "lat": 55.7, "label": "Финиш"} ], "geometry": {"type": "LineString", "coordinates": [...]}, "distance_m": 142000, "duration_s": 17100, "stats": { ... }, "scenic_score": 35, "scenic_pois": [ {"type": "natural=water", "name": "Озеро Сенеж", "lon": 36.5, "lat": 56.1}, {"type": "tourism=viewpoint", "name": "Смотровая", "lon": 36.8, "lat": 56.3} ] } ] } ``` ### Алгоритм `find_scenic_route()` 1. **Радиус поиска POI** = target_km × 0.6 2. **Запрос POI из БД** с score: - water=10, viewpoint=15, ruins=10, peak=12, cave=8, ford=5 3. **Жадный выбор POI:** - Начать со старта - На каждом шаге: выбрать POI с max(score / distance) - Условие: расстояние до POI < remaining_km × 0.5 и > 3 км - Добавить в маршрут, уменьшить remaining_km - Максимум 5 POI 4. **Построить маршрут** через все выбранные POI + возврат к старту - Один OSRM запрос со всеми waypoints 5. **Проверить дистанцию:** - Если > target_km × 1.3 — убрать последний POI, перестроить - Если < target_km × 0.5 — добавить POI, перестроить 6. **Альтернативы (до 3):** - Разделить POI на кластеры по азимуту от старта (4 сектора по 90°) - Построить маршрут для каждого кластера с POI - Имена: «Северный», «Восточный», «Южный», «Западный» ### Фронт: кнопка 🎨 и UI **Кнопка:** ```html ``` **Панель:** ``` ┌─────────────────────────────────┐ │ 🎨 Красивый маршрут │ │ 📍 Точка старта: кликни на карте│ │ 📏 Дистанция: │ │ [50] [100] [150] [200] [___] км│ │ │ │ [Построить маршрут] │ └─────────────────────────────────┘ ``` **Карточка маршрута** — стандартная + дополнительно: - `scenic_pois` с иконками (💧👁🏚🔺) - Имя маршрута по направлению --- ## Общие требования ### Взаимоисключающие режимы Активация одного режима деактивирует все остальные: ```js function deactivateAllModes() { if (routeMode) toggleRouteMode(); if (rulerMode) toggleRuler(); if (markerMode) toggleMarkerMode(); if (reconMode) toggleReconMode(); if (linkMode) toggleLinkMode(); if (scenicMode) toggleScenicMode(); } ``` ### Кнопки Добавить в `#map-controls-br` (в index.html): ```html ``` Заменить иконку геолокации: 📍 → 🎯 (чтобы не конфликтовала с Разведкой). --- ## Порядок реализации 1. **Разведка** (F-14) — бэкенд `/api/recon` + фронт 📍 2. **Связка** (F-13) — фронт 🔗 (переиспользует `/api/route`) 3. **Красивый маршрут** (F-11) — бэкенд `/api/scenic` + фронт 🎨 4. Деплой + тест ## Definition of Done - [ ] POST `/api/recon` возвращает статистику грунтовок и POI в радиусе - [ ] Круг на карте + попап со статистикой - [ ] Кнопки радиуса 20/50/100 км работают - [ ] 🔗 «Связка» строит маршрут между двумя точками с оранжевыми маркерами - [ ] 🎨 «Красивый маршрут» строит кольцевой маршрут через POI - [ ] Пресеты дистанции 50/100/150/200 км + ручной ввод - [ ] Режимы взаимоисключающие - [ ] Иконка геолокации заменена 📍→🎯 - [ ] Деплой на сервер, health OK