Files
wiki/tasks/enduro-trails/DEV_TASK_PHASE4.md
2026-05-04 23:20:01 +03:00

285 lines
11 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<button id="btn-recon" class="map-ctrl-btn" title="Разведка" onclick="toggleReconMode()">📍</button>
```
⚠️ Конфликт: текущая кнопка геолокации тоже 📍. Заменить геолокацию на иконку 🎯.
**Логика:**
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
<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):**
```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
<button id="btn-link" class="map-ctrl-btn" title="Связка" onclick="toggleLinkMode()">🔗</button>
```
**Логика:** переиспользует 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
<button id="btn-scenic" class="map-ctrl-btn" title="Красивый маршрут" onclick="toggleScenicMode()">🎨</button>
```
**Панель:**
```
┌─────────────────────────────────┐
│ 🎨 Красивый маршрут │
│ 📍 Точка старта: кликни на карте│
│ 📏 Дистанция: │
│ [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
<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>
```
Заменить иконку геолокации: 📍 → 🎯 (чтобы не конфликтовала с Разведкой).
---
## Порядок реализации
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