diff --git a/tasks/enduro-trails/DEV_TASK_PHASE4.md b/tasks/enduro-trails/DEV_TASK_PHASE4.md new file mode 100644 index 0000000..24afd1a --- /dev/null +++ b/tasks/enduro-trails/DEV_TASK_PHASE4.md @@ -0,0 +1,284 @@ +# 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 diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index c1d1f2b..f6d70c9 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -374,6 +374,18 @@ class RouteRequest(BaseModel): alternatives: int = 5 +class ReconRequest(BaseModel): + lon: float + lat: float + radius_km: float = 20 + + +class ScenicRequest(BaseModel): + lon: float + lat: float + target_km: float = 100 + + # ─── API endpoints ──────────────────────────────────────────────────────────── @app.get("/api/cache/clear")