auto-sync: 2026-05-03 20:00:01

This commit is contained in:
Stream
2026-05-03 20:00:01 +03:00
parent da67ad1615
commit fb889f84ee
6 changed files with 181 additions and 0 deletions

View File

@@ -22,6 +22,7 @@
| 🔗 **"Связка"** | Соединить два трека грунтовками |
| 📍 **"Разведка"** | Грунтовки вокруг точки |
| 🚧 **"Препятствия"** | Броды, шлагбаумы, болота, ЛЭП |
| 🌐 **"Народные треки"** | Сбор и отображение треков с внешних сервисов |
## Регионы

View File

@@ -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 {

View File

@@ -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

View File

@@ -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();

View File

@@ -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>

View 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 роутинг
## Следующий шаг
Создать файлы на сервере, собрать граф, задеплоить.