diff --git a/backend/requirements.txt b/backend/requirements.txt index 25c2274..7b268fa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,4 @@ anthropic==0.49.0 pywebpush==2.0.0 apscheduler==3.10.4 odfpy==1.4.1 +polyline==2.0.2 diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 8205af7..5d27e04 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -2,6 +2,7 @@ import json, math, os, uuid import httpx +import polyline as _polyline from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional, List @@ -14,6 +15,8 @@ from routes.push import send_push_to_user router = APIRouter() +ORS_API_KEY = os.getenv("ORS_API_KEY") + _MAX_AVG_KMH = 15.0 # Über diesem Wert wird die Route nicht für Stats/Trophäen gewertet def _check_speed(distanz_km, dauer_min) -> bool: @@ -172,6 +175,91 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)): return result +# ------------------------------------------------------------------ +# POST /api/routes/suggest — Rundweg-Vorschlag via OpenRouteService +# ------------------------------------------------------------------ +class SuggestRequest(BaseModel): + lat: float + lon: float + distance_km: float # Zieldistanz in km (z.B. 2.0, 4.0, 6.0) + seed: int = 0 # 0-4: verschiedene Routenvarianten + +@router.post("/suggest") +async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)): + if not (0.5 <= data.distance_km <= 15): + raise HTTPException(400, "distance_km muss zwischen 0.5 und 15 liegen.") + + if not ORS_API_KEY: + raise HTTPException(503, "ORS nicht konfiguriert") + + payload = { + "coordinates": [[data.lon, data.lat]], + "options": { + "round_trip": { + "length": data.distance_km * 1000, + "points": 5, + "seed": data.seed, + }, + "avoid_features": ["ferries", "steps"], + }, + "units": "m", + "geometry": True, + "instructions": False, + } + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + "https://api.openrouteservice.org/v2/directions/foot-walking", + headers={ + "Authorization": f"Bearer {ORS_API_KEY}", + "Content-Type": "application/json", + }, + json=payload, + ) + except httpx.TimeoutException: + raise HTTPException(504, "ORS-Anfrage hat das Zeitlimit überschritten.") + + if resp.status_code != 200: + try: + detail = resp.json() + except Exception: + detail = resp.text + raise HTTPException(502, f"ORS-Fehler: {detail}") + + body = resp.json() + try: + route = body["routes"][0] + geometry = route["geometry"] + summary = route["summary"] + distanz_m = summary.get("distance", data.distance_km * 1000) + dauer_s = summary.get("duration", 0) + except (KeyError, IndexError) as exc: + raise HTTPException(502, f"Unerwartete ORS-Antwort: {exc}") + + # encoded polyline → [[lat, lon], ...] + points = _polyline.decode(geometry) + gps_track = [{"lat": p[0], "lon": p[1]} for p in points] + + distanz_km = round(distanz_m / 1000, 2) + dauer_min = max(1, round(dauer_s / 60)) + + if distanz_km < 3: + schwierigkeit = "leicht" + elif distanz_km <= 5: + schwierigkeit = "mittel" + else: + schwierigkeit = "anspruchsvoll" + + return { + "name": f"Rundweg {distanz_km:.0f} km", + "gps_track": gps_track, + "distanz_km": distanz_km, + "dauer_min": dauer_min, + "schwierigkeit": schwierigkeit, + } + + # ------------------------------------------------------------------ # GET /api/routes/{id} — Route mit vollem GPS-Track # ------------------------------------------------------------------ diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 74c8701..104bfba 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '454'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '455'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index b3591d9..7f4f6d9 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -40,9 +40,15 @@ window.Page_routes = (() => { let _recPolyline = null, _recLocMarker = null; let _recWakeLock = null, _recInactTimer = null, _recDimmed = false; - // 'mine' | 'discover' + // 'mine' | 'discover' | 'suggest' let _browseMode = 'mine'; + // Vorschläge-Tab state + let _suggestKm = 4; // gewählte Distanz: 2, 4 oder 6 + let _suggestSeed = 0; // Variante: 0, 1, 2 + let _suggestResult = null; // letzte API-Antwort + let _suggestMap = null; // Leaflet-Instanz der Vorschau-Karte + // Ansichts-Modus: 'list' | 'map' let _viewMode = 'list'; let _searchMap = null; // L.map Instanz der Suchkarte @@ -121,9 +127,10 @@ window.Page_routes = (() => {
+
-
+
+
+ Gewünschte Distanz +
+
+ + + +
+
+ + +
+
+ Variante +
+
+ + + +
+
+ + + + + +
+ +
+ `; + + // Distanz-Chips + grid.querySelector('#rks-km-row').addEventListener('click', e => { + const btn = e.target.closest('.rks-km-chip'); + if (!btn) return; + _suggestKm = parseInt(btn.dataset.km, 10); + grid.querySelectorAll('.rks-km-chip').forEach(b => b.classList.toggle('active', b === btn)); + }); + + // Varianten-Buttons + grid.querySelector('#rks-var-row').addEventListener('click', e => { + const btn = e.target.closest('.rks-var-btn'); + if (!btn) return; + _suggestSeed = parseInt(btn.dataset.seed, 10); + grid.querySelectorAll('.rks-var-btn').forEach(b => b.classList.toggle('active', b === btn)); + }); + + // Berechnen + grid.querySelector('#rks-calc-btn').addEventListener('click', _calcSuggestRoute); + } + + async function _calcSuggestRoute() { + // Standort prüfen + if (!_userPos) { + try { _userPos = await API.getLocation(); } catch { + const res = document.getElementById('rks-result'); + if (res) res.innerHTML = ` +
+ +

+ Standort wird benötigt. Bitte erlaube den Zugriff in den Browser-Einstellungen. +

+
`; + return; + } + } + + // Alten Karteninhalt aufräumen + if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; } + + // Spinner anzeigen + const res = document.getElementById('rks-result'); + if (!res) return; + res.innerHTML = ` +
+
+ Berechne Rundweg… +
+ + `; + + const calcBtn = document.getElementById('rks-calc-btn'); + if (calcBtn) calcBtn.disabled = true; + + let result; + try { + result = await API.post('/routes/suggest', { + lat: _userPos.lat, + lon: _userPos.lon, + distance_km: _suggestKm, + seed: _suggestSeed, + }); + _suggestResult = result; + } catch (err) { + if (res) res.innerHTML = ` +
+ ${UI.icon('warning')} ${UI.escape(err.message || 'Fehler beim Berechnen des Rundwegs.')} +
`; + if (calcBtn) calcBtn.disabled = false; + return; + } + if (calcBtn) calcBtn.disabled = false; + + // Ergebnis rendern + const distStr = result.distanz_km ? result.distanz_km.toFixed(2) + ' km' : '–'; + const durStr = result.dauer_min + ? (result.dauer_min < 60 ? result.dauer_min + ' min' : Math.floor(result.dauer_min/60) + 'h ' + (result.dauer_min%60||'') + 'min').trim() + : '–'; + const diffLabel = { leicht: 'Leicht', mittel: 'Mittel', anspruchsvoll: 'Schwer' }[result.schwierigkeit] || ''; + + if (!res) return; + res.innerHTML = ` + +
+ + +
+ + ${UI.icon('map-trifold')} ${UI.escape(distStr)} + + + ${UI.icon('timer')} ${UI.escape(durStr)} + + ${diffLabel ? `${UI.escape(diffLabel)}` : ''} + ${UI.escape(result.name || '')} +
+ + +
+ + +
+ `; + + // Leaflet-Karte mit dem berechneten Track + const _initMap = () => { + const mapEl = document.getElementById('rks-map'); + if (!mapEl || !window.L) return; + if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; } + + const track = result.gps_track || []; + if (track.length < 2) { mapEl.innerHTML = '
Kein Track vorhanden
'; return; } + + const lls = track.map(p => [p.lat, p.lon]); + _suggestMap = L.map(mapEl, { zoomControl: false, attributionControl: false, + dragging: true, touchZoom: true, scrollWheelZoom: false }); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_suggestMap); + const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.9 }).addTo(_suggestMap); + L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1, weight:2 }).addTo(_suggestMap); + L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1, weight:2 }).addTo(_suggestMap); + _addRouteArrows(_suggestMap, track, '#3b82f6'); + _suggestMap.fitBounds(poly.getBounds(), { padding: [16, 16] }); + setTimeout(() => _suggestMap?.invalidateSize(), 120); + }; + + if (window.L) { + _initMap(); + } else { + let tries = 0; + const poll = setInterval(() => { + if (window.L || ++tries > 40) { clearInterval(poll); if (window.L) _initMap(); } + }, 100); + } + + // Navigation starten + document.getElementById('rks-nav-btn')?.addEventListener('click', () => { + if (!_suggestResult) return; + const route = { + id: 'suggest-' + Date.now(), + name: _suggestResult.name, + gps_track: _suggestResult.gps_track, + distanz_km: _suggestResult.distanz_km, + }; + _openNavOverlay(route); + }); + + // Route speichern + document.getElementById('rks-save-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('rks-save-btn'); + if (!btn || !_suggestResult) return; + await UI.asyncButton(btn, async () => { + await API.post('/routes', { + name: _suggestResult.name, + gps_track: _suggestResult.gps_track, + distanz_km: _suggestResult.distanz_km, + dauer_min: _suggestResult.dauer_min, + schwierigkeit: _suggestResult.schwierigkeit, + }); + UI.toast.success('Route gespeichert!'); + await _loadData(); + _setBrowseMode('mine'); + }); + }); } async function _loadDataNearby() { diff --git a/backend/static/sw.js b/backend/static/sw.js index 74c7811..4017bfe 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v477'; +const CACHE_VERSION = 'by-v478'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten