/* ============================================================ BAN YARO — Gassi-Routen (Komoot-Stil) Sammlung, Suche, Bewertung, GPX-Download, Fotos, Nearby POIs ============================================================ */ window.Page_routes = (() => { const _CACHE_KEY = 'by_routes_cache'; const _PENDING_KEY = 'by_routes_pending'; function _getPending() { try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } } function _setPending(list) { try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} } function _addPending(data) { const list = _getPending(); const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, created_at: new Date().toISOString(), user_id: null }; list.push(entry); _setPending(list); return entry; } async function _syncPending() { if (!navigator.onLine) return; const list = _getPending(); if (!list.length) return; let ok = 0; for (const r of [...list]) { try { const { id: _pid, _isPending, ...payload } = r; await API.routes.create(payload); _setPending(_getPending().filter(x => x.id !== r.id)); ok++; } catch {} } if (ok > 0) { UI.toast.success(`${ok} Route(n) synchronisiert.`); _loadData(); } } window.addEventListener('online', _syncPending); 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 _navRecorded = false; // Einmal-Guard pro Navigation const _PENDING_WALK_KEY = 'by_pending_nav_walk'; // localStorage-Sicherheitsnetz let _navOrientCleanup = null; let _navLastBearing = null; let _navCompassHeading = null; let _navHeadingSmoothed = null; // ---------------------------------------------------------- // NAVI-SOUNDS (Idee René 2026-06-06): links = 2× Wuff, rechts = 1× Wuff, // falscher Weg = Kläffen. WebAudio-Synthese (kein Asset, läuft offline) — // liegen echte Aufnahmen unter /sounds/wuff.mp3 + /sounds/klaeffen.mp3 // (z.B. von Yaro 🐕), werden DIE bevorzugt. iOS: Audio braucht eine User-Geste // → unlock() beim Navi-Start/Toggle. // ---------------------------------------------------------- const NavSound = (() => { let ctx = null; let files = null; // { wuff, klaeffen } HTMLAudio | leer = Synthese. // WICHTIG — zwei iOS-Fallen (René 2026-06-06): // 1. HTMLAudio mit preload lädt LAZY (canplaythrough feuert nie) → daher in der // User-Geste STUMM ANSPIELEN: primt die Wiedergabe-Erlaubnis UND erzwingt das Laden. // 2. WebAudio (AudioContext/decodeAudioData) respektiert den STUMMSCHALTER → auf // lautlos (= Gassi-Normalzustand) kam NICHTS (Prod-Befund). HTMLAudio spielt wie // Medien trotz Stummschalter → für ein Navi der richtige Kanal. Synthese = Fallback. const enabled = () => { try { return localStorage.getItem('by_nav_sound') !== '0'; } catch (e) { return true; } }; function _ctx() { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === 'suspended') ctx.resume().catch(() => {}); return ctx; } // Ein synthetischer „Wuff": Sägezahn-Sweep durch Tiefpass, kurzer Attack, schneller Decay. function _wuff(at, pitch = 1) { const c = _ctx(), t = c.currentTime + at; const o = c.createOscillator(), g = c.createGain(), f = c.createBiquadFilter(); o.type = 'sawtooth'; o.frequency.setValueAtTime(240 * pitch, t); o.frequency.exponentialRampToValueAtTime(75 * pitch, t + 0.16); f.type = 'lowpass'; f.frequency.value = 900 * pitch; f.Q.value = 3; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.9, t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22); o.connect(f); f.connect(g); g.connect(c.destination); o.start(t); o.stop(t + 0.25); } function _barks(n, pitch, gap) { if (!enabled()) return; try { const a = files && (pitch > 1.3 ? files.klaeffen : files.wuff); if (a && a.readyState >= 2) { // echte Aufnahme geladen (Schäferhund, /sounds/*.mp3) // klaeffen.mp3 ist bereits eine ~2,8-s-Bell-SEQUENZ → nur 1× abspielen; // wuff.mp3 ist ein einzelner Beller → n-mal mit Pause. const reps = a === files.klaeffen ? 1 : n; let i = 0; const play = () => { if (i++ >= reps) return; a.currentTime = 0; a.play().catch(() => {}); setTimeout(play, (a.duration || 0.4) * 1000 + 220); }; play(); return; } for (let i = 0; i < n; i++) _wuff(i * gap, pitch); // Fallback: Synthese } catch (e) {} } return { enabled, unlock() { // in User-Geste aufrufen (iOS-Autoplay-Policy) try { const c = _ctx(); const b = c.createBuffer(1, 1, 22050), s = c.createBufferSource(); s.buffer = b; s.connect(c.destination); s.start(0); } catch (e) {} if (files === null) { files = {}; ['wuff', 'klaeffen'].forEach(name => { const a = new Audio(`/sounds/${name}.mp3`); a.preload = 'auto'; // IM GESTENKONTEXT stumm anspielen: primt iOS (spätere play() ohne Geste // erlaubt) und erzwingt das tatsächliche Laden (preload allein reicht nicht). a.muted = true; a.play().then(() => { a.pause(); a.currentTime = 0; a.muted = false; }).catch(() => { a.muted = false; }); files[name] = a; }); } }, links() { _barks(2, 1.0, 0.30); }, // 2× Wuff rechts() { _barks(1, 1.0, 0.30); }, // 1× Wuff klaeffen() { _barks(4, 1.7, 0.16); }, // schnelles, höheres Bellen }; })(); let _navSndAnnouncedIdx = -1; // bis zu welchem Track-Index Abbiegungen angesagt wurden let _navSndOffRoute = false; // Off-Route-Zustand (Kläffen beim Eintritt + alle 30 s) let _navSndLastKlaeff = 0; // Recording-Overlay state let _recOvl = null, _recMap = null; let _recFollow = true; // Karte folgt dem Standort bei Aufzeichnung (Drag pausiert) 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 } let _detailMap = null; // GL-Karte im Detail-Modal (Kontext beim Schließen freigeben!) // 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 _flushPendingNavWalk(); // nicht gespeicherten Navigations-Walk nachtragen try { _userPos = await API.getLocation(); } catch {} await _loadData(); _offerResume(); // unterbrochene Aufzeichnung anbieten // 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() {} // Beim Verlassen der Seite alle Listen-/Detail-Karten freigeben (WebGL-Kontext-Leak). // Aktive Navigations-/Aufzeichnungs-Overlays (_navMap/_recMap) bleiben unangetastet. function destroy() { [_detailMap, _suggestMap, _searchMap].forEach(m => { try { m && m.remove && m.remove(); } catch (e) {} }); _detailMap = _suggestMap = _searchMap = null; try { _miniMaps.forEach(m => m.remove && m.remove()); _miniMaps.clear(); } catch (e) {} } // ---------------------------------------------------------- // 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'); // KLASSE toggeln, nicht style.display: .hidden hat display:none !important // (design-system.css) — Inline-Style kommt dagegen nie an. Kaputt seit 27a3f95 // („Filter standardmäßig zu" setzte die Klasse ins Markup, Toggle blieb auf style). if (panel) panel.classList.toggle('hidden', !_filterOpen); 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.classList.toggle('hidden', !hasFilter); // .hidden hat !important → nur classList } 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.classList.add('hidden'); if (nearbyGrp) nearbyGrp.classList.add('hidden'); if (searchRow) searchRow.style.display = 'none'; if (actRow) actRow.style.display = 'none'; const filterPanel = document.getElementById('rk-filter-panel'); if (filterPanel) { filterPanel.classList.add('hidden'); _filterOpen = false; } document.getElementById('rk-filter-btn')?.classList.remove('active'); 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.classList.add('hidden'); if (nearbyGrp && _userPos) nearbyGrp.classList.remove('hidden'); } else { if (recBtn) recBtn.style.display = ''; if (impWrap) impWrap.style.display = ''; if (_appState.user && mineGrp) mineGrp.classList.remove('hidden'); if (nearbyGrp) nearbyGrp.classList.add('hidden'); } _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)} ${result.hoehenmeter != null ? ` ${UI.icon('trend-up')} ${result.hoehenmeter} hm ` : ''} ${diffLabel ? `${UI.escape(diffLabel)}` : ''} ${UI.escape(result.name || '')}
`; const _initMap = async () => { const mapEl = document.getElementById('rks-map'); if (!mapEl) 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 = await UI.map.create(mapEl, { center: lls[0], zoom: 14, zoomControl: false, attributionControl: false, }); _suggestMap.scrollWheelZoom.disable(); const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.9 }).addTo(_suggestMap); UI.map.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1, weight:2 }).addTo(_suggestMap); UI.map.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1, weight:2 }).addTo(_suggestMap); _addRouteArrows(_suggestMap, track, '#3b82f6'); _fitRouteMap(_suggestMap, mapEl, () => poly.getBounds()); }; _initMap(); 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)); } // Unterbrochene Aufzeichnung (Reload/Crash/Update) zum Fortsetzen anbieten. let _resumeOffered = false; async function _offerResume() { if (_recActive || _resumeOffered || _recOvl) return; const saved = window.RecStore?.load(); if (!saved || saved.source !== 'routes' || !Array.isArray(saved.track) || saved.track.length < 2) return; if (Date.now() - (saved.ts || 0) > 6 * 3600 * 1000) { window.RecStore?.clear(); return; } _resumeOffered = true; const km = (saved.distKm || 0).toFixed(2); const ok = await UI.modal.confirm({ title: 'Aufzeichnung fortsetzen?', message: `Eine unterbrochene Aufzeichnung wurde gefunden (${km} km, ${saved.track.length} Punkte). Möchtest du sie fortsetzen?`, confirmText: 'Fortsetzen', cancelText: 'Später', }); if (!ok) return; // Track bleibt erhalten (erneut anbieten / Staleness räumt auf) await _openRecOvl(); await _startRecInOvl(saved); } async function _openRecOvl() { if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; } if (_recOvl) return; 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; // Listener sofort nach DOM-Einfügen — nicht nach async-Operationen ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean()); ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl); // Map-Setup: Leaflet könnte offline fehlen → alles in try/catch const pos = _userPos || { lat: 48.1, lon: 11.5 }; try { _recMap = await UI.map.create(ovl.querySelector('#rk-rec-map-wrap'), { center: [pos.lat, pos.lon], zoom: 15, zoomControl: false, attributionControl: false, }); _recLocMarker = UI.map.circleMarker([pos.lat, pos.lon], { radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1 }).addTo(_recMap); // Follow-Mode (René 2026-06-08): Karte wandert mit dem Standort. Manuelles // Verschieben pausiert das Folgen; Crosshair-Button schaltet es wieder ein. _recFollow = true; const fwrap = ovl.querySelector('#rk-rec-map-wrap'); if (!fwrap.style.position) fwrap.style.position = 'relative'; const fb = document.createElement('button'); fb.id = 'rk-rec-follow'; fb.type = 'button'; fb.title = 'Karte folgt dem Standort'; fb.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:500;width:42px;height:42px;' + 'border-radius:50%;border:none;background:var(--c-surface,#fff);' + 'box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:pointer'; fb.innerHTML = UI.icon('crosshair'); const updFb = () => { fb.style.color = _recFollow ? 'var(--c-primary)' : 'var(--c-text-secondary, #9ca3af)'; }; updFb(); fb.addEventListener('click', () => { _recFollow = true; const last = _recTrack[_recTrack.length - 1] || pos; _recMap?.setView([last.lat, last.lon]); updFb(); }); fwrap.appendChild(fb); try { _recMap.on('dragstart', () => { _recFollow = false; updFb(); }); } catch (e) {} } catch { const mapWrap = ovl.querySelector('#rk-rec-map-wrap'); if (mapWrap) mapWrap.innerHTML = `
📡 Karte offline nicht verfügbar — GPS läuft trotzdem
`; } // Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS) try { const p = await API.getLocation(); _userPos = p; _recMap?.setView([p.lat, p.lon], 16); _recLocMarker?.setLatLng([p.lat, p.lon]); } catch {} } // Aufzeichnung gedrosselt sichern (Sicherheitsnetz gegen Datenverlust). let _recPersistAt = 0; function _persistRec(force) { const now = Date.now(); if (!force && now - _recPersistAt < 8000) return; _recPersistAt = now; window.RecStore?.save({ source: 'routes', track: _recTrack, distKm: _recDistKm, startTime: _recStartTime }); } function _recDone() { window.RecStore?.clear(); window._byRecording = false; window._byReloadIfPending?.(); } async function _startRecInOvl(resume) { if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; } window._byRecording = true; // Guard: Update-Reload wird aufgeschoben _recActive = true; if (resume && Array.isArray(resume.track) && resume.track.length) { _recTrack = resume.track.slice(); _recDistKm = resume.distKm || 0; _recStartTime = resume.startTime || Date.now(); } else { _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:max(env(safe-area-inset-top,10px),10px) 14px 10px;' + '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 = ''; if (_recMap) { // Bei Fortsetzung den bestehenden Track sofort einzeichnen const seed = (resume && _recTrack.length) ? _recTrack.map(p => [p.lat, p.lon]) : []; _recPolyline = UI.map.polyline(seed, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap); if (seed.length) { const last = seed[seed.length - 1]; _recLocMarker?.setLatLng(last); _recMap.setView(last, 16); } } if (resume) { _updateRecStats(); _persistRec(true); } 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) } : {}) }); _persistRec(); _recPolyline?.addLatLng([lat, lon]); _recLocMarker?.setLatLng([lat, lon]); // Follow-Mode: Karte wandert mit (erster Fix setzt den Zoom, danach bleibt er) if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16); else if (_recFollow) _recMap?.setView([lat, lon]); _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(); _recDone(); return; } const track = [..._recTrack], distKm = _recDistKm; const dauMin = Math.round((Date.now() - _recStartTime) / 60000); _persistRec(true); // finalen Stand sichern, bevor _recTrack zurückgesetzt wird _closeRecOvlClean(); if (track.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte zum Speichern.'); _recDone(); return; } // Guard bleibt aktiv bis im Save-Modal gespeichert/verworfen wird. _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(); _recDone(); }); 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 payload = { 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(), }; if (!navigator.onLine) { _addPending(payload); UI.modal.close(); _recDone(); UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`); _loadData(); return; } const saved = await API.routes.create(payload); UI.modal.close(); _recDone(); 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')); _initSearchMap(); } else { document.getElementById('rk-map-section')?.remove(); if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); } if (grid) grid.style.display = ''; } } // ---------------------------------------------------------- // Suchkarte // ---------------------------------------------------------- async 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 = await UI.map.create('rk-search-map', { center, zoom, zoomControl: true, attributionControl: false, }); 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) 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 = UI.map.polyline(pts, { color: '#C4843A', weight: 4, opacity: 0.75, }).addTo(_searchMap); // Start-/End-Marker const startM = UI.map.circleMarker(pts[0], { radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(_searchMap); const endM = UI.map.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(allPts, { padding: [20, 20], maxZoom: 14 }); } catch {} } } } // ---------------------------------------------------------- // Daten // ---------------------------------------------------------- async function _loadData() { const _merge = (online) => { const pending = _getPending(); if (pending.length) _data = [...pending, ..._data]; if (_appState.user && _browseMode === 'mine') document.getElementById('rk-mine-group')?.classList.remove('hidden'); if (_browseMode === 'discover' && _userPos) document.getElementById('rk-nearby-group')?.classList.remove('hidden'); if (!online && pending.length) UI.toast.info('Offline — ' + pending.length + ' Route(n) warten auf Sync.'); _applyFilter(); }; try { _data = await API.routes.list(); try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {} _merge(true); } catch { try { const raw = localStorage.getItem(_CACHE_KEY); if (raw) { _data = JSON.parse(raw).data || []; UI.toast.info('Offline — zeige zuletzt geladene Routen.'); _merge(false); return; } } catch {} // Nur Pending-Routen zeigen wenn gar kein Cache _data = _getPending(); if (_data.length) { _merge(false); return; } document.getElementById('rk-grid').innerHTML = `

Offline — noch keine Routen gecacht.

`; } } // ---------------------------------------------------------- // 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) : ''; // Immer die Karte zeigen — Fotos erst beim Öffnen der Route. // Bei Routen mit Fotos einen kleinen Kamera-Marker oben rechts // einblenden (analog zum Tagebuch). const photoCount = (r.foto_urls || []).length; const photoBadge = photoCount > 0 ? `
${photoCount}
` : ''; const previewContent = ` ${photoBadge}
`; const authorLine = isDiscover ? `
${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}
` : ''; // „X× gelaufen · zuletzt …" — macht sichtbar, dass das Ablaufen mitzählt const _wc = r.my_walk_count || 0; let walkedLine = ''; if (_wc > 0) { let last = ''; if (r.my_last_walked) { const d = new Date(String(r.my_last_walked).replace(' ', 'T') + 'Z'); // walked_at ist UTC const days = Math.floor((Date.now() - d.getTime()) / 86400000); last = days <= 0 ? 'heute' : days === 1 ? 'gestern' : `vor ${days} Tagen`; } walkedLine = `
🐾 ${_wc}× gelaufen${last ? ' · zuletzt ' + last : ''}
`; } return `
${previewContent}
${authorLine} ${r._isPending ? `
${UI.icon('cloud-arrow-up')} Sync ausstehend
` : ''}
${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)') : ''}
${walkedLine}
`; } 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)); }; init(); } // Mini-Vorschau: zuerst sofort die SVG-Routenform (kein Warten), dann — sobald // gerendert — auf ein echtes Karten-PNG (Basemap + Route) upgraden. Das PNG kommt // aus EINEM geteilten Offscreen-GL-Kontext (UI.map.snapshot, mit Cache), damit viele // Listeneinträge nicht das WebGL-Kontextlimit sprengen. Ist GL aus → SVG bleibt. function _buildMiniMap(el) { const track = JSON.parse(el.dataset.track || '[]'); el.innerHTML = _svgPreview(track); if (track.length < 2 || !UI.map.snapshot) return; UI.map.snapshot(track, { key: 'r' + (el.dataset.id || '') }).then(url => { if (!url || !el.isConnected) return; // GL aus/Fehler → SVG-Platzhalter bleibt el.style.backgroundImage = `url("${url}")`; el.style.backgroundSize = 'cover'; el.style.backgroundPosition = 'center'; el.innerHTML = ''; // SVG-Platzhalter entfernen (PNG enthält die Route) }).catch(() => {}); } // ---------------------------------------------------------- // 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; // Navi-Sounds: Audio in der User-Geste freischalten (iOS) + Ansage-Status zurücksetzen NavSound.unlock(); _navSndAnnouncedIdx = -1; _navSndOffRoute = false; _navSndLastKlaeff = 0; _navMaxIdx = 0; _navRecorded = false; _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 {} } 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 = await UI.map.create(mapEl, { center: [mid.lat, mid.lon], zoom: 15, zoomControl: false, attributionControl: false, }); // Container hat im frisch eingefügten Fixed-Overlay erst jetzt seine // finale Flex-Höhe — Leaflet muss sie neu vermessen, sonst lädt es nur // oben Tiles und der Rest bleibt grau. _navMap.invalidateSize(); // Route-Polylines: erledigt (grün) + ausstehend (orange) // Geplante Route (orange). Der GELAUFENE Weg wird als Breadcrumb gezeichnet: // grün = auf der Route, rot = daneben (René 2026-06-07 — vorher malte eine // done-Linie einfach den Track grün, auch nie gelaufene Abschnitte). const remainLine = UI.map.polyline(track.map(p => [p.lat, p.lon]), { color: '#f97316', weight: 5, opacity: 0.9 }).addTo(_navMap); let _walkSeg = null, _walkSegOff = null, _walkLast = null; const _walkAdd = (lat, lon, off) => { if (!_navMap) return; if (_walkLast && _haversineKm(_walkLast[0], _walkLast[1], lat, lon) * 1000 < 2) return; // GPS-Rauschen if (!_walkSeg || _walkSegOff !== off) { _walkSegOff = off; const seed = _walkLast ? [_walkLast, [lat, lon]] : [[lat, lon]]; // nahtloser Übergang _walkSeg = UI.map.polyline(seed, { color: off ? '#dc2626' : '#22c55e', weight: 5, opacity: 0.9, }).addTo(_navMap); } else { _walkSeg.addLatLng([lat, lon]); } _walkLast = [lat, lon]; }; _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); _addRouteArrows(_navMap, track, '#3b82f6'); // iOS rendert das Flex-Layout teils verzögert — nochmal neu vermessen // und Ausschnitt erneut anpassen. setTimeout(() => { if (!_navMap) return; _navMap.invalidateSize(); _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); }, 250); // Start/End-Marker (als Variable damit Reverse sie neu setzen kann) const mkPin = (p, color) => UI.map.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 html = `
`; UI.map.svgMarker(poi.lat, poi.lon, html, { size: 32 }) .bindTooltip(poi.name || poi._label) .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); // Fortschritts-Index NUR im Fenster um den aktuellen Index suchen — die globale // Suche sprang bei RUNDEN (Start ≈ Ende) sofort ans Track-ENDE: nie Abbiege-Bellen, // alles grün, 99 % ab Start (Praxistest René 2026-06-07, Gassirunde Siegenhofen). // Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt). let _navIdxInit = false; // Runde erkennen: Start ≈ Ende (< 60 m). An einem solchen Start/Ende-Knoten ist der // ENDPUNKT oft ein paar Meter näher als der Startpunkt — die globale Erst-Suche sprang // dann sofort ans Track-ENDE → 100 % / 0 km ab Sekunde 1, kein Bellen, alles grün, und // der gelaufene-Weg-Eintrag wurde fälschlich als komplett gespeichert. Der alte 25-m- // Gleichstand reichte nicht, wenn der Start >28 m weg lag (Siegenhofen René 2026-06-07, // Deining Angie 2026-06-09). const _navIsLoop = track.length > 2 && _haversineKm(track[0].lat, track[0].lon, track[track.length - 1].lat, track[track.length - 1].lon) < 0.06; const _closestIdx = (lat, lon) => { const search = (from, to) => { let best = from, bestD = Infinity; for (let i = from; i <= to; i++) { const d = _haversineKm(lat, lon, track[i].lat, track[i].lon); if (d < bestD) { bestD = d; best = i; } } return { best, bestD }; }; if (!_navIdxInit) { _navIdxInit = true; const g = search(0, track.length - 1); if (_navIsLoop) { // Runde: steht man irgendwo in Startnähe (< 150 m), bei 0 % beginnen statt ans // nahe Track-Ende zu springen. Erst wer weit vom Start steht, ist mitten in die // Runde eingestiegen → globaler Treffer. Startfenster = erste 15 % (mind. 30 Pkt.). const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15))); const s = search(0, win); return s.bestD < 0.15 ? s.best : g.best; } // Punkt-zu-Punkt: bei Quasi-Gleichstand (< 25 m) den START bevorzugen. const s = search(0, Math.min(track.length - 1, 30)); return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best; } const w = search(Math.max(0, _navCurrentIdx - 15), Math.min(track.length - 1, _navCurrentIdx + 80)); if (w.bestD <= 0.3) return w.best; return search(0, track.length - 1).best; // verloren → neu orientieren }; 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; }; // Abbiegepunkte EINMALIG aus dem Track ableiten: Peilung über ~15-m-Stützpunkte // (sonst macht GPS-Zickzack aus jeder Geraden eine Kurve), Richtungsänderung ≥ 40° // = Abbiegung, > 0 = rechts. Mindestabstand zwischen Ansagen ~25 m. const _navTurns = (() => { const out = []; const distM = (a, b) => _haversineKm(a.lat, a.lon, b.lat, b.lon) * 1000; let lastIdx = -1; for (let i = 1; i < track.length - 1; i++) { let p = i - 1, accP = distM(track[p], track[i]); while (p > 0 && accP < 15) { p--; accP += distM(track[p], track[p + 1]); } let n = i + 1, accN = distM(track[i], track[n]); while (n < track.length - 1 && accN < 15) { n++; accN += distM(track[n - 1], track[n]); } const d = ((_bearingTo(track[i], track[n]) - _bearingTo(track[p], track[i]) + 540) % 360) - 180; if (Math.abs(d) >= 40 && (lastIdx < 0 || distM(track[lastIdx], track[i]) > 25)) { out.push({ idx: i, right: d > 0 }); lastIdx = i; } } return out; })(); const _updateStats = (idx, distToRoute, userLat, userLon) => { if (idx > _navMaxIdx) { _navMaxIdx = idx; // Fortschritt persistent sichern — überlebt App-Kill / beliebiges Schließen if (_navWalkMeta && _navWalkMeta.trackLen > 1) { const p = Math.round(_navMaxIdx / (_navWalkMeta.trackLen - 1) * 100); const km = Math.round((_navMaxIdx / (_navWalkMeta.trackLen - 1)) * _navWalkMeta.totalKm * 100) / 100; try { localStorage.setItem(_PENDING_WALK_KEY, JSON.stringify({ routeId: _navWalkMeta.routeId, walkedKm: km, pct: p, ts: Date.now() })); } catch {} } } 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(); } // Abbiege-Ansage (René: 2× Wuff = links, 1× = rechts): nächster Turn vor uns, // angesagt sobald ≤ 45 m entfernt — einmal pro Abbiegepunkt. if (userLat != null && distToRoute < 0.1) { const next = _navTurns.find(t => t.idx > idx && t.idx > _navSndAnnouncedIdx); if (next) { const dM = _haversineKm(userLat, userLon, track[next.idx].lat, track[next.idx].lon) * 1000; if (dM <= 50) { _navSndAnnouncedIdx = next.idx; if (next.right) NavSound.rechts(); else NavSound.links(); } } } const offWarn = document.getElementById('rk-nav-offwarn'); if (distToRoute * 1000 > 35) { // 50→35 m: Kläffen kam ~5 m zu spät (René 2026-06-07) offWarn.style.display = ''; if (navigator.vibrate) navigator.vibrate([200, 100, 200]); // Falscher Weg = Kläffen (beim Abkommen + Erinnerung alle 30 s) const _now = Date.now(); if (!_navSndOffRoute || _now - _navSndLastKlaeff > 30000) { _navSndOffRoute = true; _navSndLastKlaeff = _now; NavSound.klaeffen(); } } else { offWarn.style.display = 'none'; _navSndOffRoute = false; } // Verbleibende Route aktualisieren; der gelaufene Weg kommt vom Breadcrumb (s.o.) remainLine.setLatLngs(track.slice(idx).map(p => [p.lat, p.lon])); if (userLat != null) _walkAdd(userLat, userLon, distToRoute * 1000 > 35); }; // GPS-Watch await _navAcquireWakeLock(); document.addEventListener('visibilitychange', _navOnVisibility); window.addEventListener('pagehide', _recordNavWalk); // Schließen/Weg-Navigieren → speichern let _navFirstFix = true; _navWatchId = navigator.geolocation.watchPosition(pos => { const { latitude: lat, longitude: lon } = pos.coords; if (!locMarker) { locMarker = UI.map.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'); // Entsperren reagiert NUR auf den Fingerabdruck-Knopf (2 Sek. halten) — nicht mehr // auf das ganze Dim-Overlay. Tippen daneben lässt den Bildschirm bewusst gedimmt. const navUnlock = document.getElementById('rk-nav-unlock-btn'); 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'; } }; navUnlock.addEventListener('pointerdown', e => { e.stopPropagation(); try { navUnlock.setPointerCapture(e.pointerId); } catch (err) {} 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); }); navUnlock.addEventListener('pointerup', cancelLp); navUnlock.addEventListener('pointercancel', cancelLp); // Verlässt der Finger den Knopf während des Haltens → abbrechen (sonst entsperrt // ein wegrutschender Finger weiter). pointerleave reicht dank setPointerCapture. navUnlock.addEventListener('pointerleave', cancelLp); // Sicherheitsnetz: ein Tipp aufs Dim-Overlay (nicht auf den Knopf) tut nichts, // aber wir schlucken ihn, damit darunterliegende Buttons nicht reagieren. dim.addEventListener('pointerdown', e => { if (e.target === dim) e.stopPropagation(); }); // 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); }); // Navi-Sounds an/aus (Klick = User-Geste → unlock + Probe-Wuff als Bestätigung) document.getElementById('rk-nav-sound-btn')?.addEventListener('click', e => { const on = !NavSound.enabled(); try { localStorage.setItem('by_nav_sound', on ? '1' : '0'); } catch (err) {} const btn = e.currentTarget; btn.style.color = on ? 'var(--c-primary)' : 'var(--c-text-secondary)'; btn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${on ? 'speaker-high' : 'speaker-none'}`); // Probe-Wuff leicht verzögert: gibt fetch+decodeAudioData beim ERSTEN // Einschalten die Chance, das echte Sample zu laden (sonst Synthese-Fallback). if (on) { NavSound.unlock(); setTimeout(() => NavSound.rechts(), 450); } UI.toast.info(on ? 'Navi-Sounds an: 2× Wuff = links, 1× Wuff = rechts, Kläffen = falscher Weg 🐕' : 'Navi-Sounds aus.'); }); 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(); } } // Speichert den gelaufenen Walk — egal wie die Navigation verlassen wird // (In-App-Zurück, pagehide/Schließen). Einmal-Guard; bei Fehler bleibt der // Eintrag in localStorage und wird beim nächsten App-Start nachgetragen. function _recordNavWalk() { if (_navRecorded || !_navWalkMeta || _navWalkMeta.trackLen <= 1) return; const pct = Math.round(_navMaxIdx / (_navWalkMeta.trackLen - 1) * 100); if (pct < 50) return; _navRecorded = true; const walkedKm = Math.round((_navMaxIdx / (_navWalkMeta.trackLen - 1)) * _navWalkMeta.totalKm * 100) / 100; API.routes.walked(_navWalkMeta.routeId, walkedKm, pct) .then(res => { try { localStorage.removeItem(_PENDING_WALK_KEY); } catch {} const km = walkedKm.toFixed(1).replace('.', ','); const tot = res?.total_km != null ? ` · Lebenswerk ${String(res.total_km).replace('.', ',')} km` : ''; UI.toast.success(`🐾 ${km} km gezählt${tot}`); if (res?.new_badges?.length) UI.toast.success(`🏅 Neues Badge: ${res.new_badges[0].name}!`); }) .catch(() => {}); // bleibt in localStorage → Nachtrag beim nächsten Start } // Beim App-/Seiten-Start: nicht gespeicherten Walk nachtragen (App gekillt / // „wie immer" geschlossen, ohne dass ein Speicher-Event lief). function _flushPendingNavWalk() { if (_navWatchId !== null) return; // läuft gerade eine Navigation let p; try { p = JSON.parse(localStorage.getItem(_PENDING_WALK_KEY) || 'null'); } catch {} if (!p || (p.pct || 0) < 50) return; if (Date.now() - (p.ts || 0) > 7 * 24 * 3600 * 1000) { // zu alt → verwerfen try { localStorage.removeItem(_PENDING_WALK_KEY); } catch {} return; } API.routes.walked(p.routeId, p.walkedKm, p.pct) .then(res => { try { localStorage.removeItem(_PENDING_WALK_KEY); } catch {} const km = Number(p.walkedKm || 0).toFixed(1).replace('.', ','); const tot = res?.total_km != null ? ` · Lebenswerk ${String(res.total_km).replace('.', ',')} km` : ''; UI.toast.success(`🐾 ${km} km nachgetragen${tot}`); if (res?.new_badges?.length) UI.toast.success(`🏅 Neues Badge: ${res.new_badges[0].name}!`); }) .catch(() => {}); // bleibt für den nächsten Versuch } function _closeNav() { _recordNavWalk(); // ZUERST speichern (vor jedem Reset) 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); window.removeEventListener('pagehide', _recordNavWalk); if (_navOrientCleanup) { _navOrientCleanup(); _navOrientCleanup = null; } _navWalkMeta = null; _navMaxIdx = 0; _navRecorded = false; _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 const mapEl = document.getElementById('rk-trim-map'); const center = fullTrack[Math.floor(fullTrack.length/2)]; const trimMap = await UI.map.create(mapEl, { center: [center.lat, center.lon], zoom: 14, zoomControl: false, attributionControl: false, }); // Marker & Polylines let greyBefore = UI.map.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap); let activeLine = UI.map.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(trimMap); let greyAfter = UI.map.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap); const mkMarker = (lat, lon, color) => UI.map.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(UI.map.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, { lat: p.lat, lng: 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') : ''} ${(window.BY?.offlineTiles?.() && track.length >= 2) ? _actionBtn('rd-offline', 'cloud-arrow-down', 'Offline') : ''}
${ownerRow}
`; // onClose: GL-Kontext der Detailkarte freigeben — sonst leakt jede geöffnete Route // einen WebGL-Kontext. Nach ~8 wirft MapLibre, und UI.map.create fällt auf // Leaflet+OSM-Raster zurück (genau das Symptom: Detailkarte plötzlich OSM-Raster // statt GL, und der Zoom passt nicht mehr). UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer, onClose: () => { if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; } } }); 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)); // Route offline speichern: Kachel-Korridor ±1 km um den Track + Marker → IndexedDB // (für mehrtägige Unternehmungen entlang der Route, docs/OFFLINE_MAPS_PLAN.md). document.getElementById('rd-offline')?.addEventListener('click', async () => { const btn = document.getElementById('rd-offline'); if (!btn || btn.dataset.busy) return; btn.dataset.busy = '1'; const label = btn.querySelector('span'); try { await UI.loadMapLibreUI(); // lädt pmtiles + map-offline (byt://-Stack) bei Bedarf const res = await MapOffline.downloadCorridor(track, { bufferKm: 1, name: route.name, onProgress: p => { if (label) label.textContent = `${(p.bytes / 1048576).toFixed(1)} MB`; }, }); if (label) label.textContent = 'Offline ✓'; UI.toast.success(`Route offline gespeichert — Korridor ±1 km, ${res.pois || 0} Marker, ` + `${(res.bytes / 1048576).toFixed(1)} MB.${res.capped ? ' (50-MB-Limit erreicht)' : ''}`); window.OfflineIndicator?.refresh(); // Gespeicherte Bereiche sofort auf der Detailkarte zeigen (blau) — sonst ist der // Korridor „unsichtbar", v.a. wenn er im schon gespeicherten Gebiet liegt. try { const gl = _detailMap?._gl; if (gl) { const gj = await MapOffline.coverage(); if (gl.getSource('rd-off-cov')) gl.getSource('rd-off-cov').setData(gj); else { gl.addSource('rd-off-cov', { type: 'geojson', data: gj }); gl.addLayer({ id: 'rd-off-cov', type: 'fill', source: 'rd-off-cov', paint: { 'fill-color': ['match', ['get', 'kind'], 'funkloch', '#f59e0b', '#3b82f6'], 'fill-opacity': 0.15 } }); } } } catch (e) {} } catch (e) { if (label) label.textContent = 'Offline'; UI.toast.error('Offline-Speichern fehlgeschlagen.'); } finally { delete btn.dataset.busy; } }); // 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) { if (_detailMap) { _detailMap.remove(); _detailMap = null; } _detailMap = await _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'); UI.noteModal('route', route.id, label, null); }); // Mini-Map (modulweite _detailMap → wird beim Schließen im onClose freigegeben) if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; } setTimeout(async () => { const el = document.getElementById('rk-detail-map'); if (!el || !track.length) return; _detailMap = await _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]); // Rotation INNERHALB des SVG (am Pfad), NICHT als CSS-transform am SVG-Element: // maplibregl.Marker setzt transform:translate() aufs Element → würde rotate() killen // (Pfeile zeigten alle nach Norden). const html = ` `; UI.map.svgMarker(track[i].lat, track[i].lon, html, { size: 20 }).addTo(map); next += spacing; } } } // Karte robust auf die ganze Route fitten. // WICHTIG (iOS): MapLibre verwirft ein fitBounds, das VOR dem ersten Render läuft — // die Karte bleibt dann beim Start-Zoom (zoom 14, center=Start) hängen, statt auf die // Route zu zoomen. (In Headless-Chromium passiert das nicht, daher fiel es dort nicht // auf.) Deshalb fitten wir auf das 'load'/'idle'-Event der Karte — DANN ist sie wirklich // gerendert und der Fit bleibt. Feste Timeouts + ResizeObserver als Sicherheitsnetz. function _fitRouteMap(m, el, getBounds, opts) { opts = opts || { padding: [16, 16], maxZoom: 16 }; let active = true; const sized = () => !el || (el.clientWidth > 0 && el.clientHeight > 0); const fit = () => { if (!active) return; try { m.invalidateSize(); m.fitBounds(getBounds(), opts); } catch (e) {} }; const onReady = () => { if (!active) return; fit(); // Erstes Ready-Event mit korrekt vermessenem Container = der gute Fit → danach Schluss, // damit der Nutzer frei zoomen/pannen kann. if (sized()) { active = false; try { m.off && m.off('idle', onReady); m.off && m.off('load', onReady); } catch (e) {} } }; fit(); [120, 350, 700, 1200, 2000].forEach(t => setTimeout(fit, t)); try { m.on('load', onReady); } catch (e) {} try { m.on('idle', onReady); } catch (e) {} if (window.ResizeObserver && el) { const ro = new ResizeObserver(() => fit()); ro.observe(el); setTimeout(() => { try { ro.disconnect(); } catch (e) {} }, 4000); } setTimeout(() => { active = false; }, 4000); } async function _buildDetailMap(el, track) { const lls = track.map(p => [p.lat, p.lon]); const m = await UI.map.create(el, { center: lls[0], zoom: 14, zoomControl: false, attributionControl: false, }); const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m); _addRouteArrows(m, track, '#3b82f6'); UI.map.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m); UI.map.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m); _fitRouteMap(m, el, () => poly.getBounds()); 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 }); // r.ok prüfen: SW antwortet offline mit 503+JSON ({detail:…}) → json() wirft nicht const r = await fetch(`/api/osm/pois?${params}`); if (!r.ok) throw new Error(`pois ${r.status}`); const pois = await r.json(); (Array.isArray(pois) ? 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) // ---------------------------------------------------------- return { init, refresh, onDogChange, destroy }; })();