From 6477326edaca43dbdb36282e373779f96304591c Mon Sep 17 00:00:00 2001 From: Stream Date: Mon, 4 May 2026 10:40:01 +0300 Subject: [PATCH] auto-sync: 2026-05-04 10:40:01 --- tasks/enduro-trails/DEV_TASK_PHASE3.md | 475 ++++++++++++++++++ tasks/enduro-trails/prototype/app.py | 223 +++++++- tasks/enduro-trails/prototype/static/app.css | 108 ++++ .../enduro-trails/prototype/static/index.html | 41 +- 4 files changed, 828 insertions(+), 19 deletions(-) create mode 100644 tasks/enduro-trails/DEV_TASK_PHASE3.md 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 +
+
+ + Вариант N + XX км + X ч Y мин +
+
+ +
+
+
+
+
+
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 | Координаты:
-