auto-sync: 2026-05-04 23:20:01

This commit is contained in:
Stream
2026-05-04 23:20:01 +03:00
parent f6c24c4217
commit fe0e709f0d
2 changed files with 296 additions and 0 deletions

View File

@@ -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
<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

View File

@@ -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")