auto-sync: 2026-05-03 20:00:01
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
| 🔗 **"Связка"** | Соединить два трека грунтовками |
|
||||
| 📍 **"Разведка"** | Грунтовки вокруг точки |
|
||||
| 🚧 **"Препятствия"** | Броды, шлагбаумы, болота, ЛЭП |
|
||||
| 🌐 **"Народные треки"** | Сбор и отображение треков с внешних сервисов |
|
||||
|
||||
## Регионы
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
101
tasks/enduro-trails/prototype/static/app.js
vendored
101
tasks/enduro-trails/prototype/static/app.js
vendored
@@ -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();
|
||||
|
||||
@@ -90,8 +90,19 @@
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
28
tasks/enduro-trails/reports/dev-2026-05-03-osrm-routing.md
Normal file
28
tasks/enduro-trails/reports/dev-2026-05-03-osrm-routing.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Dev Report: Enduro Trails — OSRM роутинг "Дикий путь"
|
||||
Дата: 2026-05-03
|
||||
Статус: IN PROGRESS
|
||||
|
||||
## Задача
|
||||
Добавить роутинг "Дикий путь": маршрут А→Б с максимизацией грунтовых дорог.
|
||||
Фаза 1: OSRM на сервере (Lua профиль + Docker + сборка графа)
|
||||
Фаза 2: API endpoint в app.py
|
||||
Фаза 3: Фронт (кнопка, панель, JS логика)
|
||||
|
||||
## Сделано
|
||||
- [x] requirements.txt — добавлен httpx==0.27.0
|
||||
- [x] app.py — импорт httpx, OSRM_URL, endpoint /api/route
|
||||
- [x] index.html — кнопка 🗺️, route-panel
|
||||
- [x] app.js — toggleRouteMode, clearRoute, buildRoute, initRouteClicks
|
||||
- [ ] Lua профиль на сервере
|
||||
- [ ] docker-compose.yml на сервере
|
||||
- [ ] Сборка OSRM графа
|
||||
- [ ] Деплой приложения
|
||||
|
||||
## Изменённые файлы
|
||||
- `prototype/requirements.txt` — добавлен httpx
|
||||
- `prototype/app.py` — OSRM_URL + /api/route endpoint
|
||||
- `prototype/static/index.html` — кнопка + route-panel
|
||||
- `prototype/static/app.js` — JS роутинг
|
||||
|
||||
## Следующий шаг
|
||||
Создать файлы на сервере, собрать граф, задеплоить.
|
||||
Reference in New Issue
Block a user