/* ============================================================ BAN YARO — Gassi-Routen (Komoot-Stil) Sammlung, Suche, Bewertung, GPX-Download, Fotos, Nearby POIs ============================================================ */ window.Page_routes = (() => { let _container = null; let _appState = null; let _data = []; let _filtered = []; let _userPos = null; let _search = ''; let _difficulty = ''; let _terrain = ''; let _sortBy = 'newest'; let _onlyMine = false; let _isRecording = false; let _filterOpen = false; // 'mine' | 'discover' let _browseMode = 'mine'; // Ansichts-Modus: 'list' | 'map' let _viewMode = 'list'; let _searchMap = null; // L.map Instanz der Suchkarte let _searchLines = new Map(); // routeId → { line, route } // Mini-Karten auf den Route-Cards let _miniMaps = new Map(); // routeId → L.map const DIFFICULTY_LABEL = { leicht: '🟢 Leicht', mittel: '🟡 Mittel', anspruchsvoll: '🔴 Anspruchsvoll' }; const TERRAIN_LABEL = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' }; const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' }; // POI-Typen die entlang einer Route gezeigt werden const NEARBY_TYPES = [ { type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café' }, { type: 'parkplatz', icon: '🅿️', label: 'Parkplatz' }, { type: 'drinking_water', icon: '💧', label: 'Wasserstelle' }, { type: 'bank', icon: '🪑', label: 'Bank' }, ]; // _esc und _emptyState ersetzt durch UI.escape() / UI.emptyState() async function init(container, appState) { _container = container; _appState = appState; _render(); UI.loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden try { _userPos = await API.getLocation(); } catch {} await _loadData(); // Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen const params = new URLSearchParams((location.hash.split('?')[1] || '')); const deepId = params.get('id'); if (deepId) { _openDetail(parseInt(deepId, 10)); } } function refresh() { _syncRecBtn(); _loadData(); } function onDogChange() {} // ---------------------------------------------------------- // Render // ---------------------------------------------------------- function _render() { _container.innerHTML = `
Lädt Routen…
`; let _searchTimer = null; document.getElementById('rk-search').addEventListener('input', e => { clearTimeout(_searchTimer); _searchTimer = setTimeout(() => { _search = e.target.value.toLowerCase().trim(); _applyFilter(); }, 300); }); document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list')); document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map')); document.getElementById('rk-filter-btn').addEventListener('click', _toggleFilterPanel); document.getElementById('rk-rec-btn').addEventListener('click', () => { if (window.Page_map?.isRecording?.()) { window.Page_map.stopRecording(); _syncRecBtn(); } else { App.navigate('map'); setTimeout(() => window.Page_map?.startRecording?.(), 600); } }); 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')); } function _syncRecBtn() { const recording = window.Page_map?.isRecording?.() ?? false; _isRecording = recording; const btn = document.getElementById('rk-rec-btn'); if (!btn) return; if (recording) { btn.className = 'btn btn-danger btn-sm rk-rec-btn rk-rec-btn--active'; btn.innerHTML = UI.icon('path') + ' Stopp aufnehmen'; } else { btn.className = 'btn btn-primary btn-sm rk-rec-btn'; btn.innerHTML = UI.icon('path') + ' Aufzeichnen'; } } function _toggleFilterPanel() { _filterOpen = !_filterOpen; const panel = document.getElementById('rk-filter-panel'); const btn = document.getElementById('rk-filter-btn'); if (panel) panel.style.display = _filterOpen ? '' : 'none'; if (btn) btn.classList.toggle('active', _filterOpen); } function _updateFilterBadge() { const badge = document.getElementById('rk-filter-badge'); if (!badge) return; const hasFilter = _difficulty !== '' || _terrain !== '' || _sortBy !== 'newest' || _onlyMine; badge.style.display = hasFilter ? '' : 'none'; } function _setBrowseMode(mode) { _browseMode = mode; document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine'); document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover'); 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'); if (mode === 'discover') { if (recBtn) recBtn.style.display = 'none'; if (impWrap) impWrap.style.display = 'none'; if (mineGrp) mineGrp.style.display = 'none'; if (nearbyGrp && _userPos) nearbyGrp.style.display = ''; } else { if (recBtn) recBtn.style.display = ''; if (impWrap) impWrap.style.display = ''; if (_appState.user && mineGrp) mineGrp.style.display = ''; if (nearbyGrp) nearbyGrp.style.display = 'none'; } _onlyMine = false; document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active')); _applyFilter(); } 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); } } // ---------------------------------------------------------- // View-Toggle // ---------------------------------------------------------- function _switchView(mode) { _viewMode = mode; document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list'); document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map'); const layout = document.querySelector('.rk-layout'); const grid = document.getElementById('rk-grid'); if (mode === 'map') { if (grid) grid.style.display = 'none'; // Alten Map-Container entfernen falls vorhanden document.getElementById('rk-map-section')?.remove(); if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); } // Als fixed Overlay direkt in — kein Konflikt mit .rk-layout overflow:hidden const mapH = window.innerHeight - 160; const sec = document.createElement('div'); sec.id = 'rk-map-section'; sec.className = 'rk-map-section'; sec.innerHTML = `
Route antippen um Details zu sehen
`; document.body.appendChild(sec); document.getElementById('rk-map-back')?.addEventListener('click', () => _switchView('list')); // Wie _initMiniMaps: pollen bis window.L bereit ist _pollAndInitSearchMap(); } else { document.getElementById('rk-map-section')?.remove(); if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); } if (grid) grid.style.display = ''; } } // ---------------------------------------------------------- // Suchkarte // ---------------------------------------------------------- function _pollAndInitSearchMap() { if (window.L) { _initSearchMap(); return; } let tries = 0; const poll = setInterval(() => { if (window.L || ++tries > 40) { clearInterval(poll); if (window.L) _initSearchMap(); } }, 100); } function _initSearchMap() { if (!document.getElementById('rk-search-map')) return; const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4]; const zoom = _userPos ? 13 : 6; _searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false }) .setView(center, zoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap); setTimeout(() => _searchMap?.invalidateSize(), 100); setTimeout(() => _searchMap?.invalidateSize(), 600); _renderRoutesOnMap(); // Standort-Button document.getElementById('rk-map-gps')?.addEventListener('click', async () => { try { const pos = await API.getLocation(); _userPos = pos; _searchMap.setView([pos.lat, pos.lon], 14); } catch { UI.toast.warning('Standort nicht verfügbar.'); } }); // Geocoding-Suche const locInput = document.getElementById('rk-map-loc'); let _geoDebounce; locInput?.addEventListener('keydown', e => { if (e.key !== 'Enter') return; clearTimeout(_geoDebounce); _geocodeAndFly(locInput.value.trim()); }); locInput?.addEventListener('input', () => { clearTimeout(_geoDebounce); const q = locInput.value.trim(); if (q.length < 3) return; _geoDebounce = setTimeout(() => _geocodeAndFly(q), 800); }); } async function _geocodeAndFly(query) { if (!query || !_searchMap) return; try { const r = await fetch( `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`, { cache: 'no-store' } ); const data = await r.json(); if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; } const { lat, lon, boundingbox } = data[0]; if (boundingbox) { _searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]], { maxZoom: 14 }); } else { _searchMap.setView([+lat, +lon], 13); } } catch { UI.toast.warning('Suche fehlgeschlagen.'); } } function _renderRoutesOnMap() { if (!_searchMap || !window.L) return; // Alte Linien entfernen _searchLines.forEach(({ line }) => line.remove()); _searchLines.clear(); const hint = document.getElementById('rk-map-hint'); _data.forEach(route => { const pts = (route.preview_track || []).map(p => [p.lat, p.lon]); if (pts.length < 2) return; const line = L.polyline(pts, { color: '#C4843A', weight: 4, opacity: 0.75, }).addTo(_searchMap); // Start-/End-Marker const startM = L.circleMarker(pts[0], { radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(_searchMap); const endM = L.circleMarker(pts[pts.length - 1], { radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(_searchMap); // Tooltip mit Namen und Distanz const tip = `${UI.escape(route.name)}${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`; line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' }); // Hover-Highlight line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 })); line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 })); // Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten) const onClick = () => { if (hint) hint.textContent = `Lädt „${route.name}"…`; _openDetail(route.id).finally(() => { if (hint) hint.textContent = 'Route antippen um Details zu sehen'; }); }; line.on('click', onClick); startM.on('click', onClick); _searchLines.set(route.id, { line, startM, endM }); }); // Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal) if (_data.length && _searchLines.size && !_userPos) { const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs()); if (allPts.length) { try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); } catch {} } } } // ---------------------------------------------------------- // Daten // ---------------------------------------------------------- async function _loadData() { try { _data = await API.routes.list(); // "Meine Routen"-Filter nur zeigen wenn eingeloggt und im Mine-Modus if (_appState.user && _browseMode === 'mine') { document.getElementById('rk-mine-group')?.style.setProperty('display', ''); } // Standort-abhängiger Filter im Entdecken-Modus if (_browseMode === 'discover' && _userPos) { document.getElementById('rk-nearby-group')?.style.setProperty('display', ''); } _applyFilter(); } catch (err) { document.getElementById('rk-grid').innerHTML = `

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

`; } } // ---------------------------------------------------------- // Filter // ---------------------------------------------------------- const DOG_ORDER = { premium: 4, sehr_gut: 3, gut: 2, eingeschränkt: 1 }; function _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' && _appState.user) { list = list.filter(r => r.user_id !== _appState.user.id); } 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 (_sortBy === 'distance') list.sort((a,b) => (b.distanz_km||0) - (a.distanz_km||0)); else if (_sortBy === 'rating') list.sort((a,b) => (b.bewertung||0) - (a.bewertung||0)); else if (_sortBy === 'dog') list.sort((a,b) => (DOG_ORDER[b.hunde_tauglichkeit]||0) - (DOG_ORDER[a.hunde_tauglichkeit]||0)); _filtered = list; _renderGrid(); if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap(); } // ---------------------------------------------------------- // Grid // ---------------------------------------------------------- function _renderGrid() { const grid = document.getElementById('rk-grid'); if (!grid) return; if (!_filtered.length) { if (_data.length) { // Filter aktiv aber kein Ergebnis const emptyMsg = _search ? `Keine Routen gefunden für „${UI.escape(_search)}".` : 'Keine Routen passen zu deinen Filtern.'; grid.innerHTML = `
🔍

${emptyMsg}

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

Noch keine öffentlichen Routen

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

`; } else { // Noch gar keine eigenen Routen grid.innerHTML = UI.emptyState({ icon: UI.icon('map-trifold'), title: 'Noch keine Routen', text: 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.', action: ``, }); document.getElementById('rk-empty-rec')?.addEventListener('click', () => { App.navigate('map'); setTimeout(() => window.Page_map?.startRecording?.(), 600); }); } return; } // Alte Mini-Maps zerstören bevor DOM neu geschrieben wird _miniMaps.forEach(m => m.remove()); _miniMaps.clear(); grid.innerHTML = _filtered.map(r => _cardHTML(r)).join(''); _initMiniMaps(); grid.querySelectorAll('.rk-card').forEach(card => { card.addEventListener('click', e => { if (e.target.closest('.rk-stars,.rk-dl-btn')) return; _openDetail(parseInt(card.dataset.id)); }); }); grid.querySelectorAll('.rk-star').forEach(star => { star.addEventListener('click', e => { e.stopPropagation(); _rateRoute(parseInt(star.dataset.id), parseFloat(star.dataset.val)); }); }); grid.querySelectorAll('.rk-dl-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); _downloadGpx(parseInt(btn.dataset.id)); }); }); } // ---------------------------------------------------------- // Karte HTML // ---------------------------------------------------------- function _cardHTML(r) { const isDiscover = _browseMode === 'discover'; const privBadge = !r.is_public ? `${UI.icon('lock')} Privat` : ''; const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || ''; const terrain = TERRAIN_LABEL[r.untergrund] || ''; const paws = HUNDE_LABEL[r.hunde_tauglichkeit] || ''; const dist = r.distanz_km ? `${r.distanz_km.toFixed(1)} km` : ''; const dur = r.dauer_min ? _fmtDur(r.dauer_min) : ''; const firstPhoto = (r.foto_urls || [])[0]; const previewContent = firstPhoto ? `` : `
`; const authorLine = isDiscover ? `
${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}
` : ''; return `
${previewContent}
${authorLine}
${UI.escape(r.name)}
${dist ? `${UI.icon('map-trifold')} ${dist}` : ''} ${dur ? `${UI.icon('timer')} ${dur}` : ''} ${terrain ? `${terrain}` : ''} ${paws ? `${paws}` : ''}
${isDiscover ? '' : privBadge} ${diffLabel ? `${diffLabel}` : ''} ${r.schatten ? `${UI.icon('tree')} Schatten` : ''} ${r.leine_empfohlen ? `${UI.icon('link')} Leine` : ''}
`; } function _starsHTML(id, avg, count) { const stars = [1,2,3,4,5].map(n => `` ).join(''); return stars + (count > 0 ? `${avg.toFixed(1)} (${count})` : ''); } // ---------------------------------------------------------- // Mini-Karten (OSM-Tiles via Leaflet, lazy per IntersectionObserver) // ---------------------------------------------------------- function _initMiniMaps() { const init = () => { const obs = new IntersectionObserver((entries) => { entries.forEach(entry => { if (!entry.isIntersecting) return; obs.unobserve(entry.target); _buildMiniMap(entry.target); }); }, { rootMargin: '150px' }); document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el)); }; if (window.L) { init(); return; } // Leaflet noch am Laden — kurz pollen let tries = 0; const poll = setInterval(() => { if (window.L || ++tries > 30) { clearInterval(poll); if (window.L) init(); } }, 100); } function _buildMiniMap(el) { const track = JSON.parse(el.dataset.track || '[]'); const routeId = parseInt(el.dataset.id); if (track.length < 2) { el.innerHTML = '
🗺️
'; return; } const lls = track.map(p => [p.lat, p.lon]); const m = L.map(el, { zoomControl: false, attributionControl: false, dragging: false, touchZoom: false, scrollWheelZoom: false, doubleClickZoom: false, keyboard: false, boxZoom: false, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 17 }).addTo(m); const poly = L.polyline(lls, { color: '#C4843A', weight: 3, opacity: 0.9 }).addTo(m); L.circleMarker(lls[0], { radius: 5, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(m); L.circleMarker(lls.at(-1), { radius: 5, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(m); m.fitBounds(poly.getBounds(), { padding: [8, 8] }); _miniMaps.set(routeId, m); } // ---------------------------------------------------------- // SVG-Vorschau (Fallback, wird nicht mehr direkt genutzt) // ---------------------------------------------------------- function _svgPreview(track) { if (!track || track.length < 2) return `
🗺️
`; const lats = track.map(p=>p.lat), lons = track.map(p=>p.lon); const minLat=Math.min(...lats), maxLat=Math.max(...lats); const minLon=Math.min(...lons), maxLon=Math.max(...lons); const latR=maxLat-minLat||0.0001, lonR=maxLon-minLon||0.0001; const W=200,H=120,PAD=10; const pts = track.map(p=>{ const x=(PAD+(p.lon-minLon)/lonR*(W-2*PAD)).toFixed(1); const y=(H-PAD-(p.lat-minLat)/latR*(H-2*PAD)).toFixed(1); return `${x},${y}`; }).join(' '); const [sx,sy]=pts.split(' ')[0].split(','); const [ex,ey]=pts.split(' ').at(-1).split(','); return ` `; } // ---------------------------------------------------------- // Detail-Modal // ---------------------------------------------------------- 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 ? `${UI.icon('map-trifold')} ${route.distanz_km.toFixed(2)} km` : ''} ${route.dauer_min ? `${UI.icon('timer')} ${_fmtDur(route.dauer_min)}` : ''} ${route.schwierigkeit ? `${DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit}` : ''} ${route.untergrund ? `${TERRAIN_LABEL[route.untergrund]||route.untergrund}` : ''} ${paws ? `${paws}` : ''} ${route.schatten ? `${UI.icon('tree')} Schatten` : ''} ${route.leine_empfohlen ? `${UI.icon('link')} Leine empfohlen` : ''} ${!route.is_public ? `${UI.icon('lock')} Privat` : ''}
${route.beschreibung ? `

${UI.escape(route.beschreibung)}

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

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

`; const footer = `
${isOwn ? `
` : ''}
`; UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer }); UI.ratingStars({ containerId: `rk-rating-${route.id}`, targetType: 'route', targetId: route.id, isLoggedIn: !!_appState.user, }); document.getElementById('rd-close')?.addEventListener('click', UI.modal.close); document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route)); // Teilen-Button document.getElementById('rd-share')?.addEventListener('click', async () => { const shareUrl = location.origin + '/#routes?id=' + route.id; const text = `${route.name} — ${(route.distanz_km||0).toFixed(1)} km`; if (navigator.share) { navigator.share({ title: route.name, text, url: shareUrl }).catch(() => {}); } else { await navigator.clipboard.writeText(shareUrl); UI.toast.info('Link kopiert!'); } }); // Navi-Button document.getElementById('rd-navi')?.addEventListener('click', () => { const track = route.gps_track || []; if (track.length < 2) { UI.toast.warning('Keine GPS-Daten vorhanden.'); return; } const start = track[0]; const end = track[track.length - 1]; const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const url = isIOS ? `maps://maps.apple.com/?saddr=${start.lat},${start.lon}&daddr=${end.lat},${end.lon}&dirflg=w` : `https://www.google.com/maps/dir/?api=1&origin=${start.lat},${start.lon}&destination=${end.lat},${end.lon}&travelmode=walking`; window.open(url, '_blank'); }); // An Freund senden document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route)); // Sichtbarkeit toggle document.getElementById('rd-vis')?.addEventListener('click', async () => { try { await API.routes.update(route.id, { is_public: !route.is_public }); route.is_public = !route.is_public; const btn = document.getElementById('rd-vis'); if (btn) btn.innerHTML = route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'; 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); } }); // 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); } }); // Foto-Upload document.getElementById('rk-photo-input')?.addEventListener('change', async e => { const file = e.target.files?.[0]; if (!file) return; try { const res = await API.routes.addPhoto(route.id, file); route.foto_urls = res.foto_urls; UI.toast.success('Foto gespeichert!'); // Reload detail UI.modal.close(); setTimeout(() => _openDetail(route.id), 200); } catch (err) { UI.toast.error(err.message); } }); // Mini-Map setTimeout(() => { const el = document.getElementById('rk-detail-map'); if (!el || !track.length) return; if (window.L) _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 = ''; } } function _buildDetailMap(el, track) { const m = L.map(el, { zoomControl: false, attributionControl: false }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(m); const lls = track.map(p => [p.lat, p.lon]); const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m); L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m); L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m); m.fitBounds(poly.getBounds(), { padding:[10,10] }); } // ---------------------------------------------------------- // 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 }) => { try { const params = new URLSearchParams({ type, fast: 'true', ...bbox }); const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); pois .filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf .forEach(p => results.push({ ...p, _icon: icon, _label: label })); } catch {} })); return results; } function _renderNearby(pois) { const el = document.getElementById('rk-nearby'); if (!el) return; if (!pois.length) { el.innerHTML = ''; return; } // Gruppieren nach Typ 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); }); el.innerHTML = `
${UI.icon('map-pin')} Entlang der Route
${Object.values(byType).map(group => `
${group.icon} ${UI.escape(group.label)} (${group.items.length})
${group.items.slice(0, 5).map(p => `
${UI.escape(p.name || group.label)} ${p.opening_hours ? `${UI.icon('clock')} ${UI.escape(p.opening_hours)}` : ''} ${p.phone ? `${UI.icon('phone')} ${UI.escape(p.phone)}` : ''}
`).join('')} ${group.items.length > 5 ? `
+${group.items.length-5} weitere
` : ''}
`).join('')} `; } // ---------------------------------------------------------- // 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, }); 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')); } }); } return { init, refresh, onDogChange }; })();