Feature: Rundweg-Vorschläge via OpenRouteService — 2/4/6 km, 3 Varianten, Navigation+Speichern — SW by-v478, APP_VER 455

This commit is contained in:
rene 2026-04-29 08:04:25 +02:00
parent b09a569689
commit 369eae5e5a
5 changed files with 396 additions and 19 deletions

View file

@ -12,3 +12,4 @@ anthropic==0.49.0
pywebpush==2.0.0 pywebpush==2.0.0
apscheduler==3.10.4 apscheduler==3.10.4
odfpy==1.4.1 odfpy==1.4.1
polyline==2.0.2

View file

@ -2,6 +2,7 @@
import json, math, os, uuid import json, math, os, uuid
import httpx import httpx
import polyline as _polyline
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
@ -14,6 +15,8 @@ from routes.push import send_push_to_user
router = APIRouter() 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 _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: 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 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 # GET /api/routes/{id} — Route mit vollem GPS-Track
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {

View file

@ -40,9 +40,15 @@ window.Page_routes = (() => {
let _recPolyline = null, _recLocMarker = null; let _recPolyline = null, _recLocMarker = null;
let _recWakeLock = null, _recInactTimer = null, _recDimmed = false; let _recWakeLock = null, _recInactTimer = null, _recDimmed = false;
// 'mine' | 'discover' // 'mine' | 'discover' | 'suggest'
let _browseMode = 'mine'; 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' // Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list'; let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte let _searchMap = null; // L.map Instanz der Suchkarte
@ -121,9 +127,10 @@ window.Page_routes = (() => {
<div class="rk-mode-toggle" id="rk-mode-toggle"> <div class="rk-mode-toggle" id="rk-mode-toggle">
<button class="rk-mode-btn${_browseMode==='mine'?' active':''}" id="rk-mode-mine">${UI.icon('user')} Meine Routen</button> <button class="rk-mode-btn${_browseMode==='mine'?' active':''}" id="rk-mode-mine">${UI.icon('user')} Meine Routen</button>
<button class="rk-mode-btn${_browseMode==='discover'?' active':''}" id="rk-mode-discover">${UI.icon('map-pin')} Entdecken</button> <button class="rk-mode-btn${_browseMode==='discover'?' active':''}" id="rk-mode-discover">${UI.icon('map-pin')} Entdecken</button>
<button class="rk-mode-btn${_browseMode==='suggest'?' active':''}" id="rk-mode-suggest">${UI.icon('sparkle')} Vorschläge</button>
</div> </div>
<!-- Zeile 2: Suche + View-Toggle (gleiche Höhe wie Aktions-Buttons) --> <!-- Zeile 2: Suche + View-Toggle (gleiche Höhe wie Aktions-Buttons) -->
<div style="display:flex;gap:8px;align-items:stretch;margin-bottom:var(--space-3)"> <div id="rk-search-row" style="display:flex;gap:8px;align-items:stretch;margin-bottom:var(--space-3)">
<div style="position:relative;flex:1;min-width:0"> <div style="position:relative;flex:1;min-width:0">
<svg class="ph-icon" aria-hidden="true" <svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:12px;top:50%;transform:translateY(-50%); style="position:absolute;left:12px;top:50%;transform:translateY(-50%);
@ -253,6 +260,7 @@ window.Page_routes = (() => {
// Mode toggle // Mode toggle
document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine')); document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine'));
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover')); document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover'));
document.getElementById('rk-mode-suggest').addEventListener('click', () => _setBrowseMode('suggest'));
} }
function _syncRecBtn() { function _syncRecBtn() {
@ -278,26 +286,306 @@ window.Page_routes = (() => {
_browseMode = mode; _browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine'); document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');
document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover'); document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover');
document.getElementById('rk-mode-suggest')?.classList.toggle('active', mode === 'suggest');
const recBtn = document.getElementById('rk-rec-btn'); const recBtn = document.getElementById('rk-rec-btn');
const impWrap = document.getElementById('rk-imp-wrap'); const impWrap = document.getElementById('rk-imp-wrap');
const mineGrp = document.getElementById('rk-mine-group'); const mineGrp = document.getElementById('rk-mine-group');
const nearbyGrp = document.getElementById('rk-nearby-group'); const nearbyGrp = document.getElementById('rk-nearby-group');
const searchRow = document.getElementById('rk-search-row'); // Zeile 2: Suche + View-Toggle
const filterBtn = document.getElementById('rk-filter-btn');
const actRow = filterBtn?.parentElement; // Zeile 3: Aktions-Buttons
if (mode === 'discover') { if (mode === 'suggest') {
if (recBtn) recBtn.style.display = 'none'; if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none'; if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.style.display = 'none'; if (mineGrp) mineGrp.style.display = 'none';
if (nearbyGrp && _userPos) nearbyGrp.style.display = '';
} else {
if (recBtn) recBtn.style.display = '';
if (impWrap) impWrap.style.display = '';
if (_appState.user && mineGrp) mineGrp.style.display = '';
if (nearbyGrp) nearbyGrp.style.display = 'none'; if (nearbyGrp) nearbyGrp.style.display = 'none';
if (searchRow) searchRow.style.display = 'none';
if (actRow) actRow.style.display = 'none';
const filterPanel = document.getElementById('rk-filter-panel');
if (filterPanel) filterPanel.style.display = 'none';
_renderSuggestTab();
} else {
if (searchRow) searchRow.style.display = '';
if (actRow) actRow.style.display = '';
if (mode === 'discover') {
if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.style.display = 'none';
if (nearbyGrp && _userPos) nearbyGrp.style.display = '';
} else {
if (recBtn) recBtn.style.display = '';
if (impWrap) impWrap.style.display = '';
if (_appState.user && mineGrp) mineGrp.style.display = '';
if (nearbyGrp) nearbyGrp.style.display = 'none';
}
_onlyMine = false;
document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active'));
_applyFilter();
} }
_onlyMine = false; }
document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active'));
_applyFilter(); // ----------------------------------------------------------
// Vorschläge-Tab
// ----------------------------------------------------------
function _renderSuggestTab() {
const grid = document.getElementById('rk-grid');
if (!grid) return;
// Leaflet-Karte aus vorherigem Besuch aufräumen
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
// Styles einmalig injizieren
if (!document.getElementById('rk-suggest-styles')) {
const style = document.createElement('style');
style.id = 'rk-suggest-styles';
style.textContent = `
.rks-km-chip {
flex:1;padding:14px 8px;border-radius:var(--radius-lg);
border:2px solid var(--c-border-light);background:var(--c-surface);
color:var(--c-text);font-size:1.1rem;font-weight:700;cursor:pointer;
transition:border-color .15s,background .15s,color .15s;text-align:center;
}
.rks-km-chip.active {
border-color:var(--c-primary);background:var(--c-primary);color:#fff;
}
.rks-var-btn {
flex:1;padding:8px 4px;border-radius:8px;font-size:0.8rem;font-weight:600;
border:1.5px solid var(--c-border-light);background:var(--c-surface);
color:var(--c-text-secondary);cursor:pointer;
transition:border-color .15s,background .15s,color .15s;
}
.rks-var-btn.active {
border-color:var(--c-primary);color:var(--c-primary);background:rgba(var(--c-primary-rgb,99,102,241),0.08);
}
#rks-map { border-radius:var(--radius-lg);overflow:hidden; }
`;
document.head.appendChild(style);
}
grid.innerHTML = `
<div style="padding:var(--space-4) var(--space-2);display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Distanz-Auswahl -->
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3)">
Gewünschte Distanz
</div>
<div style="display:flex;gap:var(--space-3)" id="rks-km-row">
<button class="rks-km-chip${_suggestKm===2?' active':''}" data-km="2">2 km</button>
<button class="rks-km-chip${_suggestKm===4?' active':''}" data-km="4">4 km</button>
<button class="rks-km-chip${_suggestKm===6?' active':''}" data-km="6">6 km</button>
</div>
</div>
<!-- Varianten-Auswahl -->
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3)">
Variante
</div>
<div style="display:flex;gap:var(--space-2)" id="rks-var-row">
<button class="rks-var-btn${_suggestSeed===0?' active':''}" data-seed="0">Variante 1</button>
<button class="rks-var-btn${_suggestSeed===1?' active':''}" data-seed="1">Variante 2</button>
<button class="rks-var-btn${_suggestSeed===2?' active':''}" data-seed="2">Variante 3</button>
</div>
</div>
<!-- Berechnen-Button -->
<button id="rks-calc-btn" style="${_btnStyle(true)}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
Route berechnen
</button>
<!-- Ergebnis-Bereich (initial leer) -->
<div id="rks-result"></div>
</div>
`;
// 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 = `
<div style="padding:var(--space-5);text-align:center;color:var(--c-text-secondary);
border:1.5px solid var(--c-border-light);border-radius:var(--radius-lg);
background:var(--c-surface)">
<svg class="ph-icon" aria-hidden="true" style="width:32px;height:32px;color:var(--c-text-muted);margin-bottom:var(--space-3)">
<use href="/icons/phosphor.svg#map-pin-slash"></use>
</svg>
<p style="margin:0;font-size:0.9rem">
Standort wird benötigt. Bitte erlaube den Zugriff in den Browser-Einstellungen.
</p>
</div>`;
return;
}
}
// Alten Karteninhalt aufräumen
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
// Spinner anzeigen
const res = document.getElementById('rks-result');
if (!res) return;
res.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;gap:var(--space-4);
padding:var(--space-6);color:var(--c-text-secondary)">
<div style="width:32px;height:32px;border:3px solid var(--c-border);
border-top-color:var(--c-primary);border-radius:50%;
animation:spin 0.8s linear infinite"></div>
<span style="font-size:0.9rem">Berechne Rundweg</span>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
`;
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 = `
<div style="padding:var(--space-4);border-radius:var(--radius-lg);
background:rgba(220,38,38,0.08);border:1px solid rgba(220,38,38,0.25);
color:#f87171;font-size:0.9rem">
${UI.icon('warning')} ${UI.escape(err.message || 'Fehler beim Berechnen des Rundwegs.')}
</div>`;
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 = `
<!-- Karte -->
<div id="rks-map" style="height:250px;background:var(--c-surface);margin-bottom:var(--space-3)"></div>
<!-- Info-Zeile -->
<div style="display:flex;gap:var(--space-3);align-items:center;flex-wrap:wrap;margin-bottom:var(--space-4)">
<span style="${_pillStyle('rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)')}">
${UI.icon('map-trifold')} ${UI.escape(distStr)}
</span>
<span style="${_pillStyle('rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)')}">
${UI.icon('timer')} ${UI.escape(durStr)}
</span>
${diffLabel ? `<span style="${_pillStyle(
{leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'}[result.schwierigkeit]||'rgba(107,114,128,0.10)',
{leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'}[result.schwierigkeit]||'#9ca3af',
{leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'}[result.schwierigkeit]||'rgba(107,114,128,0.30)')}">${UI.escape(diffLabel)}</span>` : ''}
<span style="font-size:0.85rem;color:var(--c-text-secondary);flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(result.name || '')}</span>
</div>
<!-- Aktions-Buttons -->
<div style="display:flex;gap:var(--space-3)">
<button id="rks-nav-btn" style="${_btnStyle(false)}flex:1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#navigation-arrow"></use></svg>
Navigation starten
</button>
<button id="rks-save-btn" style="${_btnStyle(true)}flex:1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
Route speichern
</button>
</div>
`;
// 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 = '<div style="height:100%;display:flex;align-items:center;justify-content:center;color:var(--c-text-muted)">Kein Track vorhanden</div>'; 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() { async function _loadDataNearby() {

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v477'; const CACHE_VERSION = 'by-v478';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten