/* ============================================================
BAN YARO — Gassi-Routen (Komoot-Stil)
Sammlung, Suche, Bewertung, GPX-Download, Fotos, Nearby POIs
============================================================ */
window.Page_routes = (() => {
const _CACHE_KEY = 'by_routes_cache';
const _PENDING_KEY = 'by_routes_pending';
function _getPending() {
try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; }
}
function _setPending(list) {
try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {}
}
function _addPending(data) {
const list = _getPending();
const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true,
created_at: new Date().toISOString(), user_id: null };
list.push(entry);
_setPending(list);
return entry;
}
async function _syncPending() {
if (!navigator.onLine) return;
const list = _getPending();
if (!list.length) return;
let ok = 0;
for (const r of [...list]) {
try {
const { id: _pid, _isPending, ...payload } = r;
await API.routes.create(payload);
_setPending(_getPending().filter(x => x.id !== r.id));
ok++;
} catch {}
}
if (ok > 0) { UI.toast.success(`${ok} Route(n) synchronisiert.`); _loadData(); }
}
window.addEventListener('online', _syncPending);
let _container = null;
let _appState = null;
let _data = [];
let _filtered = [];
let _userPos = null;
let _search = '';
let _difficulty = '';
let _terrain = '';
let _sortBy = 'newest';
let _onlyMine = false;
let _isRecording = false;
let _filterOpen = false;
// Navigation-Overlay state
let _navOvl = null, _navMap = null, _navWatchId = null;
let _navWakeLock = null, _navInactTimer = null, _navDimmed = false;
let _navCurrentIdx = 0;
let _navPois = [];
let _navMaxIdx = 0;
let _navWalkMeta = null; // { routeId, totalKm, trackLen }
let _navRecorded = false; // Einmal-Guard pro Navigation
const _PENDING_WALK_KEY = 'by_pending_nav_walk'; // localStorage-Sicherheitsnetz
let _navOrientCleanup = null;
let _navLastBearing = null;
let _navCompassHeading = null;
let _navHeadingSmoothed = null;
// ----------------------------------------------------------
// NAVI-SOUNDS (Idee René 2026-06-06): links = 2× Wuff, rechts = 1× Wuff,
// falscher Weg = Kläffen. WebAudio-Synthese (kein Asset, läuft offline) —
// liegen echte Aufnahmen unter /sounds/wuff.mp3 + /sounds/klaeffen.mp3
// (z.B. von Yaro 🐕), werden DIE bevorzugt. iOS: Audio braucht eine User-Geste
// → unlock() beim Navi-Start/Toggle.
// ----------------------------------------------------------
const NavSound = (() => {
let ctx = null;
let files = null; // { wuff, klaeffen } HTMLAudio | leer = Synthese.
// WICHTIG — zwei iOS-Fallen (René 2026-06-06):
// 1. HTMLAudio mit preload lädt LAZY (canplaythrough feuert nie) → daher in der
// User-Geste STUMM ANSPIELEN: primt die Wiedergabe-Erlaubnis UND erzwingt das Laden.
// 2. WebAudio (AudioContext/decodeAudioData) respektiert den STUMMSCHALTER → auf
// lautlos (= Gassi-Normalzustand) kam NICHTS (Prod-Befund). HTMLAudio spielt wie
// Medien trotz Stummschalter → für ein Navi der richtige Kanal. Synthese = Fallback.
const enabled = () => { try { return localStorage.getItem('by_nav_sound') !== '0'; } catch (e) { return true; } };
function _ctx() {
if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
return ctx;
}
// Ein synthetischer „Wuff": Sägezahn-Sweep durch Tiefpass, kurzer Attack, schneller Decay.
function _wuff(at, pitch = 1) {
const c = _ctx(), t = c.currentTime + at;
const o = c.createOscillator(), g = c.createGain(), f = c.createBiquadFilter();
o.type = 'sawtooth';
o.frequency.setValueAtTime(240 * pitch, t);
o.frequency.exponentialRampToValueAtTime(75 * pitch, t + 0.16);
f.type = 'lowpass'; f.frequency.value = 900 * pitch; f.Q.value = 3;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.9, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
o.connect(f); f.connect(g); g.connect(c.destination);
o.start(t); o.stop(t + 0.25);
}
function _barks(n, pitch, gap) {
if (!enabled()) return;
try {
const a = files && (pitch > 1.3 ? files.klaeffen : files.wuff);
if (a && a.readyState >= 2) { // echte Aufnahme geladen (Schäferhund, /sounds/*.mp3)
// klaeffen.mp3 ist bereits eine ~2,8-s-Bell-SEQUENZ → nur 1× abspielen;
// wuff.mp3 ist ein einzelner Beller → n-mal mit Pause.
const reps = a === files.klaeffen ? 1 : n;
let i = 0;
const play = () => {
if (i++ >= reps) return;
a.currentTime = 0;
a.play().catch(() => {});
setTimeout(play, (a.duration || 0.4) * 1000 + 220);
};
play();
return;
}
for (let i = 0; i < n; i++) _wuff(i * gap, pitch); // Fallback: Synthese
} catch (e) {}
}
return {
enabled,
unlock() { // in User-Geste aufrufen (iOS-Autoplay-Policy)
try {
const c = _ctx();
const b = c.createBuffer(1, 1, 22050), s = c.createBufferSource();
s.buffer = b; s.connect(c.destination); s.start(0);
} catch (e) {}
if (files === null) {
files = {};
['wuff', 'klaeffen'].forEach(name => {
const a = new Audio(`/sounds/${name}.mp3`);
a.preload = 'auto';
// IM GESTENKONTEXT stumm anspielen: primt iOS (spätere play() ohne Geste
// erlaubt) und erzwingt das tatsächliche Laden (preload allein reicht nicht).
a.muted = true;
a.play().then(() => {
a.pause(); a.currentTime = 0; a.muted = false;
}).catch(() => { a.muted = false; });
files[name] = a;
});
}
},
links() { _barks(2, 1.0, 0.30); }, // 2× Wuff
rechts() { _barks(1, 1.0, 0.30); }, // 1× Wuff
klaeffen() { _barks(4, 1.7, 0.16); }, // schnelles, höheres Bellen
};
})();
let _navSndAnnouncedIdx = -1; // bis zu welchem Track-Index Abbiegungen angesagt wurden
let _navSndOffRoute = false; // Off-Route-Zustand (Kläffen beim Eintritt + alle 30 s)
let _navSndLastKlaeff = 0;
// Recording-Overlay state
let _recOvl = null, _recMap = null;
let _recFollow = true; // Karte folgt dem Standort bei Aufzeichnung (Drag pausiert)
let _recActive = false;
let _recTrack = [], _recDistKm = 0, _recStartTime = null;
let _recTimerInt = null, _recWatchId = null;
let _recPolyline = null, _recLocMarker = null;
let _recWakeLock = null, _recInactTimer = null, _recDimmed = false;
// 'mine' | 'discover' | 'suggest'
let _browseMode = 'mine';
// Vorschläge-Tab state
let _suggestKm = 4; // gewählte Distanz: 2, 4 oder 6
let _suggestSeed = 0; // Variante: 0, 1, 2
let _suggestResult = null; // letzte API-Antwort
let _suggestMap = null; // Leaflet-Instanz der Vorschau-Karte
// Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
let _searchLines = new Map(); // routeId → { line, route }
let _detailMap = null; // GL-Karte im Detail-Modal (Kontext beim Schließen freigeben!)
// Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map
const DIFFICULTY_LABEL = { leicht: 'Leicht', mittel: 'Mittel', anspruchsvoll: 'Schwer' };
const TERRAIN_LABEL = { wald: 'Wald', asphalt: 'Asphalt', wiese: 'Wiese', mix: 'Mix' };
const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' };
const HUNDE_TEXT = { eingeschränkt: 'Leine', gut: 'gut',
sehr_gut: 'sehr gut', premium: 'premium' };
const DIFF_COLOR = { leicht: 'background:rgba(22,163,74,0.10);color:#4ade80;border-color:rgba(22,163,74,0.30)',
mittel: 'background:rgba(234,179,8,0.10);color:#facc15;border-color:rgba(234,179,8,0.30)',
anspruchsvoll: 'background:rgba(220,38,38,0.10);color:#f87171;border-color:rgba(220,38,38,0.30)' };
// POI-Typen entlang der Route — nur relevante/interessante Orte
const NEARBY_TYPES = [
{ type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café', svgIcon: 'fork-knife', color: '#F97316' },
{ type: 'tierarzt', icon: '🏥', label: 'Tierarzt', svgIcon: 'first-aid', color: '#EF4444' },
{ type: 'shop', icon: '🐾', label: 'Zoobedarf', svgIcon: 'shopping-cart', color: '#3B82F6' },
];
// _esc und _emptyState ersetzt durch UI.escape() / UI.emptyState()
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
// Vorberechneter Vorschlag vom Welcome-Chip → direkt in Suggest-Tab anzeigen
if (params._suggestResult) {
_suggestResult = params._suggestResult;
_suggestKm = params._suggestKm || _suggestKm;
_suggestSeed = params._suggestSeed || _suggestSeed;
_browseMode = 'suggest';
}
_render();
UI.loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
_flushPendingNavWalk(); // nicht gespeicherten Navigations-Walk nachtragen
try { _userPos = await API.getLocation(); } catch {}
await _loadData();
_offerResume(); // unterbrochene Aufzeichnung anbieten
// Vorschlag sofort rendern (Leaflet war noch nicht bereit bei _render)
if (params._suggestResult) {
_renderSuggestTab();
_showSuggestResult(params._suggestResult);
}
// Deep-Link: /#routes?id=123 → direkt Route-Detail öffnen
const urlParams = new URLSearchParams((location.hash.split('?')[1] || ''));
const deepId = urlParams.get('id');
if (deepId) {
_openDetail(parseInt(deepId, 10));
}
}
function refresh() {
// Button-Zeile neu rendern damit style-Änderungen nach einem JS-Update sichtbar werden
const btnRow = document.querySelector('#rk-filter-btn')?.parentElement;
if (btnRow) {
btnRow.innerHTML = `
`;
document.getElementById('rk-filter-btn').addEventListener('click', _toggleFilterPanel);
document.getElementById('rk-rec-btn').addEventListener('click', _openRecOvl);
document.getElementById('rk-import-input').addEventListener('change', e => {
const file = e.target.files?.[0]; if (file) _importFile(file); e.target.value = '';
});
_updateFilterBadge();
}
_syncRecBtn();
_loadData();
}
function onDogChange() {}
// Beim Verlassen der Seite alle Listen-/Detail-Karten freigeben (WebGL-Kontext-Leak).
// Aktive Navigations-/Aufzeichnungs-Overlays (_navMap/_recMap) bleiben unangetastet.
function destroy() {
[_detailMap, _suggestMap, _searchMap].forEach(m => { try { m && m.remove && m.remove(); } catch (e) {} });
_detailMap = _suggestMap = _searchMap = null;
try { _miniMaps.forEach(m => m.remove && m.remove()); _miniMaps.clear(); } catch (e) {}
}
// ----------------------------------------------------------
// Render
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
`;
let _searchTimer = null;
document.getElementById('rk-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_search = e.target.value.toLowerCase().trim();
_applyFilter();
}, 300);
});
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-filter-btn').addEventListener('click', _toggleFilterPanel);
document.getElementById('rk-rec-btn').addEventListener('click', _openRecOvl);
document.getElementById('rk-import-input').addEventListener('change', e => {
const file = e.target.files?.[0];
if (file) _importFile(file);
e.target.value = ''; // reset so same file can be re-selected
});
document.getElementById('rk-filters').addEventListener('click', e => {
const chip = e.target.closest('.rk-chip');
if (!chip) return;
const { filter, val } = chip.dataset;
chip.closest('.rk-chips-row').querySelectorAll('.rk-chip')
.forEach(c => c.classList.remove('active'));
chip.classList.add('active');
if (filter === 'difficulty') _difficulty = val;
if (filter === 'terrain') _terrain = val;
if (filter === 'sort') _sortBy = val;
if (filter === 'mine') _onlyMine = chip.classList.contains('active') && val === 'mine';
if (filter === 'nearby') { _loadDataNearby(); return; } // async, calls _applyFilter itself
_updateFilterBadge();
_applyFilter();
});
// Mode toggle
document.getElementById('rk-mode-mine').addEventListener('click', () => _setBrowseMode('mine'));
document.getElementById('rk-mode-discover').addEventListener('click', () => _setBrowseMode('discover'));
document.getElementById('rk-mode-suggest').addEventListener('click', () => _setBrowseMode('suggest'));
}
function _syncRecBtn() {
// no-op: recording now handled by self-contained overlay
}
function _toggleFilterPanel() {
_filterOpen = !_filterOpen;
const panel = document.getElementById('rk-filter-panel');
const btn = document.getElementById('rk-filter-btn');
// KLASSE toggeln, nicht style.display: .hidden hat display:none !important
// (design-system.css) — Inline-Style kommt dagegen nie an. Kaputt seit 27a3f95
// („Filter standardmäßig zu" setzte die Klasse ins Markup, Toggle blieb auf style).
if (panel) panel.classList.toggle('hidden', !_filterOpen);
if (btn) btn.classList.toggle('active', _filterOpen);
}
function _updateFilterBadge() {
const badge = document.getElementById('rk-filter-badge');
if (!badge) return;
const hasFilter = _difficulty !== '' || _terrain !== '' || _sortBy !== 'newest' || _onlyMine;
badge.classList.toggle('hidden', !hasFilter); // .hidden hat !important → nur classList
}
function _setBrowseMode(mode) {
_browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');
document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover');
document.getElementById('rk-mode-suggest')?.classList.toggle('active', mode === 'suggest');
const recBtn = document.getElementById('rk-rec-btn');
const impWrap = document.getElementById('rk-imp-wrap');
const mineGrp = document.getElementById('rk-mine-group');
const nearbyGrp = document.getElementById('rk-nearby-group');
const searchRow = document.getElementById('rk-search-row'); // Zeile 2: Suche + View-Toggle
const filterBtn = document.getElementById('rk-filter-btn');
const actRow = filterBtn?.parentElement; // Zeile 3: Aktions-Buttons
if (mode === 'suggest') {
if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.classList.add('hidden');
if (nearbyGrp) nearbyGrp.classList.add('hidden');
if (searchRow) searchRow.style.display = 'none';
if (actRow) actRow.style.display = 'none';
const filterPanel = document.getElementById('rk-filter-panel');
if (filterPanel) { filterPanel.classList.add('hidden'); _filterOpen = false; }
document.getElementById('rk-filter-btn')?.classList.remove('active');
if (!App.hasPro(_appState?.user)) {
document.getElementById('rk-list')?.replaceChildren();
const gate = document.createElement('div');
gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)';
gate.innerHTML = `
Ban Yaro Pro
Routenvorschläge sind ein Pro-Feature.
`;
document.getElementById('rk-list')?.appendChild(gate);
} else {
_renderSuggestTab();
}
} else {
if (searchRow) searchRow.style.display = '';
if (actRow) actRow.style.display = '';
if (mode === 'discover') {
if (recBtn) recBtn.style.display = 'none';
if (impWrap) impWrap.style.display = 'none';
if (mineGrp) mineGrp.classList.add('hidden');
if (nearbyGrp && _userPos) nearbyGrp.classList.remove('hidden');
} else {
if (recBtn) recBtn.style.display = '';
if (impWrap) impWrap.style.display = '';
if (_appState.user && mineGrp) mineGrp.classList.remove('hidden');
if (nearbyGrp) nearbyGrp.classList.add('hidden');
}
_onlyMine = false;
document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active'));
_applyFilter();
}
}
// ----------------------------------------------------------
// Vorschläge-Tab
// ----------------------------------------------------------
function _renderSuggestTab() {
const grid = document.getElementById('rk-grid');
if (!grid) return;
// Leaflet-Karte aus vorherigem Besuch aufräumen
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
// Styles einmalig injizieren
if (!document.getElementById('rk-suggest-styles')) {
const style = document.createElement('style');
style.id = 'rk-suggest-styles';
style.textContent = `
.rks-km-chip {
flex:1;padding:14px 8px;border-radius:var(--radius-lg);
border:2px solid var(--c-border-light);background:var(--c-surface);
color:var(--c-text);font-size:1.1rem;font-weight:700;cursor:pointer;
transition:border-color .15s,background .15s,color .15s;text-align:center;
}
.rks-km-chip.active {
border-color:var(--c-primary);background:var(--c-primary);color:#fff;
}
.rks-var-btn {
flex:1;padding:8px 4px;border-radius:8px;font-size:0.8rem;font-weight:600;
border:1.5px solid var(--c-border-light);background:var(--c-surface);
color:var(--c-text-secondary);cursor:pointer;
transition:border-color .15s,background .15s,color .15s;
}
.rks-var-btn.active {
border-color:var(--c-primary);color:var(--c-primary);background:rgba(var(--c-primary-rgb,99,102,241),0.08);
}
#rks-map { border-radius:var(--radius-lg);overflow:hidden; }
`;
document.head.appendChild(style);
}
grid.innerHTML = `
Gewünschte Distanz
Variante
`;
// Distanz-Chips
grid.querySelector('#rks-km-row').addEventListener('click', e => {
const btn = e.target.closest('.rks-km-chip');
if (!btn) return;
_suggestKm = parseInt(btn.dataset.km, 10);
grid.querySelectorAll('.rks-km-chip').forEach(b => b.classList.toggle('active', b === btn));
});
// Varianten-Buttons
grid.querySelector('#rks-var-row').addEventListener('click', e => {
const btn = e.target.closest('.rks-var-btn');
if (!btn) return;
_suggestSeed = parseInt(btn.dataset.seed, 10);
grid.querySelectorAll('.rks-var-btn').forEach(b => b.classList.toggle('active', b === btn));
});
// Berechnen
grid.querySelector('#rks-calc-btn').addEventListener('click', _calcSuggestRoute);
}
async function _calcSuggestRoute() {
// Standort prüfen
if (!_userPos) {
try { _userPos = await API.getLocation(); } catch {
const res = document.getElementById('rks-result');
if (res) res.innerHTML = `
Standort wird benötigt. Bitte erlaube den Zugriff in den Browser-Einstellungen.
`;
return;
}
}
// Alten Karteninhalt aufräumen
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
// Spinner anzeigen
const res = document.getElementById('rks-result');
if (!res) return;
res.innerHTML = `
`;
const calcBtn = document.getElementById('rks-calc-btn');
if (calcBtn) calcBtn.disabled = true;
let result;
try {
result = await API.post('/routes/suggest', {
lat: _userPos.lat,
lon: _userPos.lon,
distance_km: _suggestKm,
seed: _suggestSeed,
});
_suggestResult = result;
} catch (err) {
const is429 = err.status === 429 || String(err.message).includes('Wochenlimit');
if (res) res.innerHTML = `
${is429 ? 'Wochenlimit erreicht' : 'Fehler beim Berechnen'}
${is429 ? 'Du hast diese Woche alle 20 Routenvorschläge genutzt. Montag gibt es neue.' : UI.escape(err.message || 'Unbekannter Fehler')}
`;
if (calcBtn) calcBtn.disabled = false;
return;
}
if (calcBtn) calcBtn.disabled = false;
_showSuggestResult(result);
}
function _showSuggestResult(result) {
_suggestResult = result;
const res = document.getElementById('rks-result');
if (!res) return;
const distStr = result.distanz_km ? result.distanz_km.toFixed(2) + ' km' : '–';
const durStr = result.dauer_min
? (result.dauer_min < 60 ? result.dauer_min + ' min' : Math.floor(result.dauer_min/60) + 'h ' + (result.dauer_min%60||'') + 'min').trim()
: '–';
const diffLabel = { leicht: 'Leicht', mittel: 'Mittel', anspruchsvoll: 'Schwer' }[result.schwierigkeit] || '';
const limitHint = (result.weekly_remaining != null)
? `
Noch ${result.weekly_remaining} von 20 Anfragen diese Woche
`
: '';
res.innerHTML = `
${limitHint}
${UI.icon('map-trifold')} ${UI.escape(distStr)}
${UI.icon('timer')} ${UI.escape(durStr)}
${result.hoehenmeter != null ? `
${UI.icon('trend-up')} ${result.hoehenmeter} hm
` : ''}
${diffLabel ? `${UI.escape(diffLabel)}` : ''}
${UI.escape(result.name || '')}
`;
const _initMap = async () => {
const mapEl = document.getElementById('rks-map');
if (!mapEl) return;
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
const track = result.gps_track || [];
if (track.length < 2) return;
const lls = track.map(p => [p.lat, p.lon]);
_suggestMap = await UI.map.create(mapEl, {
center: lls[0], zoom: 14,
zoomControl: false, attributionControl: false,
});
_suggestMap.scrollWheelZoom.disable();
const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.9 }).addTo(_suggestMap);
UI.map.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1, weight:2 }).addTo(_suggestMap);
UI.map.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1, weight:2 }).addTo(_suggestMap);
_addRouteArrows(_suggestMap, track, '#3b82f6');
_fitRouteMap(_suggestMap, mapEl, () => poly.getBounds());
};
_initMap();
document.getElementById('rks-nav-btn')?.addEventListener('click', () => {
_openNavOverlay({ id: 'suggest-' + Date.now(), name: result.name,
gps_track: result.gps_track, distanz_km: result.distanz_km });
});
document.getElementById('rks-save-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('rks-save-btn');
if (!btn) return;
await UI.asyncButton(btn, async () => {
await API.post('/routes', { name: result.name, gps_track: result.gps_track,
distanz_km: result.distanz_km, dauer_min: result.dauer_min, schwierigkeit: result.schwierigkeit });
UI.toast.success('Route gespeichert!');
await _loadData();
_setBrowseMode('mine');
});
});
}
async function _loadDataNearby() {
if (!_userPos) {
try { _userPos = await API.getLocation(); } catch { UI.toast.warning('Standort nicht verfügbar.'); return; }
}
try {
_data = await API.routes.listNearby(_userPos.lat, _userPos.lon, 10000);
_applyFilter();
} catch (err) {
UI.toast.error('Fehler beim Laden: ' + err.message);
}
}
// ----------------------------------------------------------
// Recording Overlay
// ----------------------------------------------------------
function _haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371, dLat = (lat2-lat1)*Math.PI/180, dLon = (lon2-lon1)*Math.PI/180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
// Unterbrochene Aufzeichnung (Reload/Crash/Update) zum Fortsetzen anbieten.
let _resumeOffered = false;
async function _offerResume() {
if (_recActive || _resumeOffered || _recOvl) return;
const saved = window.RecStore?.load();
if (!saved || saved.source !== 'routes' || !Array.isArray(saved.track) || saved.track.length < 2) return;
if (Date.now() - (saved.ts || 0) > 6 * 3600 * 1000) { window.RecStore?.clear(); return; }
_resumeOffered = true;
const km = (saved.distKm || 0).toFixed(2);
const ok = await UI.modal.confirm({
title: 'Aufzeichnung fortsetzen?',
message: `Eine unterbrochene Aufzeichnung wurde gefunden (${km} km, ${saved.track.length} Punkte). Möchtest du sie fortsetzen?`,
confirmText: 'Fortsetzen',
cancelText: 'Später',
});
if (!ok) return; // Track bleibt erhalten (erneut anbieten / Staleness räumt auf)
await _openRecOvl();
await _startRecInOvl(saved);
}
async function _openRecOvl() {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
if (_recOvl) return;
const ovl = document.createElement('div');
ovl.id = 'rk-rec-ovl';
ovl.style.cssText = 'position:fixed;inset:0;z-index:900;display:flex;flex-direction:column;background:var(--c-bg)';
ovl.innerHTML = `
Route
00:00
·
0.00 km
`;
document.body.appendChild(ovl);
_recOvl = ovl;
// Listener sofort nach DOM-Einfügen — nicht nach async-Operationen
ovl.querySelector('#rk-rec-cancel').addEventListener('click', () => _closeRecOvlClean());
ovl.querySelector('#rk-rec-startbtn').addEventListener('click', _startRecInOvl);
// Map-Setup: Leaflet könnte offline fehlen → alles in try/catch
const pos = _userPos || { lat: 48.1, lon: 11.5 };
try {
_recMap = await UI.map.create(ovl.querySelector('#rk-rec-map-wrap'), {
center: [pos.lat, pos.lon], zoom: 15,
zoomControl: false, attributionControl: false,
});
_recLocMarker = UI.map.circleMarker([pos.lat, pos.lon], {
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
}).addTo(_recMap);
// Follow-Mode (René 2026-06-08): Karte wandert mit dem Standort. Manuelles
// Verschieben pausiert das Folgen; Crosshair-Button schaltet es wieder ein.
_recFollow = true;
const fwrap = ovl.querySelector('#rk-rec-map-wrap');
if (!fwrap.style.position) fwrap.style.position = 'relative';
const fb = document.createElement('button');
fb.id = 'rk-rec-follow';
fb.type = 'button';
fb.title = 'Karte folgt dem Standort';
fb.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:500;width:42px;height:42px;'
+ 'border-radius:50%;border:none;background:var(--c-surface,#fff);'
+ 'box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;cursor:pointer';
fb.innerHTML = UI.icon('crosshair');
const updFb = () => { fb.style.color = _recFollow ? 'var(--c-primary)' : 'var(--c-text-secondary, #9ca3af)'; };
updFb();
fb.addEventListener('click', () => {
_recFollow = true;
const last = _recTrack[_recTrack.length - 1] || pos;
_recMap?.setView([last.lat, last.lon]);
updFb();
});
fwrap.appendChild(fb);
try { _recMap.on('dragstart', () => { _recFollow = false; updFb(); }); } catch (e) {}
} catch {
const mapWrap = ovl.querySelector('#rk-rec-map-wrap');
if (mapWrap) mapWrap.innerHTML =
`
📡
Karte offline nicht verfügbar — GPS läuft trotzdem
`;
}
// Genaueren Standort nachladen (best-effort, klappt auch offline via gespeichertem GPS)
try {
const p = await API.getLocation();
_userPos = p;
_recMap?.setView([p.lat, p.lon], 16);
_recLocMarker?.setLatLng([p.lat, p.lon]);
} catch {}
}
// Aufzeichnung gedrosselt sichern (Sicherheitsnetz gegen Datenverlust).
let _recPersistAt = 0;
function _persistRec(force) {
const now = Date.now();
if (!force && now - _recPersistAt < 8000) return;
_recPersistAt = now;
window.RecStore?.save({ source: 'routes', track: _recTrack, distKm: _recDistKm, startTime: _recStartTime });
}
function _recDone() {
window.RecStore?.clear();
window._byRecording = false;
window._byReloadIfPending?.();
}
async function _startRecInOvl(resume) {
if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; }
window._byRecording = true; // Guard: Update-Reload wird aufgeschoben
_recActive = true;
if (resume && Array.isArray(resume.track) && resume.track.length) {
_recTrack = resume.track.slice(); _recDistKm = resume.distKm || 0;
_recStartTime = resume.startTime || Date.now();
} else {
_recTrack = []; _recDistKm = 0; _recStartTime = Date.now();
}
// iOS-Hinweis: Display muss wach bleiben
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
const banner = document.createElement('div');
banner.style.cssText = 'position:absolute;top:0;left:0;right:0;z-index:960;' +
'background:rgba(30,30,30,0.92);color:#fff;font-size:13px;line-height:1.4;' +
'padding:max(env(safe-area-inset-top,10px),10px) 14px 10px;' +
'display:flex;align-items:flex-start;gap:10px;' +
'border-bottom:1px solid rgba(255,255,255,0.1)';
banner.innerHTML = `
Display wach lassen! Auf iPhone stoppt die GPS-Aufzeichnung, wenn das Display ausgeht — Helligkeit hochsetzen oder Bildschirm nicht sperren.`;
document.getElementById('rk-rec-map-wrap')?.appendChild(banner);
setTimeout(() => banner.remove(), 9000);
}
const ctrl = document.getElementById('rk-rec-ctrl');
ctrl.innerHTML = `
`;
// Long-Press 1.8s zum Stoppen
let _stopTimer = null, _stopTick = null;
const btn = ctrl.querySelector('#rk-rec-stopbtn');
const fill = ctrl.querySelector('#rk-stop-fill');
const startHold = () => {
if (_stopTimer) return;
const DURATION = 1800;
const start = Date.now();
_stopTick = setInterval(() => {
const p = Math.min((Date.now() - start) / DURATION, 1);
fill.style.transition = 'none';
fill.style.transform = `scaleX(${p})`;
}, 30);
_stopTimer = setTimeout(() => {
clearInterval(_stopTick); _stopTick = null; _stopTimer = null;
fill.style.transform = 'scaleX(1)';
_stopRecInOvl(true);
}, DURATION);
};
const cancelHold = () => {
if (!_stopTimer && !_stopTick) return;
clearTimeout(_stopTimer); clearInterval(_stopTick);
_stopTimer = null; _stopTick = null;
fill.style.transition = 'transform 0.25s ease';
fill.style.transform = 'scaleX(0)';
};
btn.addEventListener('pointerdown', e => { e.preventDefault(); startHold(); });
btn.addEventListener('pointerup', cancelHold);
btn.addEventListener('pointerleave', cancelHold);
btn.addEventListener('pointercancel', cancelHold);
document.getElementById('rk-rec-stats-bar').style.display = '';
if (_recMap) {
// Bei Fortsetzung den bestehenden Track sofort einzeichnen
const seed = (resume && _recTrack.length) ? _recTrack.map(p => [p.lat, p.lon]) : [];
_recPolyline = UI.map.polyline(seed, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
if (seed.length) {
const last = seed[seed.length - 1];
_recLocMarker?.setLatLng(last);
_recMap.setView(last, 16);
}
}
if (resume) { _updateRecStats(); _persistRec(true); }
await _recAcquireWakeLock();
document.addEventListener('visibilitychange', _recOnVisibility);
_recWatchId = navigator.geolocation.watchPosition(pos => {
const lat = pos.coords.latitude, lon = pos.coords.longitude;
const alt = pos.coords.altitude ?? null;
if (_recTrack.length) {
const prev = _recTrack[_recTrack.length - 1];
const d = _haversineKm(prev.lat, prev.lon, lat, lon);
if (d < 0.003) return;
_recDistKm += d;
}
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
_persistRec();
_recPolyline?.addLatLng([lat, lon]);
_recLocMarker?.setLatLng([lat, lon]);
// Follow-Mode: Karte wandert mit (erster Fix setzt den Zoom, danach bleibt er)
if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16);
else if (_recFollow) _recMap?.setView([lat, lon]);
_updateRecStats();
}, () => {}, { enableHighAccuracy: true, maximumAge: 2000 });
_recTimerInt = setInterval(_updateRecStats, 1000);
_resetRecInactTimer();
// Interaktion auf der Aufzeichnungsseite → Inaktivitäts-Timer zurücksetzen
_recOvl.addEventListener('touchstart', _onRecOvlTouch, { passive: true });
_recOvl.addEventListener('pointerdown', _onRecOvlTouch);
// Long-Press auf Fingerabdruck-Button → nach 2s zurück zur Aufzeichnung
const dim = document.getElementById('rk-rec-dim');
const unlockBtn = document.getElementById('rk-dim-unlock-btn');
let _lpTimer = null;
const cancelLp = () => {
clearTimeout(_lpTimer);
const prog = document.getElementById('rk-dim-prog');
if (prog) { prog.style.transition = 'none'; prog.style.strokeDashoffset = '150.8'; }
};
unlockBtn.addEventListener('pointerdown', e => {
e.stopPropagation();
unlockBtn.setPointerCapture(e.pointerId);
const prog = document.getElementById('rk-dim-prog');
if (prog) { prog.style.transition = 'stroke-dashoffset 2s linear'; prog.style.strokeDashoffset = '0'; }
_lpTimer = setTimeout(() => {
dim.style.display = 'none';
_recDimmed = false;
_resetRecInactTimer();
}, 2000);
});
unlockBtn.addEventListener('pointerup', cancelLp);
unlockBtn.addEventListener('pointercancel', cancelLp);
unlockBtn.addEventListener('pointerleave', cancelLp);
}
function _onRecOvlTouch(e) {
// Touches auf dem Dim-Overlay ignorieren (dort Long-Press-Logik)
if (document.getElementById('rk-rec-dim')?.contains(e.target)) return;
_resetRecInactTimer();
}
function _updateRecStats() {
if (!_recStartTime) return;
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const timeStr = `${mm}:${ss}`;
const distStr = `${_recDistKm.toFixed(2)} km`;
let paceStr = '–:––';
if (_recDistKm >= 0.05) {
const mPerKm = (elapsed / 60) / _recDistKm;
const pm = Math.floor(mPerKm);
const ps = String(Math.round((mPerKm - pm) * 60)).padStart(2, '0');
paceStr = `${pm}:${ps}`;
}
const q = id => document.getElementById(id);
if (q('rk-rec-time')) q('rk-rec-time').textContent = timeStr;
if (q('rk-rec-dist')) q('rk-rec-dist').textContent = distStr;
if (q('rk-rec-pace')) q('rk-rec-pace').textContent = paceStr;
if (q('rk-rec-dim-dauer')) q('rk-rec-dim-dauer').textContent = timeStr;
if (q('rk-rec-dim-dist')) q('rk-rec-dim-dist').textContent = distStr;
}
function _resetRecInactTimer() {
if (_recDimmed) return; // Dim wird nur per Long-Press aufgehoben
clearTimeout(_recInactTimer);
if (!_recActive) return;
_recInactTimer = setTimeout(() => {
const dim = document.getElementById('rk-rec-dim');
if (dim) {
dim.style.display = 'flex';
_recDimmed = true;
}
}, 5000);
}
async function _recAcquireWakeLock() {
if (!('wakeLock' in navigator) || _recWakeLock) return;
try {
_recWakeLock = await navigator.wakeLock.request('screen');
_recWakeLock.addEventListener('release', () => {
_recWakeLock = null;
// OS hat Lock entzogen (Anruf, Tab-Wechsel etc.) → sofort neu anfordern
if (_recActive) _recAcquireWakeLock();
});
} catch {}
}
function _recOnVisibility() {
if (_recActive && document.visibilityState === 'visible' && !_recWakeLock) {
_recAcquireWakeLock();
}
}
async function _stopRecInOvl(save) {
if (!_recActive && save) return;
_recActive = false;
if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; }
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
if (_recInactTimer){ clearTimeout(_recInactTimer); _recInactTimer = null; }
if (_recWakeLock) { try { await _recWakeLock.release(); } catch {} _recWakeLock = null; }
document.removeEventListener('visibilitychange', _recOnVisibility);
_recOvl?.removeEventListener('touchstart', _onRecOvlTouch);
_recOvl?.removeEventListener('pointerdown', _onRecOvlTouch);
if (!save) { _closeRecOvlClean(); _recDone(); return; }
const track = [..._recTrack], distKm = _recDistKm;
const dauMin = Math.round((Date.now() - _recStartTime) / 60000);
_persistRec(true); // finalen Stand sichern, bevor _recTrack zurückgesetzt wird
_closeRecOvlClean();
if (track.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte zum Speichern.'); _recDone(); return; }
// Guard bleibt aktiv bis im Save-Modal gespeichert/verworfen wird.
_showRecSaveModal(track, distKm, dauMin);
}
function _closeRecOvlClean() {
if (_recMap) { _recMap.remove(); _recMap = null; }
if (_recOvl) { _recOvl.remove(); _recOvl = null; }
_recPolyline = null; _recLocMarker = null;
_recTrack = []; _recDistKm = 0; _recStartTime = null; _recDimmed = false;
}
async function _prefillRouteName(track, distKm) {
const inp = document.querySelector('#rk-rms-form [name="name"]');
if (!inp || inp.value) return;
const pt = track[0];
const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
const km = distKm.toFixed(1);
let ort = '';
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' });
const data = await r.json();
const a = data.address || {};
ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || '';
} catch {}
if (inp && !inp.value) inp.value = ort
? `Gassirunde ${ort} · ${date} · ${km} km`
: `Gassirunde · ${date} · ${km} km`;
}
function _showRecSaveModal(track, distKm, dauMin) {
const body = `
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
`;
const footer = `
`;
UI.modal.open({ title: `${UI.icon('path')} Route benennen`, body, footer });
_prefillRouteName(track, distKm);
document.getElementById('rk-rms-paw')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('rk-rms-paw-val').value = btn.dataset.val;
});
document.getElementById('rk-rms-discard')?.addEventListener('click', () => { UI.modal.close(); _recDone(); });
document.getElementById('rk-rms-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="rk-rms-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const payload = {
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
distanz_km: Math.round(distKm * 100) / 100,
dauer_min: dauMin,
schwierigkeit: fd.schwierigkeit || 'leicht',
untergrund: fd.untergrund || null,
schatten: 'schatten' in fd,
leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
client_time: API.clientNow(),
};
if (!navigator.onLine) {
_addPending(payload);
UI.modal.close();
_recDone();
UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`);
_loadData();
return;
}
const saved = await API.routes.create(payload);
UI.modal.close();
_recDone();
UI.toast.success(`Route „${saved.name}" gespeichert!`);
_loadData();
});
});
}
// ----------------------------------------------------------
// View-Toggle
// ----------------------------------------------------------
function _switchView(mode) {
_viewMode = mode;
document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list');
document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map');
const layout = document.querySelector('.rk-layout');
const grid = document.getElementById('rk-grid');
if (mode === 'map') {
if (grid) grid.style.display = 'none';
// Alten Map-Container entfernen falls vorhanden
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
// Als fixed Overlay direkt in — kein Konflikt mit .rk-layout overflow:hidden
const mapH = window.innerHeight - 160;
const sec = document.createElement('div');
sec.id = 'rk-map-section';
sec.className = 'rk-map-section';
sec.innerHTML = `
Route antippen um Details zu sehen
`;
document.body.appendChild(sec);
document.getElementById('rk-map-back')?.addEventListener('click', () => _switchView('list'));
_initSearchMap();
} else {
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
if (grid) grid.style.display = '';
}
}
// ----------------------------------------------------------
// Suchkarte
// ----------------------------------------------------------
async function _initSearchMap() {
if (!document.getElementById('rk-search-map')) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
const zoom = _userPos ? 13 : 6;
_searchMap = await UI.map.create('rk-search-map', {
center, zoom,
zoomControl: true, attributionControl: false,
});
setTimeout(() => _searchMap?.invalidateSize(), 100);
setTimeout(() => _searchMap?.invalidateSize(), 600);
_renderRoutesOnMap();
// Standort-Button
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
_userPos = pos;
_searchMap.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
});
// Geocoding-Suche
const locInput = document.getElementById('rk-map-loc');
let _geoDebounce;
locInput?.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
clearTimeout(_geoDebounce);
_geocodeAndFly(locInput.value.trim());
});
locInput?.addEventListener('input', () => {
clearTimeout(_geoDebounce);
const q = locInput.value.trim();
if (q.length < 3) return;
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
});
}
async function _geocodeAndFly(query) {
if (!query || !_searchMap) return;
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const data = await r.json();
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
const { lat, lon, boundingbox } = data[0];
if (boundingbox) {
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
{ maxZoom: 14 });
} else {
_searchMap.setView([+lat, +lon], 13);
}
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
}
function _renderRoutesOnMap() {
if (!_searchMap) return;
// Alte Linien entfernen
_searchLines.forEach(({ line }) => line.remove());
_searchLines.clear();
const hint = document.getElementById('rk-map-hint');
_data.forEach(route => {
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
if (pts.length < 2) return;
const line = UI.map.polyline(pts, {
color: '#C4843A', weight: 4, opacity: 0.75,
}).addTo(_searchMap);
// Start-/End-Marker
const startM = UI.map.circleMarker(pts[0], {
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
const endM = UI.map.circleMarker(pts[pts.length - 1], {
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
// Tooltip mit Namen und Distanz
const tip = `${UI.escape(route.name)}${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
// Hover-Highlight
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
const onClick = () => {
if (hint) hint.textContent = `Lädt „${route.name}"…`;
_openDetail(route.id).finally(() => {
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
});
};
line.on('click', onClick);
startM.on('click', onClick);
_searchLines.set(route.id, { line, startM, endM });
});
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
if (_data.length && _searchLines.size && !_userPos) {
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
if (allPts.length) {
try { _searchMap.fitBounds(allPts, { padding: [20, 20], maxZoom: 14 }); }
catch {}
}
}
}
// ----------------------------------------------------------
// Daten
// ----------------------------------------------------------
async function _loadData() {
const _merge = (online) => {
const pending = _getPending();
if (pending.length) _data = [...pending, ..._data];
if (_appState.user && _browseMode === 'mine')
document.getElementById('rk-mine-group')?.classList.remove('hidden');
if (_browseMode === 'discover' && _userPos)
document.getElementById('rk-nearby-group')?.classList.remove('hidden');
if (!online && pending.length)
UI.toast.info('Offline — ' + pending.length + ' Route(n) warten auf Sync.');
_applyFilter();
};
try {
_data = await API.routes.list();
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _data })); } catch {}
_merge(true);
} catch {
try {
const raw = localStorage.getItem(_CACHE_KEY);
if (raw) {
_data = JSON.parse(raw).data || [];
UI.toast.info('Offline — zeige zuletzt geladene Routen.');
_merge(false);
return;
}
} catch {}
// Nur Pending-Routen zeigen wenn gar kein Cache
_data = _getPending();
if (_data.length) { _merge(false); return; }
document.getElementById('rk-grid').innerHTML =
`Offline — noch keine Routen gecacht.
`;
}
}
// ----------------------------------------------------------
// Filter
// ----------------------------------------------------------
const DOG_ORDER = { premium: 4, sehr_gut: 3, gut: 2, eingeschränkt: 1 };
function _geoDistKm(r) {
if (!_userPos || !r.start_lat || !r.start_lon) return Infinity;
const R = 6371, dLat = (r.start_lat - _userPos.lat) * Math.PI / 180;
const dLon = (r.start_lon - _userPos.lon) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 +
Math.cos(_userPos.lat*Math.PI/180) * Math.cos(r.start_lat*Math.PI/180) *
Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function _applyFilter() {
let list = [..._data];
// Browse-Modus-Filter
if (_browseMode === 'mine' && _appState.user) {
list = list.filter(r => r.user_id === _appState.user.id);
} else if (_browseMode === 'discover') {
list = list.filter(r => r.is_public === true);
}
if (_search) list = list.filter(r =>
(r.name||'').toLowerCase().includes(_search) ||
(r.beschreibung||'').toLowerCase().includes(_search) ||
(r.user_name||'').toLowerCase().includes(_search));
if (_difficulty) list = list.filter(r => r.schwierigkeit === _difficulty);
if (_terrain) list = list.filter(r => r.untergrund === _terrain);
if (_onlyMine && _appState.user && _browseMode === 'mine')
list = list.filter(r => r.user_id === _appState.user.id);
if (_browseMode === 'discover') {
list.sort((a, b) => _geoDistKm(a) - _geoDistKm(b));
} else if (_sortBy === 'distance') list.sort((a,b) => (b.distanz_km||0) - (a.distanz_km||0));
else if (_sortBy === 'rating') list.sort((a,b) => (b.bewertung||0) - (a.bewertung||0));
else if (_sortBy === 'dog') list.sort((a,b) =>
(DOG_ORDER[b.hunde_tauglichkeit]||0) - (DOG_ORDER[a.hunde_tauglichkeit]||0));
_filtered = list;
_renderGrid();
if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap();
}
// ----------------------------------------------------------
// Grid
// ----------------------------------------------------------
function _renderGrid() {
const grid = document.getElementById('rk-grid');
if (!grid) return;
if (!_filtered.length) {
if (_data.length) {
// Filter aktiv aber kein Ergebnis
const emptyMsg = _search
? `Keine Routen gefunden für „${UI.escape(_search)}".`
: 'Keine Routen passen zu deinen Filtern.';
grid.innerHTML = `
🔍
${emptyMsg}
`;
document.getElementById('rk-empty-reset')?.addEventListener('click', () => {
_search = ''; _difficulty = ''; _terrain = ''; _sortBy = 'newest'; _onlyMine = false;
document.getElementById('rk-search').value = '';
document.querySelectorAll('.rk-chip').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.rk-chip[data-val=""]').forEach(c => c.classList.add('active'));
document.querySelector('.rk-chip[data-val="newest"]')?.classList.add('active');
_updateFilterBadge();
_applyFilter();
});
} else if (_browseMode === 'discover') {
// Entdecken: keine fremden Routen vorhanden
grid.innerHTML = `
${UI.icon('compass')}
Noch keine öffentlichen Routen
Andere Nutzer haben noch keine Routen geteilt. Sei der Erste!
`;
} else {
// Noch gar keine eigenen Routen
grid.innerHTML = UI.emptyState({
icon: UI.icon('map-trifold'),
title: 'Noch keine Routen',
text: 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
action: ``,
});
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
});
}
return;
}
// Alte Mini-Maps zerstören bevor DOM neu geschrieben wird
_miniMaps.forEach(m => m.remove());
_miniMaps.clear();
grid.innerHTML = _filtered.map(r => _cardHTML(r)).join('');
_initMiniMaps();
grid.querySelectorAll('.rk-card').forEach(card => {
card.addEventListener('click', e => {
if (e.target.closest('.rk-stars,.rk-dl-btn')) return;
_openDetail(parseInt(card.dataset.id));
});
});
grid.querySelectorAll('.rk-star').forEach(star => {
star.addEventListener('click', e => {
e.stopPropagation();
_rateRoute(parseInt(star.dataset.id), parseFloat(star.dataset.val));
});
});
grid.querySelectorAll('.rk-dl-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_downloadGpx(parseInt(btn.dataset.id));
});
});
}
// ----------------------------------------------------------
// Karte HTML
// ----------------------------------------------------------
function _cardHTML(r) {
const isDiscover = _browseMode === 'discover';
const privBadge = !r.is_public ? `${UI.icon('lock')} Privat` : '';
const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || '';
const terrain = TERRAIN_LABEL[r.untergrund] || '';
const paws = HUNDE_LABEL[r.hunde_tauglichkeit] || '';
const dist = r.distanz_km ? `${r.distanz_km.toFixed(1)} km` : '';
const dur = r.dauer_min ? _fmtDur(r.dauer_min) : '';
// Immer die Karte zeigen — Fotos erst beim Öffnen der Route.
// Bei Routen mit Fotos einen kleinen Kamera-Marker oben rechts
// einblenden (analog zum Tagebuch).
const photoCount = (r.foto_urls || []).length;
const photoBadge = photoCount > 0
? `
${photoCount}
`
: '';
const previewContent = `
${photoBadge}
`;
const authorLine = isDiscover
? `${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}
`
: '';
// „X× gelaufen · zuletzt …" — macht sichtbar, dass das Ablaufen mitzählt
const _wc = r.my_walk_count || 0;
let walkedLine = '';
if (_wc > 0) {
let last = '';
if (r.my_last_walked) {
const d = new Date(String(r.my_last_walked).replace(' ', 'T') + 'Z'); // walked_at ist UTC
const days = Math.floor((Date.now() - d.getTime()) / 86400000);
last = days <= 0 ? 'heute' : days === 1 ? 'gestern' : `vor ${days} Tagen`;
}
walkedLine = `
🐾 ${_wc}× gelaufen${last ? ' · zuletzt ' + last : ''}
`;
}
return `
${previewContent}
${authorLine}
${r._isPending ? `
${UI.icon('cloud-arrow-up')} Sync ausstehend
` : ''}
${UI.escape(r.name)}
${dist ? _pill(dist, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}
${dur ? _pill(dur, 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}
${diffLabel ? _pill(diffLabel,
({leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'})[r.schwierigkeit]||'rgba(107,114,128,0.10)',
({leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'})[r.schwierigkeit]||'#9ca3af',
({leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'})[r.schwierigkeit]||'rgba(107,114,128,0.30)') : ''}
${r.hunde_tauglichkeit ? _pill(HUNDE_TEXT[r.hunde_tauglichkeit]||'', 'rgba(234,179,8,0.10)','#facc15','rgba(234,179,8,0.30)') : ''}
${!isDiscover && !r.is_public ? _pill('Privat','rgba(59,130,246,0.10)','#60a5fa','rgba(59,130,246,0.30)') : ''}
${walkedLine}
`;
}
function _starsHTML(id, avg, count) {
const stars = [1,2,3,4,5].map(n =>
`★`
).join('');
return stars + (count > 0 ? `${avg.toFixed(1)} (${count})` : '');
}
// ----------------------------------------------------------
// Mini-Karten (OSM-Tiles via Leaflet, lazy per IntersectionObserver)
// ----------------------------------------------------------
function _initMiniMaps() {
const init = () => {
const obs = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
obs.unobserve(entry.target);
_buildMiniMap(entry.target);
});
}, { rootMargin: '150px' });
document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el));
};
init();
}
// Mini-Vorschau: zuerst sofort die SVG-Routenform (kein Warten), dann — sobald
// gerendert — auf ein echtes Karten-PNG (Basemap + Route) upgraden. Das PNG kommt
// aus EINEM geteilten Offscreen-GL-Kontext (UI.map.snapshot, mit Cache), damit viele
// Listeneinträge nicht das WebGL-Kontextlimit sprengen. Ist GL aus → SVG bleibt.
function _buildMiniMap(el) {
const track = JSON.parse(el.dataset.track || '[]');
el.innerHTML = _svgPreview(track);
if (track.length < 2 || !UI.map.snapshot) return;
UI.map.snapshot(track, { key: 'r' + (el.dataset.id || '') }).then(url => {
if (!url || !el.isConnected) return; // GL aus/Fehler → SVG-Platzhalter bleibt
el.style.backgroundImage = `url("${url}")`;
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
el.innerHTML = ''; // SVG-Platzhalter entfernen (PNG enthält die Route)
}).catch(() => {});
}
// ----------------------------------------------------------
// SVG-Vorschau (Fallback, wird nicht mehr direkt genutzt)
// ----------------------------------------------------------
function _svgPreview(track) {
if (!track || track.length < 2)
return `🗺️
`;
const lats = track.map(p=>p.lat), lons = track.map(p=>p.lon);
const minLat=Math.min(...lats), maxLat=Math.max(...lats);
const minLon=Math.min(...lons), maxLon=Math.max(...lons);
const latR=maxLat-minLat||0.0001, lonR=maxLon-minLon||0.0001;
const W=200,H=120,PAD=10;
const pts = track.map(p=>{
const x=(PAD+(p.lon-minLon)/lonR*(W-2*PAD)).toFixed(1);
const y=(H-PAD-(p.lat-minLat)/latR*(H-2*PAD)).toFixed(1);
return `${x},${y}`;
}).join(' ');
const [sx,sy]=pts.split(' ')[0].split(',');
const [ex,ey]=pts.split(' ').at(-1).split(',');
return ``;
}
// Button-Hilfsfunktion für Header-Buttons (cache-unabhängig)
const _btnStyle = (primary = false) =>
`flex:1;display:flex;align-items:center;justify-content:center;gap:6px;` +
`height:46px;padding:0 16px;font-size:14px;font-weight:600;` +
`border-radius:10px;border:1.5px solid ${primary ? 'var(--c-primary)' : 'var(--c-border)'};` +
`background:${primary ? 'var(--c-primary)' : 'var(--c-surface)'};` +
`color:${primary ? '#fff' : 'var(--c-text)'};white-space:nowrap;box-sizing:border-box;cursor:pointer;`;
// Pill-Hilfsfunktionen (inline styles — unabhängig vom CSS-Cache)
const _pillStyle = (bg, color, border) =>
`display:inline-flex;align-items:center;font-size:10px;font-weight:600;` +
`padding:2px 8px;border-radius:999px;white-space:nowrap;` +
`background:${bg};color:${color};border:1px solid ${border};`;
const _pill = (text, bg, color, border) =>
`${text}`;
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
// ----------------------------------------------------------
// Navigation-Overlay
// ----------------------------------------------------------
async function _openNavOverlay(route) {
const track = route.gps_track || [];
if (track.length < 2) return;
// Navi-Sounds: Audio in der User-Geste freischalten (iOS) + Ansage-Status zurücksetzen
NavSound.unlock();
_navSndAnnouncedIdx = -1;
_navSndOffRoute = false;
_navSndLastKlaeff = 0;
_navMaxIdx = 0;
_navRecorded = false;
_navLastBearing = null;
_navWalkMeta = { routeId: route.id, totalKm: route.distanz_km || 0, trackLen: track.length };
// Kompass-Permission iOS 13+ — muss synchron in User-Gesture sein
if (typeof DeviceOrientationEvent?.requestPermission === 'function') {
try { await DeviceOrientationEvent.requestPermission(); } catch {}
}
const ovl = document.createElement('div');
ovl.id = 'rk-nav-ovl';
ovl.style.cssText = 'position:fixed;inset:0;z-index:850;display:flex;flex-direction:column;background:var(--c-bg)';
ovl.innerHTML = `
${UI.escape(route.name)}
⚠️ Du hast die Route verlassen
`;
document.body.appendChild(ovl);
_navOvl = ovl;
// Kompass-Listener (Pfeil dreht sich mit Geräteausrichtung)
const _updateDimArrow = () => {
const el = document.getElementById('rk-nav-dim-arrow');
if (!el || _navLastBearing == null) return;
let compassHeading = null;
if (_navCompassHeading != null) compassHeading = _navCompassHeading;
const rot = compassHeading != null
? _navLastBearing - compassHeading // relativ zur Geräteausrichtung
: _navLastBearing; // absolut (Nordoben-Fallback)
el.style.transform = `rotate(${rot.toFixed(1)}deg)`;
};
const _onOrientation = (e) => {
let raw = null;
if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading;
else if (e.alpha != null) raw = (360 - e.alpha) % 360;
if (raw == null) return;
// Exponential-Moving-Average mit Wrap-Around-Behandlung
if (_navHeadingSmoothed == null) {
_navHeadingSmoothed = raw;
} else {
const diff = ((raw - _navHeadingSmoothed + 540) % 360) - 180;
_navHeadingSmoothed = (_navHeadingSmoothed + 0.12 * diff + 360) % 360;
}
_navCompassHeading = _navHeadingSmoothed;
_updateDimArrow();
};
window.addEventListener('deviceorientation', _onOrientation, true);
_navOrientCleanup = () => window.removeEventListener('deviceorientation', _onOrientation, true);
// Karte initialisieren
const mapEl = document.getElementById('rk-nav-map');
const mid = track[Math.floor(track.length / 2)];
_navMap = await UI.map.create(mapEl, {
center: [mid.lat, mid.lon], zoom: 15,
zoomControl: false, attributionControl: false,
});
// Container hat im frisch eingefügten Fixed-Overlay erst jetzt seine
// finale Flex-Höhe — Leaflet muss sie neu vermessen, sonst lädt es nur
// oben Tiles und der Rest bleibt grau.
_navMap.invalidateSize();
// Route-Polylines: erledigt (grün) + ausstehend (orange)
// Geplante Route (orange). Der GELAUFENE Weg wird als Breadcrumb gezeichnet:
// grün = auf der Route, rot = daneben (René 2026-06-07 — vorher malte eine
// done-Linie einfach den Track grün, auch nie gelaufene Abschnitte).
const remainLine = UI.map.polyline(track.map(p => [p.lat, p.lon]), { color: '#f97316', weight: 5, opacity: 0.9 }).addTo(_navMap);
let _walkSeg = null, _walkSegOff = null, _walkLast = null;
const _walkAdd = (lat, lon, off) => {
if (!_navMap) return;
if (_walkLast && _haversineKm(_walkLast[0], _walkLast[1], lat, lon) * 1000 < 2) return; // GPS-Rauschen
if (!_walkSeg || _walkSegOff !== off) {
_walkSegOff = off;
const seed = _walkLast ? [_walkLast, [lat, lon]] : [[lat, lon]]; // nahtloser Übergang
_walkSeg = UI.map.polyline(seed, {
color: off ? '#dc2626' : '#22c55e', weight: 5, opacity: 0.9,
}).addTo(_navMap);
} else {
_walkSeg.addLatLng([lat, lon]);
}
_walkLast = [lat, lon];
};
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
_addRouteArrows(_navMap, track, '#3b82f6');
// iOS rendert das Flex-Layout teils verzögert — nochmal neu vermessen
// und Ausschnitt erneut anpassen.
setTimeout(() => {
if (!_navMap) return;
_navMap.invalidateSize();
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
}, 250);
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
const mkPin = (p, color) => UI.map.circleMarker([p.lat, p.lon], {
radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1
}).addTo(_navMap);
let startPin = mkPin(track[0], '#22c55e');
let endPin = mkPin(track[track.length - 1], '#ef4444');
// Live-Position-Marker
let locMarker = null;
// POIs laden und als kleine Kreis-Marker einfügen
_loadNearbyPois(track).then(pois => {
_navPois = pois;
pois.forEach(poi => {
const svgIcon = poi._svgIcon || 'map-pin';
const color = poi._color || '#6b7280';
const html = `
`;
UI.map.svgMarker(poi.lat, poi.lon, html, { size: 32 })
.bindTooltip(poi.name || poi._label)
.bindPopup(`${UI.escape(poi.name||poi._label)}
${poi.phone ? `
📞 ${UI.escape(poi.phone)}` : ''}
${poi.opening_hours ? `
🕐 ${UI.escape(poi.opening_hours)}` : ''}`)
.addTo(_navMap);
});
}).catch(() => {});
// Höhenprofil laden
API.routes.elevation(route.id).then(data => {
const elevs = data.elevations || [];
if (elevs.length < 2) return;
const elevEl = document.getElementById('rk-nav-elev');
if (!elevEl) return;
elevEl.style.display = '';
elevEl.innerHTML = _buildElevationSVG(elevs);
}).catch(() => {});
// Hilfsfunktionen
const _navHaversine = (a, b) => _haversineKm(a.lat, a.lon, b.lat, b.lon);
// Fortschritts-Index NUR im Fenster um den aktuellen Index suchen — die globale
// Suche sprang bei RUNDEN (Start ≈ Ende) sofort ans Track-ENDE: nie Abbiege-Bellen,
// alles grün, 99 % ab Start (Praxistest René 2026-06-07, Gassirunde Siegenhofen).
// Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt).
let _navIdxInit = false;
// Runde erkennen: Start ≈ Ende (< 60 m). An einem solchen Start/Ende-Knoten ist der
// ENDPUNKT oft ein paar Meter näher als der Startpunkt — die globale Erst-Suche sprang
// dann sofort ans Track-ENDE → 100 % / 0 km ab Sekunde 1, kein Bellen, alles grün, und
// der gelaufene-Weg-Eintrag wurde fälschlich als komplett gespeichert. Der alte 25-m-
// Gleichstand reichte nicht, wenn der Start >28 m weg lag (Siegenhofen René 2026-06-07,
// Deining Angie 2026-06-09).
const _navIsLoop = track.length > 2 &&
_haversineKm(track[0].lat, track[0].lon,
track[track.length - 1].lat, track[track.length - 1].lon) < 0.06;
const _closestIdx = (lat, lon) => {
const search = (from, to) => {
let best = from, bestD = Infinity;
for (let i = from; i <= to; i++) {
const d = _haversineKm(lat, lon, track[i].lat, track[i].lon);
if (d < bestD) { bestD = d; best = i; }
}
return { best, bestD };
};
if (!_navIdxInit) {
_navIdxInit = true;
const g = search(0, track.length - 1);
if (_navIsLoop) {
// Runde: steht man irgendwo in Startnähe (< 150 m), bei 0 % beginnen statt ans
// nahe Track-Ende zu springen. Erst wer weit vom Start steht, ist mitten in die
// Runde eingestiegen → globaler Treffer. Startfenster = erste 15 % (mind. 30 Pkt.).
const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15)));
const s = search(0, win);
return s.bestD < 0.15 ? s.best : g.best;
}
// Punkt-zu-Punkt: bei Quasi-Gleichstand (< 25 m) den START bevorzugen.
const s = search(0, Math.min(track.length - 1, 30));
return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best;
}
const w = search(Math.max(0, _navCurrentIdx - 15), Math.min(track.length - 1, _navCurrentIdx + 80));
if (w.bestD <= 0.3) return w.best;
return search(0, track.length - 1).best; // verloren → neu orientieren
};
const _remainingKm = (fromIdx) => {
let d = 0;
for (let i = fromIdx; i < track.length - 1; i++)
d += _navHaversine(track[i], track[i+1]);
return d;
};
const _bearingTo = (a, b) => {
const φ1 = a.lat * Math.PI / 180, φ2 = b.lat * Math.PI / 180;
const Δλ = (b.lon - a.lon) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
};
// Abbiegepunkte EINMALIG aus dem Track ableiten: Peilung über ~15-m-Stützpunkte
// (sonst macht GPS-Zickzack aus jeder Geraden eine Kurve), Richtungsänderung ≥ 40°
// = Abbiegung, > 0 = rechts. Mindestabstand zwischen Ansagen ~25 m.
const _navTurns = (() => {
const out = [];
const distM = (a, b) => _haversineKm(a.lat, a.lon, b.lat, b.lon) * 1000;
let lastIdx = -1;
for (let i = 1; i < track.length - 1; i++) {
let p = i - 1, accP = distM(track[p], track[i]);
while (p > 0 && accP < 15) { p--; accP += distM(track[p], track[p + 1]); }
let n = i + 1, accN = distM(track[i], track[n]);
while (n < track.length - 1 && accN < 15) { n++; accN += distM(track[n - 1], track[n]); }
const d = ((_bearingTo(track[i], track[n]) - _bearingTo(track[p], track[i]) + 540) % 360) - 180;
if (Math.abs(d) >= 40 && (lastIdx < 0 || distM(track[lastIdx], track[i]) > 25)) {
out.push({ idx: i, right: d > 0 });
lastIdx = i;
}
}
return out;
})();
const _updateStats = (idx, distToRoute, userLat, userLon) => {
if (idx > _navMaxIdx) {
_navMaxIdx = idx;
// Fortschritt persistent sichern — überlebt App-Kill / beliebiges Schließen
if (_navWalkMeta && _navWalkMeta.trackLen > 1) {
const p = Math.round(_navMaxIdx / (_navWalkMeta.trackLen - 1) * 100);
const km = Math.round((_navMaxIdx / (_navWalkMeta.trackLen - 1)) * _navWalkMeta.totalKm * 100) / 100;
try { localStorage.setItem(_PENDING_WALK_KEY, JSON.stringify({ routeId: _navWalkMeta.routeId, walkedKm: km, pct: p, ts: Date.now() })); } catch {}
}
}
const pct = Math.round(idx / (track.length - 1) * 100);
const rem = _remainingKm(idx);
document.getElementById('rk-nav-pct').textContent = pct + '%';
document.getElementById('rk-nav-progbar').style.width = pct + '%';
document.getElementById('rk-nav-remain').textContent = rem.toFixed(2) + ' km';
document.getElementById('rk-nav-offdist').textContent = distToRoute < 1
? Math.round(distToRoute * 1000) + ' m' : distToRoute.toFixed(1) + ' km';
// Screensaver: "zur Route" wenn weit weg, sonst "verbleibend"
const offRoute = distToRoute > 0.5;
document.getElementById('rk-nav-dim-pct').textContent = offRoute ? '–' : pct + '%';
document.getElementById('rk-nav-dim-remain').textContent = offRoute
? (distToRoute < 1 ? Math.round(distToRoute * 1000) + ' m' : distToRoute.toFixed(1) + ' km')
: rem.toFixed(2) + ' km';
const dimLabel = document.getElementById('rk-nav-dim-label');
if (dimLabel) dimLabel.textContent = offRoute ? 'zur Route' : 'verbleibend';
// Bearing zum nächsten Punkt aktualisieren → _onOrientation übernimmt das Rendern
if (userLat != null && idx < track.length - 1) {
_navLastBearing = _bearingTo({ lat: userLat, lon: userLon }, track[idx + 1]);
_updateDimArrow();
}
// Abbiege-Ansage (René: 2× Wuff = links, 1× = rechts): nächster Turn vor uns,
// angesagt sobald ≤ 45 m entfernt — einmal pro Abbiegepunkt.
if (userLat != null && distToRoute < 0.1) {
const next = _navTurns.find(t => t.idx > idx && t.idx > _navSndAnnouncedIdx);
if (next) {
const dM = _haversineKm(userLat, userLon, track[next.idx].lat, track[next.idx].lon) * 1000;
if (dM <= 50) {
_navSndAnnouncedIdx = next.idx;
if (next.right) NavSound.rechts(); else NavSound.links();
}
}
}
const offWarn = document.getElementById('rk-nav-offwarn');
if (distToRoute * 1000 > 35) { // 50→35 m: Kläffen kam ~5 m zu spät (René 2026-06-07)
offWarn.style.display = '';
if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
// Falscher Weg = Kläffen (beim Abkommen + Erinnerung alle 30 s)
const _now = Date.now();
if (!_navSndOffRoute || _now - _navSndLastKlaeff > 30000) {
_navSndOffRoute = true;
_navSndLastKlaeff = _now;
NavSound.klaeffen();
}
} else {
offWarn.style.display = 'none';
_navSndOffRoute = false;
}
// Verbleibende Route aktualisieren; der gelaufene Weg kommt vom Breadcrumb (s.o.)
remainLine.setLatLngs(track.slice(idx).map(p => [p.lat, p.lon]));
if (userLat != null) _walkAdd(userLat, userLon, distToRoute * 1000 > 35);
};
// GPS-Watch
await _navAcquireWakeLock();
document.addEventListener('visibilitychange', _navOnVisibility);
window.addEventListener('pagehide', _recordNavWalk); // Schließen/Weg-Navigieren → speichern
let _navFirstFix = true;
_navWatchId = navigator.geolocation.watchPosition(pos => {
const { latitude: lat, longitude: lon } = pos.coords;
if (!locMarker) {
locMarker = UI.map.circleMarker([lat, lon], {
radius: 10, color: '#fff', weight: 3, fillColor: '#3b82f6', fillOpacity: 1,
className: 'rk-nav-loc-pulse'
}).addTo(_navMap);
} else {
locMarker.setLatLng([lat, lon]);
}
// Nächsten Trackpunkt nur nutzen wenn User < 500m von der Route entfernt ist
const closestIdx = _closestIdx(lat, lon);
const distToRoute = _haversineKm(lat, lon, track[closestIdx].lat, track[closestIdx].lon);
if (distToRoute < 0.5) {
_navCurrentIdx = closestIdx;
}
// Karte beim ersten Fix zoomen — User-Position nur einschließen wenn < 20 km entfernt
if (_navFirstFix) {
_navFirstFix = false;
try {
const bounds = distToRoute < 20
? remainLine.getBounds().extend([lat, lon])
: remainLine.getBounds();
_navMap.fitBounds(bounds, { padding: [40, 40] });
} catch {}
}
_updateStats(_navCurrentIdx, distToRoute, lat, lon);
}, () => {}, { enableHighAccuracy: true, maximumAge: 3000 });
// Dim-Modus
const _navOnTouch = (e) => {
if (document.getElementById('rk-nav-dim')?.contains(e.target)) return;
_navResetInactTimer();
};
ovl.addEventListener('touchstart', _navOnTouch, { passive: true });
ovl.addEventListener('pointerdown', _navOnTouch);
_navResetInactTimer();
const dim = document.getElementById('rk-nav-dim');
// Entsperren reagiert NUR auf den Fingerabdruck-Knopf (2 Sek. halten) — nicht mehr
// auf das ganze Dim-Overlay. Tippen daneben lässt den Bildschirm bewusst gedimmt.
const navUnlock = document.getElementById('rk-nav-unlock-btn');
let _lpTimer = null;
const cancelLp = () => {
clearTimeout(_lpTimer);
const prog = document.getElementById('rk-nav-dim-prog');
if (prog) { prog.style.transition = 'none'; prog.style.strokeDashoffset = '150.8'; }
};
navUnlock.addEventListener('pointerdown', e => {
e.stopPropagation();
try { navUnlock.setPointerCapture(e.pointerId); } catch (err) {}
const prog = document.getElementById('rk-nav-dim-prog');
if (prog) { prog.style.transition = 'stroke-dashoffset 2s linear'; prog.style.strokeDashoffset = '0'; }
_lpTimer = setTimeout(() => {
dim.style.display = 'none'; _navDimmed = false; _navResetInactTimer();
}, 2000);
});
navUnlock.addEventListener('pointerup', cancelLp);
navUnlock.addEventListener('pointercancel', cancelLp);
// Verlässt der Finger den Knopf während des Haltens → abbrechen (sonst entsperrt
// ein wegrutschender Finger weiter). pointerleave reicht dank setPointerCapture.
navUnlock.addEventListener('pointerleave', cancelLp);
// Sicherheitsnetz: ein Tipp aufs Dim-Overlay (nicht auf den Knopf) tut nichts,
// aber wir schlucken ihn, damit darunterliegende Buttons nicht reagieren.
dim.addEventListener('pointerdown', e => { if (e.target === dim) e.stopPropagation(); });
// Aktions-Buttons
document.getElementById('rk-nav-back').addEventListener('click', _closeNav);
document.getElementById('rk-nav-rate')?.addEventListener('click', () => {
UI.modal.open({
title: `${UI.icon('star')} Route bewerten`,
body: `
${[1,2,3,4,5].map(n =>
``
).join('')}
`,
footer: '',
});
document.querySelector('#modal-container .modal-body')?.addEventListener('click', async e => {
const btn = e.target.closest('[data-stars]');
if (!btn) return;
try {
await API.routes.rate(route.id, parseInt(btn.dataset.stars));
UI.modal.close(); UI.toast.success('Bewertung gespeichert!');
} catch (err) { UI.toast.error(err.message); }
});
});
document.getElementById('rk-nav-feedback')?.addEventListener('click', () => {
const body = `
Dein Feedback wird direkt an den Route-Ersteller gesendet.
`;
const footer = `
`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} Feedback senden`, body, footer });
document.getElementById('rk-nav-fb-send')?.addEventListener('click', async () => {
const text = document.getElementById('rk-nav-fb-text')?.value?.trim();
if (!text || text.length < 5) { UI.toast.warning('Bitte etwas mehr schreiben.'); return; }
try {
await API.routes.feedback(route.id, text);
UI.modal.close(); UI.toast.success('Feedback gesendet!');
} catch (err) { UI.toast.error(err.message); }
});
});
document.getElementById('rk-nav-center-btn')?.addEventListener('click', () => {
if (locMarker) _navMap.setView(locMarker.getLatLng(), 16);
});
// Navi-Sounds an/aus (Klick = User-Geste → unlock + Probe-Wuff als Bestätigung)
document.getElementById('rk-nav-sound-btn')?.addEventListener('click', e => {
const on = !NavSound.enabled();
try { localStorage.setItem('by_nav_sound', on ? '1' : '0'); } catch (err) {}
const btn = e.currentTarget;
btn.style.color = on ? 'var(--c-primary)' : 'var(--c-text-secondary)';
btn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${on ? 'speaker-high' : 'speaker-none'}`);
// Probe-Wuff leicht verzögert: gibt fetch+decodeAudioData beim ERSTEN
// Einschalten die Chance, das echte Sample zu laden (sonst Synthese-Fallback).
if (on) { NavSound.unlock(); setTimeout(() => NavSound.rechts(), 450); }
UI.toast.info(on
? 'Navi-Sounds an: 2× Wuff = links, 1× Wuff = rechts, Kläffen = falscher Weg 🐕'
: 'Navi-Sounds aus.');
});
document.getElementById('rk-nav-pois')?.addEventListener('click', () => {
if (!_navPois.length) { UI.toast.info('Keine POIs entlang dieser Route.'); return; }
const byType = {};
_navPois.forEach(p => {
if (!byType[p._label]) byType[p._label] = { icon: p._icon, items: [] };
byType[p._label].items.push(p);
});
const body = Object.entries(byType).map(([label, group]) => `
${group.icon} ${UI.escape(label)}
${group.items.map(p => `
${UI.escape(p.name||label)}
${p.opening_hours ? `
🕐 ${UI.escape(p.opening_hours)}
` : ''}
${p.phone ? `
` : ''}
Navi
`).join('')}
`).join('');
UI.modal.open({ title: `${UI.icon('map-pin')} POIs entlang der Route`, body, footer: `` });
});
}
function _navResetInactTimer() {
if (_navDimmed) return;
clearTimeout(_navInactTimer);
_navInactTimer = setTimeout(() => {
const dim = document.getElementById('rk-nav-dim');
if (dim) { dim.style.display = 'flex'; _navDimmed = true; }
}, 10000);
}
async function _navAcquireWakeLock() {
if (!('wakeLock' in navigator) || _navWakeLock) return;
try {
_navWakeLock = await navigator.wakeLock.request('screen');
_navWakeLock.addEventListener('release', () => {
_navWakeLock = null;
if (_navWatchId !== null) _navAcquireWakeLock();
});
} catch {}
}
function _navOnVisibility() {
if (_navWatchId !== null && document.visibilityState === 'visible' && !_navWakeLock) {
_navAcquireWakeLock();
}
}
// Speichert den gelaufenen Walk — egal wie die Navigation verlassen wird
// (In-App-Zurück, pagehide/Schließen). Einmal-Guard; bei Fehler bleibt der
// Eintrag in localStorage und wird beim nächsten App-Start nachgetragen.
function _recordNavWalk() {
if (_navRecorded || !_navWalkMeta || _navWalkMeta.trackLen <= 1) return;
const pct = Math.round(_navMaxIdx / (_navWalkMeta.trackLen - 1) * 100);
if (pct < 50) return;
_navRecorded = true;
const walkedKm = Math.round((_navMaxIdx / (_navWalkMeta.trackLen - 1)) * _navWalkMeta.totalKm * 100) / 100;
API.routes.walked(_navWalkMeta.routeId, walkedKm, pct)
.then(res => {
try { localStorage.removeItem(_PENDING_WALK_KEY); } catch {}
const km = walkedKm.toFixed(1).replace('.', ',');
const tot = res?.total_km != null ? ` · Lebenswerk ${String(res.total_km).replace('.', ',')} km` : '';
UI.toast.success(`🐾 ${km} km gezählt${tot}`);
if (res?.new_badges?.length) UI.toast.success(`🏅 Neues Badge: ${res.new_badges[0].name}!`);
})
.catch(() => {}); // bleibt in localStorage → Nachtrag beim nächsten Start
}
// Beim App-/Seiten-Start: nicht gespeicherten Walk nachtragen (App gekillt /
// „wie immer" geschlossen, ohne dass ein Speicher-Event lief).
function _flushPendingNavWalk() {
if (_navWatchId !== null) return; // läuft gerade eine Navigation
let p;
try { p = JSON.parse(localStorage.getItem(_PENDING_WALK_KEY) || 'null'); } catch {}
if (!p || (p.pct || 0) < 50) return;
if (Date.now() - (p.ts || 0) > 7 * 24 * 3600 * 1000) { // zu alt → verwerfen
try { localStorage.removeItem(_PENDING_WALK_KEY); } catch {}
return;
}
API.routes.walked(p.routeId, p.walkedKm, p.pct)
.then(res => {
try { localStorage.removeItem(_PENDING_WALK_KEY); } catch {}
const km = Number(p.walkedKm || 0).toFixed(1).replace('.', ',');
const tot = res?.total_km != null ? ` · Lebenswerk ${String(res.total_km).replace('.', ',')} km` : '';
UI.toast.success(`🐾 ${km} km nachgetragen${tot}`);
if (res?.new_badges?.length) UI.toast.success(`🏅 Neues Badge: ${res.new_badges[0].name}!`);
})
.catch(() => {}); // bleibt für den nächsten Versuch
}
function _closeNav() {
_recordNavWalk(); // ZUERST speichern (vor jedem Reset)
if (_navWatchId !== null) { navigator.geolocation.clearWatch(_navWatchId); _navWatchId = null; }
if (_navInactTimer) { clearTimeout(_navInactTimer); _navInactTimer = null; }
if (_navWakeLock) { try { _navWakeLock.release(); } catch {} _navWakeLock = null; }
document.removeEventListener('visibilitychange', _navOnVisibility);
window.removeEventListener('pagehide', _recordNavWalk);
if (_navOrientCleanup) { _navOrientCleanup(); _navOrientCleanup = null; }
_navWalkMeta = null;
_navMaxIdx = 0;
_navRecorded = false;
_navLastBearing = null;
_navCompassHeading = null;
_navHeadingSmoothed = null;
if (_navMap) { _navMap.remove(); _navMap = null; }
if (_navOvl) { _navOvl.remove(); _navOvl = null; }
_navDimmed = false;
_navPois = [];
}
function _buildElevationSVG(points) {
const alts = points.map(p => p.alt || 0);
const minA = Math.min(...alts), maxA = Math.max(...alts);
const range = maxA - minA || 1;
const W = 800, H = 56, pad = 4;
const x = (i) => pad + (i / (points.length - 1)) * (W - 2 * pad);
const y = (a) => H - pad - ((a - minA) / range) * (H - 2 * pad);
const pts = points.map((p, i) => `${x(i).toFixed(1)},${y(p.alt||0).toFixed(1)}`).join(' ');
const fill = points.map((p, i) => `${x(i).toFixed(1)},${y(p.alt||0).toFixed(1)}`).join(' ') +
` ${x(points.length-1).toFixed(1)},${H} ${x(0).toFixed(1)},${H}`;
return `
${Math.round(maxA)}m
${Math.round(minA)}m
`;
}
// ----------------------------------------------------------
// Route kürzen (Datenschutz-Overlay)
// ----------------------------------------------------------
function _trimCalcKm(track) {
let d = 0;
for (let i = 1; i < track.length; i++)
d += _haversineKm(track[i-1].lat, track[i-1].lon, track[i].lat, track[i].lon);
return Math.round(d * 100) / 100;
}
async function _openTrimOverlay(route) {
const fullTrack = route.gps_track || [];
if (fullTrack.length < 4) return;
let startIdx = 0;
let endIdx = fullTrack.length - 1;
let clickMode = 'start'; // 'start' | 'end'
const origKm = route.original_km ?? route.distanz_km ?? 0;
const origMin = route.original_dauer_min ?? route.dauer_min ?? 0;
const ovl = document.createElement('div');
ovl.id = 'rk-trim-ovl';
ovl.style.cssText = 'position:fixed;inset:0;z-index:850;display:flex;flex-direction:column;background:var(--c-bg)';
ovl.innerHTML = `
Route kürzen
`;
document.body.appendChild(ovl);
// Map initialisieren
const mapEl = document.getElementById('rk-trim-map');
const center = fullTrack[Math.floor(fullTrack.length/2)];
const trimMap = await UI.map.create(mapEl, {
center: [center.lat, center.lon], zoom: 14,
zoomControl: false, attributionControl: false,
});
// Marker & Polylines
let greyBefore = UI.map.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
let activeLine = UI.map.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(trimMap);
let greyAfter = UI.map.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
const mkMarker = (lat, lon, color) => UI.map.circleMarker([lat, lon], {
radius: 9, color: '#fff', weight: 2.5, fillColor: color, fillOpacity: 1
}).addTo(trimMap);
let startMarker = mkMarker(fullTrack[0].lat, fullTrack[0].lon, '#22c55e');
let endMarker = mkMarker(fullTrack[fullTrack.length-1].lat, fullTrack[fullTrack.length-1].lon, '#ef4444');
const update = () => {
const slice = fullTrack.slice(startIdx, endIdx + 1);
const lls = (pts) => pts.map(p => [p.lat, p.lon]);
greyBefore.setLatLngs(lls(fullTrack.slice(0, startIdx + 1)));
activeLine.setLatLngs(lls(slice));
greyAfter.setLatLngs(lls(fullTrack.slice(endIdx)));
startMarker.setLatLng([fullTrack[startIdx].lat, fullTrack[startIdx].lon]);
endMarker.setLatLng([fullTrack[endIdx].lat, fullTrack[endIdx].lon]);
const newKm = _trimCalcKm(slice);
const pace = origKm > 0 ? origMin / origKm : 10;
const newMin = Math.max(1, Math.round(newKm * pace));
document.getElementById('rk-trim-start-lbl').textContent = `${startIdx} Punkte`;
document.getElementById('rk-trim-end-lbl').textContent = `${fullTrack.length - 1 - endIdx} Punkte`;
document.getElementById('rk-trim-stats').innerHTML =
`Neue Länge: ${newKm.toFixed(2)} km · ca. ${newMin} min
· Original: ${origKm.toFixed(2)} km · ${origMin} min (bleibt angerechnet)`;
};
update();
trimMap.fitBounds(UI.map.polyline(fullTrack.map(p => [p.lat, p.lon])).getBounds(), { padding: [20, 20] });
// Nächsten Track-Punkt zu einem Klick finden
const nearestIdx = (latlng) => {
let best = 0, bestD = Infinity;
fullTrack.forEach((p, i) => {
const d = trimMap.distance(latlng, { lat: p.lat, lng: p.lon });
if (d < bestD) { bestD = d; best = i; }
});
return best;
};
// Karten-Klick
trimMap.on('click', e => {
const idx = nearestIdx(e.latlng);
if (clickMode === 'start') {
startIdx = Math.min(idx, endIdx - 1);
document.getElementById('rk-trim-slider-start').value = startIdx;
} else {
endIdx = Math.max(idx, startIdx + 1);
document.getElementById('rk-trim-slider-end').value = fullTrack.length - 1 - endIdx;
}
update();
});
// Slider
document.getElementById('rk-trim-slider-start').addEventListener('input', e => {
startIdx = Math.min(parseInt(e.target.value), endIdx - 1);
update();
});
document.getElementById('rk-trim-slider-end').addEventListener('input', e => {
endIdx = Math.max(fullTrack.length - 1 - parseInt(e.target.value), startIdx + 1);
update();
});
// Modus-Toggle
const modeStart = document.getElementById('rk-trim-mode-start');
const modeEnd = document.getElementById('rk-trim-mode-end');
modeStart.addEventListener('click', () => {
clickMode = 'start';
modeStart.className = 'btn btn-sm btn-primary'; modeStart.style.flex = '1';
modeEnd.className = 'btn btn-sm btn-secondary'; modeEnd.style.flex = '1';
});
modeEnd.addEventListener('click', () => {
clickMode = 'end';
modeEnd.className = 'btn btn-sm btn-primary'; modeEnd.style.flex = '1';
modeStart.className = 'btn btn-sm btn-secondary'; modeStart.style.flex = '1';
});
// Abbrechen
document.getElementById('rk-trim-cancel').addEventListener('click', () => {
trimMap.remove(); ovl.remove();
});
// Speichern
document.getElementById('rk-trim-save').addEventListener('click', async () => {
const btn = document.getElementById('rk-trim-save');
const trimmed = fullTrack.slice(startIdx, endIdx + 1);
await UI.asyncButton(btn, async () => {
const saved = await API.routes.trim(route.id, trimmed);
// Lokalen State aktualisieren
const idx = _data.findIndex(r => r.id === route.id);
if (idx !== -1) Object.assign(_data[idx], saved);
trimMap.remove(); ovl.remove();
UI.toast.success('Route gekürzt — Originaldaten bleiben für die Statistik erhalten.');
_applyFilter();
});
});
}
async function _openDetail(routeId) {
let route;
try { route = await API.routes.get(routeId); }
catch (err) { UI.toast.error(err.message); return; }
const isOwn = _appState.user?.id === route.user_id;
const track = route.gps_track || [];
const photos = route.foto_urls || [];
const paws = HUNDE_LABEL[route.hunde_tauglichkeit] || '';
const photoGallery = photos.length ? `
${photos.map(u => `
})
`).join('')}
${isOwn ? `
` : ''}
` :
isOwn ? `` : '';
const body = `
${photoGallery}
${route.distanz_km ? _pill(route.distanz_km.toFixed(1)+' km', 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}
${route.dauer_min ? _pill(_fmtDur(route.dauer_min), 'rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)') : ''}
${route.schwierigkeit ? _pill(DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit,
({leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'})[route.schwierigkeit]||'rgba(107,114,128,0.10)',
({leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'})[route.schwierigkeit]||'#9ca3af',
({leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'})[route.schwierigkeit]||'rgba(107,114,128,0.30)') : ''}
${route.hunde_tauglichkeit ? _pill(HUNDE_TEXT[route.hunde_tauglichkeit]||route.hunde_tauglichkeit,'rgba(234,179,8,0.10)','#facc15','rgba(234,179,8,0.30)') : ''}
${isOwn
? ``
: _pill(route.is_public?'Öffentlich':'Privat', route.is_public?'rgba(22,163,74,0.10)':'rgba(59,130,246,0.10)', route.is_public?'#4ade80':'#60a5fa', route.is_public?'rgba(22,163,74,0.30)':'rgba(59,130,246,0.30)')
}
${route.beschreibung ? `${UI.escape(route.beschreibung)}
` : ''}
Lädt Orte entlang der Route…
${track.length} GPS-Punkte · von ${UI.escape(route.user_name||'Anonym')}
`;
const _actionBtn = (id, icon, label, danger = false) =>
``;
const ownerRow = isOwn ? `
${_actionBtn('rd-send-friend', 'paper-plane-tilt', 'Senden')}
${track.length >= 4 ? _actionBtn('rd-trim', 'pencil-simple', 'Kürzen') : ''}
${_actionBtn('rd-reverse', 'path', 'Umkehren')}
${(_appState?.dogs?.length > 0) ? _actionBtn('rd-dogs', 'dog', 'Hunde') : ''}
${_actionBtn('rd-del', 'trash', 'Löschen', true)}
` : '';
const footer = `
${_actionBtn('rd-gpx', 'download-simple', 'GPX')}
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
${_actionBtn('rd-navi', 'map-pin', 'Navi')}
${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
${(window.BY?.offlineTiles?.() && track.length >= 2) ? _actionBtn('rd-offline', 'cloud-arrow-down', 'Offline') : ''}
${ownerRow}
`;
// onClose: GL-Kontext der Detailkarte freigeben — sonst leakt jede geöffnete Route
// einen WebGL-Kontext. Nach ~8 wirft MapLibre, und UI.map.create fällt auf
// Leaflet+OSM-Raster zurück (genau das Symptom: Detailkarte plötzlich OSM-Raster
// statt GL, und der Zoom passt nicht mehr).
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer,
onClose: () => { if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; } } });
UI.ratingStars({
containerId: `rk-rating-${route.id}`,
targetType: 'route',
targetId: route.id,
isLoggedIn: !!_appState.user,
});
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
// Route offline speichern: Kachel-Korridor ±1 km um den Track + Marker → IndexedDB
// (für mehrtägige Unternehmungen entlang der Route, docs/OFFLINE_MAPS_PLAN.md).
document.getElementById('rd-offline')?.addEventListener('click', async () => {
const btn = document.getElementById('rd-offline');
if (!btn || btn.dataset.busy) return;
btn.dataset.busy = '1';
const label = btn.querySelector('span');
try {
await UI.loadMapLibreUI(); // lädt pmtiles + map-offline (byt://-Stack) bei Bedarf
const res = await MapOffline.downloadCorridor(track, {
bufferKm: 1, name: route.name,
onProgress: p => { if (label) label.textContent = `${(p.bytes / 1048576).toFixed(1)} MB`; },
});
if (label) label.textContent = 'Offline ✓';
UI.toast.success(`Route offline gespeichert — Korridor ±1 km, ${res.pois || 0} Marker, `
+ `${(res.bytes / 1048576).toFixed(1)} MB.${res.capped ? ' (50-MB-Limit erreicht)' : ''}`);
window.OfflineIndicator?.refresh();
// Gespeicherte Bereiche sofort auf der Detailkarte zeigen (blau) — sonst ist der
// Korridor „unsichtbar", v.a. wenn er im schon gespeicherten Gebiet liegt.
try {
const gl = _detailMap?._gl;
if (gl) {
const gj = await MapOffline.coverage();
if (gl.getSource('rd-off-cov')) gl.getSource('rd-off-cov').setData(gj);
else {
gl.addSource('rd-off-cov', { type: 'geojson', data: gj });
gl.addLayer({ id: 'rd-off-cov', type: 'fill', source: 'rd-off-cov',
paint: { 'fill-color': ['match', ['get', 'kind'], 'funkloch', '#f59e0b', '#3b82f6'],
'fill-opacity': 0.15 } });
}
}
} catch (e) {}
} catch (e) {
if (label) label.textContent = 'Offline';
UI.toast.error('Offline-Speichern fehlgeschlagen.');
} finally {
delete btn.dataset.busy;
}
});
// Teilen-Button
document.getElementById('rd-share')?.addEventListener('click', async () => {
const shareUrl = location.origin + '/#routes?id=' + route.id;
const text = `${route.name} — ${(route.distanz_km||0).toFixed(1)} km`;
if (navigator.share) {
navigator.share({ title: route.name, text, url: shareUrl }).catch(() => {});
} else {
await navigator.clipboard.writeText(shareUrl);
UI.toast.info('Link kopiert!');
}
});
// Navi-Button
document.getElementById('rd-navi')?.addEventListener('click', () => {
if ((route.gps_track || []).length < 2) {
UI.toast.warning('Keine GPS-Daten vorhanden.'); return;
}
UI.modal.close();
_openNavOverlay(route);
});
// An Freund senden
document.getElementById('rd-send-friend')?.addEventListener('click', () => _openSendToFriendModal(route));
// Sichtbarkeit toggle — Pill im Body
document.getElementById('rd-vis-pill')?.addEventListener('click', async () => {
const pill = document.getElementById('rd-vis-pill');
try {
await API.routes.update(route.id, { is_public: !route.is_public });
route.is_public = !route.is_public;
if (pill) {
pill.style.cssText = _pillStyle(
route.is_public ? 'rgba(22,163,74,0.10)' : 'rgba(59,130,246,0.10)',
route.is_public ? '#4ade80' : '#60a5fa',
route.is_public ? 'rgba(22,163,74,0.30)' : 'rgba(59,130,246,0.30)') + 'cursor:pointer;';
pill.innerHTML = route.is_public ? 'Öffentlich' : 'Privat';
pill.title = route.is_public ? 'Auf Privat setzen' : 'Auf Öffentlich setzen';
}
const r = _data.find(x => x.id === route.id);
if (r) r.is_public = route.is_public;
_applyFilter();
UI.toast.success(route.is_public ? 'Route ist jetzt öffentlich.' : 'Route ist jetzt privat.');
} catch (err) { UI.toast.error(err.message); }
});
// Umkehren
document.getElementById('rd-reverse')?.addEventListener('click', async () => {
try {
await API.routes.reverse(route.id);
route.gps_track = [...route.gps_track].reverse();
// Karte neu aufbauen mit umgekehrtem Track
const el = document.getElementById('rk-detail-map');
if (el) {
if (_detailMap) { _detailMap.remove(); _detailMap = null; }
_detailMap = await _buildDetailMap(el, route.gps_track);
}
UI.toast.success('Route dauerhaft umgekehrt');
} catch (err) { UI.toast.error(err.message); }
});
// Hunde bearbeiten
document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route));
// Löschen
document.getElementById('rd-del')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Route löschen?', message: `„${route.name}" wird dauerhaft entfernt.`,
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.routes.delete(route.id);
_data = _data.filter(r => r.id !== route.id);
UI.modal.close();
_applyFilter();
UI.toast.success('Route gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Route kürzen
document.getElementById('rd-trim')?.addEventListener('click', () => {
UI.modal.close();
_openTrimOverlay(route);
});
// Foto-Upload
document.getElementById('rk-photo-input')?.addEventListener('change', async e => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
try {
for (const file of files) {
const res = await API.routes.addPhoto(route.id, file);
route.foto_urls = res.foto_urls;
}
UI.toast.success(files.length > 1 ? `${files.length} Fotos gespeichert!` : 'Foto gespeichert!');
UI.modal.close();
setTimeout(() => _openDetail(route.id), 200);
} catch (err) { UI.toast.error(err.message); }
});
// Notiz-Button
document.getElementById('rd-note')?.addEventListener('click', () => {
const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route');
UI.noteModal('route', route.id, label, null);
});
// Mini-Map (modulweite _detailMap → wird beim Schließen im onClose freigegeben)
if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; }
setTimeout(async () => {
const el = document.getElementById('rk-detail-map');
if (!el || !track.length) return;
_detailMap = await _buildDetailMap(el, track);
}, 80);
// Nearby POIs laden
if (track.length >= 2) {
_loadNearbyPois(track).then(pois => _renderNearby(pois));
} else {
const nb = document.getElementById('rk-nearby');
if (nb) nb.innerHTML = '';
}
}
// ----------------------------------------------------------
// Hunde einer Route bearbeiten
// ----------------------------------------------------------
function _openEditDogsModal(route) {
const dogs = _appState?.dogs || [];
if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; }
const currentIds = new Set(route.dog_ids || []);
const dogRows = dogs.map(d => {
const checked = currentIds.has(d.id);
const av = d.foto_url
? `
`
: ``;
return ``;
}).join('');
const body = `
Welche Hunde waren bei dieser Route dabei?
${dogRows}
`;
const footer = `
`;
UI.modal.open({ title: `${UI.icon('dog')} Hunde bearbeiten`, body, footer });
// Checkbox-Pill Styling
document.querySelectorAll('.rd-dog-cb').forEach(cb => {
const label = cb.closest('label');
cb.addEventListener('change', () => {
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
label.style.color = cb.checked ? 'var(--c-primary)' : '';
});
});
document.getElementById('rd-dogs-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-dogs-save')?.addEventListener('click', async () => {
const btn = document.getElementById('rd-dogs-save');
await UI.asyncButton(btn, async () => {
const dogIds = [...document.querySelectorAll('.rd-dog-cb:checked')].map(c => parseInt(c.value));
await API.routes.updateDogs(route.id, dogIds);
route.dog_ids = dogIds;
UI.modal.close();
UI.toast.success('Hunde aktualisiert.');
});
});
}
// Richtungspfeile gleichmäßig entlang des Tracks platzieren
function _addRouteArrows(map, track, color = '#fff') {
if (track.length < 2) return;
const R = 6371;
const hav = (a, b) => {
const dLat = (b.lat - a.lat) * Math.PI / 180;
const dLon = (b.lon - a.lon) * Math.PI / 180;
const s = Math.sin(dLat/2)**2 + Math.cos(a.lat*Math.PI/180)*Math.cos(b.lat*Math.PI/180)*Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1-s));
};
const brng = (a, b) => {
const φ1 = a.lat*Math.PI/180, φ2 = b.lat*Math.PI/180;
const Δλ = (b.lon - a.lon)*Math.PI/180;
return (Math.atan2(Math.sin(Δλ)*Math.cos(φ2), Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ)) * 180/Math.PI + 360) % 360;
};
// Gesamtdistanz und kumulierte Abstände berechnen
let total = 0;
const cum = [0];
for (let i = 1; i < track.length; i++) {
total += hav(track[i-1], track[i]);
cum.push(total);
}
const spacing = Math.max(0.4, total / 7); // max 7 Pfeile, min 400m Abstand
let next = spacing * 0.5; // ersten Pfeil bei halber Spacing-Distanz
for (let i = 1; i < track.length - 1; i++) {
if (cum[i] >= next) {
const deg = brng(track[i-1], track[i]);
// Rotation INNERHALB des SVG (am Pfad), NICHT als CSS-transform am SVG-Element:
// maplibregl.Marker setzt transform:translate() aufs Element → würde rotate() killen
// (Pfeile zeigten alle nach Norden).
const html = ``;
UI.map.svgMarker(track[i].lat, track[i].lon, html, { size: 20 }).addTo(map);
next += spacing;
}
}
}
// Karte robust auf die ganze Route fitten.
// WICHTIG (iOS): MapLibre verwirft ein fitBounds, das VOR dem ersten Render läuft —
// die Karte bleibt dann beim Start-Zoom (zoom 14, center=Start) hängen, statt auf die
// Route zu zoomen. (In Headless-Chromium passiert das nicht, daher fiel es dort nicht
// auf.) Deshalb fitten wir auf das 'load'/'idle'-Event der Karte — DANN ist sie wirklich
// gerendert und der Fit bleibt. Feste Timeouts + ResizeObserver als Sicherheitsnetz.
function _fitRouteMap(m, el, getBounds, opts) {
opts = opts || { padding: [16, 16], maxZoom: 16 };
let active = true;
const sized = () => !el || (el.clientWidth > 0 && el.clientHeight > 0);
const fit = () => { if (!active) return; try { m.invalidateSize(); m.fitBounds(getBounds(), opts); } catch (e) {} };
const onReady = () => {
if (!active) return;
fit();
// Erstes Ready-Event mit korrekt vermessenem Container = der gute Fit → danach Schluss,
// damit der Nutzer frei zoomen/pannen kann.
if (sized()) { active = false; try { m.off && m.off('idle', onReady); m.off && m.off('load', onReady); } catch (e) {} }
};
fit();
[120, 350, 700, 1200, 2000].forEach(t => setTimeout(fit, t));
try { m.on('load', onReady); } catch (e) {}
try { m.on('idle', onReady); } catch (e) {}
if (window.ResizeObserver && el) {
const ro = new ResizeObserver(() => fit());
ro.observe(el);
setTimeout(() => { try { ro.disconnect(); } catch (e) {} }, 4000);
}
setTimeout(() => { active = false; }, 4000);
}
async function _buildDetailMap(el, track) {
const lls = track.map(p => [p.lat, p.lon]);
const m = await UI.map.create(el, {
center: lls[0], zoom: 14,
zoomControl: false, attributionControl: false,
});
const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
_addRouteArrows(m, track, '#3b82f6');
UI.map.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m);
UI.map.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m);
_fitRouteMap(m, el, () => poly.getBounds());
return m;
}
// ----------------------------------------------------------
// Nearby POIs
// ----------------------------------------------------------
// Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt
function _isNearTrack(poi, track, maxMeters) {
const R = 6371000;
const plat = poi.lat * Math.PI / 180;
const plon = poi.lon * Math.PI / 180;
for (const pt of track) {
const dlat = plat - pt.lat * Math.PI / 180;
const dlon = plon - pt.lon * Math.PI / 180;
const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon;
if (R * Math.sqrt(a) <= maxMeters) return true;
}
return false;
}
async function _loadNearbyPois(track) {
const lats = track.map(p => p.lat), lons = track.map(p => p.lon);
const south = Math.min(...lats), north = Math.max(...lats);
const west = Math.min(...lons), east = Math.max(...lons);
// Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach
const pad = 0.0015;
const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad };
const results = [];
await Promise.all(NEARBY_TYPES.map(async ({ type, icon, label, svgIcon, color }) => {
try {
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
// r.ok prüfen: SW antwortet offline mit 503+JSON ({detail:…}) → json() wirft nicht
const r = await fetch(`/api/osm/pois?${params}`);
if (!r.ok) throw new Error(`pois ${r.status}`);
const pois = await r.json();
(Array.isArray(pois) ? pois : [])
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
.forEach(p => results.push({ ...p, _icon: icon, _label: label, _svgIcon: svgIcon, _color: color }));
} catch {}
}));
return results;
}
function _renderNearby(pois) {
const el = document.getElementById('rk-nearby');
if (!el) return;
if (!pois.length) { el.innerHTML = ''; return; }
const byType = {};
pois.forEach(p => {
const key = p._label;
if (!byType[key]) byType[key] = { icon: p._icon, label: key, items: [] };
byType[key].items.push(p);
});
let gIdx = 0;
el.innerHTML = `
${UI.icon('map-pin')} Entlang der Route
${Object.values(byType).map(group => {
const id = `rk-ng-${gIdx++}`;
return `
${group.items.map(p => `
`).join('')}
`;
}).join('')}
`;
// Einklapp-Logik
el.querySelectorAll('.rk-nearby-group-header').forEach(btn => {
const target = document.getElementById(btn.dataset.target);
const chevron = btn.querySelector('.rk-nearby-chevron');
let open = false;
target.style.display = 'none';
chevron.style.transform = 'rotate(-90deg)';
btn.addEventListener('click', () => {
open = !open;
target.style.display = open ? '' : 'none';
chevron.style.transform = open ? '' : 'rotate(-90deg)';
});
});
// POI auf Karte zeigen
el.querySelectorAll('.rk-nearby-item--link').forEach(btn => {
btn.addEventListener('click', () => {
const lat = parseFloat(btn.dataset.lat);
const lon = parseFloat(btn.dataset.lon);
const name = btn.dataset.name;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const url = isIOS
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lon}`
: `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
window.open(url, '_blank');
});
});
}
// ----------------------------------------------------------
// Bewerten
// ----------------------------------------------------------
async function _rateRoute(id, wertung) {
try {
const res = await API.routes.rate(id, wertung);
const r = _data.find(x => x.id === id);
if (r) { r.bewertung = res.bewertung; r.anz_bewertungen = res.anz_bewertungen; }
_applyFilter();
UI.toast.success(`Bewertet: ${wertung} ★`);
} catch (err) { UI.toast.error(err.message || 'Fehler beim Bewerten.'); }
}
// ----------------------------------------------------------
// GPX
// ----------------------------------------------------------
async function _downloadGpx(id) {
try {
const route = await API.routes.get(id);
_downloadGpxDirect(route);
} catch (err) { UI.toast.error(err.message); }
}
function _downloadGpxDirect(route) {
const track = route.gps_track || [];
if (!track.length) { UI.toast.warning('Keine GPS-Daten.'); return; }
const pts = track.map(p => ` `).join('\n');
const gpx = `
${UI.escape(route.name)}\n${pts}\n
`;
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${route.name.replace(/[^a-z0-9äöü]/gi,'_')}.gpx`;
a.click();
URL.revokeObjectURL(url);
UI.toast.success('GPX heruntergeladen!');
}
function _fmtDur(min) {
if (min < 60) return `${min} min`;
return `${Math.floor(min/60)}h ${min%60?(min%60)+'min':''}`.trim();
}
// ----------------------------------------------------------
// Import: GPX / KML / TCX
// ----------------------------------------------------------
async function _importFile(file) {
const text = await file.text();
const ext = file.name.split('.').pop().toLowerCase();
let parsed;
try {
if (ext === 'gpx') parsed = _parseGpx(text, file.name);
else if (ext === 'kml') parsed = _parseKml(text, file.name);
else if (ext === 'tcx') parsed = _parseTcx(text, file.name);
else { UI.toast.error('Format nicht unterstützt. Bitte GPX, KML oder TCX.'); return; }
} catch (err) {
UI.toast.error(`Datei konnte nicht gelesen werden: ${err.message}`);
return;
}
if (!parsed || parsed.track.length < 2) {
UI.toast.error('Keine GPS-Punkte in der Datei gefunden.');
return;
}
_openImportModal(parsed);
}
function _parseGpx(text, filename) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const ns = doc.documentElement.getAttribute('xmlns') || '';
const resolve = tag => {
// Namespace-aware oder fallback
const els = doc.getElementsByTagNameNS(ns, tag);
return els.length ? els : doc.getElementsByTagName(tag);
};
// Track-Punkte aus
const trkpts = resolve('trkpt');
const track = [];
let startTime = null, endTime = null;
for (const pt of trkpts) {
const lat = parseFloat(pt.getAttribute('lat'));
const lon = parseFloat(pt.getAttribute('lon'));
if (isNaN(lat) || isNaN(lon)) continue;
const point = { lat, lon };
// Elevation (optional)
const eleEl = pt.getElementsByTagName('ele')[0] || pt.getElementsByTagNameNS(ns,'ele')[0];
if (eleEl) point.ele = parseFloat(eleEl.textContent);
// Timestamp (optional)
const timeEl = pt.getElementsByTagName('time')[0] || pt.getElementsByTagNameNS(ns,'time')[0];
if (timeEl) {
const t = new Date(timeEl.textContent);
if (!startTime) startTime = t;
endTime = t;
}
track.push(point);
}
// Falls keine → Route-Punkte versuchen
if (!track.length) {
const rtepts = resolve('rtept');
for (const pt of rtepts) {
const lat = parseFloat(pt.getAttribute('lat'));
const lon = parseFloat(pt.getAttribute('lon'));
if (!isNaN(lat) && !isNaN(lon)) track.push({ lat, lon });
}
}
// Name aus Datei
const nameEl = resolve('name')[0];
const name = nameEl?.textContent?.trim() ||
filename.replace(/\.gpx$/i, '').replace(/_/g,' ');
const dauer_min = (startTime && endTime)
? Math.round((endTime - startTime) / 60000) : null;
return { track, name, dauer_min, source: 'GPX' };
}
function _parseKml(text, filename) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const track = [];
// enthält "lon,lat,ele lon,lat,ele …" oder newline-getrennt
const coordEls = doc.getElementsByTagName('coordinates');
for (const el of coordEls) {
const raw = el.textContent.trim().replace(/\n/g, ' ');
for (const tuple of raw.split(/\s+/)) {
const parts = tuple.split(',');
if (parts.length >= 2) {
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lon)) {
const point = { lat, lon };
if (parts[2]) point.ele = parseFloat(parts[2]);
track.push(point);
}
}
}
}
const nameEl = doc.getElementsByTagName('name')[0];
const name = nameEl?.textContent?.trim() ||
filename.replace(/\.kml$/i, '').replace(/_/g,' ');
return { track, name, dauer_min: null, source: 'KML' };
}
function _parseTcx(text, filename) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const track = [];
let startTime = null, endTime = null;
const trackpoints = doc.getElementsByTagName('Trackpoint');
for (const tp of trackpoints) {
const latEl = tp.getElementsByTagName('LatitudeDegrees')[0];
const lonEl = tp.getElementsByTagName('LongitudeDegrees')[0];
if (!latEl || !lonEl) continue;
const lat = parseFloat(latEl.textContent);
const lon = parseFloat(lonEl.textContent);
if (isNaN(lat) || isNaN(lon)) continue;
const point = { lat, lon };
const altEl = tp.getElementsByTagName('AltitudeMeters')[0];
if (altEl) point.ele = parseFloat(altEl.textContent);
const timeEl = tp.getElementsByTagName('Time')[0];
if (timeEl) {
const t = new Date(timeEl.textContent);
if (!startTime) startTime = t;
endTime = t;
}
track.push(point);
}
const nameEl = doc.getElementsByTagName('Name')[0];
const name = nameEl?.textContent?.trim() ||
filename.replace(/\.tcx$/i, '').replace(/_/g,' ');
const dauer_min = (startTime && endTime)
? Math.round((endTime - startTime) / 60000) : null;
return { track, name, dauer_min, source: 'TCX' };
}
// Haversine (client-seitig für Import-Stats)
function _calcDistance(track) {
const R2RAD = Math.PI / 180;
let dist = 0;
for (let i = 1; i < track.length; i++) {
const a = track[i-1], b = track[i];
const dlat = (b.lat - a.lat) * R2RAD;
const dlon = (b.lon - a.lon) * R2RAD;
const sin2 = Math.sin(dlat/2)**2 +
Math.cos(a.lat*R2RAD)*Math.cos(b.lat*R2RAD)*Math.sin(dlon/2)**2;
dist += 2 * 6371000 * Math.asin(Math.sqrt(sin2));
}
return dist / 1000; // km
}
// ----------------------------------------------------------
// Import-Modal
// ----------------------------------------------------------
function _openImportModal(parsed) {
const { track, name, dauer_min, source } = parsed;
const distanz_km = _calcDistance(track);
const preview = _svgPreview(_simplifyPreview(track, 60));
const body = `
${preview}
${UI.icon('map-pin')} ${track.length} Punkte
${UI.icon('map-trifold')} ${distanz_km.toFixed(2)} km
${dauer_min ? `${UI.icon('timer')} ${_fmtDur(dauer_min)}` : ''}
${source}
`;
const footer = `
`;
UI.modal.open({ title: '📥 Route importieren', body, footer });
document.getElementById('ri-cancel')?.addEventListener('click', UI.modal.close);
// Paw-Selector
let _selPaw = 'sehr_gut';
document.getElementById('ri-paws')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('#ri-paws .rk-paw-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_selPaw = btn.dataset.val;
});
document.getElementById('ri-save')?.addEventListener('click', async () => {
const nameVal = document.getElementById('ri-name')?.value.trim();
if (!nameVal) { UI.toast.error('Bitte einen Namen eingeben.'); return; }
const saveBtn = document.getElementById('ri-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
try {
// Track auf max 2000 Punkte reduzieren (API-Limit / Performance)
const reducedTrack = _simplifyPreview(track, 2000);
await API.routes.create({
name: nameVal,
beschreibung: document.getElementById('ri-desc')?.value.trim() || null,
gps_track: reducedTrack,
distanz_km: Math.round(distanz_km * 100) / 100,
dauer_min: dauer_min || null,
schwierigkeit: document.getElementById('ri-diff')?.value || 'leicht',
untergrund: document.getElementById('ri-terrain')?.value || null,
schatten: document.getElementById('ri-schatten')?.checked,
leine_empfohlen: document.getElementById('ri-leine')?.checked,
is_public: document.getElementById('ri-public')?.checked,
hunde_tauglichkeit: _selPaw,
client_time: API.clientNow(),
});
UI.modal.close();
UI.toast.success('Route importiert! 🥾');
_loadData();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
saveBtn.disabled = false;
saveBtn.innerHTML = UI.icon('floppy-disk') + ' Route speichern';
}
});
}
function _simplifyPreview(track, maxPts) {
if (track.length <= maxPts) return track;
const step = track.length / maxPts;
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
}
// ----------------------------------------------------------
// An Freund senden
// ----------------------------------------------------------
async function _openSendToFriendModal(route) {
const shareUrl = location.origin + '/#routes?id=' + route.id;
// Freunde laden
let friends = [];
try {
friends = await API.friends.list();
} catch (err) {
UI.toast.error('Freunde konnten nicht geladen werden.');
return;
}
if (!friends.length) {
UI.toast.info('Du hast noch keine Freunde hinzugefügt.');
return;
}
const friendRows = friends.map(f => {
const initial = (f.name || '?')[0].toUpperCase();
return `
${UI.escape(initial)}
${UI.escape(f.name || 'Anonym')}
`;
}).join('');
const body = `${friendRows}
`;
const footer = ``;
UI.modal.open({
title: `${UI.icon('chat-circle-dots')} An Freund senden`,
body,
footer,
});
document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('rk-friend-list')?.addEventListener('click', async e => {
const row = e.target.closest('.rk-friend-row');
if (!row) return;
const partnerId = parseInt(row.dataset.id, 10);
const partnerName = row.dataset.name;
try {
const conv = await API.chat.start(partnerId);
const convId = conv.id;
const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`;
await API.chat.send(convId, text);
UI.modal.close();
UI.toast.success(`Gesendet an ${partnerName}`);
} catch (err) {
UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler'));
}
});
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
return { init, refresh, onDogChange, destroy };
})();