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

@ -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 = (() => {

View file

@ -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 = (() => {
<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==='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>
<!-- 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">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:12px;top:50%;transform:translateY(-50%);
@ -253,6 +260,7 @@ window.Page_routes = (() => {
// Mode toggle
document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine'));
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover'));
document.getElementById('rk-mode-suggest').addEventListener('click', () => _setBrowseMode('suggest'));
}
function _syncRecBtn() {
@ -278,26 +286,306 @@ window.Page_routes = (() => {
_browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');
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 impWrap = document.getElementById('rk-imp-wrap');
const mineGrp = document.getElementById('rk-mine-group');
const recBtn = document.getElementById('rk-rec-btn');
const impWrap = document.getElementById('rk-imp-wrap');
const mineGrp = document.getElementById('rk-mine-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 (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 (mode === 'suggest') {
if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.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() {

View file

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