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

This commit is contained in:
Stream
2026-05-04 23:40:01 +03:00
parent 2a2dbe8d1e
commit a1d45c74a9
2 changed files with 374 additions and 1 deletions

View File

@@ -574,6 +574,318 @@ async def post_recon(req: ReconRequest):
raise HTTPException(500, f"Ошибка БД: {e}")
# ─── Scenic route helpers ─────────────────────────────────────────────────────
SCENIC_POI_SCORES = {
"natural=water": 10,
"tourism=viewpoint": 15,
"historic=ruins": 10,
"natural=peak": 12,
"natural=cave_entrance": 8,
"ford=yes": 5,
}
SCENIC_POI_ICONS = {
"natural=water": "💧",
"tourism=viewpoint": "👁",
"historic=ruins": "🏚",
"natural=peak": "🔺",
"natural=cave_entrance": "🕳",
"ford=yes": "🌊",
}
DIRECTION_NAMES = {0: "Северный", 1: "Восточный", 2: "Южный", 3: "Западный"}
def _angle_bucket(lon, lat, center_lon, center_lat):
"""Возвращает сектор 0-3 (С, В, Ю, З) от центра."""
a = math.degrees(math.atan2(lon - center_lon, lat - center_lat)) % 360
return int(a // 90)
async def _osrm_route_waypoints(waypoints_lonlat: list[tuple[float, float]]) -> dict | None:
"""Запрос к OSRM со списком waypoints. Возвращает полный ответ OSRM."""
coords_str = ";".join(f"{lon},{lat}" for lon, lat in waypoints_lonlat)
radiuses_str = ";".join(["5000"] * len(waypoints_lonlat))
url = (
f"{OSRM_URL}/route/v1/driving/{coords_str}"
f"?overview=full&geometries=geojson&alternatives=false"
f"&radiuses={radiuses_str}"
)
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(url)
data = resp.json()
except Exception:
return None
if data.get("code") != "Ok" or not data.get("routes"):
# Retry with wider snap
radiuses_wide = ";".join(["10000"] * len(waypoints_lonlat))
url2 = (
f"{OSRM_URL}/route/v1/driving/{coords_str}"
f"?overview=full&geometries=geojson&alternatives=false"
f"&radiuses={radiuses_wide}"
)
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(url2)
data = resp.json()
except Exception:
return None
if data.get("code") != "Ok" or not data.get("routes"):
return None
return data
def _merge_geometries(geometries: list[dict]) -> dict:
"""Объединяет несколько LineString GeoJSON в одну."""
all_coords = []
for g in geometries:
if g.get("type") == "LineString":
all_coords.extend(g.get("coordinates", []))
return {"type": "LineString", "coordinates": all_coords}
async def _build_scenic_for_cluster(
start_lon, start_lat, poi_list, target_km, conn
) -> dict | None:
"""
Строит кольцевой маршрут: start → POI1 → POI2 → ... → start.
poi_list: [{lon, lat, poi_type, name, score}, ...]
"""
if not poi_list:
return None
# Ограничиваем до 5 POI
pois = poi_list[:5]
# Waypoints: start → POI sequence → start
wp = [(start_lon, start_lat)]
for p in pois:
wp.append((p["lon"], p["lat"]))
wp.append((start_lon, start_lat))
osrm_data = await _osrm_route_waypoints(wp)
if not osrm_data or not osrm_data.get("routes"):
return None
route = osrm_data["routes"][0]
geometry = route["geometry"]
distance_m = route["distance"]
duration_s = route["duration"]
# Если слишком длинный (> target * 1.3) — убрать последний POI и перестроить
if distance_m > target_km * 1300 and len(pois) > 1:
pois = pois[:-1]
wp = [(start_lon, start_lat)]
for p in pois:
wp.append((p["lon"], p["lat"]))
wp.append((start_lon, start_lat))
osrm_data2 = await _osrm_route_waypoints(wp)
if osrm_data2 and osrm_data2.get("routes"):
route = osrm_data2["routes"][0]
geometry = route["geometry"]
distance_m = route["distance"]
duration_s = route["duration"]
# Статистика покрытия
stats = None
if conn:
try:
stats = calc_route_stats(geometry, conn)
except Exception:
pass
# Waypoint labels для ответа
waypoints_out = [{"lon": start_lon, "lat": start_lat, "label": "Старт"}]
for p in pois:
icon = SCENIC_POI_ICONS.get(p["poi_type"], "📍")
name = p.get("name") or p["poi_type"]
waypoints_out.append({"lon": p["lon"], "lat": p["lat"], "label": f"{icon} {name}"})
waypoints_out.append({"lon": start_lon, "lat": start_lat, "label": "Финиш"})
scenic_score = sum(p.get("score", 0) for p in pois)
scenic_pois_out = [
{
"type": p["poi_type"],
"name": p.get("name", ""),
"lon": p["lon"],
"lat": p["lat"],
}
for p in pois
]
return {
"name": "",
"waypoints": waypoints_out,
"geometry": geometry,
"distance_m": round(distance_m),
"duration_s": round(duration_s),
"stats": stats,
"scenic_score": scenic_score,
"scenic_pois": scenic_pois_out,
}
@app.post("/api/scenic")
async def post_scenic(req: ScenicRequest):
"""
Красивый маршрут: кольцевой маршрут через живописные POI.
"""
lon, lat, target_km = req.lon, req.lat, req.target_km
if target_km < 20 or target_km > 500:
raise HTTPException(400, "Дистанция должна быть от 20 до 500 км")
# Радиус поиска POI
search_radius_km = target_km * 0.6
lat_rad = math.radians(lat)
delta_lat = search_radius_km / 111.0
delta_lon = search_radius_km / (111.0 * math.cos(lat_rad))
try:
conn = get_db()
cur = conn.cursor()
# Найти POI в радиусе
cur.execute("""
SELECT poi_type, name, lon, lat
FROM poi
WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ?
ORDER BY poi_type
""", (lon - delta_lon, lon + delta_lon, lat - delta_lat, lat + delta_lat))
poi_rows = cur.fetchall()
# Фильтруем POI по score и назначаем баллы
scored_pois = []
for row in poi_rows:
pt = row["poi_type"] or ""
if pt in SCENIC_POI_SCORES:
p_lon = row["lon"]
p_lat = row["lat"]
# Не берём POI ближе 3 км от старта
d = haversine_m(lon, lat, p_lon, p_lat)
if d < 3000:
continue
scored_pois.append({
"poi_type": pt,
"name": row["name"] or "",
"lon": p_lon,
"lat": p_lat,
"score": SCENIC_POI_SCORES[pt],
"distance_m": d,
})
if not scored_pois:
# Нет красивых мест — строим просто кольцо через ближайшие грунтовки
# Пробуем кольцо: старт → точка на расстоянии ~target_km/3 → старт
angle = 0
third_dist = target_km / 3.0
mid_lat = lat + (third_dist / 111.0)
mid_lon = lon
osrm_data = await _osrm_route_waypoints([
(lon, lat), (mid_lon, mid_lat), (lon, lat)
])
if osrm_data and osrm_data.get("routes"):
route = osrm_data["routes"][0]
geometry = route["geometry"]
stats = calc_route_stats(geometry, conn) if conn else None
conn.close()
return {
"routes": [{
"name": "Маршрут по грунтовкам",
"waypoints": [
{"lon": lon, "lat": lat, "label": "Старт"},
{"lon": mid_lon, "lat": mid_lat, "label": "Разворот"},
{"lon": lon, "lat": lat, "label": "Финиш"},
],
"geometry": geometry,
"distance_m": round(route["distance"]),
"duration_s": round(route["duration"]),
"stats": stats,
"scenic_score": 0,
"scenic_pois": [],
}]
}
conn.close()
raise HTTPException(404, "Не удалось построить маршрут")
# Разделить POI на кластеры по азимуту (4 сектора)
clusters: dict[int, list] = {0: [], 1: [], 2: [], 3: []}
for p in scored_pois:
bucket = _angle_bucket(p["lon"], p["lat"], lon, lat)
clusters[bucket].append(p)
# Для каждого кластера — жадный выбор POI
remaining_km = target_km * 0.8 # 80% дистанции на POI, 20% на возврат
cluster_poi_lists = {}
for bucket, pois in clusters.items():
if not pois:
cluster_poi_lists[bucket] = []
continue
# Сортируем по score/distance (жадный)
for p in pois:
p["score_per_km"] = p["score"] / max(p["distance_m"] / 1000.0, 1.0)
pois.sort(key=lambda p: p["score_per_km"], reverse=True)
selected = []
used_km = 0.0
for p in pois:
d_km = p["distance_m"] / 1000.0
if d_km > remaining_km * 0.5:
continue
if d_km < 3:
continue
if used_km + d_km > remaining_km:
continue
selected.append(p)
used_km += d_km
if len(selected) >= 5:
break
cluster_poi_lists[bucket] = selected
# Строим маршруты для кластеров с POI (до 3 альтернативных)
routes_out = []
used_buckets = 0
for bucket in range(4):
if used_buckets >= 3:
break
pois = cluster_poi_lists.get(bucket, [])
if not pois:
continue
result = await _build_scenic_for_cluster(lon, lat, pois, target_km, conn)
if result:
# Проверяем дистанцию: не < 50% target
if result["distance_m"] < target_km * 500:
continue
result["name"] = DIRECTION_NAMES.get(bucket, f"Вариант {used_buckets + 1}")
routes_out.append(result)
used_buckets += 1
# Если ни одного маршрута — попробовать все POI вместе
if not routes_out and scored_pois:
# Топ-5 POI по score_per_km
all_sorted = sorted(scored_pois, key=lambda p: p["score_per_km"], reverse=True)[:5]
result = await _build_scenic_for_cluster(lon, lat, all_sorted, target_km, conn)
if result:
result["name"] = "Маршрут"
routes_out.append(result)
conn.close()
if not routes_out:
raise HTTPException(404, "Не удалось построить красивый маршрут")
return {"routes": routes_out}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, f"Ошибка: {e}")
@app.post("/api/route")
async def post_route(req: RouteRequest):
"""

View File

@@ -130,12 +130,73 @@
<div id="route-cards" style="margin-top:10px;"></div>
</div>
<!-- ─── Панель Разведки (F-14) ──────────────────────────────────────────── -->
<div id="recon-panel" style="display:none; position:absolute; bottom:40px; left:12px;
background:rgba(255,255,255,0.97); border:1px solid #ddd; border-radius:8px;
padding:12px; font-size:13px; z-index:5; width:240px;
box-shadow:0 2px 12px rgba(0,0,0,0.15);">
<div style="font-weight:600; color:#e07b00; margin-bottom:8px;">📍 Разведка</div>
<div id="recon-stats" style="color:#888; font-size:12px;">Кликни на карту для разведки</div>
<div style="margin-top:8px; display:flex; gap:4px;">
<button onclick="setReconRadius(20)" class="recon-radius-btn active" data-km="20">20 км</button>
<button onclick="setReconRadius(50)" class="recon-radius-btn" data-km="50">50 км</button>
<button onclick="setReconRadius(100)" class="recon-radius-btn" data-km="100">100 км</button>
</div>
<button onclick="clearRecon()" style="width:100%; margin-top:6px; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:11px;">
✕ Сбросить
</button>
</div>
<!-- ─── Панель Связки (F-13) ────────────────────────────────────────────── -->
<div id="link-panel" style="display:none; position:absolute; bottom:40px;
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 style="font-weight:600; color:#e07b00; margin-bottom:8px;">🔗 Связка</div>
<div id="link-status" style="color:#888; font-size:12px; margin-bottom:8px;">1⃣ Кликни конец первого трека</div>
<div id="link-cards" style="margin-top:8px;"></div>
<button onclick="clearLink()" style="width:100%; margin-top:6px; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">
✕ Сбросить
</button>
</div>
<!-- ─── Панель Красивого маршрута (F-11) ────────────────────────────────── -->
<div id="scenic-panel" style="display:none; position:absolute; bottom:40px;
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 style="font-weight:600; color:#e07b00; margin-bottom:8px;">🎨 Красивый маршрут</div>
<div id="scenic-status" style="color:#888; font-size:12px; margin-bottom:8px;">📍 Точка старта: кликни на карте</div>
<div style="margin-bottom:8px;">
<span style="font-size:12px; color:#555;">📏 Дистанция:</span>
<div style="display:flex; gap:4px; margin-top:4px; flex-wrap:wrap;">
<button onclick="setScenicKm(50)" class="scenic-km-btn" data-km="50">50</button>
<button onclick="setScenicKm(100)" class="scenic-km-btn active" data-km="100">100</button>
<button onclick="setScenicKm(150)" class="scenic-km-btn" data-km="150">150</button>
<button onclick="setScenicKm(200)" class="scenic-km-btn" data-km="200">200</button>
<input type="number" id="scenic-custom-km" placeholder="км" min="20" max="500"
style="width:60px; padding:3px 6px; font-size:12px; border:1px solid #ccc; border-radius:4px; text-align:center;" />
</div>
</div>
<button onclick="buildScenicRoute()" id="btn-build-scenic"
style="width:100%; padding:6px; background:#ff6600; color:#fff; border:none; border-radius:4px; cursor:pointer; font-size:13px; font-weight:600;">
🎨 Построить маршрут
</button>
<div id="scenic-cards" style="margin-top:10px;"></div>
<button onclick="clearScenic()" style="width:100%; margin-top:6px; padding:4px; background:#f0f0f0; border:1px solid #ccc; border-radius:4px; cursor:pointer; font-size:12px;">
✕ Сбросить
</button>
</div>
<div id="map-controls-br" class="custom-map-ctrl">
<button id="btn-compass" class="map-ctrl-btn" title="Свободное вращение" onclick="toggleCompass()">🧭</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-markers" class="map-ctrl-btn" title="Добавить метку" onclick="toggleMarkerMode()">🚩</button>
<button id="btn-scenic" class="map-ctrl-btn" title="Красивый маршрут" onclick="toggleScenicMode()">🎨</button>
<button id="btn-link" class="map-ctrl-btn" title="Связка" onclick="toggleLinkMode()">🔗</button>
<button id="btn-recon" class="map-ctrl-btn" title="Разведка" onclick="toggleReconMode()">📍</button>
</div>
</div>