/* ============================================================ BAN YARO — Gassi-Routen (Komoot-Stil) Sammlung, Suche, Bewertung, GPX-Download, Fotos, Nearby POIs ============================================================ */ window.Page_routes = (() => { let _container = null; let _appState = null; let _data = []; let _filtered = []; let _userPos = null; let _search = ''; let _difficulty = ''; let _terrain = ''; let _sortBy = 'newest'; let _onlyMine = false; let _isRecording = false; let _filterOpen = false; // Navigation-Overlay state let _navOvl = null, _navMap = null, _navWatchId = null; let _navWakeLock = null, _navInactTimer = null, _navDimmed = false; let _navCurrentIdx = 0; let _navPois = []; let _navMaxIdx = 0; let _navWalkMeta = null; // { routeId, totalKm, trackLen } let _navOrientCleanup = null; let _navLastBearing = null; let _navCompassHeading = null; let _navHeadingSmoothed = null; // Recording-Overlay state let _recOvl = null, _recMap = null; let _recActive = false; let _recTrack = [], _recDistKm = 0, _recStartTime = null; let _recTimerInt = null, _recWatchId = null; let _recPolyline = null, _recLocMarker = null; let _recWakeLock = null, _recInactTimer = null, _recDimmed = false; // '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 let _searchLines = new Map(); // routeId → { line, route } // Mini-Karten auf den Route-Cards let _miniMaps = new Map(); // routeId → L.map const DIFFICULTY_LABEL = { leicht: 'Leicht', mittel: 'Mittel', anspruchsvoll: 'Schwer' }; const TERRAIN_LABEL = { wald: 'Wald', asphalt: 'Asphalt', wiese: 'Wiese', mix: 'Mix' }; const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' }; const HUNDE_TEXT = { eingeschränkt: 'Leine', gut: 'gut', sehr_gut: 'sehr gut', premium: 'premium' }; const DIFF_COLOR = { leicht: 'background:rgba(22,163,74,0.10);color:#4ade80;border-color:rgba(22,163,74,0.30)', mittel: 'background:rgba(234,179,8,0.10);color:#facc15;border-color:rgba(234,179,8,0.30)', anspruchsvoll: 'background:rgba(220,38,38,0.10);color:#f87171;border-color:rgba(220,38,38,0.30)' }; // POI-Typen entlang der Route — nur relevante/interessante Orte const NEARBY_TYPES = [ { type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café', svgIcon: 'fork-knife', color: '#F97316' }, { type: 'tierarzt', icon: '🏥', label: 'Tierarzt', svgIcon: 'first-aid', color: '#EF4444' }, { type: 'shop', icon: '🐾', label: 'Zoobedarf', svgIcon: 'shopping-cart', color: '#3B82F6' }, ]; // _esc und _emptyState ersetzt durch UI.escape() / UI.emptyState() async function init(container, appState, params = {}) { _container = container; _appState = appState; // Vorberechneter Vorschlag vom Welcome-Chip → direkt in Suggest-Tab anzeigen if (params._suggestResult) { _suggestResult = params._suggestResult; _suggestKm = params._suggestKm || _suggestKm; _suggestSeed = params._suggestSeed || _suggestSeed; _browseMode = 'suggest'; } _render(); UI.loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden try { _userPos = await API.getLocation(); } catch {} await _loadData(); // Vorschlag sofort rendern (Leaflet war noch nicht bereit bei _render) if (params._suggestResult) { _renderSuggestTab(); _showSuggestResult(params._suggestResult); } // Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen const urlParams = new URLSearchParams((location.hash.split('?')[1] || '')); const deepId = urlParams.get('id'); if (deepId) { _openDetail(parseInt(deepId, 10)); } } function refresh() { // Button-Zeile neu rendern damit style-Änderungen nach einem JS-Update sichtbar werden const btnRow = document.querySelector('#rk-filter-btn')?.parentElement; if (btnRow) { btnRow.innerHTML = ` `; document.getElementById('rk-filter-btn').addEventListener('click', _toggleFilterPanel); document.getElementById('rk-rec-btn').addEventListener('click', _openRecOvl); document.getElementById('rk-import-input').addEventListener('change', e => { const file = e.target.files?.[0]; if (file) _importFile(file); e.target.value = ''; }); _updateFilterBadge(); } _syncRecBtn(); _loadData(); } function onDogChange() {} // ---------------------------------------------------------- // Render // ---------------------------------------------------------- function _render() { _container.innerHTML = `
Lädt Routen…
`; let _searchTimer = null; document.getElementById('rk-search').addEventListener('input', e => { clearTimeout(_searchTimer); _searchTimer = setTimeout(() => { _search = e.target.value.toLowerCase().trim(); _applyFilter(); }, 300); }); document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list')); document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map')); document.getElementById('rk-filter-btn').addEventListener('click', _toggleFilterPanel); document.getElementById('rk-rec-btn').addEventListener('click', _openRecOvl); document.getElementById('rk-import-input').addEventListener('change', e => { const file = e.target.files?.[0]; if (file) _importFile(file); e.target.value = ''; // reset so same file can be re-selected }); document.getElementById('rk-filters').addEventListener('click', e => { const chip = e.target.closest('.rk-chip'); if (!chip) return; const { filter, val } = chip.dataset; chip.closest('.rk-chips-row').querySelectorAll('.rk-chip') .forEach(c => c.classList.remove('active')); chip.classList.add('active'); if (filter === 'difficulty') _difficulty = val; if (filter === 'terrain') _terrain = val; if (filter === 'sort') _sortBy = val; if (filter === 'mine') _onlyMine = chip.classList.contains('active') && val === 'mine'; if (filter === 'nearby') { _loadDataNearby(); return; } // async, calls _applyFilter itself _updateFilterBadge(); _applyFilter(); }); // 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() { // no-op: recording now handled by self-contained overlay } function _toggleFilterPanel() { _filterOpen = !_filterOpen; const panel = document.getElementById('rk-filter-panel'); const btn = document.getElementById('rk-filter-btn'); if (panel) panel.style.display = _filterOpen ? '' : 'none'; if (btn) btn.classList.toggle('active', _filterOpen); } function _updateFilterBadge() { const badge = document.getElementById('rk-filter-badge'); if (!badge) return; const hasFilter = _difficulty !== '' || _terrain !== '' || _sortBy !== 'newest' || _onlyMine; badge.style.display = hasFilter ? '' : 'none'; } function _setBrowseMode(mode) { _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 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 === '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'; if (!App.hasPro(_appState?.user)) { document.getElementById('rk-list')?.replaceChildren(); const gate = document.createElement('div'); gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)'; gate.innerHTML = `
Ban Yaro Pro
Routenvorschläge sind ein Pro-Feature.
`; document.getElementById('rk-list')?.appendChild(gate); } else { _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(); } } // ---------------------------------------------------------- // 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 = `
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) { const is429 = err.status === 429 || String(err.message).includes('Wochenlimit'); if (res) res.innerHTML = `

${is429 ? 'Wochenlimit erreicht' : 'Fehler beim Berechnen'}

${is429 ? 'Du hast diese Woche alle 20 Routenvorschläge genutzt. Montag gibt es neue.' : UI.escape(err.message || 'Unbekannter Fehler')}

`; if (calcBtn) calcBtn.disabled = false; return; } if (calcBtn) calcBtn.disabled = false; _showSuggestResult(result); } function _showSuggestResult(result) { _suggestResult = result; const res = document.getElementById('rks-result'); if (!res) return; 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] || ''; const limitHint = (result.weekly_remaining != null) ? `

Noch ${result.weekly_remaining} von 20 Anfragen diese Woche

` : ''; res.innerHTML = ` ${limitHint}
${UI.icon('map-trifold')} ${UI.escape(distStr)} ${UI.icon('timer')} ${UI.escape(durStr)} ${diffLabel ? `${UI.escape(diffLabel)}` : ''} ${UI.escape(result.name || '')}
`; 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) 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); } document.getElementById('rks-nav-btn')?.addEventListener('click', () => { _openNavOverlay({ id: 'suggest-' + Date.now(), name: result.name, gps_track: result.gps_track, distanz_km: result.distanz_km }); }); document.getElementById('rks-save-btn')?.addEventListener('click', async () => { const btn = document.getElementById('rks-save-btn'); if (!btn) return; await UI.asyncButton(btn, async () => { await API.post('/routes', { name: result.name, gps_track: result.gps_track, distanz_km: result.distanz_km, dauer_min: result.dauer_min, schwierigkeit: result.schwierigkeit }); UI.toast.success('Route gespeichert!'); await _loadData(); _setBrowseMode('mine'); }); }); } async function _loadDataNearby() { if (!_userPos) { try { _userPos = await API.getLocation(); } catch { UI.toast.warning('Standort nicht verfügbar.'); return; } } try { _data = await API.routes.listNearby(_userPos.lat, _userPos.lon, 10000); _applyFilter(); } catch (err) { UI.toast.error('Fehler beim Laden: ' + err.message); } } // ---------------------------------------------------------- // Recording Overlay // ---------------------------------------------------------- function _haversineKm(lat1, lon1, lat2, lon2) { const R = 6371, dLat = (lat2-lat1)*Math.PI/180, dLon = (lon2-lon1)*Math.PI/180; const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } async function _openRecOvl() { if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } if (_recOvl) return; await UI.loadLeaflet?.() ?? Promise.resolve(); const ovl = document.createElement('div'); ovl.id = 'rk-rec-ovl'; ovl.style.cssText = 'position:fixed;inset:0;z-index:900;display:flex;flex-direction:column;background:var(--c-bg)'; ovl.innerHTML = `
`; document.body.appendChild(ovl); _recOvl = ovl; const pos = _userPos || { lat: 48.1, lon: 11.5 }; _recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false }) .setView([pos.lat, pos.lon], 15); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); _recLocMarker = L.circleMarker([pos.lat, pos.lon], { radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1 }).addTo(_recMap); // Get accurate position try { const p = await API.getLocation(); _userPos = p; _recMap.setView([p.lat, p.lon], 16); _recLocMarker.setLatLng([p.lat, p.lon]); } catch {} ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean()); ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl); } async function _startRecInOvl() { if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; } _recActive = true; _recTrack = []; _recDistKm = 0; _recStartTime = Date.now(); // iOS-Hinweis: Display muss wach bleiben if (/iPad|iPhone|iPod/.test(navigator.userAgent)) { const banner = document.createElement('div'); banner.style.cssText = 'position:absolute;top:0;left:0;right:0;z-index:960;' + 'background:rgba(30,30,30,0.92);color:#fff;font-size:13px;line-height:1.4;' + 'padding:10px 14px;display:flex;align-items:flex-start;gap:10px;' + 'border-bottom:1px solid rgba(255,255,255,0.1)'; banner.innerHTML = ` Display wach lassen! Auf iPhone stoppt die GPS-Aufzeichnung, wenn das Display ausgeht — Helligkeit hochsetzen oder Bildschirm nicht sperren.`; document.getElementById('rk-rec-map-wrap')?.appendChild(banner); setTimeout(() => banner.remove(), 9000); } const ctrl = document.getElementById('rk-rec-ctrl'); ctrl.innerHTML = ` `; // Long-Press 1.8s zum Stoppen let _stopTimer = null, _stopTick = null; const btn = ctrl.querySelector('#rk-rec-stopbtn'); const fill = ctrl.querySelector('#rk-stop-fill'); const startHold = () => { if (_stopTimer) return; const DURATION = 1800; const start = Date.now(); _stopTick = setInterval(() => { const p = Math.min((Date.now() - start) / DURATION, 1); fill.style.transition = 'none'; fill.style.transform = `scaleX(${p})`; }, 30); _stopTimer = setTimeout(() => { clearInterval(_stopTick); _stopTick = null; _stopTimer = null; fill.style.transform = 'scaleX(1)'; _stopRecInOvl(true); }, DURATION); }; const cancelHold = () => { if (!_stopTimer && !_stopTick) return; clearTimeout(_stopTimer); clearInterval(_stopTick); _stopTimer = null; _stopTick = null; fill.style.transition = 'transform 0.25s ease'; fill.style.transform = 'scaleX(0)'; }; btn.addEventListener('pointerdown', e => { e.preventDefault(); startHold(); }); btn.addEventListener('pointerup', cancelHold); btn.addEventListener('pointerleave', cancelHold); btn.addEventListener('pointercancel', cancelHold); document.getElementById('rk-rec-stats-bar').style.display = ''; _recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap); await _recAcquireWakeLock(); document.addEventListener('visibilitychange', _recOnVisibility); _recWatchId = navigator.geolocation.watchPosition(pos => { const lat = pos.coords.latitude, lon = pos.coords.longitude; const alt = pos.coords.altitude ?? null; if (_recTrack.length) { const prev = _recTrack[_recTrack.length - 1]; const d = _haversineKm(prev.lat, prev.lon, lat, lon); if (d < 0.003) return; _recDistKm += d; } _recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) }); _recPolyline.addLatLng([lat, lon]); _recLocMarker.setLatLng([lat, lon]); if (_recTrack.length === 1) _recMap.setView([lat, lon], 16); _updateRecStats(); }, () => {}, { enableHighAccuracy: true, maximumAge: 2000 }); _recTimerInt = setInterval(_updateRecStats, 1000); _resetRecInactTimer(); // Interaktion auf der Aufzeichnungsseite → Inaktivitäts-Timer zurücksetzen _recOvl.addEventListener('touchstart', _onRecOvlTouch, { passive: true }); _recOvl.addEventListener('pointerdown', _onRecOvlTouch); // Long-Press auf Fingerabdruck-Button → nach 2s zurück zur Aufzeichnung const dim = document.getElementById('rk-rec-dim'); const unlockBtn = document.getElementById('rk-dim-unlock-btn'); let _lpTimer = null; const cancelLp = () => { clearTimeout(_lpTimer); const prog = document.getElementById('rk-dim-prog'); if (prog) { prog.style.transition = 'none'; prog.style.strokeDashoffset = '150.8'; } }; unlockBtn.addEventListener('pointerdown', e => { e.stopPropagation(); unlockBtn.setPointerCapture(e.pointerId); const prog = document.getElementById('rk-dim-prog'); if (prog) { prog.style.transition = 'stroke-dashoffset 2s linear'; prog.style.strokeDashoffset = '0'; } _lpTimer = setTimeout(() => { dim.style.display = 'none'; _recDimmed = false; _resetRecInactTimer(); }, 2000); }); unlockBtn.addEventListener('pointerup', cancelLp); unlockBtn.addEventListener('pointercancel', cancelLp); unlockBtn.addEventListener('pointerleave', cancelLp); } function _onRecOvlTouch(e) { // Touches auf dem Dim-Overlay ignorieren (dort Long-Press-Logik) if (document.getElementById('rk-rec-dim')?.contains(e.target)) return; _resetRecInactTimer(); } function _updateRecStats() { if (!_recStartTime) return; const elapsed = Math.floor((Date.now() - _recStartTime) / 1000); const mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); const ss = String(elapsed % 60).padStart(2, '0'); const timeStr = `${mm}:${ss}`; const distStr = `${_recDistKm.toFixed(2)} km`; let paceStr = '–:––'; if (_recDistKm >= 0.05) { const mPerKm = (elapsed / 60) / _recDistKm; const pm = Math.floor(mPerKm); const ps = String(Math.round((mPerKm - pm) * 60)).padStart(2, '0'); paceStr = `${pm}:${ps}`; } const q = id => document.getElementById(id); if (q('rk-rec-time')) q('rk-rec-time').textContent = timeStr; if (q('rk-rec-dist')) q('rk-rec-dist').textContent = distStr; if (q('rk-rec-pace')) q('rk-rec-pace').textContent = paceStr; if (q('rk-rec-dim-dauer')) q('rk-rec-dim-dauer').textContent = timeStr; if (q('rk-rec-dim-dist')) q('rk-rec-dim-dist').textContent = distStr; } function _resetRecInactTimer() { if (_recDimmed) return; // Dim wird nur per Long-Press aufgehoben clearTimeout(_recInactTimer); if (!_recActive) return; _recInactTimer = setTimeout(() => { const dim = document.getElementById('rk-rec-dim'); if (dim) { dim.style.display = 'flex'; _recDimmed = true; } }, 5000); } async function _recAcquireWakeLock() { if (!('wakeLock' in navigator) || _recWakeLock) return; try { _recWakeLock = await navigator.wakeLock.request('screen'); _recWakeLock.addEventListener('release', () => { _recWakeLock = null; // OS hat Lock entzogen (Anruf, Tab-Wechsel etc.) → sofort neu anfordern if (_recActive) _recAcquireWakeLock(); }); } catch {} } function _recOnVisibility() { if (_recActive && document.visibilityState === 'visible' && !_recWakeLock) { _recAcquireWakeLock(); } } async function _stopRecInOvl(save) { if (!_recActive && save) return; _recActive = false; if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; } if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; } if (_recInactTimer){ clearTimeout(_recInactTimer); _recInactTimer = null; } if (_recWakeLock) { try { await _recWakeLock.release(); } catch {} _recWakeLock = null; } document.removeEventListener('visibilitychange', _recOnVisibility); _recOvl?.removeEventListener('touchstart', _onRecOvlTouch); _recOvl?.removeEventListener('pointerdown', _onRecOvlTouch); if (!save) { _closeRecOvlClean(); return; } const track = [..._recTrack], distKm = _recDistKm; const dauMin = Math.round((Date.now() - _recStartTime) / 60000); _closeRecOvlClean(); if (track.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte zum Speichern.'); return; } _showRecSaveModal(track, distKm, dauMin); } function _closeRecOvlClean() { if (_recMap) { _recMap.remove(); _recMap = null; } if (_recOvl) { _recOvl.remove(); _recOvl = null; } _recPolyline = null; _recLocMarker = null; _recTrack = []; _recDistKm = 0; _recStartTime = null; _recDimmed = false; } async function _prefillRouteName(track, distKm) { const inp = document.querySelector('#rk-rms-form [name="name"]'); if (!inp || inp.value) return; const pt = track[0]; const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); const km = distKm.toFixed(1); let ort = ''; try { const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' }); const data = await r.json(); const a = data.address || {}; ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || ''; } catch {} if (inp && !inp.value) inp.value = ort ? `Gassirunde ${ort} · ${date} · ${km} km` : `Gassirunde · ${date} · ${km} km`; } function _showRecSaveModal(track, distKm, dauMin) { const body = `

${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min

`; const footer = ` `; UI.modal.open({ title: `${UI.icon('path')} Route benennen`, body, footer }); _prefillRouteName(track, distKm); document.getElementById('rk-rms-paw')?.addEventListener('click', e => { const btn = e.target.closest('.rk-paw-btn'); if (!btn) return; document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected')); btn.classList.add('selected'); document.getElementById('rk-rms-paw-val').value = btn.dataset.val; }); document.getElementById('rk-rms-discard')?.addEventListener('click', () => UI.modal.close()); document.getElementById('rk-rms-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="rk-rms-form"][type="submit"]'); const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { const saved = await API.routes.create({ name: fd.name?.trim(), beschreibung: fd.beschreibung || null, gps_track: track, distanz_km: Math.round(distKm * 100) / 100, dauer_min: dauMin, schwierigkeit: fd.schwierigkeit || 'leicht', untergrund: fd.untergrund || null, schatten: 'schatten' in fd, leine_empfohlen: 'leine_empfohlen' in fd, is_public: 'is_public' in fd, hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', client_time: API.clientNow(), }); UI.modal.close(); UI.toast.success(`Route „${saved.name}" gespeichert!`); _loadData(); }); }); } // ---------------------------------------------------------- // View-Toggle // ---------------------------------------------------------- function _switchView(mode) { _viewMode = mode; document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list'); document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map'); const layout = document.querySelector('.rk-layout'); const grid = document.getElementById('rk-grid'); if (mode === 'map') { if (grid) grid.style.display = 'none'; // Alten Map-Container entfernen falls vorhanden document.getElementById('rk-map-section')?.remove(); if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); } // Als fixed Overlay direkt in — kein Konflikt mit .rk-layout overflow:hidden const mapH = window.innerHeight - 160; const sec = document.createElement('div'); sec.id = 'rk-map-section'; sec.className = 'rk-map-section'; sec.innerHTML = `
Route antippen um Details zu sehen
`; document.body.appendChild(sec); document.getElementById('rk-map-back')?.addEventListener('click', () => _switchView('list')); // Wie _initMiniMaps: pollen bis window.L bereit ist _pollAndInitSearchMap(); } else { document.getElementById('rk-map-section')?.remove(); if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); } if (grid) grid.style.display = ''; } } // ---------------------------------------------------------- // Suchkarte // ---------------------------------------------------------- function _pollAndInitSearchMap() { if (window.L) { _initSearchMap(); return; } let tries = 0; const poll = setInterval(() => { if (window.L || ++tries > 40) { clearInterval(poll); if (window.L) _initSearchMap(); } }, 100); } function _initSearchMap() { if (!document.getElementById('rk-search-map')) return; const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4]; const zoom = _userPos ? 13 : 6; _searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false }) .setView(center, zoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap); setTimeout(() => _searchMap?.invalidateSize(), 100); setTimeout(() => _searchMap?.invalidateSize(), 600); _renderRoutesOnMap(); // Standort-Button document.getElementById('rk-map-gps')?.addEventListener('click', async () => { try { const pos = await API.getLocation(); _userPos = pos; _searchMap.setView([pos.lat, pos.lon], 14); } catch { UI.toast.warning('Standort nicht verfügbar.'); } }); // Geocoding-Suche const locInput = document.getElementById('rk-map-loc'); let _geoDebounce; locInput?.addEventListener('keydown', e => { if (e.key !== 'Enter') return; clearTimeout(_geoDebounce); _geocodeAndFly(locInput.value.trim()); }); locInput?.addEventListener('input', () => { clearTimeout(_geoDebounce); const q = locInput.value.trim(); if (q.length < 3) return; _geoDebounce = setTimeout(() => _geocodeAndFly(q), 800); }); } async function _geocodeAndFly(query) { if (!query || !_searchMap) return; try { const r = await fetch( `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`, { cache: 'no-store' } ); const data = await r.json(); if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; } const { lat, lon, boundingbox } = data[0]; if (boundingbox) { _searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]], { maxZoom: 14 }); } else { _searchMap.setView([+lat, +lon], 13); } } catch { UI.toast.warning('Suche fehlgeschlagen.'); } } function _renderRoutesOnMap() { if (!_searchMap || !window.L) return; // Alte Linien entfernen _searchLines.forEach(({ line }) => line.remove()); _searchLines.clear(); const hint = document.getElementById('rk-map-hint'); _data.forEach(route => { const pts = (route.preview_track || []).map(p => [p.lat, p.lon]); if (pts.length < 2) return; const line = L.polyline(pts, { color: '#C4843A', weight: 4, opacity: 0.75, }).addTo(_searchMap); // Start-/End-Marker const startM = L.circleMarker(pts[0], { radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(_searchMap); const endM = L.circleMarker(pts[pts.length - 1], { radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(_searchMap); // Tooltip mit Namen und Distanz const tip = `${UI.escape(route.name)}${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`; line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' }); // Hover-Highlight line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 })); line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 })); // Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten) const onClick = () => { if (hint) hint.textContent = `Lädt „${route.name}"…`; _openDetail(route.id).finally(() => { if (hint) hint.textContent = 'Route antippen um Details zu sehen'; }); }; line.on('click', onClick); startM.on('click', onClick); _searchLines.set(route.id, { line, startM, endM }); }); // Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal) if (_data.length && _searchLines.size && !_userPos) { const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs()); if (allPts.length) { try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); } catch {} } } } // ---------------------------------------------------------- // Daten // ---------------------------------------------------------- async function _loadData() { try { _data = await API.routes.list(); // "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus if (_appState.user && _browseMode === 'mine') { document.getElementById('rk-mine-group')?.style.setProperty('display', ''); } // Standort-abhängiger Filter im Entdecken-Modus if (_browseMode === 'discover' && _userPos) { document.getElementById('rk-nearby-group')?.style.setProperty('display', ''); } _applyFilter(); } catch (err) { document.getElementById('rk-grid').innerHTML = `

Fehler: ${UI.escape(err.message)}

`; } } // ---------------------------------------------------------- // Filter // ---------------------------------------------------------- const DOG_ORDER = { premium: 4, sehr_gut: 3, gut: 2, eingeschränkt: 1 }; function _geoDistKm(r) { if (!_userPos || !r.start_lat || !r.start_lon) return Infinity; const R = 6371, dLat = (r.start_lat - _userPos.lat) * Math.PI / 180; const dLon = (r.start_lon - _userPos.lon) * Math.PI / 180; const a = Math.sin(dLat/2)**2 + Math.cos(_userPos.lat*Math.PI/180) * Math.cos(r.start_lat*Math.PI/180) * Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } function _applyFilter() { let list = [..._data]; // Browse-Modus-Filter if (_browseMode === 'mine' && _appState.user) { list = list.filter(r => r.user_id === _appState.user.id); } else if (_browseMode === 'discover') { list = list.filter(r => r.is_public === true); } if (_search) list = list.filter(r => (r.name||'').toLowerCase().includes(_search) || (r.beschreibung||'').toLowerCase().includes(_search) || (r.user_name||'').toLowerCase().includes(_search)); if (_difficulty) list = list.filter(r => r.schwierigkeit === _difficulty); if (_terrain) list = list.filter(r => r.untergrund === _terrain); if (_onlyMine && _appState.user && _browseMode === 'mine') list = list.filter(r => r.user_id === _appState.user.id); if (_browseMode === 'discover') { list.sort((a, b) => _geoDistKm(a) - _geoDistKm(b)); } else if (_sortBy === 'distance') list.sort((a,b) => (b.distanz_km||0) - (a.distanz_km||0)); else if (_sortBy === 'rating') list.sort((a,b) => (b.bewertung||0) - (a.bewertung||0)); else if (_sortBy === 'dog') list.sort((a,b) => (DOG_ORDER[b.hunde_tauglichkeit]||0) - (DOG_ORDER[a.hunde_tauglichkeit]||0)); _filtered = list; _renderGrid(); if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap(); } // ---------------------------------------------------------- // Grid // ---------------------------------------------------------- function _renderGrid() { const grid = document.getElementById('rk-grid'); if (!grid) return; if (!_filtered.length) { if (_data.length) { // Filter aktiv aber kein Ergebnis const emptyMsg = _search ? `Keine Routen gefunden für „${UI.escape(_search)}".` : 'Keine Routen passen zu deinen Filtern.'; grid.innerHTML = `
🔍

${emptyMsg}

`; document.getElementById('rk-empty-reset')?.addEventListener('click', () => { _search = ''; _difficulty = ''; _terrain = ''; _sortBy = 'newest'; _onlyMine = false; document.getElementById('rk-search').value = ''; document.querySelectorAll('.rk-chip').forEach(c => c.classList.remove('active')); document.querySelectorAll('.rk-chip[data-val=""]').forEach(c => c.classList.add('active')); document.querySelector('.rk-chip[data-val="newest"]')?.classList.add('active'); _updateFilterBadge(); _applyFilter(); }); } else if (_browseMode === 'discover') { // Entdecken: keine fremden Routen vorhanden grid.innerHTML = `
${UI.icon('compass')}

Noch keine öffentlichen Routen

Andere Nutzer haben noch keine Routen geteilt. Sei der Erste!

`; } else { // Noch gar keine eigenen Routen grid.innerHTML = UI.emptyState({ icon: UI.icon('map-trifold'), title: 'Noch keine Routen', text: 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.', action: ``, }); document.getElementById('rk-empty-rec')?.addEventListener('click', () => { App.navigate('map'); setTimeout(() => window.Page_map?.startRecording?.(), 600); }); } return; } // Alte Mini-Maps zerstören bevor DOM neu geschrieben wird _miniMaps.forEach(m => m.remove()); _miniMaps.clear(); grid.innerHTML = _filtered.map(r => _cardHTML(r)).join(''); _initMiniMaps(); grid.querySelectorAll('.rk-card').forEach(card => { card.addEventListener('click', e => { if (e.target.closest('.rk-stars,.rk-dl-btn')) return; _openDetail(parseInt(card.dataset.id)); }); }); grid.querySelectorAll('.rk-star').forEach(star => { star.addEventListener('click', e => { e.stopPropagation(); _rateRoute(parseInt(star.dataset.id), parseFloat(star.dataset.val)); }); }); grid.querySelectorAll('.rk-dl-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); _downloadGpx(parseInt(btn.dataset.id)); }); }); } // ---------------------------------------------------------- // Karte HTML // ---------------------------------------------------------- function _cardHTML(r) { const isDiscover = _browseMode === 'discover'; const privBadge = !r.is_public ? `${UI.icon('lock')} Privat` : ''; const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || ''; const terrain = TERRAIN_LABEL[r.untergrund] || ''; const paws = HUNDE_LABEL[r.hunde_tauglichkeit] || ''; const dist = r.distanz_km ? `${r.distanz_km.toFixed(1)} km` : ''; const dur = r.dauer_min ? _fmtDur(r.dauer_min) : ''; const firstPhoto = (r.foto_urls || [])[0]; const previewContent = firstPhoto ? `` : `
`; const authorLine = isDiscover ? `
${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}
` : ''; return `
${previewContent}
${authorLine}
${UI.escape(r.name)}
${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${dur ? _pill(dur, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${diffLabel ? _pill(diffLabel, ({leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'})[r.schwierigkeit]||'rgba(107,114,128,0.10)', ({leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'})[r.schwierigkeit]||'#9ca3af', ({leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'})[r.schwierigkeit]||'rgba(107,114,128,0.30)') : ''} ${r.hunde_tauglichkeit ? _pill(HUNDE_TEXT[r.hunde_tauglichkeit]||'', 'rgba(234,179,8,0.10)','#facc15','rgba(234,179,8,0.30)') : ''} ${!isDiscover && !r.is_public ? _pill('Privat','rgba(59,130,246,0.10)','#60a5fa','rgba(59,130,246,0.30)') : ''}
`; } function _starsHTML(id, avg, count) { const stars = [1,2,3,4,5].map(n => `` ).join(''); return stars + (count > 0 ? `${avg.toFixed(1)} (${count})` : ''); } // ---------------------------------------------------------- // Mini-Karten (OSM-Tiles via Leaflet, lazy per IntersectionObserver) // ---------------------------------------------------------- function _initMiniMaps() { const init = () => { const obs = new IntersectionObserver((entries) => { entries.forEach(entry => { if (!entry.isIntersecting) return; obs.unobserve(entry.target); _buildMiniMap(entry.target); }); }, { rootMargin: '150px' }); document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el)); }; if (window.L) { init(); return; } // Leaflet noch am Laden — kurz pollen let tries = 0; const poll = setInterval(() => { if (window.L || ++tries > 30) { clearInterval(poll); if (window.L) init(); } }, 100); } function _buildMiniMap(el) { const track = JSON.parse(el.dataset.track || '[]'); const routeId = parseInt(el.dataset.id); if (track.length < 2) { el.innerHTML = '
🗺️
'; return; } const lls = track.map(p => [p.lat, p.lon]); const m = L.map(el, { zoomControl: false, attributionControl: false, dragging: false, touchZoom: false, scrollWheelZoom: false, doubleClickZoom: false, keyboard: false, boxZoom: false, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 17 }).addTo(m); const poly = L.polyline(lls, { color: '#C4843A', weight: 3, opacity: 0.9 }).addTo(m); L.circleMarker(lls[0], { radius: 5, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(m); L.circleMarker(lls.at(-1), { radius: 5, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(m); m.fitBounds(poly.getBounds(), { padding: [8, 8] }); _miniMaps.set(routeId, m); } // ---------------------------------------------------------- // SVG-Vorschau (Fallback, wird nicht mehr direkt genutzt) // ---------------------------------------------------------- function _svgPreview(track) { if (!track || track.length < 2) return `
🗺️
`; const lats = track.map(p=>p.lat), lons = track.map(p=>p.lon); const minLat=Math.min(...lats), maxLat=Math.max(...lats); const minLon=Math.min(...lons), maxLon=Math.max(...lons); const latR=maxLat-minLat||0.0001, lonR=maxLon-minLon||0.0001; const W=200,H=120,PAD=10; const pts = track.map(p=>{ const x=(PAD+(p.lon-minLon)/lonR*(W-2*PAD)).toFixed(1); const y=(H-PAD-(p.lat-minLat)/latR*(H-2*PAD)).toFixed(1); return `${x},${y}`; }).join(' '); const [sx,sy]=pts.split(' ')[0].split(','); const [ex,ey]=pts.split(' ').at(-1).split(','); return ` `; } // Button-Hilfsfunktion für Header-Buttons (cache-unabhängig) const _btnStyle = (primary = false) => `flex:1;display:flex;align-items:center;justify-content:center;gap:6px;` + `height:46px;padding:0 16px;font-size:14px;font-weight:600;` + `border-radius:10px;border:1.5px solid ${primary ? 'var(--c-primary)' : 'var(--c-border)'};` + `background:${primary ? 'var(--c-primary)' : 'var(--c-surface)'};` + `color:${primary ? '#fff' : 'var(--c-text)'};white-space:nowrap;box-sizing:border-box;cursor:pointer;`; // Pill-Hilfsfunktionen (inline styles — unabhängig vom CSS-Cache) const _pillStyle = (bg, color, border) => `display:inline-flex;align-items:center;font-size:10px;font-weight:600;` + `padding:2px 8px;border-radius:999px;white-space:nowrap;` + `background:${bg};color:${color};border:1px solid ${border};`; const _pill = (text, bg, color, border) => `${text}`; // ---------------------------------------------------------- // Detail-Modal // ---------------------------------------------------------- // ---------------------------------------------------------- // Navigation-Overlay // ---------------------------------------------------------- async function _openNavOverlay(route) { const track = route.gps_track || []; if (track.length < 2) return; _navMaxIdx = 0; _navLastBearing = null; _navWalkMeta = { routeId: route.id, totalKm: route.distanz_km || 0, trackLen: track.length }; // Kompass-Permission iOS 13+ — muss synchron in User-Gesture sein if (typeof DeviceOrientationEvent?.requestPermission === 'function') { try { await DeviceOrientationEvent.requestPermission(); } catch {} } await UI.loadLeaflet?.() ?? Promise.resolve(); const ovl = document.createElement('div'); ovl.id = 'rk-nav-ovl'; ovl.style.cssText = 'position:fixed;inset:0;z-index:850;display:flex;flex-direction:column;background:var(--c-bg)'; ovl.innerHTML = `
${UI.escape(route.name)}
Fortschritt
0%
Verbleibend
– km
Zur Route
– m
`; document.body.appendChild(ovl); _navOvl = ovl; // Kompass-Listener (Pfeil dreht sich mit Geräteausrichtung) const _updateDimArrow = () => { const el = document.getElementById('rk-nav-dim-arrow'); if (!el || _navLastBearing == null) return; let compassHeading = null; if (_navCompassHeading != null) compassHeading = _navCompassHeading; const rot = compassHeading != null ? _navLastBearing - compassHeading // relativ zur Geräteausrichtung : _navLastBearing; // absolut (Nordoben-Fallback) el.style.transform = `rotate(${rot.toFixed(1)}deg)`; }; const _onOrientation = (e) => { let raw = null; if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; else if (e.alpha != null) raw = (360 - e.alpha) % 360; if (raw == null) return; // Exponential-Moving-Average mit Wrap-Around-Behandlung if (_navHeadingSmoothed == null) { _navHeadingSmoothed = raw; } else { const diff = ((raw - _navHeadingSmoothed + 540) % 360) - 180; _navHeadingSmoothed = (_navHeadingSmoothed + 0.12 * diff + 360) % 360; } _navCompassHeading = _navHeadingSmoothed; _updateDimArrow(); }; window.addEventListener('deviceorientation', _onOrientation, true); _navOrientCleanup = () => window.removeEventListener('deviceorientation', _onOrientation, true); // Karte initialisieren const mapEl = document.getElementById('rk-nav-map'); const mid = track[Math.floor(track.length / 2)]; _navMap = L.map(mapEl, { zoomControl: false, attributionControl: false }) .setView([mid.lat, mid.lon], 15); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_navMap); // Route-Polylines: erledigt (grün) + ausstehend (orange) const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap); const remainLine = L.polyline(track.map(p => [p.lat, p.lon]), { color: '#f97316', weight: 5, opacity: 0.9 }).addTo(_navMap); _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); _addRouteArrows(_navMap, track, '#3b82f6'); // Start/End-Marker (als Variable damit Reverse sie neu setzen kann) const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], { radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1 }).addTo(_navMap); let startPin = mkPin(track[0], '#22c55e'); let endPin = mkPin(track[track.length - 1], '#ef4444'); // Live-Position-Marker let locMarker = null; // POIs laden und als kleine Kreis-Marker einfügen _loadNearbyPois(track).then(pois => { _navPois = pois; pois.forEach(poi => { const svgIcon = poi._svgIcon || 'map-pin'; const color = poi._color || '#6b7280'; const icon = L.divIcon({ className: '', html: `
`, iconSize: [32, 32], iconAnchor: [16, 16], }); L.marker([poi.lat, poi.lon], { icon }) .bindTooltip(poi.name || poi._label, { direction: 'top', offset: [0, -16] }) .bindPopup(`${UI.escape(poi.name||poi._label)} ${poi.phone ? `
📞 ${UI.escape(poi.phone)}` : ''} ${poi.opening_hours ? `
🕐 ${UI.escape(poi.opening_hours)}` : ''}`) .addTo(_navMap); }); }).catch(() => {}); // Höhenprofil laden API.routes.elevation(route.id).then(data => { const elevs = data.elevations || []; if (elevs.length < 2) return; const elevEl = document.getElementById('rk-nav-elev'); if (!elevEl) return; elevEl.style.display = ''; elevEl.innerHTML = _buildElevationSVG(elevs); }).catch(() => {}); // Hilfsfunktionen const _navHaversine = (a, b) => _haversineKm(a.lat, a.lon, b.lat, b.lon); const _closestIdx = (lat, lon) => { let best = 0, bestD = Infinity; track.forEach((p, i) => { const d = _haversineKm(lat, lon, p.lat, p.lon); if (d < bestD) { bestD = d; best = i; } }); return best; }; const _remainingKm = (fromIdx) => { let d = 0; for (let i = fromIdx; i < track.length - 1; i++) d += _navHaversine(track[i], track[i+1]); return d; }; const _bearingTo = (a, b) => { const φ1 = a.lat * Math.PI / 180, φ2 = b.lat * Math.PI / 180; const Δλ = (b.lon - a.lon) * Math.PI / 180; const y = Math.sin(Δλ) * Math.cos(φ2); const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; }; const _updateStats = (idx, distToRoute, userLat, userLon) => { if (idx > _navMaxIdx) _navMaxIdx = idx; const pct = Math.round(idx / (track.length - 1) * 100); const rem = _remainingKm(idx); document.getElementById('rk-nav-pct').textContent = pct + '%'; document.getElementById('rk-nav-progbar').style.width = pct + '%'; document.getElementById('rk-nav-remain').textContent = rem.toFixed(2) + ' km'; document.getElementById('rk-nav-offdist').textContent = distToRoute < 1 ? Math.round(distToRoute * 1000) + ' m' : distToRoute.toFixed(1) + ' km'; // Screensaver: "zur Route" wenn weit weg, sonst "verbleibend" const offRoute = distToRoute > 0.5; document.getElementById('rk-nav-dim-pct').textContent = offRoute ? '–' : pct + '%'; document.getElementById('rk-nav-dim-remain').textContent = offRoute ? (distToRoute < 1 ? Math.round(distToRoute * 1000) + ' m' : distToRoute.toFixed(1) + ' km') : rem.toFixed(2) + ' km'; const dimLabel = document.getElementById('rk-nav-dim-label'); if (dimLabel) dimLabel.textContent = offRoute ? 'zur Route' : 'verbleibend'; // Bearing zum nächsten Punkt aktualisieren → _onOrientation übernimmt das Rendern if (userLat != null && idx < track.length - 1) { _navLastBearing = _bearingTo({ lat: userLat, lon: userLon }, track[idx + 1]); _updateDimArrow(); } const offWarn = document.getElementById('rk-nav-offwarn'); if (distToRoute * 1000 > 50) { offWarn.style.display = ''; if (navigator.vibrate) navigator.vibrate([200, 100, 200]); } else { offWarn.style.display = 'none'; } // Polylines aktualisieren doneLine.setLatLngs(track.slice(0, idx + 1).map(p => [p.lat, p.lon])); remainLine.setLatLngs(track.slice(idx).map(p => [p.lat, p.lon])); }; // GPS-Watch await _navAcquireWakeLock(); document.addEventListener('visibilitychange', _navOnVisibility); let _navFirstFix = true; _navWatchId = navigator.geolocation.watchPosition(pos => { const { latitude: lat, longitude: lon } = pos.coords; if (!locMarker) { locMarker = L.circleMarker([lat, lon], { radius: 10, color: '#fff', weight: 3, fillColor: '#3b82f6', fillOpacity: 1, className: 'rk-nav-loc-pulse' }).addTo(_navMap); } else { locMarker.setLatLng([lat, lon]); } // Nächsten Trackpunkt nur nutzen wenn User < 500m von der Route entfernt ist const closestIdx = _closestIdx(lat, lon); const distToRoute = _haversineKm(lat, lon, track[closestIdx].lat, track[closestIdx].lon); if (distToRoute < 0.5) { _navCurrentIdx = closestIdx; } // Karte beim ersten Fix zoomen — User-Position nur einschließen wenn < 20 km entfernt if (_navFirstFix) { _navFirstFix = false; try { const bounds = distToRoute < 20 ? remainLine.getBounds().extend([lat, lon]) : remainLine.getBounds(); _navMap.fitBounds(bounds, { padding: [40, 40] }); } catch {} } _updateStats(_navCurrentIdx, distToRoute, lat, lon); }, () => {}, { enableHighAccuracy: true, maximumAge: 3000 }); // Dim-Modus const _navOnTouch = (e) => { if (document.getElementById('rk-nav-dim')?.contains(e.target)) return; _navResetInactTimer(); }; ovl.addEventListener('touchstart', _navOnTouch, { passive: true }); ovl.addEventListener('pointerdown', _navOnTouch); _navResetInactTimer(); const dim = document.getElementById('rk-nav-dim'); let _lpTimer = null; const cancelLp = () => { clearTimeout(_lpTimer); const prog = document.getElementById('rk-nav-dim-prog'); if (prog) { prog.style.transition = 'none'; prog.style.strokeDashoffset = '150.8'; } }; dim.addEventListener('pointerdown', e => { e.stopPropagation(); const prog = document.getElementById('rk-nav-dim-prog'); if (prog) { prog.style.transition = 'stroke-dashoffset 2s linear'; prog.style.strokeDashoffset = '0'; } _lpTimer = setTimeout(() => { dim.style.display = 'none'; _navDimmed = false; _navResetInactTimer(); }, 2000); }); dim.addEventListener('pointerup', cancelLp); dim.addEventListener('pointercancel', cancelLp); dim.addEventListener('pointerleave', cancelLp); // Aktions-Buttons document.getElementById('rk-nav-back').addEventListener('click', _closeNav); document.getElementById('rk-nav-rate')?.addEventListener('click', () => { UI.modal.open({ title: `${UI.icon('star')} Route bewerten`, body: `
${[1,2,3,4,5].map(n => `` ).join('')}
`, footer: '', }); document.querySelector('#modal-container .modal-body')?.addEventListener('click', async e => { const btn = e.target.closest('[data-stars]'); if (!btn) return; try { await API.routes.rate(route.id, parseInt(btn.dataset.stars)); UI.modal.close(); UI.toast.success('Bewertung gespeichert!'); } catch (err) { UI.toast.error(err.message); } }); }); document.getElementById('rk-nav-feedback')?.addEventListener('click', () => { const body = `

Dein Feedback wird direkt an den Route-Ersteller gesendet.

`; const footer = ` `; UI.modal.open({ title: `${UI.icon('chat-circle-dots')} Feedback senden`, body, footer }); document.getElementById('rk-nav-fb-send')?.addEventListener('click', async () => { const text = document.getElementById('rk-nav-fb-text')?.value?.trim(); if (!text || text.length < 5) { UI.toast.warning('Bitte etwas mehr schreiben.'); return; } try { await API.routes.feedback(route.id, text); UI.modal.close(); UI.toast.success('Feedback gesendet!'); } catch (err) { UI.toast.error(err.message); } }); }); document.getElementById('rk-nav-center-btn')?.addEventListener('click', () => { if (locMarker) _navMap.setView(locMarker.getLatLng(), 16); }); document.getElementById('rk-nav-pois')?.addEventListener('click', () => { if (!_navPois.length) { UI.toast.info('Keine POIs entlang dieser Route.'); return; } const byType = {}; _navPois.forEach(p => { if (!byType[p._label]) byType[p._label] = { icon: p._icon, items: [] }; byType[p._label].items.push(p); }); const body = Object.entries(byType).map(([label, group]) => `
${group.icon} ${UI.escape(label)}
${group.items.map(p => `
${UI.escape(p.name||label)}
${p.opening_hours ? `
🕐 ${UI.escape(p.opening_hours)}
` : ''} ${p.phone ? `
📞 ${UI.escape(p.phone)}
` : ''}
Navi
`).join('')}
`).join(''); UI.modal.open({ title: `${UI.icon('map-pin')} POIs entlang der Route`, body, footer: `` }); }); } function _navResetInactTimer() { if (_navDimmed) return; clearTimeout(_navInactTimer); _navInactTimer = setTimeout(() => { const dim = document.getElementById('rk-nav-dim'); if (dim) { dim.style.display = 'flex'; _navDimmed = true; } }, 10000); } async function _navAcquireWakeLock() { if (!('wakeLock' in navigator) || _navWakeLock) return; try { _navWakeLock = await navigator.wakeLock.request('screen'); _navWakeLock.addEventListener('release', () => { _navWakeLock = null; if (_navWatchId !== null) _navAcquireWakeLock(); }); } catch {} } function _navOnVisibility() { if (_navWatchId !== null && document.visibilityState === 'visible' && !_navWakeLock) { _navAcquireWakeLock(); } } function _closeNav() { if (_navWatchId !== null) { navigator.geolocation.clearWatch(_navWatchId); _navWatchId = null; } if (_navInactTimer) { clearTimeout(_navInactTimer); _navInactTimer = null; } if (_navWakeLock) { try { _navWakeLock.release(); } catch {} _navWakeLock = null; } document.removeEventListener('visibilitychange', _navOnVisibility); if (_navWalkMeta && _navWalkMeta.trackLen > 1) { const pct = Math.round(_navMaxIdx / (_navWalkMeta.trackLen - 1) * 100); if (pct >= 50) { const walkedKm = Math.round((_navMaxIdx / (_navWalkMeta.trackLen - 1)) * _navWalkMeta.totalKm * 100) / 100; API.routes.walked(_navWalkMeta.routeId, walkedKm, pct) .then(res => { if (res.new_badges?.length) UI.toast.success(`🏅 Neues Badge: ${res.new_badges[0].name}!`); }) .catch(() => {}); } } if (_navOrientCleanup) { _navOrientCleanup(); _navOrientCleanup = null; } _navWalkMeta = null; _navMaxIdx = 0; _navLastBearing = null; _navCompassHeading = null; _navHeadingSmoothed = null; if (_navMap) { _navMap.remove(); _navMap = null; } if (_navOvl) { _navOvl.remove(); _navOvl = null; } _navDimmed = false; _navPois = []; } function _buildElevationSVG(points) { const alts = points.map(p => p.alt || 0); const minA = Math.min(...alts), maxA = Math.max(...alts); const range = maxA - minA || 1; const W = 800, H = 56, pad = 4; const x = (i) => pad + (i / (points.length - 1)) * (W - 2 * pad); const y = (a) => H - pad - ((a - minA) / range) * (H - 2 * pad); const pts = points.map((p, i) => `${x(i).toFixed(1)},${y(p.alt||0).toFixed(1)}`).join(' '); const fill = points.map((p, i) => `${x(i).toFixed(1)},${y(p.alt||0).toFixed(1)}`).join(' ') + ` ${x(points.length-1).toFixed(1)},${H} ${x(0).toFixed(1)},${H}`; return `
${Math.round(maxA)}m ${Math.round(minA)}m
`; } // ---------------------------------------------------------- // Route kürzen (Datenschutz-Overlay) // ---------------------------------------------------------- function _trimCalcKm(track) { let d = 0; for (let i = 1; i < track.length; i++) d += _haversineKm(track[i-1].lat, track[i-1].lon, track[i].lat, track[i].lon); return Math.round(d * 100) / 100; } async function _openTrimOverlay(route) { const fullTrack = route.gps_track || []; if (fullTrack.length < 4) return; let startIdx = 0; let endIdx = fullTrack.length - 1; let clickMode = 'start'; // 'start' | 'end' const origKm = route.original_km ?? route.distanz_km ?? 0; const origMin = route.original_dauer_min ?? route.dauer_min ?? 0; const ovl = document.createElement('div'); ovl.id = 'rk-trim-ovl'; ovl.style.cssText = 'position:fixed;inset:0;z-index:850;display:flex;flex-direction:column;background:var(--c-bg)'; ovl.innerHTML = `
Route kürzen
Anfang abschneiden 0 Punkte
Ende abschneiden 0 Punkte
`; document.body.appendChild(ovl); // Map initialisieren await UI.loadLeaflet?.() ?? Promise.resolve(); const mapEl = document.getElementById('rk-trim-map'); const center = fullTrack[Math.floor(fullTrack.length/2)]; const trimMap = L.map(mapEl, { zoomControl: false, attributionControl: false }) .setView([center.lat, center.lon], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(trimMap); // Marker & Polylines let greyBefore = L.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap); let activeLine = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(trimMap); let greyAfter = L.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap); const mkMarker = (lat, lon, color) => L.circleMarker([lat, lon], { radius: 9, color: '#fff', weight: 2.5, fillColor: color, fillOpacity: 1 }).addTo(trimMap); let startMarker = mkMarker(fullTrack[0].lat, fullTrack[0].lon, '#22c55e'); let endMarker = mkMarker(fullTrack[fullTrack.length-1].lat, fullTrack[fullTrack.length-1].lon, '#ef4444'); const update = () => { const slice = fullTrack.slice(startIdx, endIdx + 1); const lls = (pts) => pts.map(p => [p.lat, p.lon]); greyBefore.setLatLngs(lls(fullTrack.slice(0, startIdx + 1))); activeLine.setLatLngs(lls(slice)); greyAfter.setLatLngs(lls(fullTrack.slice(endIdx))); startMarker.setLatLng([fullTrack[startIdx].lat, fullTrack[startIdx].lon]); endMarker.setLatLng([fullTrack[endIdx].lat, fullTrack[endIdx].lon]); const newKm = _trimCalcKm(slice); const pace = origKm > 0 ? origMin / origKm : 10; const newMin = Math.max(1, Math.round(newKm * pace)); document.getElementById('rk-trim-start-lbl').textContent = `${startIdx} Punkte`; document.getElementById('rk-trim-end-lbl').textContent = `${fullTrack.length - 1 - endIdx} Punkte`; document.getElementById('rk-trim-stats').innerHTML = `Neue Länge: ${newKm.toFixed(2)} km · ca. ${newMin} min  ·  Original: ${origKm.toFixed(2)} km · ${origMin} min (bleibt angerechnet)`; }; update(); trimMap.fitBounds(L.polyline(fullTrack.map(p => [p.lat, p.lon])).getBounds(), { padding: [20, 20] }); // Nächsten Track-Punkt zu einem Klick finden const nearestIdx = (latlng) => { let best = 0, bestD = Infinity; fullTrack.forEach((p, i) => { const d = trimMap.distance(latlng, L.latLng(p.lat, p.lon)); if (d < bestD) { bestD = d; best = i; } }); return best; }; // Karten-Klick trimMap.on('click', e => { const idx = nearestIdx(e.latlng); if (clickMode === 'start') { startIdx = Math.min(idx, endIdx - 1); document.getElementById('rk-trim-slider-start').value = startIdx; } else { endIdx = Math.max(idx, startIdx + 1); document.getElementById('rk-trim-slider-end').value = fullTrack.length - 1 - endIdx; } update(); }); // Slider document.getElementById('rk-trim-slider-start').addEventListener('input', e => { startIdx = Math.min(parseInt(e.target.value), endIdx - 1); update(); }); document.getElementById('rk-trim-slider-end').addEventListener('input', e => { endIdx = Math.max(fullTrack.length - 1 - parseInt(e.target.value), startIdx + 1); update(); }); // Modus-Toggle const modeStart = document.getElementById('rk-trim-mode-start'); const modeEnd = document.getElementById('rk-trim-mode-end'); modeStart.addEventListener('click', () => { clickMode = 'start'; modeStart.className = 'btn btn-sm btn-primary'; modeStart.style.flex = '1'; modeEnd.className = 'btn btn-sm btn-secondary'; modeEnd.style.flex = '1'; }); modeEnd.addEventListener('click', () => { clickMode = 'end'; modeEnd.className = 'btn btn-sm btn-primary'; modeEnd.style.flex = '1'; modeStart.className = 'btn btn-sm btn-secondary'; modeStart.style.flex = '1'; }); // Abbrechen document.getElementById('rk-trim-cancel').addEventListener('click', () => { trimMap.remove(); ovl.remove(); }); // Speichern document.getElementById('rk-trim-save').addEventListener('click', async () => { const btn = document.getElementById('rk-trim-save'); const trimmed = fullTrack.slice(startIdx, endIdx + 1); await UI.asyncButton(btn, async () => { const saved = await API.routes.trim(route.id, trimmed); // Lokalen State aktualisieren const idx = _data.findIndex(r => r.id === route.id); if (idx !== -1) Object.assign(_data[idx], saved); trimMap.remove(); ovl.remove(); UI.toast.success('Route gekürzt — Originaldaten bleiben für die Statistik erhalten.'); _applyFilter(); }); }); } async function _openDetail(routeId) { let route; try { route = await API.routes.get(routeId); } catch (err) { UI.toast.error(err.message); return; } const isOwn = _appState.user?.id === route.user_id; const track = route.gps_track || []; const photos = route.foto_urls || []; const paws = HUNDE_LABEL[route.hunde_tauglichkeit] || ''; const photoGallery = photos.length ? ` ` : isOwn ? `` : ''; const body = `
${photoGallery}
${route.distanz_km ? _pill(route.distanz_km.toFixed(1)+' km', 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${route.dauer_min ? _pill(_fmtDur(route.dauer_min), 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''} ${route.schwierigkeit ? _pill(DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit, ({leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'})[route.schwierigkeit]||'rgba(107,114,128,0.10)', ({leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'})[route.schwierigkeit]||'#9ca3af', ({leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'})[route.schwierigkeit]||'rgba(107,114,128,0.30)') : ''} ${route.hunde_tauglichkeit ? _pill(HUNDE_TEXT[route.hunde_tauglichkeit]||route.hunde_tauglichkeit,'rgba(234,179,8,0.10)','#facc15','rgba(234,179,8,0.30)') : ''} ${isOwn ? `` : _pill(route.is_public?'Öffentlich':'Privat', route.is_public?'rgba(22,163,74,0.10)':'rgba(59,130,246,0.10)', route.is_public?'#4ade80':'#60a5fa', route.is_public?'rgba(22,163,74,0.30)':'rgba(59,130,246,0.30)') }
${route.beschreibung ? `

${UI.escape(route.beschreibung)}

` : ''}
Lädt Orte entlang der Route…

${track.length} GPS-Punkte · von ${UI.escape(route.user_name||'Anonym')}

`; const _actionBtn = (id, icon, label, danger = false) => ``; const ownerRow = isOwn ? `
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')} ${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''} ${_actionBtn('rd-reverse', 'path', 'Umkehren')} ${(_appState?.dogs?.length > 0) ? _actionBtn('rd-dogs', 'dog', 'Hunde') : ''} ${_actionBtn('rd-del', 'trash', 'Löschen', true)}
` : ''; const footer = `
${_actionBtn('rd-gpx', 'download-simple', 'GPX')} ${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')} ${_actionBtn('rd-navi', 'map-pin', 'Navi')} ${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
${ownerRow}
`; UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer }); UI.ratingStars({ containerId: `rk-rating-${route.id}`, targetType: 'route', targetId: route.id, isLoggedIn: !!_appState.user, }); document.getElementById('rd-close')?.addEventListener('click', UI.modal.close); document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route)); // Teilen-Button document.getElementById('rd-share')?.addEventListener('click', async () => { const shareUrl = location.origin + '/#routes?id=' + route.id; const text = `${route.name} — ${(route.distanz_km||0).toFixed(1)} km`; if (navigator.share) { navigator.share({ title: route.name, text, url: shareUrl }).catch(() => {}); } else { await navigator.clipboard.writeText(shareUrl); UI.toast.info('Link kopiert!'); } }); // Navi-Button document.getElementById('rd-navi')?.addEventListener('click', () => { if ((route.gps_track || []).length < 2) { UI.toast.warning('Keine GPS-Daten vorhanden.'); return; } UI.modal.close(); _openNavOverlay(route); }); // An Freund senden document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route)); // Sichtbarkeit toggle — Pill im Body document.getElementById('rd-vis-pill')?.addEventListener('click', async () => { const pill = document.getElementById('rd-vis-pill'); try { await API.routes.update(route.id, { is_public: !route.is_public }); route.is_public = !route.is_public; if (pill) { pill.style.cssText = _pillStyle( route.is_public ? 'rgba(22,163,74,0.10)' : 'rgba(59,130,246,0.10)', route.is_public ? '#4ade80' : '#60a5fa', route.is_public ? 'rgba(22,163,74,0.30)' : 'rgba(59,130,246,0.30)') + 'cursor:pointer;'; pill.innerHTML = route.is_public ? 'Öffentlich' : 'Privat'; pill.title = route.is_public ? 'Auf Privat setzen' : 'Auf Öffentlich setzen'; } const r = _data.find(x => x.id === route.id); if (r) r.is_public = route.is_public; _applyFilter(); UI.toast.success(route.is_public ? 'Route ist jetzt öffentlich.' : 'Route ist jetzt privat.'); } catch (err) { UI.toast.error(err.message); } }); // Umkehren document.getElementById('rd-reverse')?.addEventListener('click', async () => { try { await API.routes.reverse(route.id); route.gps_track = [...route.gps_track].reverse(); // Karte neu aufbauen mit umgekehrtem Track const el = document.getElementById('rk-detail-map'); if (el && window.L) { if (_detailMap) { _detailMap.remove(); _detailMap = null; } _detailMap = _buildDetailMap(el, route.gps_track); } UI.toast.success('Route dauerhaft umgekehrt'); } catch (err) { UI.toast.error(err.message); } }); // Hunde bearbeiten document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route)); // Löschen document.getElementById('rd-del')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title: 'Route löschen?', message: `„${route.name}" wird dauerhaft entfernt.`, confirmText: 'Löschen', danger: true, }); if (!ok) return; try { await API.routes.delete(route.id); _data = _data.filter(r => r.id !== route.id); UI.modal.close(); _applyFilter(); UI.toast.success('Route gelöscht.'); } catch (err) { UI.toast.error(err.message); } }); // Route kürzen document.getElementById('rd-trim')?.addEventListener('click', () => { UI.modal.close(); _openTrimOverlay(route); }); // Foto-Upload document.getElementById('rk-photo-input')?.addEventListener('change', async e => { const files = Array.from(e.target.files || []); if (!files.length) return; try { for (const file of files) { const res = await API.routes.addPhoto(route.id, file); route.foto_urls = res.foto_urls; } UI.toast.success(files.length > 1 ? `${files.length} Fotos gespeichert!` : 'Foto gespeichert!'); UI.modal.close(); setTimeout(() => _openDetail(route.id), 200); } catch (err) { UI.toast.error(err.message); } }); // Notiz-Button document.getElementById('rd-note')?.addEventListener('click', () => { const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route'); _openNoteModal('route', route.id, label, null); }); // Mini-Map let _detailMap = null; setTimeout(() => { const el = document.getElementById('rk-detail-map'); if (!el || !track.length) return; if (window.L) _detailMap = _buildDetailMap(el, track); }, 80); // Nearby POIs laden if (track.length >= 2) { _loadNearbyPois(track).then(pois => _renderNearby(pois)); } else { const nb = document.getElementById('rk-nearby'); if (nb) nb.innerHTML = ''; } } // ---------------------------------------------------------- // Hunde einer Route bearbeiten // ---------------------------------------------------------- function _openEditDogsModal(route) { const dogs = _appState?.dogs || []; if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; } const currentIds = new Set(route.dog_ids || []); const dogRows = dogs.map(d => { const checked = currentIds.has(d.id); const av = d.foto_url ? `` : ``; return ``; }).join(''); const body = `

Welche Hunde waren bei dieser Route dabei?

${dogRows}
`; const footer = ` `; UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer }); // Checkbox-Pill Styling document.querySelectorAll('.rd-dog-cb').forEach(cb => { const label = cb.closest('label'); cb.addEventListener('change', () => { label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)'; label.style.background = cb.checked ? 'var(--c-primary-subtle)' : ''; label.style.color = cb.checked ? 'var(--c-primary)' : ''; }); }); document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('rd-dogs-save')?.addEventListener('click', async () => { const btn = document.getElementById('rd-dogs-save'); await UI.asyncButton(btn, async () => { const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value)); await API.routes.updateDogs(route.id, dogIds); route.dog_ids = dogIds; UI.modal.close(); UI.toast.success('Hunde aktualisiert.'); }); }); } // Richtungspfeile gleichmäßig entlang des Tracks platzieren function _addRouteArrows(map, track, color = '#fff') { if (track.length < 2) return; const R = 6371; const hav = (a, b) => { const dLat = (b.lat - a.lat) * Math.PI / 180; const dLon = (b.lon - a.lon) * Math.PI / 180; const s = Math.sin(dLat/2)**2 + Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)); }; const brng = (a, b) => { const φ1 = a.lat*Math.PI/180, φ2 = b.lat*Math.PI/180; const Δλ = (b.lon - a.lon)*Math.PI/180; return (Math.atan2(Math.sin(Δλ)*Math.cos(φ2), Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ)) * 180/Math.PI + 360) % 360; }; // Gesamtdistanz und kumulierte Abstände berechnen let total = 0; const cum = [0]; for (let i = 1; i < track.length; i++) { total += hav(track[i-1], track[i]); cum.push(total); } const spacing = Math.max(0.4, total / 7); // max 7 Pfeile, min 400m Abstand let next = spacing * 0.5; // ersten Pfeil bei halber Spacing-Distanz for (let i = 1; i < track.length - 1; i++) { if (cum[i] >= next) { const deg = brng(track[i-1], track[i]); const icon = L.divIcon({ className: '', html: ` `, iconSize: [20, 20], iconAnchor: [10, 10], }); L.marker([track[i].lat, track[i].lon], { icon, interactive: false, zIndexOffset: -100 }).addTo(map); next += spacing; } } } function _buildDetailMap(el, track) { const m = L.map(el, { zoomControl: false, attributionControl: false }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(m); const lls = track.map(p => [p.lat, p.lon]); const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m); _addRouteArrows(m, track, '#3b82f6'); L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m); L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m); m.fitBounds(poly.getBounds(), { padding:[10,10] }); return m; } // ---------------------------------------------------------- // Nearby POIs // ---------------------------------------------------------- // Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt function _isNearTrack(poi, track, maxMeters) { const R = 6371000; const plat = poi.lat * Math.PI / 180; const plon = poi.lon * Math.PI / 180; for (const pt of track) { const dlat = plat - pt.lat * Math.PI / 180; const dlon = plon - pt.lon * Math.PI / 180; const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon; if (R * Math.sqrt(a) <= maxMeters) return true; } return false; } async function _loadNearbyPois(track) { const lats = track.map(p => p.lat), lons = track.map(p => p.lon); const south = Math.min(...lats), north = Math.max(...lats); const west = Math.min(...lons), east = Math.max(...lons); // Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach const pad = 0.0015; const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad }; const results = []; await Promise.all(NEARBY_TYPES.map(async ({ type, icon, label, svgIcon, color }) => { try { const params = new URLSearchParams({ type, fast: 'true', ...bbox }); const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); pois .filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf .forEach(p => results.push({ ...p, _icon: icon, _label: label, _svgIcon: svgIcon, _color: color })); } catch {} })); return results; } function _renderNearby(pois) { const el = document.getElementById('rk-nearby'); if (!el) return; if (!pois.length) { el.innerHTML = ''; return; } const byType = {}; pois.forEach(p => { const key = p._label; if (!byType[key]) byType[key] = { icon: p._icon, label: key, items: [] }; byType[key].items.push(p); }); let gIdx = 0; el.innerHTML = `
${UI.icon('map-pin')} Entlang der Route
${Object.values(byType).map(group => { const id = `rk-ng-${gIdx++}`; return `
${group.items.map(p => ` `).join('')}
`; }).join('')} `; // Einklapp-Logik el.querySelectorAll('.rk-nearby-group-header').forEach(btn => { const target = document.getElementById(btn.dataset.target); const chevron = btn.querySelector('.rk-nearby-chevron'); let open = false; target.style.display = 'none'; chevron.style.transform = 'rotate(-90deg)'; btn.addEventListener('click', () => { open = !open; target.style.display = open ? '' : 'none'; chevron.style.transform = open ? '' : 'rotate(-90deg)'; }); }); // POI auf Karte zeigen el.querySelectorAll('.rk-nearby-item--link').forEach(btn => { btn.addEventListener('click', () => { const lat = parseFloat(btn.dataset.lat); const lon = parseFloat(btn.dataset.lon); const name = btn.dataset.name; const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const url = isIOS ? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lon}` : `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`; window.open(url, '_blank'); }); }); } // ---------------------------------------------------------- // Bewerten // ---------------------------------------------------------- async function _rateRoute(id, wertung) { try { const res = await API.routes.rate(id, wertung); const r = _data.find(x => x.id === id); if (r) { r.bewertung = res.bewertung; r.anz_bewertungen = res.anz_bewertungen; } _applyFilter(); UI.toast.success(`Bewertet: ${wertung} ★`); } catch (err) { UI.toast.error(err.message || 'Fehler beim Bewerten.'); } } // ---------------------------------------------------------- // GPX // ---------------------------------------------------------- async function _downloadGpx(id) { try { const route = await API.routes.get(id); _downloadGpxDirect(route); } catch (err) { UI.toast.error(err.message); } } function _downloadGpxDirect(route) { const track = route.gps_track || []; if (!track.length) { UI.toast.warning('Keine GPS-Daten.'); return; } const pts = track.map(p => ` `).join('\n'); const gpx = ` ${UI.escape(route.name)}\n${pts}\n `; const blob = new Blob([gpx], { type: 'application/gpx+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${route.name.replace(/[^a-z0-9äöü]/gi,'_')}.gpx`; a.click(); URL.revokeObjectURL(url); UI.toast.success('GPX heruntergeladen!'); } function _fmtDur(min) { if (min < 60) return `${min} min`; return `${Math.floor(min/60)}h ${min%60?(min%60)+'min':''}`.trim(); } // ---------------------------------------------------------- // Import: GPX / KML / TCX // ---------------------------------------------------------- async function _importFile(file) { const text = await file.text(); const ext = file.name.split('.').pop().toLowerCase(); let parsed; try { if (ext === 'gpx') parsed = _parseGpx(text, file.name); else if (ext === 'kml') parsed = _parseKml(text, file.name); else if (ext === 'tcx') parsed = _parseTcx(text, file.name); else { UI.toast.error('Format nicht unterstützt. Bitte GPX, KML oder TCX.'); return; } } catch (err) { UI.toast.error(`Datei konnte nicht gelesen werden: ${err.message}`); return; } if (!parsed || parsed.track.length < 2) { UI.toast.error('Keine GPS-Punkte in der Datei gefunden.'); return; } _openImportModal(parsed); } function _parseGpx(text, filename) { const doc = new DOMParser().parseFromString(text, 'application/xml'); const ns = doc.documentElement.getAttribute('xmlns') || ''; const resolve = tag => { // Namespace-aware oder fallback const els = doc.getElementsByTagNameNS(ns, tag); return els.length ? els : doc.getElementsByTagName(tag); }; // Track-Punkte aus const trkpts = resolve('trkpt'); const track = []; let startTime = null, endTime = null; for (const pt of trkpts) { const lat = parseFloat(pt.getAttribute('lat')); const lon = parseFloat(pt.getAttribute('lon')); if (isNaN(lat) || isNaN(lon)) continue; const point = { lat, lon }; // Elevation (optional) const eleEl = pt.getElementsByTagName('ele')[0] || pt.getElementsByTagNameNS(ns,'ele')[0]; if (eleEl) point.ele = parseFloat(eleEl.textContent); // Timestamp (optional) const timeEl = pt.getElementsByTagName('time')[0] || pt.getElementsByTagNameNS(ns,'time')[0]; if (timeEl) { const t = new Date(timeEl.textContent); if (!startTime) startTime = t; endTime = t; } track.push(point); } // Falls keine → Route-Punkte versuchen if (!track.length) { const rtepts = resolve('rtept'); for (const pt of rtepts) { const lat = parseFloat(pt.getAttribute('lat')); const lon = parseFloat(pt.getAttribute('lon')); if (!isNaN(lat) && !isNaN(lon)) track.push({ lat, lon }); } } // Name aus Datei const nameEl = resolve('name')[0]; const name = nameEl?.textContent?.trim() || filename.replace(/\.gpx$/i, '').replace(/_/g,' '); const dauer_min = (startTime && endTime) ? Math.round((endTime - startTime) / 60000) : null; return { track, name, dauer_min, source: 'GPX' }; } function _parseKml(text, filename) { const doc = new DOMParser().parseFromString(text, 'application/xml'); const track = []; // enthält "lon,lat,ele lon,lat,ele …" oder newline-getrennt const coordEls = doc.getElementsByTagName('coordinates'); for (const el of coordEls) { const raw = el.textContent.trim().replace(/\n/g, ' '); for (const tuple of raw.split(/\s+/)) { const parts = tuple.split(','); if (parts.length >= 2) { const lon = parseFloat(parts[0]); const lat = parseFloat(parts[1]); if (!isNaN(lat) && !isNaN(lon)) { const point = { lat, lon }; if (parts[2]) point.ele = parseFloat(parts[2]); track.push(point); } } } } const nameEl = doc.getElementsByTagName('name')[0]; const name = nameEl?.textContent?.trim() || filename.replace(/\.kml$/i, '').replace(/_/g,' '); return { track, name, dauer_min: null, source: 'KML' }; } function _parseTcx(text, filename) { const doc = new DOMParser().parseFromString(text, 'application/xml'); const track = []; let startTime = null, endTime = null; const trackpoints = doc.getElementsByTagName('Trackpoint'); for (const tp of trackpoints) { const latEl = tp.getElementsByTagName('LatitudeDegrees')[0]; const lonEl = tp.getElementsByTagName('LongitudeDegrees')[0]; if (!latEl || !lonEl) continue; const lat = parseFloat(latEl.textContent); const lon = parseFloat(lonEl.textContent); if (isNaN(lat) || isNaN(lon)) continue; const point = { lat, lon }; const altEl = tp.getElementsByTagName('AltitudeMeters')[0]; if (altEl) point.ele = parseFloat(altEl.textContent); const timeEl = tp.getElementsByTagName('Time')[0]; if (timeEl) { const t = new Date(timeEl.textContent); if (!startTime) startTime = t; endTime = t; } track.push(point); } const nameEl = doc.getElementsByTagName('Name')[0]; const name = nameEl?.textContent?.trim() || filename.replace(/\.tcx$/i, '').replace(/_/g,' '); const dauer_min = (startTime && endTime) ? Math.round((endTime - startTime) / 60000) : null; return { track, name, dauer_min, source: 'TCX' }; } // Haversine (client-seitig für Import-Stats) function _calcDistance(track) { const R2RAD = Math.PI / 180; let dist = 0; for (let i = 1; i < track.length; i++) { const a = track[i-1], b = track[i]; const dlat = (b.lat - a.lat) * R2RAD; const dlon = (b.lon - a.lon) * R2RAD; const sin2 = Math.sin(dlat/2)**2 + Math.cos(a.lat*R2RAD)*Math.cos(b.lat*R2RAD)*Math.sin(dlon/2)**2; dist += 2 * 6371000 * Math.asin(Math.sqrt(sin2)); } return dist / 1000; // km } // ---------------------------------------------------------- // Import-Modal // ---------------------------------------------------------- function _openImportModal(parsed) { const { track, name, dauer_min, source } = parsed; const distanz_km = _calcDistance(track); const preview = _svgPreview(_simplifyPreview(track, 60)); const body = `
${preview}
${UI.icon('map-pin')} ${track.length} Punkte ${UI.icon('map-trifold')} ${distanz_km.toFixed(2)} km ${dauer_min ? `${UI.icon('timer')} ${_fmtDur(dauer_min)}` : ''} ${source}
`; const footer = ` `; UI.modal.open({ title: '📥 Route importieren', body, footer }); document.getElementById('ri-cancel')?.addEventListener('click', UI.modal.close); // Paw-Selector let _selPaw = 'sehr_gut'; document.getElementById('ri-paws')?.addEventListener('click', e => { const btn = e.target.closest('.rk-paw-btn'); if (!btn) return; document.querySelectorAll('#ri-paws .rk-paw-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _selPaw = btn.dataset.val; }); document.getElementById('ri-save')?.addEventListener('click', async () => { const nameVal = document.getElementById('ri-name')?.value.trim(); if (!nameVal) { UI.toast.error('Bitte einen Namen eingeben.'); return; } const saveBtn = document.getElementById('ri-save'); saveBtn.disabled = true; saveBtn.textContent = 'Speichert…'; try { // Track auf max 2000 Punkte reduzieren (API-Limit / Performance) const reducedTrack = _simplifyPreview(track, 2000); await API.routes.create({ name: nameVal, beschreibung: document.getElementById('ri-desc')?.value.trim() || null, gps_track: reducedTrack, distanz_km: Math.round(distanz_km * 100) / 100, dauer_min: dauer_min || null, schwierigkeit: document.getElementById('ri-diff')?.value || 'leicht', untergrund: document.getElementById('ri-terrain')?.value || null, schatten: document.getElementById('ri-schatten')?.checked, leine_empfohlen: document.getElementById('ri-leine')?.checked, is_public: document.getElementById('ri-public')?.checked, hunde_tauglichkeit: _selPaw, client_time: API.clientNow(), }); UI.modal.close(); UI.toast.success('Route importiert! 🥾'); _loadData(); } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); saveBtn.disabled = false; saveBtn.innerHTML = UI.icon('floppy-disk') + ' Route speichern'; } }); } function _simplifyPreview(track, maxPts) { if (track.length <= maxPts) return track; const step = track.length / maxPts; return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]); } // ---------------------------------------------------------- // An Freund senden // ---------------------------------------------------------- async function _openSendToFriendModal(route) { const shareUrl = location.origin + '/#routes?id=' + route.id; // Freunde laden let friends = []; try { friends = await API.friends.list(); } catch (err) { UI.toast.error('Freunde konnten nicht geladen werden.'); return; } if (!friends.length) { UI.toast.info('Du hast noch keine Freunde hinzugefügt.'); return; } const friendRows = friends.map(f => { const initial = (f.name || '?')[0].toUpperCase(); return `
${UI.escape(initial)}
${UI.escape(f.name || 'Anonym')}
`; }).join(''); const body = `
${friendRows}
`; const footer = ``; UI.modal.open({ title: `${UI.icon('chat-circle-dots')} An Freund senden`, body, footer, }); document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('rk-friend-list')?.addEventListener('click', async e => { const row = e.target.closest('.rk-friend-row'); if (!row) return; const partnerId = parseInt(row.dataset.id, 10); const partnerName = row.dataset.name; try { const conv = await API.chat.start(partnerId); const convId = conv.id; const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`; await API.chat.send(convId, text); UI.modal.close(); UI.toast.success(`Gesendet an ${partnerName}`); } catch (err) { UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler')); } }); } // ---------------------------------------------------------- // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) // ---------------------------------------------------------- async function _openNoteModal(parentType, parentId, parentLabel, locationName) { let existingNote = null; try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} const ovl = document.createElement('div'); ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; ovl.innerHTML = `
Notiz — ${UI.escape(parentLabel)}
`; document.body.appendChild(ovl); const close = () => ovl.remove(); ovl.querySelector('#rk-note-close')?.addEventListener('click', close); ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close); ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => { const text = ovl.querySelector('#rk-note-text')?.value?.trim() || ''; const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() }; try { if (existingNote?.id) { await API.notes.update(existingNote.id, payload); } else { await API.notes.create(parentType, String(parentId), payload); } UI.toast.success('Notiz gespeichert.'); close(); } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } }); } return { init, refresh, onDogChange }; })();