auto-sync: 2026-05-04 23:40:01
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user