11 KiB
Dev Task: Enduro Trails — Фаза 4 «Продвинутый роутинг»
Приоритет: HIGH
Проект: enduro-trails
BRD: BRD_PHASE4.md
Контекст
Фаза 3 реализована: роутинг A→B с альтернативами, статистика, GPX, флажки. Фаза 4 — 3 новых режима роутинга: Разведка, Связка, Красивый маршрут.
Деплой
SSH из контейнера НЕ работает. Используй Node.js ssh2:
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
Запрос:
{ "lon": 37.6, "lat": 55.7, "radius_km": 20 }
Ответ:
{
"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 логика:
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:
<button id="btn-recon" class="map-ctrl-btn" title="Разведка" onclick="toggleReconMode()">📍</button>
⚠️ Конфликт: текущая кнопка геолокации тоже 📍. Заменить геолокацию на иконку 🎯.
Логика:
- Нажал 📍 →
reconMode = true, курсор crosshair, деактивировать другие режимы - Клик на карту → POST
/api/recon, нарисовать круг + показать попап - Круг: GeoJSON Polygon (64 точки) с полупрозрачной заливкой
#ff6600, opacity 0.1 - Попап: статистика
- Кнопки радиуса [20] [50] [100] — перезапрос API
- Выход: повторный клик на 📍 или другой режим
Попап (HTML div):
<div id="recon-panel" style="display:none; position:absolute; bottom:40px; left:12px;
background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
padding:12px; font-size:13px; z-index:5; width:240px;
box-shadow:0 2px 12px rgba(0,0,0,0.15);">
<div style="font-weight:600; color:#e07b00; margin-bottom:8px;">📍 Разведка</div>
<div id="recon-stats"></div>
<div style="margin-top:8px; display:flex; gap:4px;">
<button onclick="setReconRadius(20)" class="recon-radius-btn active">20 км</button>
<button onclick="setReconRadius(50)" class="recon-radius-btn">50 км</button>
<button onclick="setReconRadius(100)" class="recon-radius-btn">100 км</button>
</div>
</div>
Круг на карте (GeoJSON Polygon):
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
Кнопка:
<button id="btn-link" class="map-ctrl-btn" title="Связка" onclick="toggleLinkMode()">🔗</button>
Логика: переиспользует POST /api/route с alternatives=3.
- Нажал 🔗 →
linkMode = true, курсор crosshair - Клик → маркер «1️⃣» (оранжевый)
- Клик → маркер «2️⃣» (оранжевый)
- Автоматический POST
/api/routeс этими двумя точками - Карточки маршрутов (как «Дикий путь»)
Отличия от «Дикого пути»:
- Заголовок панели: «🔗 Связка»
- Маркеры: оранжевые с цифрами 1️⃣ 2️⃣ (не зелёный/красный A/B)
- В карточке акцент: «грунтовая связка N% грунта»
- Нет промежуточных точек (не нужно для связки)
Задача 3: «Красивый маршрут» (F-11) — ПОСЛЕДНЕЙ
Бэкенд: новый endpoint POST /api/scenic
Запрос:
{ "lon": 37.6, "lat": 55.7, "target_km": 150 }
Ответ:
{
"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()
- Радиус поиска POI = target_km × 0.6
- Запрос POI из БД с score:
- water=10, viewpoint=15, ruins=10, peak=12, cave=8, ford=5
- Жадный выбор POI:
- Начать со старта
- На каждом шаге: выбрать POI с max(score / distance)
- Условие: расстояние до POI < remaining_km × 0.5 и > 3 км
- Добавить в маршрут, уменьшить remaining_km
- Максимум 5 POI
- Построить маршрут через все выбранные POI + возврат к старту
- Один OSRM запрос со всеми waypoints
- Проверить дистанцию:
- Если > target_km × 1.3 — убрать последний POI, перестроить
- Если < target_km × 0.5 — добавить POI, перестроить
- Альтернативы (до 3):
- Разделить POI на кластеры по азимуту от старта (4 сектора по 90°)
- Построить маршрут для каждого кластера с POI
- Имена: «Северный», «Восточный», «Южный», «Западный»
Фронт: кнопка 🎨 и UI
Кнопка:
<button id="btn-scenic" class="map-ctrl-btn" title="Красивый маршрут" onclick="toggleScenicMode()">🎨</button>
Панель:
┌─────────────────────────────────┐
│ 🎨 Красивый маршрут │
│ 📍 Точка старта: кликни на карте│
│ 📏 Дистанция: │
│ [50] [100] [150] [200] [___] км│
│ │
│ [Построить маршрут] │
└─────────────────────────────────┘
Карточка маршрута — стандартная + дополнительно:
scenic_poisс иконками (💧👁🏚🔺)- Имя маршрута по направлению
Общие требования
Взаимоисключающие режимы
Активация одного режима деактивирует все остальные:
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):
<button id="btn-scenic" class="map-ctrl-btn" title="Красивый маршрут" onclick="toggleScenicMode()">🎨</button>
<button id="btn-link" class="map-ctrl-btn" title="Связка" onclick="toggleLinkMode()">🔗</button>
<button id="btn-recon" class="map-ctrl-btn" title="Разведка" onclick="toggleReconMode()">📍</button>
Заменить иконку геолокации: 📍 → 🎯 (чтобы не конфликтовала с Разведкой).
Порядок реализации
- Разведка (F-14) — бэкенд
/api/recon+ фронт 📍 - Связка (F-13) — фронт 🔗 (переиспользует
/api/route) - Красивый маршрут (F-11) — бэкенд
/api/scenic+ фронт 🎨 - Деплой + тест
Definition of Done
- POST
/api/reconвозвращает статистику грунтовок и POI в радиусе - Круг на карте + попап со статистикой
- Кнопки радиуса 20/50/100 км работают
- 🔗 «Связка» строит маршрут между двумя точками с оранжевыми маркерами
- 🎨 «Красивый маршрут» строит кольцевой маршрут через POI
- Пресеты дистанции 50/100/150/200 км + ручной ввод
- Режимы взаимоисключающие
- Иконка геолокации заменена 📍→🎯
- Деплой на сервер, health OK