diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index 1404154..dcba177 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -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): """ diff --git a/tasks/enduro-trails/prototype/static/index.html b/tasks/enduro-trails/prototype/static/index.html index f4efbf1..c8b5687 100644 --- a/tasks/enduro-trails/prototype/static/index.html +++ b/tasks/enduro-trails/prototype/static/index.html @@ -130,12 +130,73 @@
+ + + + + + + + +