diff --git a/tasks/enduro-trails/DEV_TASK_PHASE3.md b/tasks/enduro-trails/DEV_TASK_PHASE3.md
new file mode 100644
index 0000000..3a64a41
--- /dev/null
+++ b/tasks/enduro-trails/DEV_TASK_PHASE3.md
@@ -0,0 +1,475 @@
+# Dev Task: Enduro Trails — Фаза 3 «Умный маршрут»
+
+**Приоритет:** HIGH
+**Проект:** enduro-trails
+**Сервер:** `slin@82.22.50.71`, пароль: `motoZ@yaz2010`
+**Контейнер приложения:** `prototype-enduro-trails-1`, порт `5558`
+**Контейнер OSRM:** `osrm-osrm-routed-1`, порт `5559`
+**URL:** `https://openclaw.mva154.duckdns.org/enduro/`
+**Workspace:** `/home/node/.openclaw/workspace/tasks/enduro-trails/prototype/`
+**Код на сервере:** `/home/slin/enduro-trails/prototype/`
+**БД:** `/home/slin/enduro-trails/data/centralfederal.sqlite`
+
+---
+
+## Контекст
+
+Прототип работает. Фаза 2 завершена: роутинг A→B, поиск, линейка.
+Фаза 3 — расширение роутинга: альтернативные маршруты, статистика покрытия, человекочитаемое время, промежуточные точки, GPX-экспорт, флажки на карте.
+
+Текущий стек:
+- **Бэкенд:** FastAPI + uvicorn (4 workers), `app.py`
+- **Фронт:** MapLibre GL JS 4.1.3, vanilla JS, `app.js` + `app.css` + `index.html`
+- **OSRM:** контейнер `osrm-osrm-routed-1`, порт 5559, `OSRM_URL=http://172.22.0.1:5559`
+- **БД:** SQLite (Spatialite), таблицы `trails` и `poi`
+
+---
+
+## Задачи
+
+---
+
+### Задача 1: Человекочитаемое время (F-03)
+
+**Файл:** `static/app.js`
+
+Добавить утилитарную функцию `formatDuration(seconds)` и применить везде где отображается время.
+
+```js
+function formatDuration(seconds) {
+ const totalMin = Math.round(seconds / 60);
+ if (totalMin < 60) return totalMin + ' мин';
+ const days = Math.floor(totalMin / 1440);
+ const hours = Math.floor((totalMin % 1440) / 60);
+ const mins = totalMin % 60;
+ if (days > 0) {
+ if (mins === 0) return `${days} дн ${hours} ч`;
+ return `${days} дн ${hours} ч ${mins} мин`;
+ }
+ if (mins === 0) return `${hours} ч`;
+ return `${hours} ч ${mins} мин`;
+}
+```
+
+Применить: заменить `~${p.duration_min} мин` на `formatDuration(p.duration_s)` (бэкенд должен вернуть `duration_s`).
+
+---
+
+### Задача 2: Альтернативные маршруты + статистика (F-01 + F-02)
+
+#### 2.1 Бэкенд — новый endpoint `/api/route` (POST, заменяет GET)
+
+Текущий GET `/api/route` заменить на POST. Принимает JSON:
+
+```json
+{
+ "waypoints": [
+ {"lon": 37.6, "lat": 55.7},
+ {"lon": 39.5, "lat": 56.2}
+ ],
+ "alternatives": 5
+}
+```
+
+Логика:
+1. Собрать строку координат для OSRM: `lon1,lat1;lon2,lat2;...`
+2. Запросить OSRM с `alternatives=5&overview=full&geometries=geojson`
+3. Для каждого маршрута из ответа — вызвать `calc_route_stats(geometry, conn)`
+4. Вернуть массив маршрутов
+
+Функция `calc_route_stats(geometry, conn)`:
+- Принять GeoJSON LineString (список координат `[lon, lat]`)
+- Разбить на сегменты по ~100м (каждые N точек, где N ≈ 100м / средний шаг)
+- Для каждого сегмента взять среднюю точку, найти ближайший трек в БД:
+ ```sql
+ SELECT highway_type, track_type, length_m
+ FROM trails
+ WHERE min_lon <= ? AND max_lon >= ? AND min_lat <= ? AND max_lat >= ?
+ ORDER BY ABS(min_lon - ?) + ABS(min_lat - ?)
+ LIMIT 1
+ ```
+- Суммировать длины по категориям:
+ - `track_lev12`: highway_type='track' AND track_type IN ('grade1','grade2')
+ - `track_lev345`: highway_type='track' AND track_type IN ('grade3','grade4','grade5') или track_type IS NULL
+ - `path`: highway_type IN ('path','bridleway','footway')
+ - `asphalt`: всё остальное (primary, secondary, tertiary, residential, unclassified и т.д.)
+- Вернуть словарь с метрами и процентами
+
+Формат ответа:
+```json
+{
+ "routes": [
+ {
+ "index": 0,
+ "geometry": {"type": "LineString", "coordinates": [...]},
+ "distance_m": 142000,
+ "duration_s": 16500,
+ "stats": {
+ "track_lev12_m": 68000,
+ "track_lev345_m": 42000,
+ "path_m": 12000,
+ "asphalt_m": 20000,
+ "track_lev12_pct": 48,
+ "track_lev345_pct": 30,
+ "path_pct": 8,
+ "asphalt_pct": 14,
+ "dirt_total_pct": 86
+ }
+ }
+ ]
+}
+```
+
+Если stats не удалось посчитать — вернуть `"stats": null`, не падать.
+
+#### 2.2 Фронт — панель альтернативных маршрутов
+
+**Цвета маршрутов** (по индексу):
+```js
+const ROUTE_COLORS = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888'];
+```
+
+**Состояние:**
+```js
+let routeWaypoints = []; // [{lon, lat}, ...]
+let routeResults = []; // массив маршрутов из API
+let activeRouteIdx = 0; // выбранный маршрут
+let waypointMarkers = []; // MapLibre маркеры точек
+let addingWaypoint = false; // режим добавления промежуточной точки
+```
+
+**HTML панели маршрутов** (добавить в `index.html`, заменить текущий `#route-panel`):
+
+```html
+
+
+
+
+
+ 📍 Точки маршрута
+
+
+
+
+
+
Кликни точку старта
+
+
+
+
+
+
+
+
+
+
+```
+
+**Функция рендера карточек** `renderRouteCards(routes)`:
+
+Для каждого маршрута генерировать HTML карточки:
+```html
+
+
+
+
XX% грунт · XX% асфальт
+
+
+
🟡 Lev1-2 XX км XX%
+
🔴 Lev3-5 XX км XX%
+
🔴 Тропы XX км XX%
+
⬜ Асфальт XX км XX%
+
+
+
+
+
+
+
+```
+
+CSS для карточек (добавить в `app.css`):
+```css
+.route-card {
+ border: 2px solid #eee;
+ border-radius: 6px;
+ padding: 8px 10px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+.route-card:hover { background: #fff8f0; }
+.route-card.active { border-color: #ff6600; background: #fff8f0; }
+.route-card-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+}
+.route-color-dot {
+ width: 10px; height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.route-card-title { font-weight: 600; flex: 1; }
+.route-card-dist { color: #333; font-weight: 600; }
+.route-card-time { color: #666; font-size: 12px; }
+.route-coverage-bar {
+ display: flex;
+ height: 6px;
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 4px;
+ background: #eee;
+}
+.route-coverage-bar div { height: 100%; min-width: 3px; }
+.route-card-summary { font-size: 11px; color: #666; margin-bottom: 4px; }
+.route-stat-row { font-size: 12px; padding: 2px 0; color: #444; }
+.route-details-toggle {
+ font-size: 11px; color: #888; background: none; border: none;
+ cursor: pointer; padding: 2px 0; width: 100%; text-align: right;
+}
+```
+
+**Функция `selectRoute(idx)`:**
+- Установить `activeRouteIdx = idx`
+- Обновить стиль всех слоёв маршрутов на карте (активный — жирнее, остальные — тоньше и прозрачнее)
+- Обновить CSS класс `active` на карточках
+
+**Hover на карточке:**
+- `onmouseenter` → подсветить маршрут на карте (увеличить line-width)
+- `onmouseleave` → вернуть к состоянию active/inactive
+
+**Клик на линию маршрута на карте:**
+- Добавить обработчик `map.on('click', 'route-line-N', ...)` → `selectRoute(N)`
+
+---
+
+### Задача 3: Промежуточные точки (F-04)
+
+**Логика добавления точки:**
+- Кнопка «+ Точка» → `startAddWaypoint()` → устанавливает `addingWaypoint = true`, меняет курсор
+- Следующий клик на карту (в `initRouteClicks`) → добавляет точку в `routeWaypoints` как промежуточную (между A и B)
+- После добавления — `addingWaypoint = false`, курсор сбрасывается
+- Перестройка маршрута если A и B уже установлены
+
+**Маркеры точек:**
+- A: зелёный кружок с буквой «A»
+- B: красный кружок с буквой «B»
+- Промежуточные: белый кружок с цветной обводкой (#0066ff) и номером
+
+**Перетаскивание:**
+- Все маркеры создавать с `draggable: true`
+- На событие `dragend` → обновить координаты в `routeWaypoints`, вызвать `buildRoute()` с debounce 300ms
+
+**Панель точек `renderWaypointsList()`:**
+```html
+
+ A
+ 55.7512, 37.6184
+
+
+```
+- Drag-and-drop для изменения порядка (HTML5 draggable API)
+- После изменения порядка — перестройка маршрута
+
+**Лимит:** максимум 10 точек (A + 8 промежуточных + B). При достижении — скрыть кнопку «+ Точка».
+
+---
+
+### Задача 4: Экспорт GPX (F-05)
+
+**Функция `downloadGPX()`** (фронт, без бэкенда):
+
+```js
+function downloadGPX() {
+ const route = routeResults[activeRouteIdx];
+ if (!route) return;
+
+ const now = new Date();
+ const dateStr = now.toISOString().slice(0, 10);
+ const timeStr = now.toISOString().replace(/[-:]/g, '').slice(0, 15);
+ const filename = `enduro-${timeStr}.gpx`;
+
+ const distKm = (route.distance_m / 1000).toFixed(1);
+ const dirtPct = route.stats ? route.stats.dirt_total_pct : '?';
+
+ // Waypoints: точки маршрута + флажки из localStorage
+ const wpts = routeWaypoints.map((wp, i) => {
+ const name = i === 0 ? 'Старт' : i === routeWaypoints.length - 1 ? 'Финиш' : `Точка ${i}`;
+ return ` ${name}`;
+ });
+
+ // Добавить флажки из localStorage
+ const markers = loadMarkers();
+ markers.forEach(m => {
+ wpts.push(` ${escapeXml(m.name)}${m.icon}`);
+ });
+
+ // Трек
+ const trkpts = route.geometry.coordinates.map(([lon, lat]) =>
+ ` `
+ ).join('\n');
+
+ const gpx = `
+
+
+ Enduro route ${dateStr}
+ ${distKm} км · ${dirtPct}% грунт
+
+
+${wpts.join('\n')}
+
+ Enduro route ${dateStr}
+
+${trkpts}
+
+
+`;
+
+ const blob = new Blob([gpx], { type: 'application/gpx+xml' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+function escapeXml(str) {
+ return (str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+```
+
+---
+
+### Задача 5: Флажки / именованные метки (F-06)
+
+**localStorage ключ:** `enduro_markers`
+
+**Структура метки:**
+```js
+{ id: Date.now(), name: 'Заправка', icon: '⛽', lat: 55.71, lon: 37.62 }
+```
+
+**Иконки:** `['🚩', '⛺', '🔧', '⛽', '💧', '📍']`
+
+**Функции:**
+- `loadMarkers()` → читает из localStorage, возвращает массив
+- `saveMarkers(markers)` → пишет в localStorage
+- `addMarker(lngLat)` → показывает диалог, сохраняет, рисует маркер
+- `renderMarkers()` → при загрузке страницы рисует все сохранённые метки
+- `removeMarker(id)` → удаляет из localStorage и с карты
+
+**Диалог добавления метки** (простой `prompt` или inline div над картой):
+```js
+function addMarker(lngLat) {
+ const name = prompt('Название метки (Enter = автоимя):') ?? '';
+ const markers = loadMarkers();
+ const autoName = name.trim() || `Метка ${markers.length + 1}`;
+ // Выбор иконки — упрощённо: первый вызов = 🚩, можно расширить позже
+ const icon = '🚩';
+ const marker = { id: Date.now(), name: autoName, icon, lat: lngLat.lat, lon: lngLat.lng };
+ markers.push(marker);
+ saveMarkers(markers);
+ drawMarker(marker);
+}
+```
+
+**Попап метки** при клике:
+```html
+НАЗВАНИЕ
+LAT, LON
+
+
+
+```
+
+**Кнопка в панели управления** (добавить в `index.html`):
+```html
+
+```
+
+**Режим добавления:** `markerMode = true/false`, при клике на карту → `addMarker(lngLat)`
+
+**Лимит:** 50 меток. При достижении — `alert('Достигнут лимит 50 меток')`.
+
+**Кнопка «Очистить все»** — добавить в панель меток с `confirm()`.
+
+---
+
+## Деплой
+
+После реализации всех задач задеплоить на сервер:
+
+```bash
+# Синхронизировать файлы
+sshpass -p 'motoZ@yaz2010' scp -r /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/app.py slin@82.22.50.71:/home/slin/enduro-trails/prototype/app.py
+sshpass -p 'motoZ@yaz2010' scp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.js slin@82.22.50.71:/home/slin/enduro-trails/prototype/static/app.js
+sshpass -p 'motoZ@yaz2010' scp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.css slin@82.22.50.71:/home/slin/enduro-trails/prototype/static/app.css
+sshpass -p 'motoZ@yaz2010' scp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/index.html slin@82.22.50.71:/home/slin/enduro-trails/prototype/static/index.html
+
+# Перезапустить контейнер
+sshpass -p 'motoZ@yaz2010' ssh slin@82.22.50.71 'cd /home/slin/enduro-trails && docker compose restart prototype'
+
+# Проверить health
+sleep 5 && curl -s https://openclaw.mva154.duckdns.org/enduro/api/health
+```
+
+---
+
+## Definition of Done
+
+- [ ] `formatDuration(seconds)` реализована и применена везде
+- [ ] POST `/api/route` принимает waypoints + alternatives=5
+- [ ] Ответ содержит массив `routes` с `geometry`, `distance_m`, `duration_s`, `stats`
+- [ ] На карте отображаются до 5 маршрутов разными цветами
+- [ ] Панель карточек с компактной и развёрнутой статистикой
+- [ ] Hover на карточке подсвечивает маршрут
+- [ ] Клик на карточку / линию карты выбирает маршрут
+- [ ] Промежуточные точки добавляются, удаляются, перетаскиваются
+- [ ] Маршрут перестраивается при изменении точек (debounce 300ms)
+- [ ] GPX скачивается с треком + waypoints + флажками
+- [ ] Флажки добавляются, сохраняются в localStorage, переживают перезагрузку
+- [ ] Флажки попадают в GPX
+- [ ] Деплой на сервер выполнен
+- [ ] `curl https://openclaw.mva154.duckdns.org/enduro/api/health` возвращает 200
+
+## Что НЕ делать
+
+- Не трогать tile math и MVT-логику
+- Не менять стиль карты (цвета треков, подложку)
+- Не пересоздавать БД
+- Не менять порт (5558)
+- Не добавлять новые зависимости в requirements.txt без крайней необходимости
diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py
index 7c2d80e..4c7bc83 100644
--- a/tasks/enduro-trails/prototype/app.py
+++ b/tasks/enduro-trails/prototype/app.py
@@ -3,6 +3,7 @@
app.py — FastAPI сервер для Enduro Trails
- Раздаёт статику из static/
- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite
+- /api/route (POST) — роутинг через OSRM с альтернативами и статистикой покрытия
- /api/health — статус БД
"""
@@ -13,11 +14,13 @@ import sqlite3
import json
from pathlib import Path
from shapely.geometry import LineString
+from typing import List
from functools import lru_cache
from fastapi import FastAPI, HTTPException, Response
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
import httpx
import uvicorn
@@ -148,9 +151,7 @@ def simplify_coords(coords, z):
def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
- """Собирает MVT тайл. Передаёт полные геометрии без серверного клиппинга.
- MapLibre GL сам клипит на клиенте. quantize_bounds расширяем на 10% чтобы
- точки за границей тайла правильно квантизировались."""
+ """Собирает MVT тайл."""
import mapbox_vector_tile
west, south, east, north = tile_to_bbox(z, x, y)
@@ -203,10 +204,6 @@ def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
if not layers:
return b""
- # quantize_bounds ДОЛЖЕН быть точно равен bbox тайла — без буфера.
- # Буфер в SQL нужен чтобы захватить треки за границей тайла,
- # но quantize_bounds определяет систему координат для MVT-пикселей.
- # Любое расширение сдвигает треки относительно подложки.
return mapbox_vector_tile.encode(
layers,
quantize_bounds=(west, south, east, north),
@@ -215,6 +212,145 @@ def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
)
+# ─── Route stats ──────────────────────────────────────────────────────────────
+
+def haversine_m(lon1, lat1, lon2, lat2) -> float:
+ """Расстояние между двумя точками в метрах."""
+ R = 6371000
+ phi1, phi2 = math.radians(lat1), math.radians(lat2)
+ dphi = math.radians(lat2 - lat1)
+ dlam = math.radians(lon2 - lon1)
+ a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
+ return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
+
+
+def calc_route_stats(geometry: dict, conn) -> dict | None:
+ """
+ Считает статистику покрытия маршрута по типам дорог.
+ geometry — GeoJSON LineString {"type":"LineString","coordinates":[[lon,lat],...]}
+ Возвращает словарь с метрами и процентами по категориям.
+ """
+ try:
+ coords = geometry.get("coordinates", [])
+ if len(coords) < 2:
+ return None
+
+ # Считаем общую длину маршрута и шаг между точками
+ total_len = 0.0
+ seg_lengths = []
+ for i in range(len(coords) - 1):
+ d = haversine_m(coords[i][0], coords[i][1], coords[i+1][0], coords[i+1][1])
+ seg_lengths.append(d)
+ total_len += d
+
+ if total_len < 1:
+ return None
+
+ # Средний шаг между точками
+ avg_step = total_len / len(seg_lengths)
+
+ # Сколько точек пропускать чтобы получить ~100м сегменты
+ # Минимум 1 точка, максимум 50
+ step = max(1, min(50, int(round(100.0 / avg_step)))) if avg_step > 0 else 5
+
+ cur = conn.cursor()
+
+ stats = {
+ "track_lev12_m": 0.0,
+ "track_lev345_m": 0.0,
+ "path_m": 0.0,
+ "asphalt_m": 0.0,
+ }
+
+ # Проходим по маршруту с шагом step, берём среднюю точку сегмента
+ i = 0
+ while i < len(coords) - 1:
+ end_i = min(i + step, len(coords) - 1)
+ # Средняя точка сегмента
+ mid_lon = (coords[i][0] + coords[end_i][0]) / 2
+ mid_lat = (coords[i][1] + coords[end_i][1]) / 2
+
+ # Длина этого сегмента
+ seg_len = sum(seg_lengths[i:end_i])
+
+ # Bbox для поиска ближайшего трека (~500м вокруг точки)
+ delta = 0.005 # ~500м
+ try:
+ cur.execute("""
+ SELECT highway_type, track_type, length_m
+ FROM trails
+ WHERE min_lon <= ? AND max_lon >= ?
+ AND min_lat <= ? AND max_lat >= ?
+ ORDER BY ABS(min_lon - ?) + ABS(min_lat - ?)
+ LIMIT 1
+ """, (
+ mid_lon + delta, mid_lon - delta,
+ mid_lat + delta, mid_lat - delta,
+ mid_lon, mid_lat
+ ))
+ row = cur.fetchone()
+ except Exception:
+ row = None
+
+ if row:
+ hw = (row["highway_type"] or "").lower()
+ tt = (row["track_type"] or "").lower()
+ if hw == "track":
+ if tt in ("grade1", "grade2"):
+ stats["track_lev12_m"] += seg_len
+ else:
+ # grade3/4/5 или NULL
+ stats["track_lev345_m"] += seg_len
+ elif hw in ("path", "bridleway", "footway"):
+ stats["path_m"] += seg_len
+ else:
+ stats["asphalt_m"] += seg_len
+ else:
+ # Нет данных — считаем асфальтом
+ stats["asphalt_m"] += seg_len
+
+ i = end_i
+
+ # Считаем итоговую длину из статистики
+ computed_total = (
+ stats["track_lev12_m"] + stats["track_lev345_m"] +
+ stats["path_m"] + stats["asphalt_m"]
+ )
+ if computed_total < 1:
+ return None
+
+ def pct(v):
+ return round(v / computed_total * 100)
+
+ dirt_total = stats["track_lev12_m"] + stats["track_lev345_m"] + stats["path_m"]
+
+ return {
+ "track_lev12_m": round(stats["track_lev12_m"]),
+ "track_lev345_m": round(stats["track_lev345_m"]),
+ "path_m": round(stats["path_m"]),
+ "asphalt_m": round(stats["asphalt_m"]),
+ "track_lev12_pct": pct(stats["track_lev12_m"]),
+ "track_lev345_pct": pct(stats["track_lev345_m"]),
+ "path_pct": pct(stats["path_m"]),
+ "asphalt_pct": pct(stats["asphalt_m"]),
+ "dirt_total_pct": pct(dirt_total),
+ }
+ except Exception:
+ return None
+
+
+# ─── Pydantic models ──────────────────────────────────────────────────────────
+
+class Waypoint(BaseModel):
+ lon: float
+ lat: float
+
+
+class RouteRequest(BaseModel):
+ waypoints: List[Waypoint]
+ alternatives: int = 5
+
+
# ─── API endpoints ────────────────────────────────────────────────────────────
@app.get("/api/cache/clear")
@@ -231,7 +367,6 @@ async def get_tile(z: int, x: int, y: int):
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
raise HTTPException(400, "Invalid x/y for zoom level")
- # Проверяем кэш до обращения к БД
cached = get_cached_tile(z, x, y)
if cached is not None:
return Response(
@@ -245,7 +380,6 @@ async def get_tile(z: int, x: int, y: int):
west, south, east, north = tile_to_bbox(z, x, y)
- # Расширенный bbox для SQL-запроса (на 15% за каждую сторону)
buf_x = (east - west) * 0.15
buf_y = (north - south) * 0.15
q_west = west - buf_x
@@ -262,7 +396,6 @@ async def get_tile(z: int, x: int, y: int):
else:
limit = 25000
- # Минимальная длина трека по зуму — фильтруем мусор на низких зумах
if z <= 7:
min_length = 2000
elif z == 8:
@@ -302,7 +435,6 @@ async def get_tile(z: int, x: int, y: int):
mvt = build_mvt(trails_rows, poi_rows, z, x, y)
- # Кэшируем только непустые тайлы
if mvt:
set_cached_tile(z, x, y, mvt)
@@ -317,12 +449,78 @@ async def get_tile(z: int, x: int, y: int):
)
+@app.post("/api/route")
+async def post_route(req: RouteRequest):
+ """
+ Роутинг через OSRM с альтернативными маршрутами и статистикой покрытия.
+ Принимает JSON: {"waypoints": [{"lon":..,"lat":..}, ...], "alternatives": 5}
+ """
+ if len(req.waypoints) < 2:
+ raise HTTPException(400, "Нужно минимум 2 точки")
+
+ # Строим строку координат для OSRM
+ coords_str = ";".join(f"{wp.lon},{wp.lat}" for wp in req.waypoints)
+ alternatives = max(1, min(5, req.alternatives))
+
+ url = (
+ f"{OSRM_URL}/route/v1/driving/{coords_str}"
+ f"?alternatives={alternatives}&overview=full&geometries=geojson&annotations=false"
+ )
+
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ resp = await client.get(url)
+ data = resp.json()
+ except Exception as e:
+ raise HTTPException(503, f"OSRM недоступен: {e}")
+
+ if data.get("code") != "Ok" or not data.get("routes"):
+ raise HTTPException(404, "Маршрут не найден")
+
+ # Открываем БД один раз для всех маршрутов
+ try:
+ conn = get_db()
+ except Exception as e:
+ conn = None
+
+ routes_out = []
+ for idx, route in enumerate(data["routes"]):
+ geometry = route["geometry"]
+ distance_m = route["distance"]
+ duration_s = route["duration"]
+
+ # Считаем статистику покрытия
+ stats = None
+ if conn is not None:
+ try:
+ stats = calc_route_stats(geometry, conn)
+ except Exception:
+ stats = None
+
+ routes_out.append({
+ "index": idx,
+ "geometry": geometry,
+ "distance_m": round(distance_m),
+ "duration_s": round(duration_s),
+ "stats": stats,
+ })
+
+ if conn is not None:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+ return {"routes": routes_out}
+
+
+# Обратная совместимость — старый GET endpoint (для линейки и прочего)
@app.get("/api/route")
async def get_route(
from_lon: float, from_lat: float,
to_lon: float, to_lat: float
):
- """Роутинг через OSRM. Параметры: from_lon, from_lat, to_lon, to_lat"""
+ """Роутинг через OSRM (legacy GET). Параметры: from_lon, from_lat, to_lon, to_lat"""
url = (
f"{OSRM_URL}/route/v1/driving/"
f"{from_lon},{from_lat};{to_lon},{to_lat}"
@@ -350,6 +548,7 @@ async def get_route(
"distance_m": round(distance_m),
"distance_km": round(distance_m / 1000, 1),
"duration_min": round(duration_s / 60),
+ "duration_s": round(duration_s),
}
}
diff --git a/tasks/enduro-trails/prototype/static/app.css b/tasks/enduro-trails/prototype/static/app.css
index ed09758..008667d 100644
--- a/tasks/enduro-trails/prototype/static/app.css
+++ b/tasks/enduro-trails/prototype/static/app.css
@@ -341,3 +341,111 @@ body {
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}
+
+/* ─── Фаза 3: Карточки маршрутов ─────────────────────────────────────────── */
+.route-card {
+ border: 2px solid #eee;
+ border-radius: 6px;
+ padding: 8px 10px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+.route-card:hover { background: #fff8f0; }
+.route-card.active { border-color: #ff6600; background: #fff8f0; }
+
+.route-card-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+}
+
+.route-color-dot {
+ width: 10px; height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.route-card-title { font-weight: 600; flex: 1; font-size: 13px; }
+.route-card-dist { color: #333; font-weight: 600; font-size: 13px; }
+.route-card-time { color: #666; font-size: 12px; }
+
+.route-coverage-bar {
+ display: flex;
+ height: 6px;
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 4px;
+ background: #eee;
+}
+.route-coverage-bar div { height: 100%; min-width: 3px; }
+
+.route-card-summary { font-size: 11px; color: #666; margin-bottom: 4px; }
+
+.route-card-details { margin-top: 6px; }
+.route-stat-row { font-size: 12px; padding: 2px 0; color: #444; }
+
+.route-details-toggle {
+ font-size: 11px; color: #888; background: none; border: none;
+ cursor: pointer; padding: 2px 0; width: 100%; text-align: right;
+}
+.route-details-toggle:hover { color: #ff6600; }
+
+/* ─── Фаза 3: Панель точек маршрута ──────────────────────────────────────── */
+.waypoint-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 0;
+ border-bottom: 1px solid #f0f0f0;
+ font-size: 12px;
+ cursor: grab;
+}
+.waypoint-row:last-child { border-bottom: none; }
+.waypoint-row.drag-over { background: #fff3e0; border-radius: 4px; }
+
+.waypoint-label {
+ width: 20px; height: 20px;
+ border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 10px; font-weight: 700;
+ flex-shrink: 0;
+ color: #fff;
+}
+.waypoint-label.start { background: #00aa44; }
+.waypoint-label.end { background: #cc0000; }
+.waypoint-label.mid { background: #fff; color: #0066ff; border: 2px solid #0066ff; }
+
+.waypoint-coords {
+ flex: 1;
+ color: #555;
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.waypoint-remove {
+ background: none; border: none; cursor: pointer;
+ color: #aaa; font-size: 14px; padding: 0 2px;
+ line-height: 1;
+}
+.waypoint-remove:hover { color: #cc0000; }
+
+/* ─── Фаза 3: Маркеры на карте ───────────────────────────────────────────── */
+.route-waypoint-marker {
+ display: flex; align-items: center; justify-content: center;
+ border-radius: 50%;
+ font-size: 10px; font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
+}
+
+.named-marker-el {
+ font-size: 20px;
+ cursor: pointer;
+ filter: drop-shadow(0 1px 3px rgba(0,0,0,0.4));
+ user-select: none;
+ line-height: 1;
+}
diff --git a/tasks/enduro-trails/prototype/static/index.html b/tasks/enduro-trails/prototype/static/index.html
index 67d4923..531e3e0 100644
--- a/tasks/enduro-trails/prototype/static/index.html
+++ b/tasks/enduro-trails/prototype/static/index.html
@@ -94,14 +94,40 @@
Zoom: 7 | Координаты: —
-
-
🗺️ Маршрут
-
Кликни точку старта
-
-
-
-
+
+
+
+
+
+
+ 📍 Точки маршрута
+
+
+
+
+
Кликни точку старта
+
+
+
+
+
+
+
+
+
@@ -109,6 +135,7 @@
+