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

@@ -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),
}
}

View File

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

View File

@@ -94,14 +94,40 @@
<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);">
<div style="font-weight:600; color:#e07b00; margin-bottom:8px;">🗺️ Маршрут</div>
<div id="route-status" style="color:#888; font-size:12px;">Кликни точку старта</div>
<div id="route-info" style="display:none;">
<div class="popup-row"><span class="popup-key">Дистанция</span><span class="popup-val" id="route-distance"></span></div>
<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>
<!-- ─── Панель маршрута (Фаза 3) ─────────────────────────────────────── -->
<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>
<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-locate" class="map-ctrl-btn" title="Моё местоположение" onclick="locateMe()">📍</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>