3089 lines
141 KiB
JavaScript
3089 lines
141 KiB
JavaScript
/* ============================================================
|
||
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 class="map-list-toggle" style="flex-shrink:0;width:auto">
|
||
<button id="rk-view-list" title="Liste"
|
||
class="${_viewMode==='list' ? 'active' : ''}"
|
||
style="flex:0 0 auto;width:44px">
|
||
${UI.icon('list')}
|
||
</button>
|
||
<button id="rk-view-map" title="Karte"
|
||
class="${_viewMode==='map' ? 'active' : ''}"
|
||
style="flex:0 0 auto;width:44px">
|
||
${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 = async () => {
|
||
const mapEl = document.getElementById('rks-map');
|
||
if (!mapEl) return;
|
||
if (_suggestMap) { _suggestMap.remove(); _suggestMap = null; }
|
||
const track = result.gps_track || [];
|
||
if (track.length < 2) return;
|
||
const lls = track.map(p => [p.lat, p.lon]);
|
||
_suggestMap = await UI.map.create(mapEl, {
|
||
center: lls[0], zoom: 14,
|
||
zoomControl: false, attributionControl: false,
|
||
});
|
||
_suggestMap.scrollWheelZoom.disable();
|
||
const poly = 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);
|
||
};
|
||
_initMap();
|
||
|
||
document.getElementById('rks-nav-btn')?.addEventListener('click', () => {
|
||
_openNavOverlay({ id: 'suggest-' + Date.now(), name: result.name,
|
||
gps_track: result.gps_track, distanz_km: result.distanz_km });
|
||
});
|
||
document.getElementById('rks-save-btn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('rks-save-btn');
|
||
if (!btn) return;
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.post('/routes', { name: result.name, gps_track: result.gps_track,
|
||
distanz_km: result.distanz_km, dauer_min: result.dauer_min, schwierigkeit: result.schwierigkeit });
|
||
UI.toast.success('Route gespeichert!');
|
||
await _loadData();
|
||
_setBrowseMode('mine');
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _loadDataNearby() {
|
||
if (!_userPos) {
|
||
try { _userPos = await API.getLocation(); } catch { UI.toast.warning('Standort nicht verfügbar.'); return; }
|
||
}
|
||
try {
|
||
_data = await API.routes.listNearby(_userPos.lat, _userPos.lon, 10000);
|
||
_applyFilter();
|
||
} catch (err) {
|
||
UI.toast.error('Fehler beim Laden: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Recording Overlay
|
||
// ----------------------------------------------------------
|
||
function _haversineKm(lat1, lon1, lat2, lon2) {
|
||
const R = 6371, dLat = (lat2-lat1)*Math.PI/180, dLon = (lon2-lon1)*Math.PI/180;
|
||
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||
}
|
||
|
||
async function _openRecOvl() {
|
||
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
|
||
if (_recOvl) return;
|
||
|
||
|
||
const ovl = document.createElement('div');
|
||
ovl.id = 'rk-rec-ovl';
|
||
ovl.style.cssText = 'position:fixed;inset:0;z-index:900;display:flex;flex-direction:column;background:var(--c-bg)';
|
||
ovl.innerHTML = `
|
||
<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 {
|
||
_recMap = await UI.map.create(ovl.querySelector('#rk-rec-map-wrap'), {
|
||
center: [pos.lat, pos.lon], zoom: 15,
|
||
zoomControl: false, attributionControl: false,
|
||
});
|
||
_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'));
|
||
|
||
_initSearchMap();
|
||
|
||
} else {
|
||
document.getElementById('rk-map-section')?.remove();
|
||
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
|
||
if (grid) grid.style.display = '';
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Suchkarte
|
||
// ----------------------------------------------------------
|
||
async function _initSearchMap() {
|
||
if (!document.getElementById('rk-search-map')) return;
|
||
|
||
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
|
||
const zoom = _userPos ? 13 : 6;
|
||
|
||
_searchMap = await UI.map.create('rk-search-map', {
|
||
center, zoom,
|
||
zoomControl: true, attributionControl: false,
|
||
});
|
||
setTimeout(() => _searchMap?.invalidateSize(), 100);
|
||
setTimeout(() => _searchMap?.invalidateSize(), 600);
|
||
_renderRoutesOnMap();
|
||
|
||
// Standort-Button
|
||
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
|
||
try {
|
||
const pos = await API.getLocation();
|
||
_userPos = pos;
|
||
_searchMap.setView([pos.lat, pos.lon], 14);
|
||
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
|
||
});
|
||
|
||
// Geocoding-Suche
|
||
const locInput = document.getElementById('rk-map-loc');
|
||
let _geoDebounce;
|
||
locInput?.addEventListener('keydown', e => {
|
||
if (e.key !== 'Enter') return;
|
||
clearTimeout(_geoDebounce);
|
||
_geocodeAndFly(locInput.value.trim());
|
||
});
|
||
locInput?.addEventListener('input', () => {
|
||
clearTimeout(_geoDebounce);
|
||
const q = locInput.value.trim();
|
||
if (q.length < 3) return;
|
||
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
|
||
});
|
||
}
|
||
|
||
async function _geocodeAndFly(query) {
|
||
if (!query || !_searchMap) return;
|
||
try {
|
||
const r = await fetch(
|
||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
|
||
{ cache: 'no-store' }
|
||
);
|
||
const data = await r.json();
|
||
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
|
||
const { lat, lon, boundingbox } = data[0];
|
||
if (boundingbox) {
|
||
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
|
||
{ maxZoom: 14 });
|
||
} else {
|
||
_searchMap.setView([+lat, +lon], 13);
|
||
}
|
||
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
|
||
}
|
||
|
||
function _renderRoutesOnMap() {
|
||
if (!_searchMap || !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) : '';
|
||
// Immer die Karte zeigen — Fotos erst beim Öffnen der Route.
|
||
// Bei Routen mit Fotos einen kleinen Kamera-Marker oben rechts
|
||
// einblenden (analog zum Tagebuch).
|
||
const photoCount = (r.foto_urls || []).length;
|
||
const photoBadge = photoCount > 0
|
||
? `<div class="rk-photo-badge"
|
||
style="position:absolute;top:6px;right:6px;z-index:2;
|
||
display:flex;align-items:center;gap:3px;
|
||
padding:3px 7px;border-radius:999px;
|
||
background:rgba(0,0,0,0.55);color:#fff;
|
||
font-size:11px;font-weight:600;
|
||
backdrop-filter:blur(4px);
|
||
-webkit-backdrop-filter:blur(4px);
|
||
pointer-events:none">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#camera"></use></svg>
|
||
${photoCount}
|
||
</div>`
|
||
: '';
|
||
const previewContent = `
|
||
${photoBadge}
|
||
<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 {}
|
||
}
|
||
|
||
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 = await UI.map.create(mapEl, {
|
||
center: [mid.lat, mid.lon], zoom: 15,
|
||
zoomControl: false, attributionControl: false,
|
||
});
|
||
// Container hat im frisch eingefügten Fixed-Overlay erst jetzt seine
|
||
// finale Flex-Höhe — Leaflet muss sie neu vermessen, sonst lädt es nur
|
||
// oben Tiles und der Rest bleibt grau.
|
||
_navMap.invalidateSize();
|
||
|
||
// Route-Polylines: erledigt (grün) + ausstehend (orange)
|
||
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');
|
||
|
||
// iOS rendert das Flex-Layout teils verzögert — nochmal neu vermessen
|
||
// und Ausschnitt erneut anpassen.
|
||
setTimeout(() => {
|
||
if (!_navMap) return;
|
||
_navMap.invalidateSize();
|
||
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
|
||
}, 250);
|
||
|
||
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
|
||
const mkPin = (p, color) => 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
|
||
const mapEl = document.getElementById('rk-trim-map');
|
||
const center = fullTrack[Math.floor(fullTrack.length/2)];
|
||
const trimMap = await UI.map.create(mapEl, {
|
||
center: [center.lat, center.lon], zoom: 14,
|
||
zoomControl: false, attributionControl: false,
|
||
});
|
||
|
||
// Marker & Polylines
|
||
let greyBefore = 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>
|
||
· <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) {
|
||
if (_detailMap) { _detailMap.remove(); _detailMap = null; }
|
||
_detailMap = await _buildDetailMap(el, route.gps_track);
|
||
}
|
||
UI.toast.success('Route dauerhaft umgekehrt');
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Hunde bearbeiten
|
||
document.getElementById('rd-dogs')?.addEventListener('click', () => _openEditDogsModal(route));
|
||
|
||
// Löschen
|
||
document.getElementById('rd-del')?.addEventListener('click', async () => {
|
||
const ok = await UI.modal.confirm({
|
||
title: 'Route löschen?', message: `„${route.name}" wird dauerhaft entfernt.`,
|
||
confirmText: 'Löschen', danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await API.routes.delete(route.id);
|
||
_data = _data.filter(r => r.id !== route.id);
|
||
UI.modal.close();
|
||
_applyFilter();
|
||
UI.toast.success('Route gelöscht.');
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Route kürzen
|
||
document.getElementById('rd-trim')?.addEventListener('click', () => {
|
||
UI.modal.close();
|
||
_openTrimOverlay(route);
|
||
});
|
||
|
||
// Foto-Upload
|
||
document.getElementById('rk-photo-input')?.addEventListener('change', async e => {
|
||
const files = Array.from(e.target.files || []);
|
||
if (!files.length) return;
|
||
try {
|
||
for (const file of files) {
|
||
const res = await API.routes.addPhoto(route.id, file);
|
||
route.foto_urls = res.foto_urls;
|
||
}
|
||
UI.toast.success(files.length > 1 ? `${files.length} Fotos gespeichert!` : 'Foto gespeichert!');
|
||
UI.modal.close();
|
||
setTimeout(() => _openDetail(route.id), 200);
|
||
} catch (err) { UI.toast.error(err.message); }
|
||
});
|
||
|
||
// Notiz-Button
|
||
document.getElementById('rd-note')?.addEventListener('click', () => {
|
||
const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route');
|
||
UI.noteModal('route', route.id, label, null);
|
||
});
|
||
|
||
// Mini-Map
|
||
let _detailMap = null;
|
||
setTimeout(async () => {
|
||
const el = document.getElementById('rk-detail-map');
|
||
if (!el || !track.length) return;
|
||
_detailMap = await _buildDetailMap(el, track);
|
||
}, 80);
|
||
|
||
// Nearby POIs laden
|
||
if (track.length >= 2) {
|
||
_loadNearbyPois(track).then(pois => _renderNearby(pois));
|
||
} else {
|
||
const nb = document.getElementById('rk-nearby');
|
||
if (nb) nb.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Hunde einer Route bearbeiten
|
||
// ----------------------------------------------------------
|
||
function _openEditDogsModal(route) {
|
||
const dogs = _appState?.dogs || [];
|
||
if (!dogs.length) { UI.toast.info('Keine Hunde im Profil vorhanden.'); return; }
|
||
|
||
const currentIds = new Set(route.dog_ids || []);
|
||
|
||
const dogRows = dogs.map(d => {
|
||
const checked = currentIds.has(d.id);
|
||
const av = d.foto_url
|
||
? `<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;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _buildDetailMap(el, track) {
|
||
const lls = track.map(p => [p.lat, p.lon]);
|
||
const m = await UI.map.create(el, {
|
||
center: lls[0], zoom: 14,
|
||
zoomControl: false, attributionControl: false,
|
||
});
|
||
const poly = 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)
|
||
// ----------------------------------------------------------
|
||
|
||
return { init, refresh, onDogChange };
|
||
|
||
})();
|