auto-sync: 2026-05-04 10:40:01

This commit is contained in:
Stream
2026-05-04 10:40:01 +03:00
parent 32c6939ef9
commit 6477326eda
4 changed files with 828 additions and 19 deletions

View File

@@ -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
<div id="route-panel" style="display:none; position:absolute; bottom:40px; right:10px;
background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
padding:12px; font-size:13px; z-index:5; width:280px;
box-shadow:0 2px 12px rgba(0,0,0,0.15); max-height:70vh; overflow-y:auto;">
<!-- Панель точек -->
<div id="waypoints-panel">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-weight:600; color:#e07b00;">📍 Точки маршрута</span>
<button id="btn-add-waypoint" onclick="startAddWaypoint()"
style="font-size:11px; padding:3px 8px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer;">
+ Точка
</button>
</div>
<div id="waypoints-list"></div>
</div>
<div id="route-status" style="color:#888; font-size:12px; margin:8px 0;">Кликни точку старта</div>
<!-- Кнопки действий -->
<div id="route-actions" style="display:none; margin-top:8px;">
<button onclick="buildRoute()" id="btn-build-route"
style="width:100%; padding:6px; background:#ff6600; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:13px; font-weight:600;">
🗺️ Построить маршрут
</button>
<button onclick="clearRoute()"
style="width:100%; margin-top:4px; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">
✕ Сбросить всё
</button>
</div>
<!-- Карточки маршрутов -->
<div id="route-cards" style="margin-top:10px;"></div>
</div>
```
**Функция рендера карточек** `renderRouteCards(routes)`:
Для каждого маршрута генерировать HTML карточки:
```html
<div class="route-card" id="route-card-N" onclick="selectRoute(N)">
<div class="route-card-header">
<span class="route-color-dot" style="background: COLOR"></span>
<span class="route-card-title">Вариант N</span>
<span class="route-card-dist">XX км</span>
<span class="route-card-time">X ч Y мин</span>
</div>
<div class="route-coverage-bar">
<!-- пропорциональные сегменты -->
<div style="width:XX%; background:#FFD700" title="Lev1-2: XX км"></div>
<div style="width:XX%; background:#FF4400" title="Lev3-5: XX км"></div>
<div style="width:XX%; background:#cc0000" title="Тропы: XX км"></div>
<div style="width:XX%; background:#aaaaaa" title="Асфальт: XX км"></div>
</div>
<div class="route-card-summary">XX% грунт · XX% асфальт</div>
<div class="route-card-details" id="route-details-N" style="display:none;">
<!-- развёрнутая статистика -->
<div class="route-stat-row">🟡 Lev1-2 &nbsp; XX км &nbsp; XX%</div>
<div class="route-stat-row">🔴 Lev3-5 &nbsp; XX км &nbsp; XX%</div>
<div class="route-stat-row">🔴 Тропы &nbsp; XX км &nbsp; XX%</div>
<div class="route-stat-row">⬜ Асфальт &nbsp; XX км &nbsp; XX%</div>
<div style="margin-top:6px; display:flex; gap:6px;">
<button onclick="event.stopPropagation(); downloadGPX()"
style="flex:1; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:11px;">
📥 GPX
</button>
<button onclick="event.stopPropagation(); selectRoute(N)"
style="flex:1; padding:4px; background:#ff6600; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:11px;">
Выбрать
</button>
</div>
</div>
<button class="route-details-toggle" onclick="event.stopPropagation(); toggleRouteDetails(N)">
Подробнее ▼
</button>
</div>
```
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
<div class="waypoint-row" draggable="true">
<span class="waypoint-label">A</span>
<span class="waypoint-coords">55.7512, 37.6184</span>
<button onclick="removeWaypoint(0)">✕</button>
</div>
```
- 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 ` <wpt lat="${wp.lat}" lon="${wp.lon}"><name>${name}</name></wpt>`;
});
// Добавить флажки из localStorage
const markers = loadMarkers();
markers.forEach(m => {
wpts.push(` <wpt lat="${m.lat}" lon="${m.lon}"><name>${escapeXml(m.name)}</name><sym>${m.icon}</sym></wpt>`);
});
// Трек
const trkpts = route.geometry.coordinates.map(([lon, lat]) =>
` <trkpt lat="${lat}" lon="${lon}"/>`
).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Enduro Trails" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Enduro route ${dateStr}</name>
<desc>${distKm} км · ${dirtPct}% грунт</desc>
<time>${now.toISOString()}</time>
</metadata>
${wpts.join('\n')}
<trk>
<name>Enduro route ${dateStr}</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
```
---
### Задача 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
<b>НАЗВАНИЕ</b><br>
LAT, LON<br>
<button onclick="useMarkerAsA(id)">→ Точка A</button>
<button onclick="useMarkerAsB(id)">→ Точка B</button>
<button onclick="removeMarker(id)">🗑 Удалить</button>
```
**Кнопка в панели управления** (добавить в `index.html`):
```html
<button id="btn-markers" class="map-ctrl-btn" title="Добавить метку" onclick="toggleMarkerMode()">🚩</button>
```
**Режим добавления:** `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 без крайней необходимости

View File

@@ -3,6 +3,7 @@
app.py — FastAPI сервер для Enduro Trails app.py — FastAPI сервер для Enduro Trails
- Раздаёт статику из static/ - Раздаёт статику из static/
- /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite - /api/tiles/{z}/{x}/{y}.mvt — векторные тайлы из SQLite
- /api/route (POST) — роутинг через OSRM с альтернативами и статистикой покрытия
- /api/health — статус БД - /api/health — статус БД
""" """
@@ -13,11 +14,13 @@ import sqlite3
import json import json
from pathlib import Path from pathlib import Path
from shapely.geometry import LineString from shapely.geometry import LineString
from typing import List
from functools import lru_cache from functools import lru_cache
from fastapi import FastAPI, HTTPException, Response from fastapi import FastAPI, HTTPException, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx import httpx
import uvicorn import uvicorn
@@ -148,9 +151,7 @@ def simplify_coords(coords, z):
def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes: def build_mvt(trails_rows, poi_rows, z, x, y) -> bytes:
"""Собирает MVT тайл. Передаёт полные геометрии без серверного клиппинга. """Собирает MVT тайл."""
MapLibre GL сам клипит на клиенте. quantize_bounds расширяем на 10% чтобы
точки за границей тайла правильно квантизировались."""
import mapbox_vector_tile import mapbox_vector_tile
west, south, east, north = tile_to_bbox(z, x, y) 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: if not layers:
return b"" return b""
# quantize_bounds ДОЛЖЕН быть точно равен bbox тайла — без буфера.
# Буфер в SQL нужен чтобы захватить треки за границей тайла,
# но quantize_bounds определяет систему координат для MVT-пикселей.
# Любое расширение сдвигает треки относительно подложки.
return mapbox_vector_tile.encode( return mapbox_vector_tile.encode(
layers, layers,
quantize_bounds=(west, south, east, north), 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 ──────────────────────────────────────────────────────────── # ─── API endpoints ────────────────────────────────────────────────────────────
@app.get("/api/cache/clear") @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: if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
raise HTTPException(400, "Invalid x/y for zoom level") raise HTTPException(400, "Invalid x/y for zoom level")
# Проверяем кэш до обращения к БД
cached = get_cached_tile(z, x, y) cached = get_cached_tile(z, x, y)
if cached is not None: if cached is not None:
return Response( 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) west, south, east, north = tile_to_bbox(z, x, y)
# Расширенный bbox для SQL-запроса (на 15% за каждую сторону)
buf_x = (east - west) * 0.15 buf_x = (east - west) * 0.15
buf_y = (north - south) * 0.15 buf_y = (north - south) * 0.15
q_west = west - buf_x q_west = west - buf_x
@@ -262,7 +396,6 @@ async def get_tile(z: int, x: int, y: int):
else: else:
limit = 25000 limit = 25000
# Минимальная длина трека по зуму — фильтруем мусор на низких зумах
if z <= 7: if z <= 7:
min_length = 2000 min_length = 2000
elif z == 8: 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) mvt = build_mvt(trails_rows, poi_rows, z, x, y)
# Кэшируем только непустые тайлы
if mvt: if mvt:
set_cached_tile(z, x, y, 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") @app.get("/api/route")
async def get_route( async def get_route(
from_lon: float, from_lat: float, from_lon: float, from_lat: float,
to_lon: float, to_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 = ( url = (
f"{OSRM_URL}/route/v1/driving/" f"{OSRM_URL}/route/v1/driving/"
f"{from_lon},{from_lat};{to_lon},{to_lat}" f"{from_lon},{from_lat};{to_lon},{to_lat}"
@@ -350,6 +548,7 @@ async def get_route(
"distance_m": round(distance_m), "distance_m": round(distance_m),
"distance_km": round(distance_m / 1000, 1), "distance_km": round(distance_m / 1000, 1),
"duration_min": round(duration_s / 60), "duration_min": round(duration_s / 60),
"duration_s": round(duration_s),
} }
} }

View File

@@ -341,3 +341,111 @@ body {
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; } 0% { transform: translate(-50%, -50%) scale(0.5); opacity: 1; }
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; } 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;
}

View File

@@ -94,14 +94,40 @@
<div id="stats">Zoom: <span id="zoom-val">7</span> | Координаты: <span id="coords-val"></span></div> <div id="stats">Zoom: <span id="zoom-val">7</span> | Координаты: <span id="coords-val"></span></div>
<div id="route-panel" style="display:none; position:absolute; bottom:40px; left:12px; background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:6px; padding:12px 14px; font-size:13px; z-index:5; min-width:180px; box-shadow:0 2px 8px rgba(0,0,0,0.12);"> <!-- ─── Панель маршрута (Фаза 3) ─────────────────────────────────────── -->
<div style="font-weight:600; color:#e07b00; margin-bottom:8px;">🗺️ Маршрут</div> <div id="route-panel" style="display:none; position:absolute; bottom:40px; right:10px;
<div id="route-status" style="color:#888; font-size:12px;">Кликни точку старта</div> background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
<div id="route-info" style="display:none;"> padding:12px; font-size:13px; z-index:5; width:280px;
<div class="popup-row"><span class="popup-key">Дистанция</span><span class="popup-val" id="route-distance"></span></div> box-shadow:0 2px 12px rgba(0,0,0,0.15); max-height:70vh; overflow-y:auto;">
<div class="popup-row"><span class="popup-key">Время</span><span class="popup-val" id="route-duration"></span></div>
<button onclick="clearRoute()" style="margin-top:8px; width:100%; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">✕ Сбросить</button> <!-- Панель точек -->
<div id="waypoints-panel">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-weight:600; color:#e07b00;">📍 Точки маршрута</span>
<button id="btn-add-waypoint" onclick="startAddWaypoint()"
style="font-size:11px; padding:3px 8px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer;">
+ Точка
</button>
</div>
<div id="waypoints-list"></div>
</div> </div>
<div id="route-status" style="color:#888; font-size:12px; margin:8px 0;">Кликни точку старта</div>
<!-- Кнопки действий -->
<div id="route-actions" style="display:none; margin-top:8px;">
<button onclick="buildRoute()" id="btn-build-route"
style="width:100%; padding:6px; background:#ff6600; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:13px; font-weight:600;">
🗺️ Построить маршрут
</button>
<button onclick="clearRoute()"
style="width:100%; margin-top:4px; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">
✕ Сбросить всё
</button>
</div>
<!-- Карточки маршрутов -->
<div id="route-cards" style="margin-top:10px;"></div>
</div> </div>
<div id="map-controls-br" class="custom-map-ctrl"> <div id="map-controls-br" class="custom-map-ctrl">
@@ -109,6 +135,7 @@
<button id="btn-route" class="map-ctrl-btn" title="Построить маршрут" onclick="toggleRouteMode()">🗺️</button> <button id="btn-route" class="map-ctrl-btn" title="Построить маршрут" onclick="toggleRouteMode()">🗺️</button>
<button id="btn-locate" class="map-ctrl-btn" title="Моё местоположение" onclick="locateMe()">📍</button> <button id="btn-locate" class="map-ctrl-btn" title="Моё местоположение" onclick="locateMe()">📍</button>
<button id="btn-ruler" class="map-ctrl-btn" title="Измерить расстояние" onclick="toggleRuler()">📏</button> <button id="btn-ruler" class="map-ctrl-btn" title="Измерить расстояние" onclick="toggleRuler()">📏</button>
<button id="btn-markers" class="map-ctrl-btn" title="Добавить метку" onclick="toggleMarkerMode()">🚩</button>
</div> </div>
</div> </div>