/* ============================================================
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;
// '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
let _leafletReady = false;
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' },
];
function _esc(s) {
return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
function _emptyState(icon, title, text, cta = '') {
return `
${title}
${text ? `
${text}
` : ''}
${cta ? `
${cta}
` : ''}
`;
}
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_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));
}
}
async function _loadLeaflet() {
if (_leafletReady || window.L) { _leafletReady = true; return; }
if (!document.querySelector('link[href="/css/leaflet.css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = '/css/leaflet.css';
document.head.appendChild(l);
}
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
_leafletReady = true;
}
function refresh() { _loadData(); }
function onDogChange() {}
// ----------------------------------------------------------
// Render
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
`;
document.getElementById('rk-search').addEventListener('input', e => {
_search = e.target.value.toLowerCase(); _applyFilter();
});
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-rec-btn').addEventListener('click', () => {
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-filter-group').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
_applyFilter();
});
// Mode toggle
document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine'));
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover'));
}
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 = `${_esc(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: ${_esc(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
grid.innerHTML = `
🔍
Keine Routen passen zu deinen Filtern.
`;
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');
_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 = _emptyState(
'map-trifold',
'Noch keine Routen',
'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
``
);
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')} ${_esc(r.user_name||'Anonym')}
`
: '';
return `
${previewContent}
${authorLine}
${_esc(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 ? `
${photos.map(u => `
})
`).join('')}
${isOwn ? `
` : ''}
` :
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 ? `${_esc(route.beschreibung)}
` : ''}
Lädt Orte entlang der Route…
${track.length} GPS-Punkte · von ${_esc(route.user_name||'Anonym')}
${route.bewertung ? ` · ${UI.icon('star')} ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''}
`;
const footer = `
${isOwn ? `
` : ''}
`;
UI.modal.open({ title: `🥾 ${_esc(route.name)}`, body, footer });
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', () => {
const shareUrl = location.origin + '/#routes?id=' + route.id;
if (navigator.share) {
navigator.share({ title: route.name, url: shareUrl }).catch(() => {});
} else {
navigator.clipboard.writeText(shareUrl).then(() => {
UI.toast.success('Link kopiert!');
}).catch(() => {
UI.toast.error('Link konnte nicht kopiert werden.');
});
}
});
// 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} ${_esc(group.label)} (${group.items.length})
${group.items.slice(0, 5).map(p => `
`).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 = `
${_esc(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 `
${_esc(initial)}
${_esc(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 };
})();