diff --git a/tasks/enduro-trails/PROJECT.md b/tasks/enduro-trails/PROJECT.md index df48c2d..928d475 100644 --- a/tasks/enduro-trails/PROJECT.md +++ b/tasks/enduro-trails/PROJECT.md @@ -22,6 +22,7 @@ | 🔗 **"Связка"** | Соединить два трека грунтовками | | 📍 **"Разведка"** | Грунтовки вокруг точки | | 🚧 **"Препятствия"** | Броды, шлагбаумы, болота, ЛЭП | +| 🌐 **"Народные треки"** | Сбор и отображение треков с внешних сервисов | ## Регионы diff --git a/tasks/enduro-trails/prototype/app.py b/tasks/enduro-trails/prototype/app.py index 4c4ebf9..7c2d80e 100644 --- a/tasks/enduro-trails/prototype/app.py +++ b/tasks/enduro-trails/prototype/app.py @@ -18,6 +18,7 @@ from functools import lru_cache from fastapi import FastAPI, HTTPException, Response from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +import httpx import uvicorn # ─── Tile cache ────────────────────────────────────────────────────────────── @@ -43,6 +44,7 @@ DATA_PATH = os.environ.get( "DATA_PATH", os.path.join(os.path.dirname(__file__), "../data/centralfederal.sqlite"), ) +OSRM_URL = os.environ.get("OSRM_URL", "http://172.22.0.1:5559") DATA_PATH = os.path.abspath(DATA_PATH) STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") PORT = int(os.environ.get("PORT", 5558)) @@ -315,6 +317,43 @@ async def get_tile(z: int, x: int, y: int): ) +@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""" + url = ( + f"{OSRM_URL}/route/v1/driving/" + f"{from_lon},{from_lat};{to_lon},{to_lat}" + f"?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, "Маршрут не найден") + + route = data["routes"][0] + geometry = route["geometry"] + distance_m = route["distance"] + duration_s = route["duration"] + + return { + "type": "Feature", + "geometry": geometry, + "properties": { + "distance_m": round(distance_m), + "distance_km": round(distance_m / 1000, 1), + "duration_min": round(duration_s / 60), + } + } + + @app.get("/api/health") async def health(): return { diff --git a/tasks/enduro-trails/prototype/requirements.txt b/tasks/enduro-trails/prototype/requirements.txt index 83438c5..3ea81d2 100644 --- a/tasks/enduro-trails/prototype/requirements.txt +++ b/tasks/enduro-trails/prototype/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.111.0 uvicorn==0.29.0 shapely==2.0.4 mapbox-vector-tile==2.2.0 +httpx==0.27.0 diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js index 0451be0..13840cf 100644 --- a/tasks/enduro-trails/prototype/static/app.js +++ b/tasks/enduro-trails/prototype/static/app.js @@ -111,6 +111,7 @@ async function initMap() { map.on('load', () => { document.getElementById('loading').classList.remove('visible'); checkDataAvailability(); + initRouteClicks(map); }); map.on('error', (e) => { @@ -209,4 +210,104 @@ async function checkDataAvailability() { } } +// ─── Роутинг ────────────────────────────────────────────────────────────────────────────── +let routeMode = false; +let routeStart = null; +let routeEnd = null; +let routeMarkers = []; +let routeLayer = null; + +function toggleRouteMode() { + routeMode = !routeMode; + const btn = document.getElementById('btn-route'); + const panel = document.getElementById('route-panel'); + if (routeMode) { + btn.classList.add('active'); + panel.style.display = 'block'; + clearRoute(); + window._map.getCanvas().style.cursor = 'crosshair'; + } else { + btn.classList.remove('active'); + panel.style.display = 'none'; + clearRoute(); + window._map.getCanvas().style.cursor = ''; + } +} + +function clearRoute() { + routeStart = null; + routeEnd = null; + routeMarkers.forEach(m => m.remove()); + routeMarkers = []; + const map = window._map; + if (map.getLayer('route-line')) map.removeLayer('route-line'); + if (map.getSource('route')) map.removeSource('route'); + document.getElementById('route-status').textContent = 'Кликни точку старта'; + document.getElementById('route-info').style.display = 'none'; + if (routeMode) map.getCanvas().style.cursor = 'crosshair'; +} + +async function buildRoute() { + const map = window._map; + document.getElementById('route-status').textContent = '⏳ Строю маршрут...'; + + const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; + try { + const resp = await fetch( + `${basePath}/api/route?from_lon=${routeStart[0]}&from_lat=${routeStart[1]}&to_lon=${routeEnd[0]}&to_lat=${routeEnd[1]}` + ); + if (!resp.ok) throw new Error('Маршрут не найден'); + const data = await resp.json(); + + // Рисуем линию + if (map.getSource('route')) { + map.getSource('route').setData(data); + } else { + map.addSource('route', { type: 'geojson', data }); + map.addLayer({ + id: 'route-line', + type: 'line', + source: 'route', + paint: { + 'line-color': '#0066ff', + 'line-width': 4, + 'line-opacity': 0.85, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' } + }); + } + + // Показываем статистику + const p = data.properties; + document.getElementById('route-distance').textContent = `${p.distance_km} км`; + document.getElementById('route-duration').textContent = `~${p.duration_min} мин`; + document.getElementById('route-status').textContent = '✅ Готово'; + document.getElementById('route-info').style.display = 'block'; + } catch(e) { + document.getElementById('route-status').textContent = '❌ ' + e.message; + } +} + +// Клик на карте в режиме роутинга +function initRouteClicks(map) { + map.on('click', (e) => { + if (!routeMode) return; + const { lng, lat } = e.lngLat; + + if (!routeStart) { + routeStart = [lng, lat]; + const el = document.createElement('div'); + el.style.cssText = 'width:14px;height:14px;background:#00aa00;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)'; + routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map)); + document.getElementById('route-status').textContent = 'Кликни точку финиша'; + } else if (!routeEnd) { + routeEnd = [lng, lat]; + const el = document.createElement('div'); + el.style.cssText = 'width:14px;height:14px;background:#cc0000;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,0.3)'; + routeMarkers.push(new maplibregl.Marker({element: el}).setLngLat([lng, lat]).addTo(map)); + buildRoute(); + } + }); +} + initMap(); diff --git a/tasks/enduro-trails/prototype/static/index.html b/tasks/enduro-trails/prototype/static/index.html index 2806271..082c77c 100644 --- a/tasks/enduro-trails/prototype/static/index.html +++ b/tasks/enduro-trails/prototype/static/index.html @@ -90,8 +90,19 @@