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:
parent
b09a569689
commit
369eae5e5a
5 changed files with 396 additions and 19 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue