Routen-Footer: - 2-Zeilen-Layout: Aktions-Buttons oben, Schließen full-width unten - GPX/Teilen/Navi als Text-Icon, Freund/Sichtbarkeit/Löschen als Icon-only rechts - Keine Overflow/Clipping mehr Forum-Footer: - flex-direction:column Layout mit margin-left:auto für Icon-Buttons links - Löschen/Bearbeiten (Icon) links, Schließen/Antworten rechts - flex:1 Spacer-Problem behoben
1342 lines
58 KiB
JavaScript
1342 lines
58 KiB
JavaScript
/* ============================================================
|
||
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 = `
|
||
<div class="rk-layout">
|
||
<div class="rk-header">
|
||
<div class="rk-mode-toggle" id="rk-mode-toggle">
|
||
<button class="rk-mode-btn${_browseMode==='mine'?' active':''}" id="rk-mode-mine">${UI.icon('user')} Meine Routen</button>
|
||
<button class="rk-mode-btn${_browseMode==='discover'?' active':''}" id="rk-mode-discover">${UI.icon('compass')} Entdecken</button>
|
||
</div>
|
||
<div class="rk-search-row">
|
||
<div class="diary-search-wrap" style="flex:1;min-width:0">
|
||
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||
<input class="diary-search-input" id="rk-search" type="search"
|
||
placeholder="Routen durchsuchen…" autocomplete="off">
|
||
</div>
|
||
<div class="rk-view-toggle">
|
||
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
|
||
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm rk-filter-toggle-btn" id="rk-filter-btn" title="Filter">
|
||
${UI.icon('funnel')} Filter
|
||
<span class="rk-filter-badge" id="rk-filter-badge" style="display:none"></span>
|
||
</button>
|
||
<label class="btn btn-secondary btn-sm rk-imp-btn" id="rk-imp-wrap" title="GPX / KML / TCX importieren">
|
||
${UI.icon('download-simple')} Import
|
||
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
|
||
</label>
|
||
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">${UI.icon('path')} Aufzeichnen</button>
|
||
</div>
|
||
<div class="rk-filter-panel" id="rk-filter-panel" style="display:none">
|
||
<div class="rk-filters" id="rk-filters">
|
||
<div class="rk-filter-group">
|
||
<div class="rk-filter-label">Schwierigkeit</div>
|
||
<div class="rk-chips-row">
|
||
<button class="rk-chip active" data-filter="difficulty" data-val="">Alle</button>
|
||
<button class="rk-chip" data-filter="difficulty" data-val="leicht">🟢 Leicht</button>
|
||
<button class="rk-chip" data-filter="difficulty" data-val="mittel">🟡 Mittel</button>
|
||
<button class="rk-chip" data-filter="difficulty" data-val="anspruchsvoll">🔴 Anspruchsvoll</button>
|
||
</div>
|
||
</div>
|
||
<div class="rk-filter-group">
|
||
<div class="rk-filter-label">Untergrund</div>
|
||
<div class="rk-chips-row">
|
||
<button class="rk-chip active" data-filter="terrain" data-val="">Alle Wege</button>
|
||
<button class="rk-chip" data-filter="terrain" data-val="wald">🌲 Wald</button>
|
||
<button class="rk-chip" data-filter="terrain" data-val="asphalt">🛣️ Asphalt</button>
|
||
<button class="rk-chip" data-filter="terrain" data-val="wiese">🌿 Wiese</button>
|
||
<button class="rk-chip" data-filter="terrain" data-val="mix">🔀 Mix</button>
|
||
</div>
|
||
</div>
|
||
<div class="rk-filter-group">
|
||
<div class="rk-filter-label">Sortierung</div>
|
||
<div class="rk-chips-row">
|
||
<button class="rk-chip active" data-filter="sort" data-val="newest">Neueste</button>
|
||
<button class="rk-chip" data-filter="sort" data-val="distance">Längste</button>
|
||
<button class="rk-chip" data-filter="sort" data-val="rating">Beste</button>
|
||
<button class="rk-chip" data-filter="sort" data-val="dog">Hundefreundlich</button>
|
||
</div>
|
||
</div>
|
||
<div class="rk-filter-group" id="rk-mine-group" style="display:none">
|
||
<div class="rk-filter-label">Eigene</div>
|
||
<div class="rk-chips-row">
|
||
<button class="rk-chip" data-filter="mine" data-val="mine">🔒 Nur meine</button>
|
||
</div>
|
||
</div>
|
||
<div class="rk-filter-group" id="rk-nearby-group" style="display:none">
|
||
<div class="rk-filter-label">Umgebung</div>
|
||
<div class="rk-chips-row">
|
||
<button class="rk-chip" id="rk-nearby-btn" data-filter="nearby" data-val="">${UI.icon('map-pin')} In meiner Nähe</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="rk-grid" id="rk-grid">
|
||
<div class="rk-loading">Lädt Routen…</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 <body> — 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 = `
|
||
<div class="rk-map-bar">
|
||
<button class="btn btn-secondary btn-sm" id="rk-map-back" title="Zurück zur Liste">${UI.icon('arrow-left')}</button>
|
||
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
|
||
placeholder="🔍 Ort suchen…" autocomplete="off">
|
||
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">${UI.icon('map-pin')}</button>
|
||
</div>
|
||
<div id="rk-search-map" style="flex:1;width:100%"></div>
|
||
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
|
||
`;
|
||
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 = `<b>${UI.escape(route.name)}</b>${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 =
|
||
`<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${UI.escape(err.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 = `<div class="rk-empty">
|
||
<div class="rk-empty-icon">🔍</div>
|
||
<p>${emptyMsg}</p>
|
||
<button class="btn btn-secondary" id="rk-empty-reset">Filter zurücksetzen</button>
|
||
</div>`;
|
||
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 = `<div class="rk-empty">
|
||
<div class="rk-empty-icon">${UI.icon('compass')}</div>
|
||
<h3 class="rk-empty-title">Noch keine öffentlichen Routen</h3>
|
||
<p class="rk-empty-text">Andere Nutzer haben noch keine Routen geteilt. Sei der Erste!</p>
|
||
</div>`;
|
||
} 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: `<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`,
|
||
});
|
||
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 ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : '';
|
||
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
|
||
? `<img src="${UI.escape(firstPhoto)}" style="width:100%;height:100%;object-fit:cover">`
|
||
: `<div class="rk-mini-map" data-id="${r.id}"
|
||
data-track='${JSON.stringify(r.preview_track||[])}'
|
||
style="width:100%;height:100%"></div>`;
|
||
|
||
const authorLine = isDiscover
|
||
? `<div class="rk-card-creator">${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}</div>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="rk-card" data-id="${r.id}">
|
||
<div class="rk-card-preview">${previewContent}</div>
|
||
<div class="rk-card-body">
|
||
${authorLine}
|
||
<div class="rk-card-name">${UI.escape(r.name)}</div>
|
||
<div class="rk-card-stats">
|
||
${dist ? `<span>${UI.icon('map-trifold')} ${dist}</span>` : ''}
|
||
${dur ? `<span>${UI.icon('timer')} ${dur}</span>` : ''}
|
||
${terrain ? `<span>${terrain}</span>` : ''}
|
||
${paws ? `<span title="Hundetauglichkeit">${paws}</span>` : ''}
|
||
</div>
|
||
<div class="rk-card-tags">
|
||
${isDiscover ? '' : privBadge}
|
||
${diffLabel ? `<span class="rk-badge rk-badge--${r.schwierigkeit}">${diffLabel}</span>` : ''}
|
||
${r.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
|
||
${r.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine</span>` : ''}
|
||
</div>
|
||
<div class="rk-card-footer">
|
||
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
|
||
<div class="rk-card-actions">
|
||
${isDiscover ? '' : `<span class="rk-card-author">${UI.escape(r.user_name||'Anonym')}</span>`}
|
||
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function _starsHTML(id, avg, count) {
|
||
const stars = [1,2,3,4,5].map(n =>
|
||
`<span class="rk-star${n<=Math.round(avg)?' filled':''}" data-id="${id}" data-val="${n}">★</span>`
|
||
).join('');
|
||
return stars + (count > 0 ? `<span class="rk-star-count">${avg.toFixed(1)} (${count})</span>` : '');
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 = '<div class="rk-preview-empty">🗺️</div>';
|
||
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 `<div class="rk-preview-empty">🗺️</div>`;
|
||
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 `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;display:block">
|
||
<rect width="${W}" height="${H}" fill="#e8f0e8"/>
|
||
<polyline points="${pts}" fill="none" stroke="#C4843A" stroke-width="3"
|
||
stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
||
<circle cx="${sx}" cy="${sy}" r="5" fill="#22C55E" stroke="#fff" stroke-width="1.5"/>
|
||
<circle cx="${ex}" cy="${ey}" r="5" fill="#EF4444" stroke="#fff" stroke-width="1.5"/>
|
||
</svg>`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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 ? `
|
||
<div class="rk-photo-gallery">
|
||
${photos.map(u => `<img src="${UI.escape(u)}" class="rk-photo-thumb" onclick="window.open('${UI.escape(u)}','_blank')">`).join('')}
|
||
${isOwn ? `<label class="rk-photo-add" title="Foto hinzufügen">
|
||
<span>+</span>
|
||
<input type="file" id="rk-photo-input" accept="image/*" style="display:none">
|
||
</label>` : ''}
|
||
</div>` :
|
||
isOwn ? `<label class="rk-photo-add-empty">
|
||
${UI.icon('camera')} Foto hinzufügen
|
||
<input type="file" id="rk-photo-input" accept="image/*" style="display:none">
|
||
</label>` : '';
|
||
|
||
const body = `
|
||
<div id="rk-detail-map" style="height:200px;border-radius:var(--radius-md);
|
||
margin-bottom:var(--space-3);background:var(--c-surface-2)"></div>
|
||
${photoGallery}
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin:var(--space-3) 0">
|
||
${route.distanz_km ? `<span class="rk-badge rk-badge--info">${UI.icon('map-trifold')} ${route.distanz_km.toFixed(2)} km</span>` : ''}
|
||
${route.dauer_min ? `<span class="rk-badge rk-badge--info">${UI.icon('timer')} ${_fmtDur(route.dauer_min)}</span>` : ''}
|
||
${route.schwierigkeit ? `<span class="rk-badge rk-badge--${route.schwierigkeit}">${DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit}</span>` : ''}
|
||
${route.untergrund ? `<span class="rk-badge">${TERRAIN_LABEL[route.untergrund]||route.untergrund}</span>` : ''}
|
||
${paws ? `<span class="rk-badge rk-badge--dog" title="Hundetauglichkeit">${paws}</span>` : ''}
|
||
${route.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
|
||
${route.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine empfohlen</span>` : ''}
|
||
${!route.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : ''}
|
||
</div>
|
||
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${UI.escape(route.beschreibung)}</p>` : ''}
|
||
<div id="rk-nearby" class="rk-nearby-section">
|
||
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt Orte entlang der Route…</div>
|
||
</div>
|
||
<div id="rk-rating-${route.id}"></div>
|
||
<p style="color:var(--c-text-muted);font-size:0.75rem;margin-top:var(--space-2)">
|
||
${track.length} GPS-Punkte · von ${UI.escape(route.user_name||'Anonym')}
|
||
</p>
|
||
`;
|
||
|
||
const footer = `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
||
<button type="button" class="btn btn-secondary btn-sm" id="rd-gpx" title="GPX herunterladen">${UI.icon('download-simple')} GPX</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="rd-share" title="Route teilen">${UI.icon('arrow-square-out')} Teilen</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="rd-navi" title="Navigation">${UI.icon('map-pin')} Navi</button>
|
||
<button type="button" class="forum-icon-btn" id="rd-send-friend" title="An Freund senden">${UI.icon('chat-circle-dots')}</button>
|
||
${isOwn ? `
|
||
<div style="margin-left:auto;display:flex;gap:var(--space-1);align-items:center">
|
||
<button type="button" class="btn btn-secondary btn-sm" id="rd-vis" title="${route.is_public ? 'Privat schalten' : 'Öffentlich schalten'}">
|
||
${route.is_public ? UI.icon('lock') : UI.icon('globe')}
|
||
</button>
|
||
<button type="button" class="forum-icon-btn forum-icon-btn--danger" id="rd-del" title="Route löschen">${UI.icon('trash')}</button>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="rk-nearby-title">${UI.icon('map-pin')} Entlang der Route</div>
|
||
${Object.values(byType).map(group => `
|
||
<div class="rk-nearby-group">
|
||
<div class="rk-nearby-group-label">${group.icon} ${UI.escape(group.label)} (${group.items.length})</div>
|
||
${group.items.slice(0, 5).map(p => `
|
||
<div class="rk-nearby-item">
|
||
<span class="rk-nearby-name">${UI.escape(p.name || group.label)}</span>
|
||
${p.opening_hours ? `<span class="rk-nearby-detail">${UI.icon('clock')} ${UI.escape(p.opening_hours)}</span>` : ''}
|
||
${p.phone ? `<a href="tel:${UI.escape(p.phone)}" class="rk-nearby-detail rk-nearby-phone">${UI.icon('phone')} ${UI.escape(p.phone)}</a>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
${group.items.length > 5 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 0">+${group.items.length-5} weitere</div>` : ''}
|
||
</div>
|
||
`).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 => ` <trkpt lat="${p.lat}" lon="${p.lon}"></trkpt>`).join('\n');
|
||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<gpx version="1.1" creator="Ban Yaro" xmlns="http://www.topografix.com/GPX/1/1">
|
||
<trk><name>${UI.escape(route.name)}</name><trkseg>\n${pts}\n </trkseg></trk>
|
||
</gpx>`;
|
||
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 <trkpt>
|
||
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 <trkpt> → Route-Punkte <rtept> 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 = [];
|
||
|
||
// <coordinates> 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 = `
|
||
<div class="rk-import-preview">${preview}</div>
|
||
<div class="rk-import-stats">
|
||
<span>${UI.icon('map-pin')} ${track.length} Punkte</span>
|
||
<span>${UI.icon('map-trifold')} ${distanz_km.toFixed(2)} km</span>
|
||
${dauer_min ? `<span>${UI.icon('timer')} ${_fmtDur(dauer_min)}</span>` : ''}
|
||
<span class="rk-badge rk-badge--info">${source}</span>
|
||
</div>
|
||
<form id="rk-import-form" style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-4)">
|
||
<div class="form-group">
|
||
<label class="form-label">Name *</label>
|
||
<input class="form-input" id="ri-name" value="${UI.escape(name)}" required maxlength="120">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Beschreibung</label>
|
||
<textarea class="form-input" id="ri-desc" rows="2" placeholder="Kurze Beschreibung der Route…"></textarea>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||
<div class="form-group">
|
||
<label class="form-label">Schwierigkeit</label>
|
||
<select class="form-input" id="ri-diff">
|
||
<option value="leicht">🟢 Leicht</option>
|
||
<option value="mittel">🟡 Mittel</option>
|
||
<option value="anspruchsvoll">🔴 Anspruchsvoll</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Untergrund</label>
|
||
<select class="form-input" id="ri-terrain">
|
||
<option value="">– wählen –</option>
|
||
<option value="wald">🌲 Wald</option>
|
||
<option value="asphalt">🛣️ Asphalt</option>
|
||
<option value="wiese">🌿 Wiese</option>
|
||
<option value="mix">🔀 Mix</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Hundetauglichkeit</label>
|
||
<div class="rk-paw-selector" id="ri-paws">
|
||
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">🐾 Eingeschränkt</button>
|
||
<button type="button" class="rk-paw-btn" data-val="gut">🐾🐾 Gut</button>
|
||
<button type="button" class="rk-paw-btn active" data-val="sehr_gut">🐾🐾🐾 Sehr gut</button>
|
||
<button type="button" class="rk-paw-btn" data-val="premium">🐾🐾🐾🐾 Premium</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-4)">
|
||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||
<input type="checkbox" id="ri-leine"> Leine empfohlen
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||
<input type="checkbox" id="ri-schatten"> Viel Schatten
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||
<input type="checkbox" id="ri-public"> Öffentlich
|
||
${UI.help('Öffentliche Routen können von allen Nutzern in der Entdecken-Ansicht gefunden werden.')}
|
||
</label>
|
||
</div>
|
||
</form>
|
||
`;
|
||
|
||
const footer = `
|
||
<button type="button" class="btn btn-ghost" id="ri-cancel">Abbrechen</button>
|
||
<button type="button" class="btn btn-primary flex-1" id="ri-save">${UI.icon('floppy-disk')} Route speichern</button>
|
||
`;
|
||
|
||
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 `<div class="rk-friend-row" data-id="${f.id}" data-name="${UI.escape(f.name || 'Anonym')}"
|
||
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
|
||
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
|
||
onmouseover="this.style.background='var(--c-surface-2)'"
|
||
onmouseout="this.style.background=''">
|
||
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
|
||
color:#fff;display:flex;align-items:center;justify-content:center;
|
||
font-weight:600;flex-shrink:0">${UI.escape(initial)}</div>
|
||
<span>${UI.escape(f.name || 'Anonym')}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const body = `<div id="rk-friend-list">${friendRows}</div>`;
|
||
const footer = `<button type="button" class="btn btn-ghost" id="rsf-cancel">Abbrechen</button>`;
|
||
|
||
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 };
|
||
|
||
})();
|