285 lines
11 KiB
Markdown
285 lines
11 KiB
Markdown
# 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
|