banyaro/backend/static/js/pages/routes.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

3129 lines
145 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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 _navOrientCleanup = null;
let _navLastBearing = null;
let _navCompassHeading = null;
let _navHeadingSmoothed = null;
// Recording-Overlay state
let _recOvl = null, _recMap = null;
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 }
// 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
try { _userPos = await API.getLocation(); } catch {}
await _loadData();
// 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 = `
<button id="rk-filter-btn" style="${_btnStyle()}position:relative">
${UI.icon('gear')} Filter
<span class="rk-filter-badge" id="rk-filter-badge" class="hidden"></span>
</button>
<label id="rk-imp-wrap" title="GPX / KML / TCX importieren" style="${_btnStyle()}">
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" class="hidden">
</label>
<button class="rk-rec-btn" id="rk-rec-btn" style="${_btnStyle(true)}">${UI.icon('path')} Aufzeichnen</button>
`;
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() {}
// ----------------------------------------------------------
// Render
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="rk-layout">
<div class="rk-header">
<div class="rk-mode-toggle" id="rk-mode-toggle">
<button class="rk-mode-btn${_browseMode==='mine'?' active':''}" id="rk-mode-mine">${UI.icon('user')} Meine Routen</button>
<button class="rk-mode-btn${_browseMode==='discover'?' active':''}" id="rk-mode-discover">${UI.icon('map-pin')} Entdecken</button>
<button class="rk-mode-btn${_browseMode==='suggest'?' active':''}" id="rk-mode-suggest">${UI.icon('sparkle')} Vorschläge</button>
</div>
<!-- Zeile 2: Suche + View-Toggle (gleiche Höhe wie Aktions-Buttons) -->
<div id="rk-search-row" style="display:flex;gap:8px;align-items:stretch;margin-bottom:var(--space-3)">
<div style="position:relative;flex:1;min-width:0">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:12px;top:50%;transform:translateY(-50%);
color:var(--c-text-muted);pointer-events:none;width:16px;height:16px">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input id="rk-search" type="search" placeholder="Routen suchen…" autocomplete="off"
style="width:100%;height:46px;padding:0 12px 0 36px;
border:1.5px solid var(--c-border);border-radius:10px;
font-size:14px;font-family:inherit;
background:var(--c-surface);color:var(--c-text);outline:none;
box-sizing:border-box;">
</div>
<div style="display:flex;border:1.5px solid var(--c-border);border-radius:10px;
overflow:hidden;flex-shrink:0;height:46px;box-sizing:border-box">
<button id="rk-view-list" title="Liste"
style="width:44px;height:100%;border:none;cursor:pointer;
display:flex;align-items:center;justify-content:center;
background:${_viewMode==='list' ? 'var(--c-primary)' : 'var(--c-surface)'};
color:${_viewMode==='list' ? '#fff' : 'var(--c-text-secondary)'}">
${UI.icon('list')}
</button>
<button id="rk-view-map" title="Karte"
style="width:44px;height:100%;border:none;border-left:1.5px solid var(--c-border);
cursor:pointer;display:flex;align-items:center;justify-content:center;
background:${_viewMode==='map' ? 'var(--c-primary)' : 'var(--c-surface)'};
color:${_viewMode==='map' ? '#fff' : 'var(--c-text-secondary)'}">
${UI.icon('map-trifold')}
</button>
</div>
</div>
<!-- Zeile 3: Aktions-Buttons gleichmäßig (Inline-Styles, cache-unabhängig) -->
<div style="display:flex;gap:8px">
<button id="rk-filter-btn" style="${_btnStyle()}position:relative">
${UI.icon('gear')} Filter
<span class="rk-filter-badge" id="rk-filter-badge" class="hidden"></span>
</button>
<label id="rk-imp-wrap" title="GPX / KML / TCX importieren" style="${_btnStyle()}">
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" class="hidden">
</label>
<button class="rk-rec-btn" id="rk-rec-btn" style="${_btnStyle(true)}">${UI.icon('path')} Aufzeichnen</button>
</div>
<div class="rk-filter-panel" id="rk-filter-panel" class="hidden">
<div class="rk-filters" id="rk-filters">
<div class="rk-filter-group">
<div class="rk-filter-label">Schwierigkeit</div>
<div class="rk-chips-row">
<button class="rk-chip active" data-filter="difficulty" data-val="">Alle</button>
<button class="rk-chip" data-filter="difficulty" data-val="leicht">🟢 Leicht</button>
<button class="rk-chip" data-filter="difficulty" data-val="mittel">🟡 Mittel</button>
<button class="rk-chip" data-filter="difficulty" data-val="anspruchsvoll">🔴 Anspruchsvoll</button>
</div>
</div>
<div class="rk-filter-group">
<div class="rk-filter-label">Untergrund</div>
<div class="rk-chips-row">
<button class="rk-chip active" data-filter="terrain" data-val="">Alle Wege</button>
<button class="rk-chip" data-filter="terrain" data-val="wald">🌲 Wald</button>
<button class="rk-chip" data-filter="terrain" data-val="asphalt">🛣️ Asphalt</button>
<button class="rk-chip" data-filter="terrain" data-val="wiese">🌿 Wiese</button>
<button class="rk-chip" data-filter="terrain" data-val="mix">🔀 Mix</button>
</div>
</div>
<div class="rk-filter-group">
<div class="rk-filter-label">Sortierung</div>
<div class="rk-chips-row">
<button class="rk-chip active" data-filter="sort" data-val="newest">Neueste</button>
<button class="rk-chip" data-filter="sort" data-val="distance">Längste</button>
<button class="rk-chip" data-filter="sort" data-val="rating">Beste</button>
<button class="rk-chip" data-filter="sort" data-val="dog">Hundefreundlich</button>
</div>
</div>
<div class="rk-filter-group" id="rk-mine-group" class="hidden">
<div class="rk-filter-label">Eigene</div>
<div class="rk-chips-row">
<button class="rk-chip" data-filter="mine" data-val="mine">🔒 Nur meine</button>
</div>
</div>
<div class="rk-filter-group" id="rk-nearby-group" class="hidden">
<div class="rk-filter-label">Umgebung</div>
<div class="rk-chips-row">
<button class="rk-chip" id="rk-nearby-btn" data-filter="nearby" data-val="">${UI.icon('map-pin')} In meiner Nähe</button>
</div>
</div>
</div>
</div>
</div>
<div class="rk-grid" id="rk-grid">
<div class="rk-loading">Lädt Routen…</div>
</div>
</div>
`;
let _searchTimer = null;
document.getElementById('rk-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_search = e.target.value.toLowerCase().trim();
_applyFilter();
}, 300);
});
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-filter-btn').addEventListener('click', _toggleFilterPanel);
document.getElementById('rk-rec-btn').addEventListener('click', _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');
if (panel) panel.style.display = _filterOpen ? '' : 'none';
if (btn) btn.classList.toggle('active', _filterOpen);
}
function _updateFilterBadge() {
const badge = document.getElementById('rk-filter-badge');
if (!badge) return;
const hasFilter = _difficulty !== '' || _terrain !== '' || _sortBy !== 'newest' || _onlyMine;
badge.style.display = hasFilter ? '' : 'none';
}
function _setBrowseMode(mode) {
_browseMode = mode;
document.getElementById('rk-mode-mine')?.classList.toggle('active', mode === 'mine');
document.getElementById('rk-mode-discover')?.classList.toggle('active', mode === 'discover');
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.style.display = 'none';
if (nearbyGrp) nearbyGrp.style.display = 'none';
if (searchRow) searchRow.style.display = 'none';
if (actRow) actRow.style.display = 'none';
const filterPanel = document.getElementById('rk-filter-panel');
if (filterPanel) filterPanel.style.display = 'none';
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 = `<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
<div class="text-sm">Routenvorschläge sind ein Pro-Feature.</div>`;
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.style.display = 'none';
if (nearbyGrp && _userPos) nearbyGrp.style.display = '';
} else {
if (recBtn) recBtn.style.display = '';
if (impWrap) impWrap.style.display = '';
if (_appState.user && mineGrp) mineGrp.style.display = '';
if (nearbyGrp) nearbyGrp.style.display = 'none';
}
_onlyMine = false;
document.querySelectorAll('#rk-mine-group .rk-chip').forEach(c => c.classList.remove('active'));
_applyFilter();
}
}
// ----------------------------------------------------------
// 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 = `
<div style="padding:var(--space-4) var(--space-2);display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Distanz-Auswahl -->
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3)">
Gewünschte Distanz
</div>
<div class="flex-gap-3" id="rks-km-row">
<button class="rks-km-chip${_suggestKm===2?' active':''}" data-km="2">2 km</button>
<button class="rks-km-chip${_suggestKm===4?' active':''}" data-km="4">4 km</button>
<button class="rks-km-chip${_suggestKm===6?' active':''}" data-km="6">6 km</button>
</div>
</div>
<!-- Varianten-Auswahl -->
<div>
<div style="font-size:0.75rem;font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3)">
Variante
</div>
<div class="flex-gap-2" id="rks-var-row">
<button class="rks-var-btn${_suggestSeed===0?' active':''}" data-seed="0">Variante 1</button>
<button class="rks-var-btn${_suggestSeed===1?' active':''}" data-seed="1">Variante 2</button>
<button class="rks-var-btn${_suggestSeed===2?' active':''}" data-seed="2">Variante 3</button>
</div>
</div>
<!-- Berechnen-Button -->
<button id="rks-calc-btn" style="${_btnStyle(true)}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
Route berechnen
</button>
<!-- Ergebnis-Bereich (initial leer) -->
<div id="rks-result"></div>
</div>
`;
// 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 = `
<div style="padding:var(--space-5);text-align:center;color:var(--c-text-secondary);
border:1.5px solid var(--c-border-light);border-radius:var(--radius-lg);
background:var(--c-surface)">
<svg class="ph-icon" aria-hidden="true" style="width:32px;height:32px;color:var(--c-text-muted);margin-bottom:var(--space-3)">
<use href="/icons/phosphor.svg#map-pin-slash"></use>
</svg>
<p style="margin:0;font-size:0.9rem">
Standort wird benötigt. Bitte erlaube den Zugriff in den Browser-Einstellungen.
</p>
</div>`;
return;
}
}
// Alten Karteninhalt aufräumen
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
// Spinner anzeigen
const res = document.getElementById('rks-result');
if (!res) return;
res.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;gap:var(--space-4);
padding:var(--space-6);color:var(--c-text-secondary)">
<div style="width:32px;height:32px;border:3px solid var(--c-border);
border-top-color:var(--c-primary);border-radius:50%;
animation:spin 0.8s linear infinite"></div>
<span style="font-size:0.9rem">Berechne Rundweg…</span>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
`;
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 = `
<div style="padding:var(--space-5);border-radius:var(--radius-lg);
background:${is429 ? 'rgba(234,179,8,0.08)' : 'rgba(220,38,38,0.08)'};
border:1px solid ${is429 ? 'rgba(234,179,8,0.3)' : 'rgba(220,38,38,0.25)'};
color:${is429 ? '#facc15' : '#f87171'};text-align:center">
<svg class="ph-icon" style="width:28px;height:28px;margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#${is429 ? 'calendar-x' : 'warning'}"></use>
</svg>
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-semibold)">
${is429 ? 'Wochenlimit erreicht' : 'Fehler beim Berechnen'}
</p>
<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);opacity:0.85">
${is429 ? 'Du hast diese Woche alle 20 Routenvorschläge genutzt. Montag gibt es neue.' : UI.escape(err.message || 'Unbekannter Fehler')}
</p>
</div>`;
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)
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:right;margin:0 0 var(--space-2)">
Noch ${result.weekly_remaining} von 20 Anfragen diese Woche
</p>`
: '';
res.innerHTML = `
${limitHint}<div id="rks-map" style="height:250px;background:var(--c-surface);margin-bottom:var(--space-3)"></div>
<div style="display:flex;gap:var(--space-3);align-items:center;flex-wrap:wrap;margin-bottom:var(--space-4)">
<span style="${_pillStyle('rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)')}">
${UI.icon('map-trifold')} ${UI.escape(distStr)}
</span>
<span style="${_pillStyle('rgba(107,114,128,0.10)','#9ca3af','rgba(107,114,128,0.30)')}">
${UI.icon('timer')} ${UI.escape(durStr)}
</span>
${diffLabel ? `<span style="${_pillStyle(
{leicht:'rgba(22,163,74,0.10)',mittel:'rgba(234,179,8,0.10)',anspruchsvoll:'rgba(220,38,38,0.10)'}[result.schwierigkeit]||'rgba(107,114,128,0.10)',
{leicht:'#4ade80',mittel:'#facc15',anspruchsvoll:'#f87171'}[result.schwierigkeit]||'#9ca3af',
{leicht:'rgba(22,163,74,0.30)',mittel:'rgba(234,179,8,0.30)',anspruchsvoll:'rgba(220,38,38,0.30)'}[result.schwierigkeit]||'rgba(107,114,128,0.30)')}">${UI.escape(diffLabel)}</span>` : ''}
<span style="font-size:0.85rem;color:var(--c-text-secondary);flex:1;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(result.name || '')}</span>
</div>
<div class="flex-gap-3">
<button id="rks-nav-btn" style="${_btnStyle(false)}flex:1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#navigation-arrow"></use></svg>
Navigation starten
</button>
<button id="rks-save-btn" style="${_btnStyle(true)}flex:1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
Route speichern
</button>
</div>`;
const _initMap = () => {
const mapEl = document.getElementById('rks-map');
if (!mapEl || !window.L) 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 = L.map(mapEl, { zoomControl: false, attributionControl: false,
dragging: true, touchZoom: true, scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_suggestMap);
const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.9 }).addTo(_suggestMap);
L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1, weight:2 }).addTo(_suggestMap);
L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1, weight:2 }).addTo(_suggestMap);
_addRouteArrows(_suggestMap, track, '#3b82f6');
_suggestMap.fitBounds(poly.getBounds(), { padding: [16, 16] });
setTimeout(() => _suggestMap?.invalidateSize(), 120);
};
if (window.L) { _initMap(); } else {
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 40) { clearInterval(poll); if (window.L) _initMap(); }
}, 100);
}
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));
}
async function _openRecOvl() {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
if (_recOvl) return;
try { await (UI.loadLeaflet?.() ?? Promise.resolve()); }
catch { UI.toast.warning('Karte offline nicht verfügbar — GPS-Aufzeichnung läuft trotzdem.'); }
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 = `
<div id="rk-rec-map-wrap" style="flex:1;min-height:0;position:relative"></div>
<div id="rk-rec-stats-bar" style="display:none;padding:14px 16px;background:var(--c-surface);border-top:1px solid var(--c-border)">
<div style="display:flex;justify-content:space-around;text-align:center">
<div>
<div style="font-size:10px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px">Strecke</div>
<div id="rk-rec-dist" style="font-size:22px;font-weight:700;font-variant-numeric:tabular-nums">0.00 km</div>
</div>
<div>
<div style="font-size:10px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px">Zeit</div>
<div id="rk-rec-time" style="font-size:22px;font-weight:700;font-variant-numeric:tabular-nums">00:00</div>
</div>
<div>
<div style="font-size:10px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px">Tempo</div>
<div id="rk-rec-pace" style="font-size:22px;font-weight:700;font-variant-numeric:tabular-nums">:</div>
</div>
</div>
</div>
<div id="rk-rec-ctrl" style="padding:16px;display:flex;gap:8px;background:var(--c-surface);border-top:1px solid var(--c-border)">
<button id="rk-rec-cancel" style="${_btnStyle()}flex:1">Abbruch</button>
<button id="rk-rec-startbtn" style="${_btnStyle(true)}flex:1">${UI.icon('path')} Start</button>
</div>
<div id="rk-rec-dim" style="display:none;position:fixed;inset:0;background:#000;z-index:950;flex-direction:column;align-items:center;justify-content:center;color:#fff;touch-action:none;user-select:none;-webkit-user-select:none">
<div style="font-size:11px;opacity:.35;letter-spacing:.12em;text-transform:uppercase;margin-bottom:12px">Route</div>
<div style="display:flex;gap:20px;opacity:.55;font-size:14px">
<span id="rk-rec-dim-dauer">00:00</span>
<span>·</span>
<span id="rk-rec-dim-dist">0.00 km</span>
</div>
<button id="rk-dim-unlock-btn"
style="background:none;border:none;cursor:pointer;outline:none;
display:flex;flex-direction:column;align-items:center;gap:0;
margin-top:36px;padding:0 16px 16px;-webkit-tap-highlight-color:transparent;
touch-action:none;user-select:none">
<svg width="56" height="56" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="2.5"/>
<circle id="rk-dim-prog" cx="28" cy="28" r="24" fill="none" stroke="rgba(255,255,255,.7)" stroke-width="2.5"
stroke-dasharray="150.8" stroke-dashoffset="150.8" stroke-linecap="round"
transform="rotate(-90 28 28)" style="transition:none"/>
</svg>
<!-- Fingerabdruck unter dem Ring, inline path (kein <use> wegen iOS-Bug) -->
<svg viewBox="0 0 256 256" width="28" height="28" fill="white"
style="margin-top:12px;opacity:0.5">
<path d="M126.42,24C70.73,24.85,25.21,70.09,24,125.81a103.53,103.53,0,0,0,13.52,53.54,4,4,0,0,0,7.1-.3,119.35,119.35,0,0,0,11.37-51A71.77,71.77,0,0,1,83,71.83a8,8,0,1,1,9.86,12.61A55.82,55.82,0,0,0,72,128.07a135.28,135.28,0,0,1-18.45,68.35,4,4,0,0,0,.61,4.85c2,2,4.09,4,6.25,5.82a4,4,0,0,0,6-1A151.18,151.18,0,0,0,85,158.49a8,8,0,1,1,15.68,3.19,167.33,167.33,0,0,1-21.07,53.64,4,4,0,0,0,1.6,5.63c2.47,1.25,5,2.41,7.57,3.47a4,4,0,0,0,5-1.61A183,183,0,0,0,120,128.28a8.16,8.16,0,0,1,7.44-8.21,8,8,0,0,1,8.56,8,198.94,198.94,0,0,1-25.21,97.16,4,4,0,0,0,2.95,5.92q4.55.63,9.21.86a4,4,0,0,0,3.67-2.1A214.88,214.88,0,0,0,152,128.8c.05-13.25-10.3-24.49-23.54-24.74A24,24,0,0,0,104,128a8.1,8.1,0,0,1-7.29,8,8,8,0,0,1-8.71-8,40,40,0,0,1,40.42-40c22,.23,39.68,19.17,39.57,41.16a231.37,231.37,0,0,1-20.52,94.57,4,4,0,0,0,4.62,5.51,103.49,103.49,0,0,0,10.26-3,4,4,0,0,0,2.35-2.22,243.76,243.76,0,0,0,11.48-34,8,8,0,1,1,15.5,4q-1.12,4.37-2.4,8.7a4,4,0,0,0,6.46,4.17A104,104,0,0,0,126.42,24ZM198,161.08a8,8,0,0,1-7.92,7,8.39,8.39,0,0,1-1-.06,8,8,0,0,1-6.95-8.93,252.57,252.57,0,0,0,1.92-31,56.08,56.08,0,0,0-56-56,56.78,56.78,0,0,0-7,.43,8,8,0,0,1-2-15.89,72.1,72.1,0,0,1,81,71.49A266.93,266.93,0,0,1,198,161.08Z"/>
</svg>
<div style="font-size:11px;opacity:.3;margin-top:6px">2 Sek. halten</div>
</button>
</div>
`;
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 {
if (!window.L) throw new Error('Leaflet not loaded');
_recMap = L.map(ovl.querySelector('#rk-rec-map-wrap'), { zoomControl: false, attributionControl: false })
.setView([pos.lat, pos.lon], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
_recLocMarker = L.circleMarker([pos.lat, pos.lon], {
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
}).addTo(_recMap);
} catch {
const mapWrap = ovl.querySelector('#rk-rec-map-wrap');
if (mapWrap) mapWrap.innerHTML =
`<div style="display:flex;align-items:center;justify-content:center;height:100%;
flex-direction:column;gap:8px;color:var(--c-text-secondary);font-size:14px">
<span style="font-size:2rem">📡</span>
Karte offline nicht verfügbar — GPS läuft trotzdem
</div>`;
}
// 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 {}
}
async function _startRecInOvl() {
if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; }
_recActive = true;
_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 = `
<svg style="width:18px;height:18px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true">
<use href="/icons/phosphor.svg#warning"></use></svg>
<span><strong>Display wach lassen!</strong> Auf iPhone stoppt die GPS-Aufzeichnung, wenn das Display ausgeht — Helligkeit hochsetzen oder Bildschirm nicht sperren.</span>`;
document.getElementById('rk-rec-map-wrap')?.appendChild(banner);
setTimeout(() => banner.remove(), 9000);
}
const ctrl = document.getElementById('rk-rec-ctrl');
ctrl.innerHTML = `
<button id="rk-rec-stopbtn" style="${_btnStyle()}flex:1;border-color:var(--c-danger);background:var(--c-danger);color:#fff;position:relative;overflow:hidden;touch-action:none;user-select:none;">
<span id="rk-stop-label" style="position:relative;z-index:1;display:flex;align-items:center;gap:6px;pointer-events:none">
${UI.icon('path')} Gedrückt halten zum Stoppen
</span>
<div id="rk-stop-fill" style="position:absolute;inset:0;background:rgba(0,0,0,0.35);transform:scaleX(0);transform-origin:left center;transition:transform 0.05s linear;pointer-events:none"></div>
</button>`;
// 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 && window.L) {
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
}
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) } : {}) });
_recPolyline?.addLatLng([lat, lon]);
_recLocMarker?.setLatLng([lat, lon]);
if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16);
_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(); return; }
const track = [..._recTrack], distKm = _recDistKm;
const dauMin = Math.round((Date.now() - _recStartTime) / 60000);
_closeRecOvlClean();
if (track.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte zum Speichern.'); return; }
_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 = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="rk-rms-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name" placeholder="Wird automatisch ermittelt…" required>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">
<option value="leicht">Leicht</option>
<option value="mittel">Mittel</option>
<option value="anspruchsvoll">Anspruchsvoll</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
<option value="wald">Wald</option>
<option value="asphalt">Asphalt</option>
<option value="wiese">Wiese</option>
<option value="mix">Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-select" id="rk-rms-paw">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">${UI.icon('paw-print')} Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut">${UI.icon('paw-print')}${UI.icon('paw-print')} Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut">${UI.icon('paw-print')}${UI.icon('paw-print')}${UI.icon('paw-print')} Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium">${UI.icon('paw-print')}${UI.icon('paw-print')}${UI.icon('paw-print')}${UI.icon('paw-print')} Premium</button>
</div>
<input type="hidden" name="hunde_tauglichkeit" id="rk-rms-paw-val" value="sehr_gut">
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> ${UI.icon('leaf')} Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> ${UI.icon('tag')} Leine empfohlen
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public"> ${UI.icon('eye')} Öffentlich (von allen sichtbar)
</label>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2" placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rk-rms-discard">Verwerfen</button>
<button type="submit" form="rk-rms-form" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>
`;
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());
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();
UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`);
_loadData();
return;
}
const saved = await API.routes.create(payload);
UI.modal.close();
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 <body> — kein Konflikt mit .rk-layout overflow:hidden
const mapH = window.innerHeight - 160;
const sec = document.createElement('div');
sec.id = 'rk-map-section';
sec.className = 'rk-map-section';
sec.innerHTML = `
<div class="rk-map-bar">
<button class="btn btn-secondary btn-sm" id="rk-map-back" title="Zurück zur Liste">${UI.icon('arrow-left')}</button>
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
placeholder="🔍 Ort suchen…" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">${UI.icon('map-pin')}</button>
</div>
<div id="rk-search-map" style="flex:1;width:100%"></div>
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
`;
document.body.appendChild(sec);
document.getElementById('rk-map-back')?.addEventListener('click', () => _switchView('list'));
// Wie _initMiniMaps: pollen bis window.L bereit ist
_pollAndInitSearchMap();
} else {
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
if (grid) grid.style.display = '';
}
}
// ----------------------------------------------------------
// Suchkarte
// ----------------------------------------------------------
function _pollAndInitSearchMap() {
if (window.L) { _initSearchMap(); return; }
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 40) {
clearInterval(poll);
if (window.L) _initSearchMap();
}
}, 100);
}
function _initSearchMap() {
if (!document.getElementById('rk-search-map')) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
const zoom = _userPos ? 13 : 6;
_searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap);
setTimeout(() => _searchMap?.invalidateSize(), 100);
setTimeout(() => _searchMap?.invalidateSize(), 600);
_renderRoutesOnMap();
// Standort-Button
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
_userPos = pos;
_searchMap.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
});
// Geocoding-Suche
const locInput = document.getElementById('rk-map-loc');
let _geoDebounce;
locInput?.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
clearTimeout(_geoDebounce);
_geocodeAndFly(locInput.value.trim());
});
locInput?.addEventListener('input', () => {
clearTimeout(_geoDebounce);
const q = locInput.value.trim();
if (q.length < 3) return;
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
});
}
async function _geocodeAndFly(query) {
if (!query || !_searchMap) return;
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const data = await r.json();
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
const { lat, lon, boundingbox } = data[0];
if (boundingbox) {
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
{ maxZoom: 14 });
} else {
_searchMap.setView([+lat, +lon], 13);
}
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
}
function _renderRoutesOnMap() {
if (!_searchMap || !window.L) return;
// Alte Linien entfernen
_searchLines.forEach(({ line }) => line.remove());
_searchLines.clear();
const hint = document.getElementById('rk-map-hint');
_data.forEach(route => {
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
if (pts.length < 2) return;
const line = L.polyline(pts, {
color: '#C4843A', weight: 4, opacity: 0.75,
}).addTo(_searchMap);
// Start-/End-Marker
const startM = L.circleMarker(pts[0], {
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
const endM = L.circleMarker(pts[pts.length - 1], {
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
// Tooltip mit Namen und Distanz
const tip = `<b>${UI.escape(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
// Hover-Highlight
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
const onClick = () => {
if (hint) hint.textContent = `Lädt „${route.name}"…`;
_openDetail(route.id).finally(() => {
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
});
};
line.on('click', onClick);
startM.on('click', onClick);
_searchLines.set(route.id, { line, startM, endM });
});
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
if (_data.length && _searchLines.size && !_userPos) {
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
if (allPts.length) {
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
catch {}
}
}
}
// ----------------------------------------------------------
// Daten
// ----------------------------------------------------------
async function _loadData() {
const _merge = (online) => {
const pending = _getPending();
if (pending.length) _data = [...pending, ..._data];
if (_appState.user && _browseMode === 'mine')
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
if (_browseMode === 'discover' && _userPos)
document.getElementById('rk-nearby-group')?.style.setProperty('display', '');
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 =
`<p style="color:var(--c-danger);padding:var(--space-6)">Offline — noch keine Routen gecacht.</p>`;
}
}
// ----------------------------------------------------------
// 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 = `<div class="rk-empty">
<div class="rk-empty-icon">🔍</div>
<p>${emptyMsg}</p>
<button class="btn btn-secondary" id="rk-empty-reset">Filter zurücksetzen</button>
</div>`;
document.getElementById('rk-empty-reset')?.addEventListener('click', () => {
_search = ''; _difficulty = ''; _terrain = ''; _sortBy = 'newest'; _onlyMine = false;
document.getElementById('rk-search').value = '';
document.querySelectorAll('.rk-chip').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.rk-chip[data-val=""]').forEach(c => c.classList.add('active'));
document.querySelector('.rk-chip[data-val="newest"]')?.classList.add('active');
_updateFilterBadge();
_applyFilter();
});
} else if (_browseMode === 'discover') {
// Entdecken: keine fremden Routen vorhanden
grid.innerHTML = `<div class="rk-empty">
<div class="rk-empty-icon">${UI.icon('compass')}</div>
<h3 class="rk-empty-title">Noch keine öffentlichen Routen</h3>
<p class="rk-empty-text">Andere Nutzer haben noch keine Routen geteilt. Sei der Erste!</p>
</div>`;
} else {
// Noch gar keine eigenen Routen
grid.innerHTML = UI.emptyState({
icon: UI.icon('map-trifold'),
title: 'Noch keine Routen',
text: 'Zeichne Lieblingsrouten auf oder importiere GPX-Dateien. Teile Routen mit Freunden.',
action: `<button class="btn btn-primary" id="rk-empty-rec">${UI.icon('path')} Route aufzeichnen</button>`,
});
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
});
}
return;
}
// Alte Mini-Maps zerstören bevor DOM neu geschrieben wird
_miniMaps.forEach(m => m.remove());
_miniMaps.clear();
grid.innerHTML = _filtered.map(r => _cardHTML(r)).join('');
_initMiniMaps();
grid.querySelectorAll('.rk-card').forEach(card => {
card.addEventListener('click', e => {
if (e.target.closest('.rk-stars,.rk-dl-btn')) return;
_openDetail(parseInt(card.dataset.id));
});
});
grid.querySelectorAll('.rk-star').forEach(star => {
star.addEventListener('click', e => {
e.stopPropagation();
_rateRoute(parseInt(star.dataset.id), parseFloat(star.dataset.val));
});
});
grid.querySelectorAll('.rk-dl-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_downloadGpx(parseInt(btn.dataset.id));
});
});
}
// ----------------------------------------------------------
// Karte HTML
// ----------------------------------------------------------
function _cardHTML(r) {
const isDiscover = _browseMode === 'discover';
const privBadge = !r.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : '';
const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || '';
const terrain = TERRAIN_LABEL[r.untergrund] || '';
const paws = HUNDE_LABEL[r.hunde_tauglichkeit] || '';
const dist = r.distanz_km ? `${r.distanz_km.toFixed(1)} km` : '';
const dur = r.dauer_min ? _fmtDur(r.dauer_min) : '';
const firstPhoto = (r.foto_urls || [])[0];
const previewContent = firstPhoto
? `<img src="${UI.escape(firstPhoto)}" style="width:100%;height:100%;object-fit:cover">`
: `<div class="rk-mini-map" data-id="${r.id}"
data-track='${JSON.stringify(r.preview_track||[])}'
style="width:100%;height:100%"></div>`;
const authorLine = isDiscover
? `<div class="rk-card-creator">${UI.icon('user')} ${UI.escape(r.user_name||'Anonym')}</div>`
: '';
return `
<div class="rk-card" data-id="${r.id}" ${r._isPending ? 'data-pending="1"' : ''}>
<div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body">
${authorLine}
${r._isPending ? `<div style="font-size:10px;font-weight:700;color:var(--c-warning,#d97706);
margin-bottom:3px;display:flex;align-items:center;gap:4px">
${UI.icon('cloud-arrow-up')} Sync ausstehend</div>` : ''}
<div class="rk-card-name">${UI.escape(r.name)}</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin:var(--space-2) 0">
${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)') : ''}
</div>
<div class="rk-card-footer">
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
<div class="rk-card-actions">
${isDiscover ? '' : `<span class="rk-card-author">${UI.escape(r.user_name||'Anonym')}</span>`}
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
</div>
</div>
</div>
</div>`;
}
function _starsHTML(id, avg, count) {
const stars = [1,2,3,4,5].map(n =>
`<span class="rk-star${n<=Math.round(avg)?' filled':''}" data-id="${id}" data-val="${n}">★</span>`
).join('');
return stars + (count > 0 ? `<span class="rk-star-count">${avg.toFixed(1)} (${count})</span>` : '');
}
// ----------------------------------------------------------
// Mini-Karten (OSM-Tiles via Leaflet, lazy per IntersectionObserver)
// ----------------------------------------------------------
function _initMiniMaps() {
const init = () => {
const obs = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
obs.unobserve(entry.target);
_buildMiniMap(entry.target);
});
}, { rootMargin: '150px' });
document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el));
};
if (window.L) { init(); return; }
// Leaflet noch am Laden — kurz pollen
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 30) { clearInterval(poll); if (window.L) init(); }
}, 100);
}
function _buildMiniMap(el) {
const track = JSON.parse(el.dataset.track || '[]');
const routeId = parseInt(el.dataset.id);
if (track.length < 2) {
el.innerHTML = '<div class="rk-preview-empty">🗺️</div>';
return;
}
const lls = track.map(p => [p.lat, p.lon]);
const m = L.map(el, {
zoomControl: false, attributionControl: false,
dragging: false, touchZoom: false, scrollWheelZoom: false,
doubleClickZoom: false, keyboard: false, boxZoom: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 17 }).addTo(m);
const poly = L.polyline(lls, { color: '#C4843A', weight: 3, opacity: 0.9 }).addTo(m);
L.circleMarker(lls[0], { radius: 5, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(m);
L.circleMarker(lls.at(-1), { radius: 5, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(m);
m.fitBounds(poly.getBounds(), { padding: [8, 8] });
_miniMaps.set(routeId, m);
}
// ----------------------------------------------------------
// SVG-Vorschau (Fallback, wird nicht mehr direkt genutzt)
// ----------------------------------------------------------
function _svgPreview(track) {
if (!track || track.length < 2)
return `<div class="rk-preview-empty">🗺️</div>`;
const lats = track.map(p=>p.lat), lons = track.map(p=>p.lon);
const minLat=Math.min(...lats), maxLat=Math.max(...lats);
const minLon=Math.min(...lons), maxLon=Math.max(...lons);
const latR=maxLat-minLat||0.0001, lonR=maxLon-minLon||0.0001;
const W=200,H=120,PAD=10;
const pts = track.map(p=>{
const x=(PAD+(p.lon-minLon)/lonR*(W-2*PAD)).toFixed(1);
const y=(H-PAD-(p.lat-minLat)/latR*(H-2*PAD)).toFixed(1);
return `${x},${y}`;
}).join(' ');
const [sx,sy]=pts.split(' ')[0].split(',');
const [ex,ey]=pts.split(' ').at(-1).split(',');
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;display:block">
<rect width="${W}" height="${H}" fill="#e8f0e8"/>
<polyline points="${pts}" fill="none" stroke="#C4843A" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
<circle cx="${sx}" cy="${sy}" r="5" fill="#22C55E" stroke="#fff" stroke-width="1.5"/>
<circle cx="${ex}" cy="${ey}" r="5" fill="#EF4444" stroke="#fff" stroke-width="1.5"/>
</svg>`;
}
// 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) =>
`<span style="${_pillStyle(bg, color, border)}">${text}</span>`;
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
// ----------------------------------------------------------
// Navigation-Overlay
// ----------------------------------------------------------
async function _openNavOverlay(route) {
const track = route.gps_track || [];
if (track.length < 2) return;
_navMaxIdx = 0;
_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 {}
}
await UI.loadLeaflet?.() ?? Promise.resolve();
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 = `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:max(env(safe-area-inset-top,44px),44px) 16px 8px;
background:var(--c-surface);border-bottom:1px solid var(--c-border);flex-shrink:0">
<button id="rk-nav-back" style="background:none;border:none;color:var(--c-primary);font-size:16px;cursor:pointer;padding:4px 0">← Zurück</button>
<span style="font-weight:700;font-size:14px;flex:1;text-align:center;margin:0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(route.name)}</span>
<button id="rk-nav-center-btn" style="background:none;border:none;color:var(--c-primary);cursor:pointer;padding:4px 6px;display:flex;align-items:center" title="Auf Standort zentrieren">${UI.icon('crosshair')}</button>
</div>
<div id="rk-nav-map" style="flex:1;min-height:0"></div>
<div id="rk-nav-elev" style="height:64px;background:var(--c-surface);border-top:1px solid var(--c-border);flex-shrink:0;display:none"></div>
<div style="padding:10px 16px;background:var(--c-surface);border-top:1px solid var(--c-border);flex-shrink:0">
<div style="display:flex;justify-content:space-around;text-align:center;margin-bottom:8px">
<div>
<div style="font-size:9px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.06em">Fortschritt</div>
<div id="rk-nav-pct" style="font-size:20px;font-weight:700">0%</div>
</div>
<div>
<div style="font-size:9px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.06em">Verbleibend</div>
<div id="rk-nav-remain" style="font-size:20px;font-weight:700"> km</div>
</div>
<div>
<div style="font-size:9px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.06em">Zur Route</div>
<div id="rk-nav-offdist" style="font-size:20px;font-weight:700"> m</div>
</div>
</div>
<div id="rk-nav-offwarn" style="display:none;text-align:center;padding:4px 8px;border-radius:6px;
background:rgba(220,38,38,0.10);color:#f87171;font-size:12px;font-weight:600;margin-bottom:6px">
⚠️ Du hast die Route verlassen
</div>
<div style="height:4px;background:var(--c-border);border-radius:2px;overflow:hidden">
<div id="rk-nav-progbar" style="height:100%;width:0%;background:var(--c-primary);border-radius:2px;transition:width .5s"></div>
</div>
</div>
<!-- Aktions-Buttons -->
<div style="display:flex;gap:8px;padding:8px 16px calc(8px + env(safe-area-inset-bottom,0px));
background:var(--c-surface);border-top:1px solid var(--c-border);flex-shrink:0">
<button id="rk-nav-pois" class="btn btn-secondary btn-sm flex-1">${UI.icon('map-pin')} POIs</button>
<button id="rk-nav-rate" class="btn btn-secondary btn-sm flex-1">${UI.icon('star')} Bewerten</button>
<button id="rk-nav-feedback" class="btn btn-secondary btn-sm flex-1">${UI.icon('chat-circle-dots')} Feedback</button>
</div>
<!-- Dim-Overlay -->
<div id="rk-nav-dim" style="display:none;position:fixed;inset:0;z-index:950;background:#000;
flex-direction:column;align-items:center;justify-content:center;color:#fff;
touch-action:none;user-select:none;-webkit-user-select:none">
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;opacity:.45;margin-bottom:28px">
<div style="font-size:14px"><span id="rk-nav-dim-pct">0%</span><span style="margin:0 8px">·</span><span id="rk-nav-dim-remain"> km</span></div>
<div id="rk-nav-dim-label" style="font-size:11px;opacity:.7">verbleibend</div>
</div>
<!-- Navigation-Pfeil (dreht sich zum nächsten Routenpunkt) -->
<svg id="rk-nav-dim-arrow" width="120" height="120" viewBox="0 0 100 100"
style="margin-bottom:28px;transform:rotate(0deg);transform-origin:50% 50%;transition:transform .4s ease">
<!-- Einfacher Navi-Pfeil, spitze zeigt nach oben (0° = Nord) -->
<path d="M50,8 L88,90 L50,73 L12,90 Z"
fill="rgba(255,255,255,0.15)"
stroke="rgba(255,255,255,0.9)"
stroke-width="3.5"
stroke-linejoin="round"
stroke-linecap="round"/>
</svg>
<!-- 2-Sek-Halten-Ring -->
<svg width="56" height="56" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="2.5"/>
<circle id="rk-nav-dim-prog" cx="28" cy="28" r="24" fill="none" stroke="rgba(255,255,255,.7)" stroke-width="2.5"
stroke-dasharray="150.8" stroke-dashoffset="150.8" stroke-linecap="round"
transform="rotate(-90 28 28)" style="transition:none"/>
</svg>
<div style="font-size:11px;opacity:.3;margin-top:8px">2 Sek. halten</div>
</div>
`;
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 = L.map(mapEl, { zoomControl: false, attributionControl: false })
.setView([mid.lat, mid.lon], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_navMap);
// Route-Polylines: erledigt (grün) + ausstehend (orange)
const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap);
const remainLine = L.polyline(track.map(p => [p.lat, p.lon]), { color: '#f97316', weight: 5, opacity: 0.9 }).addTo(_navMap);
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
_addRouteArrows(_navMap, track, '#3b82f6');
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
const mkPin = (p, color) => L.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 icon = L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);border:2px solid rgba(255,255,255,.7)">
<svg style="width:16px;height:16px;fill:currentColor" viewBox="0 0 256 256" aria-hidden="true">
<use href="/icons/phosphor.svg#${svgIcon}"></use>
</svg></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([poi.lat, poi.lon], { icon })
.bindTooltip(poi.name || poi._label, { direction: 'top', offset: [0, -16] })
.bindPopup(`<strong>${UI.escape(poi.name||poi._label)}</strong>
${poi.phone ? `<br>📞 <a href="tel:${UI.escape(poi.phone)}">${UI.escape(poi.phone)}</a>` : ''}
${poi.opening_hours ? `<br>🕐 ${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);
const _closestIdx = (lat, lon) => {
let best = 0, bestD = Infinity;
track.forEach((p, i) => {
const d = _haversineKm(lat, lon, p.lat, p.lon);
if (d < bestD) { bestD = d; best = i; }
});
return best;
};
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;
};
const _updateStats = (idx, distToRoute, userLat, userLon) => {
if (idx > _navMaxIdx) _navMaxIdx = idx;
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();
}
const offWarn = document.getElementById('rk-nav-offwarn');
if (distToRoute * 1000 > 50) {
offWarn.style.display = '';
if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
} else {
offWarn.style.display = 'none';
}
// Polylines aktualisieren
doneLine.setLatLngs(track.slice(0, idx + 1).map(p => [p.lat, p.lon]));
remainLine.setLatLngs(track.slice(idx).map(p => [p.lat, p.lon]));
};
// GPS-Watch
await _navAcquireWakeLock();
document.addEventListener('visibilitychange', _navOnVisibility);
let _navFirstFix = true;
_navWatchId = navigator.geolocation.watchPosition(pos => {
const { latitude: lat, longitude: lon } = pos.coords;
if (!locMarker) {
locMarker = L.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');
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'; }
};
dim.addEventListener('pointerdown', e => {
e.stopPropagation();
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);
});
dim.addEventListener('pointerup', cancelLp);
dim.addEventListener('pointercancel', cancelLp);
dim.addEventListener('pointerleave', cancelLp);
// 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: `<div style="display:flex;gap:8px;justify-content:center;padding:var(--space-4) 0">
${[1,2,3,4,5].map(n =>
`<button class="btn btn-secondary" data-stars="${n}"
style="font-size:1.4rem;padding:8px 12px">${'⭐'.repeat(n)}</button>`
).join('')}
</div>`,
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 = `
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
Dein Feedback wird direkt an den Route-Ersteller gesendet.
</p>
<textarea id="rk-nav-fb-text" class="form-control" rows="4"
placeholder="z.B. Der Weg nach links ist gesperrt…" maxlength="500"></textarea>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="rk-nav-fb-send">${UI.icon('paper-plane-tilt')} Senden</button>`;
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);
});
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]) => `
<div style="margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:.05em;margin-bottom:6px">${group.icon} ${UI.escape(label)}</div>
${group.items.map(p => `
<div style="padding:8px 0;border-bottom:1px solid var(--c-border);display:flex;justify-content:space-between;align-items:flex-start">
<div>
<div style="font-weight:600;font-size:14px">${UI.escape(p.name||label)}</div>
${p.opening_hours ? `<div style="font-size:12px;color:var(--c-text-secondary)">🕐 ${UI.escape(p.opening_hours)}</div>` : ''}
${p.phone ? `<div style="font-size:12px;color:var(--c-text-secondary)">📞 <a href="tel:${UI.escape(p.phone)}" class="text-primary">${UI.escape(p.phone)}</a></div>` : ''}
</div>
<a href="${/iPad|iPhone|iPod/.test(navigator.userAgent)
? `maps://maps.apple.com/?q=${encodeURIComponent(p.name||label)}&ll=${p.lat},${p.lon}`
: `https://www.google.com/maps/search/?api=1&query=${p.lat},${p.lon}`}"
target="_blank" style="flex-shrink:0;margin-left:8px;color:var(--c-primary);font-size:12px">Navi</a>
</div>`).join('')}
</div>`).join('');
UI.modal.open({ title: `${UI.icon('map-pin')} POIs entlang der Route`, body, footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
});
}
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();
}
}
function _closeNav() {
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);
if (_navWalkMeta && _navWalkMeta.trackLen > 1) {
const pct = Math.round(_navMaxIdx / (_navWalkMeta.trackLen - 1) * 100);
if (pct >= 50) {
const walkedKm = Math.round((_navMaxIdx / (_navWalkMeta.trackLen - 1)) * _navWalkMeta.totalKm * 100) / 100;
API.routes.walked(_navWalkMeta.routeId, walkedKm, pct)
.then(res => { if (res.new_badges?.length) UI.toast.success(`🏅 Neues Badge: ${res.new_badges[0].name}!`); })
.catch(() => {});
}
}
if (_navOrientCleanup) { _navOrientCleanup(); _navOrientCleanup = null; }
_navWalkMeta = null;
_navMaxIdx = 0;
_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 `<div style="position:relative;width:100%;height:100%">
<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"
style="width:100%;height:100%;display:block">
<polygon points="${fill}" fill="var(--c-primary)" opacity="0.15"/>
<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="2" stroke-linecap="round"/>
</svg>
<span style="position:absolute;left:5px;top:2px;font-size:10px;font-weight:600;color:var(--c-text-muted);
background:var(--c-surface);padding:0 2px;border-radius:2px;line-height:1.2">${Math.round(maxA)}m</span>
<span style="position:absolute;left:5px;bottom:2px;font-size:10px;font-weight:600;color:var(--c-text-muted);
background:var(--c-surface);padding:0 2px;border-radius:2px;line-height:1.2">${Math.round(minA)}m</span>
</div>`;
}
// ----------------------------------------------------------
// 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 = `
<div style="position:sticky;top:0;z-index:10;display:flex;align-items:center;
justify-content:space-between;
padding-top:max(env(safe-area-inset-top,44px),44px);
padding-left:16px;padding-right:16px;padding-bottom:10px;
background:var(--c-surface);border-bottom:1px solid var(--c-border);flex-shrink:0">
<button id="rk-trim-cancel" style="background:none;border:none;color:var(--c-primary);font-size:16px;cursor:pointer;padding:4px 0">← Abbrechen</button>
<span style="font-weight:700;font-size:15px">Route kürzen</span>
<button id="rk-trim-save" class="btn btn-primary btn-sm">${UI.icon('floppy-disk')} Speichern</button>
</div>
<div id="rk-trim-map" style="flex:1;min-height:0"></div>
<div style="padding:16px;background:var(--c-surface);border-top:1px solid var(--c-border);flex-shrink:0">
<!-- Modus-Toggle -->
<div style="display:flex;gap:8px;margin-bottom:12px">
<button id="rk-trim-mode-start" class="btn btn-sm btn-primary flex-1">
📍 Klick setzt: Start
</button>
<button id="rk-trim-mode-end" class="btn btn-sm btn-secondary flex-1">
📍 Klick setzt: Ende
</button>
</div>
<!-- Slider Anfang -->
<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--c-text-secondary);margin-bottom:4px">
<span>Anfang abschneiden</span>
<span id="rk-trim-start-lbl">0 Punkte</span>
</div>
<input type="range" id="rk-trim-slider-start" min="0" max="${Math.floor(fullTrack.length/2)-1}" value="0"
style="width:100%;accent-color:var(--c-primary)">
</div>
<!-- Slider Ende -->
<div style="margin-bottom:12px">
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--c-text-secondary);margin-bottom:4px">
<span>Ende abschneiden</span>
<span id="rk-trim-end-lbl">0 Punkte</span>
</div>
<input type="range" id="rk-trim-slider-end" min="0" max="${Math.floor(fullTrack.length/2)-1}" value="0"
style="width:100%;accent-color:var(--c-danger)">
</div>
<!-- Stats -->
<div id="rk-trim-stats" style="font-size:13px;color:var(--c-text-secondary);text-align:center"></div>
</div>
`;
document.body.appendChild(ovl);
// Map initialisieren
await UI.loadLeaflet?.() ?? Promise.resolve();
const mapEl = document.getElementById('rk-trim-map');
const center = fullTrack[Math.floor(fullTrack.length/2)];
const trimMap = L.map(mapEl, { zoomControl: false, attributionControl: false })
.setView([center.lat, center.lon], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(trimMap);
// Marker & Polylines
let greyBefore = L.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
let activeLine = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(trimMap);
let greyAfter = L.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
const mkMarker = (lat, lon, color) => L.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: <strong>${newKm.toFixed(2)} km</strong> · ca. <strong>${newMin} min</strong>
&nbsp;·&nbsp; <span class="text-muted">Original: ${origKm.toFixed(2)} km · ${origMin} min (bleibt angerechnet)</span>`;
};
update();
trimMap.fitBounds(L.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, L.latLng(p.lat, 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 ? `
<div class="rk-photo-gallery">
${photos.map(u => `<img src="${UI.escape(u)}" class="rk-photo-thumb" onclick="window.open('${UI.escape(u)}','_blank')">`).join('')}
${isOwn ? `<label class="rk-photo-add" title="Foto hinzufügen">
<span>+</span>
<input type="file" id="rk-photo-input" accept="image/*" multiple class="hidden">
</label>` : ''}
</div>` :
isOwn ? `<label class="rk-photo-add-empty">
${UI.icon('camera')} Foto hinzufügen
<input type="file" id="rk-photo-input" accept="image/*" multiple class="hidden">
</label>` : '';
const body = `
<div id="rk-detail-map" style="height:200px;border-radius:var(--radius-md);
margin-bottom:var(--space-3);background:var(--c-surface-2)"></div>
${photoGallery}
<div style="display:flex;flex-wrap:wrap;gap:6px;margin:var(--space-3) 0">
${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
? `<button type="button" id="rd-vis-pill" title="${route.is_public ? 'Auf Privat setzen' : 'Auf Öffentlich setzen'}"
style="${_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;">
${route.is_public ? 'Öffentlich' : 'Privat'}
</button>`
: _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)')
}
</div>
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${UI.escape(route.beschreibung)}</p>` : ''}
<div id="rk-nearby" class="rk-nearby-section">
<div class="text-sm-muted">Lädt Orte entlang der Route…</div>
</div>
<div id="rk-rating-${route.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.75rem;margin-top:var(--space-2)">
${track.length} GPS-Punkte · von ${UI.escape(route.user_name||'Anonym')}
</p>
`;
const _actionBtn = (id, icon, label, danger = false) =>
`<button type="button" id="${id}"
style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:4px;padding:var(--space-2) var(--space-1);min-height:56px;
border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);cursor:pointer;
color:${danger ? 'var(--c-danger)' : 'var(--c-text)'};
font-size:var(--text-xs);font-weight:var(--weight-semibold);
transition:background var(--transition-fast)">
${UI.icon(icon)}
<span>${label}</span>
</button>`;
const ownerRow = isOwn ? `
<div class="flex-gap-2">
${_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)}
</div>` : '';
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<div class="flex-gap-2">
${_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') : ''}
</div>
${ownerRow}
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
</div>
`;
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer });
UI.ratingStars({
containerId: `rk-rating-${route.id}`,
targetType: 'route',
targetId: route.id,
isLoggedIn: !!_appState.user,
});
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
// Teilen-Button
document.getElementById('rd-share')?.addEventListener('click', async () => {
const shareUrl = location.origin + '/#routes?id=' + route.id;
const text = `${route.name}${(route.distanz_km||0).toFixed(1)} km`;
if (navigator.share) {
navigator.share({ title: route.name, text, url: shareUrl }).catch(() => {});
} else {
await navigator.clipboard.writeText(shareUrl);
UI.toast.info('Link kopiert!');
}
});
// Navi-Button
document.getElementById('rd-navi')?.addEventListener('click', () => {
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 && window.L) {
if (_detailMap) { _detailMap.remove(); _detailMap = null; }
_detailMap = _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');
_openNoteModal('route', route.id, label, null);
});
// Mini-Map
let _detailMap = null;
setTimeout(() => {
const el = document.getElementById('rk-detail-map');
if (!el || !track.length) return;
if (window.L) _detailMap = _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
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>`;
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
border:1.5px solid ${checked ? 'var(--c-primary)' : 'var(--c-border)'};
border-radius:100px;cursor:pointer;
background:${checked ? 'var(--c-primary-subtle)' : ''};
color:${checked ? 'var(--c-primary)' : ''};
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
class="rd-dog-cb hidden">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('');
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Welche Hunde waren bei dieser Route dabei?
</p>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)" id="rd-dogs-picker">
${dogRows}
</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rd-dogs-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="rd-dogs-save">${UI.icon('floppy-disk')} Speichern</button>
`;
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]);
const icon = L.divIcon({
className: '',
html: `<svg width="20" height="20" viewBox="0 0 20 20"
style="transform:rotate(${deg.toFixed(0)}deg);transform-origin:10px 10px;display:block">
<path d="M10,3 L15,15 L10,12 L5,15 Z"
fill="${color}" fill-opacity="0.85"
stroke="rgba(0,0,0,0.25)" stroke-width="1" stroke-linejoin="round"/>
</svg>`,
iconSize: [20, 20], iconAnchor: [10, 10],
});
L.marker([track[i].lat, track[i].lon], { icon, interactive: false, zIndexOffset: -100 }).addTo(map);
next += spacing;
}
}
}
function _buildDetailMap(el, track) {
const m = L.map(el, { zoomControl: false, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(m);
const lls = track.map(p => [p.lat, p.lon]);
const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
_addRouteArrows(m, track, '#3b82f6');
L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m);
L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m);
m.fitBounds(poly.getBounds(), { padding:[10,10] });
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 });
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
pois
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
.forEach(p => results.push({ ...p, _icon: icon, _label: label, _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 = `
<div class="rk-nearby-title">${UI.icon('map-pin')} Entlang der Route</div>
${Object.values(byType).map(group => {
const id = `rk-ng-${gIdx++}`;
return `
<div class="rk-nearby-group">
<button class="rk-nearby-group-header" data-target="${id}" type="button">
<span>${group.icon} ${UI.escape(group.label)} (${group.items.length})</span>
<svg class="ph-icon rk-nearby-chevron" aria-hidden="true" style="transition:transform 0.2s">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<div id="${id}" class="rk-nearby-items">
${group.items.map(p => `
<button class="rk-nearby-item rk-nearby-item--link" type="button"
data-lat="${p.lat}" data-lon="${p.lon}"
data-name="${UI.escape(p.name || group.label)}">
<div>
<span class="rk-nearby-name">${UI.escape(p.name || group.label)}</span>
${p.opening_hours ? `<span class="rk-nearby-detail">${UI.icon('clock')} ${UI.escape(p.opening_hours)}</span>` : ''}
${p.phone ? `<span class="rk-nearby-detail">${UI.icon('phone')} ${UI.escape(p.phone)}</span>` : ''}
</div>
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-text-muted);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#map-pin"></use>
</svg>
</button>
`).join('')}
</div>
</div>`;
}).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 => ` <trkpt lat="${p.lat}" lon="${p.lon}"></trkpt>`).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Ban Yaro" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>${UI.escape(route.name)}</name><trkseg>\n${pts}\n </trkseg></trk>
</gpx>`;
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${route.name.replace(/[^a-z0-9äöü]/gi,'_')}.gpx`;
a.click();
URL.revokeObjectURL(url);
UI.toast.success('GPX heruntergeladen!');
}
function _fmtDur(min) {
if (min < 60) return `${min} min`;
return `${Math.floor(min/60)}h ${min%60?(min%60)+'min':''}`.trim();
}
// ----------------------------------------------------------
// Import: GPX / KML / TCX
// ----------------------------------------------------------
async function _importFile(file) {
const text = await file.text();
const ext = file.name.split('.').pop().toLowerCase();
let parsed;
try {
if (ext === 'gpx') parsed = _parseGpx(text, file.name);
else if (ext === 'kml') parsed = _parseKml(text, file.name);
else if (ext === 'tcx') parsed = _parseTcx(text, file.name);
else { UI.toast.error('Format nicht unterstützt. Bitte GPX, KML oder TCX.'); return; }
} catch (err) {
UI.toast.error(`Datei konnte nicht gelesen werden: ${err.message}`);
return;
}
if (!parsed || parsed.track.length < 2) {
UI.toast.error('Keine GPS-Punkte in der Datei gefunden.');
return;
}
_openImportModal(parsed);
}
function _parseGpx(text, filename) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const ns = doc.documentElement.getAttribute('xmlns') || '';
const resolve = tag => {
// Namespace-aware oder fallback
const els = doc.getElementsByTagNameNS(ns, tag);
return els.length ? els : doc.getElementsByTagName(tag);
};
// Track-Punkte aus <trkpt>
const trkpts = resolve('trkpt');
const track = [];
let startTime = null, endTime = null;
for (const pt of trkpts) {
const lat = parseFloat(pt.getAttribute('lat'));
const lon = parseFloat(pt.getAttribute('lon'));
if (isNaN(lat) || isNaN(lon)) continue;
const point = { lat, lon };
// Elevation (optional)
const eleEl = pt.getElementsByTagName('ele')[0] || pt.getElementsByTagNameNS(ns,'ele')[0];
if (eleEl) point.ele = parseFloat(eleEl.textContent);
// Timestamp (optional)
const timeEl = pt.getElementsByTagName('time')[0] || pt.getElementsByTagNameNS(ns,'time')[0];
if (timeEl) {
const t = new Date(timeEl.textContent);
if (!startTime) startTime = t;
endTime = t;
}
track.push(point);
}
// Falls keine <trkpt> → Route-Punkte <rtept> versuchen
if (!track.length) {
const rtepts = resolve('rtept');
for (const pt of rtepts) {
const lat = parseFloat(pt.getAttribute('lat'));
const lon = parseFloat(pt.getAttribute('lon'));
if (!isNaN(lat) && !isNaN(lon)) track.push({ lat, lon });
}
}
// Name aus Datei
const nameEl = resolve('name')[0];
const name = nameEl?.textContent?.trim() ||
filename.replace(/\.gpx$/i, '').replace(/_/g,' ');
const dauer_min = (startTime && endTime)
? Math.round((endTime - startTime) / 60000) : null;
return { track, name, dauer_min, source: 'GPX' };
}
function _parseKml(text, filename) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const track = [];
// <coordinates> enthält "lon,lat,ele lon,lat,ele …" oder newline-getrennt
const coordEls = doc.getElementsByTagName('coordinates');
for (const el of coordEls) {
const raw = el.textContent.trim().replace(/\n/g, ' ');
for (const tuple of raw.split(/\s+/)) {
const parts = tuple.split(',');
if (parts.length >= 2) {
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lon)) {
const point = { lat, lon };
if (parts[2]) point.ele = parseFloat(parts[2]);
track.push(point);
}
}
}
}
const nameEl = doc.getElementsByTagName('name')[0];
const name = nameEl?.textContent?.trim() ||
filename.replace(/\.kml$/i, '').replace(/_/g,' ');
return { track, name, dauer_min: null, source: 'KML' };
}
function _parseTcx(text, filename) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const track = [];
let startTime = null, endTime = null;
const trackpoints = doc.getElementsByTagName('Trackpoint');
for (const tp of trackpoints) {
const latEl = tp.getElementsByTagName('LatitudeDegrees')[0];
const lonEl = tp.getElementsByTagName('LongitudeDegrees')[0];
if (!latEl || !lonEl) continue;
const lat = parseFloat(latEl.textContent);
const lon = parseFloat(lonEl.textContent);
if (isNaN(lat) || isNaN(lon)) continue;
const point = { lat, lon };
const altEl = tp.getElementsByTagName('AltitudeMeters')[0];
if (altEl) point.ele = parseFloat(altEl.textContent);
const timeEl = tp.getElementsByTagName('Time')[0];
if (timeEl) {
const t = new Date(timeEl.textContent);
if (!startTime) startTime = t;
endTime = t;
}
track.push(point);
}
const nameEl = doc.getElementsByTagName('Name')[0];
const name = nameEl?.textContent?.trim() ||
filename.replace(/\.tcx$/i, '').replace(/_/g,' ');
const dauer_min = (startTime && endTime)
? Math.round((endTime - startTime) / 60000) : null;
return { track, name, dauer_min, source: 'TCX' };
}
// Haversine (client-seitig für Import-Stats)
function _calcDistance(track) {
const R2RAD = Math.PI / 180;
let dist = 0;
for (let i = 1; i < track.length; i++) {
const a = track[i-1], b = track[i];
const dlat = (b.lat - a.lat) * R2RAD;
const dlon = (b.lon - a.lon) * R2RAD;
const sin2 = Math.sin(dlat/2)**2 +
Math.cos(a.lat*R2RAD)*Math.cos(b.lat*R2RAD)*Math.sin(dlon/2)**2;
dist += 2 * 6371000 * Math.asin(Math.sqrt(sin2));
}
return dist / 1000; // km
}
// ----------------------------------------------------------
// Import-Modal
// ----------------------------------------------------------
function _openImportModal(parsed) {
const { track, name, dauer_min, source } = parsed;
const distanz_km = _calcDistance(track);
const preview = _svgPreview(_simplifyPreview(track, 60));
const body = `
<div class="rk-import-preview">${preview}</div>
<div class="rk-import-stats">
<span>${UI.icon('map-pin')} ${track.length} Punkte</span>
<span>${UI.icon('map-trifold')} ${distanz_km.toFixed(2)} km</span>
${dauer_min ? `<span>${UI.icon('timer')} ${_fmtDur(dauer_min)}</span>` : ''}
<span class="rk-badge rk-badge--info">${source}</span>
</div>
<form id="rk-import-form" style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-4)">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-input" id="ri-name" value="${UI.escape(name)}" required maxlength="120">
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-input" id="ri-desc" rows="2" placeholder="Kurze Beschreibung der Route…"></textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-input" id="ri-diff">
<option value="leicht">🟢 Leicht</option>
<option value="mittel">🟡 Mittel</option>
<option value="anspruchsvoll">🔴 Anspruchsvoll</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-input" id="ri-terrain">
<option value=""> wählen </option>
<option value="wald">🌲 Wald</option>
<option value="asphalt">🛣️ Asphalt</option>
<option value="wiese">🌿 Wiese</option>
<option value="mix">🔀 Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-selector" id="ri-paws">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">🐾 Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut">🐾🐾 Gut</button>
<button type="button" class="rk-paw-btn active" data-val="sehr_gut">🐾🐾🐾 Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium">🐾🐾🐾🐾 Premium</button>
</div>
</div>
<div style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-leine"> Leine empfohlen
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-schatten"> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-public"> Öffentlich
${UI.help('Öffentliche Routen können von allen Nutzern in der Entdecken-Ansicht gefunden werden.')}
</label>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-ghost" id="ri-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="ri-save">${UI.icon('floppy-disk')} Route speichern</button>
`;
UI.modal.open({ title: '📥 Route importieren', body, footer });
document.getElementById('ri-cancel')?.addEventListener('click', UI.modal.close);
// Paw-Selector
let _selPaw = 'sehr_gut';
document.getElementById('ri-paws')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('#ri-paws .rk-paw-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_selPaw = btn.dataset.val;
});
document.getElementById('ri-save')?.addEventListener('click', async () => {
const nameVal = document.getElementById('ri-name')?.value.trim();
if (!nameVal) { UI.toast.error('Bitte einen Namen eingeben.'); return; }
const saveBtn = document.getElementById('ri-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
try {
// Track auf max 2000 Punkte reduzieren (API-Limit / Performance)
const reducedTrack = _simplifyPreview(track, 2000);
await API.routes.create({
name: nameVal,
beschreibung: document.getElementById('ri-desc')?.value.trim() || null,
gps_track: reducedTrack,
distanz_km: Math.round(distanz_km * 100) / 100,
dauer_min: dauer_min || null,
schwierigkeit: document.getElementById('ri-diff')?.value || 'leicht',
untergrund: document.getElementById('ri-terrain')?.value || null,
schatten: document.getElementById('ri-schatten')?.checked,
leine_empfohlen: document.getElementById('ri-leine')?.checked,
is_public: document.getElementById('ri-public')?.checked,
hunde_tauglichkeit: _selPaw,
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 `<div class="rk-friend-row" data-id="${f.id}" data-name="${UI.escape(f.name || 'Anonym')}"
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
color:#fff;display:flex;align-items:center;justify-content:center;
font-weight:600;flex-shrink:0">${UI.escape(initial)}</div>
<span>${UI.escape(f.name || 'Anonym')}</span>
</div>`;
}).join('');
const body = `<div id="rk-friend-list">${friendRows}</div>`;
const footer = `<button type="button" class="btn btn-ghost" id="rsf-cancel">Abbrechen</button>`;
UI.modal.open({
title: `${UI.icon('chat-circle-dots')} An Freund senden`,
body,
footer,
});
document.getElementById('rsf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('rk-friend-list')?.addEventListener('click', async e => {
const row = e.target.closest('.rk-friend-row');
if (!row) return;
const partnerId = parseInt(row.dataset.id, 10);
const partnerName = row.dataset.name;
try {
const conv = await API.chat.start(partnerId);
const convId = conv.id;
const text = `Ich habe eine Route für dich: ${route.name}\n${shareUrl}`;
await API.chat.send(convId, text);
UI.modal.close();
UI.toast.success(`Gesendet an ${partnerName}`);
} catch (err) {
UI.toast.error('Senden fehlgeschlagen: ' + (err.message || 'Unbekannter Fehler'));
}
});
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
<button id="rk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="rk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu dieser Route…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="rk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="rk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#rk-note-close')?.addEventListener('click', close);
ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#rk-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, onDogChange };
})();