banyaro/backend/static/js/pages/routes.js
rene a7753c9cf5 Sprint 16: Chat-Fotos/Online/Read-Receipts, Gesundheit-Dokumente löschen, Bugfixes
- Chat: Foto-Versand (POST /api/chat/conversations/{id}/upload, media_url/media_type)
- Chat: Online-Indikator (last_seen Heartbeat, grüner Dot, 3min-Fenster)
- Chat: Read Receipts (read_at, Einzel-/Doppelhaken-Icons)
- Gesundheit: Dokument löschen (DELETE .../dokument, Datei + DB-Eintrag)
- Bug: events.user_id NOT NULL → nullable (Table-Recreation-Migration)
- Bug: scheduler INSERT user_id 0 → NULL
- Bug: Wikidata Rate-Limit: sleep 0.3s→1.0s, retries 2→4, exponentielles Backoff
- SW: by-v146, APP_VER 119
2026-04-17 22:38:33 +02:00

1101 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
BAN YARO — Gassi-Routen (Komoot-Stil)
Sammlung, Suche, Bewertung, GPX-Download, Fotos, Nearby POIs
============================================================ */
window.Page_routes = (() => {
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;
// Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
let _searchLines = new Map(); // routeId → { line, route }
// Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map
let _leafletReady = false;
const DIFFICULTY_LABEL = { leicht: '🟢 Leicht', mittel: '🟡 Mittel', anspruchsvoll: '🔴 Anspruchsvoll' };
const TERRAIN_LABEL = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' };
const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' };
// POI-Typen die entlang einer Route gezeigt werden
const NEARBY_TYPES = [
{ type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café' },
{ type: 'parkplatz', icon: '🅿️', label: 'Parkplatz' },
{ type: 'drinking_water', icon: '💧', label: 'Wasserstelle' },
{ type: 'bank', icon: '🪑', label: 'Bank' },
];
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadLeaflet(); // fire & forget — bereit wenn Cards gerendert werden
try { _userPos = await API.getLocation(); } catch {}
_loadData();
}
async function _loadLeaflet() {
if (_leafletReady || window.L) { _leafletReady = true; return; }
if (!document.querySelector('link[href="/css/leaflet.css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = '/css/leaflet.css';
document.head.appendChild(l);
}
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
_leafletReady = true;
}
function refresh() { _loadData(); }
function onDogChange() {}
// ----------------------------------------------------------
// Render
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="rk-layout">
<div class="rk-header">
<div class="rk-search-row">
<input class="rk-search" id="rk-search" type="search"
placeholder="🔍 Route suchen…" autocomplete="off">
<div class="rk-view-toggle">
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
</div>
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
</label>
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">${UI.icon('path')} Aufzeichnen</button>
</div>
<div class="rk-filters" id="rk-filters">
<div class="rk-filter-group">
<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 class="rk-filter-group">
<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 class="rk-filter-group">
<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 class="rk-filter-group" id="rk-mine-group" style="display:none">
<button class="rk-chip" data-filter="mine" data-val="mine">🔒 Nur meine</button>
</div>
</div>
</div>
<div class="rk-grid" id="rk-grid">
<div class="rk-loading">Lädt Routen…</div>
</div>
</div>
`;
document.getElementById('rk-search').addEventListener('input', e => {
_search = e.target.value.toLowerCase(); _applyFilter();
});
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-rec-btn').addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
});
document.getElementById('rk-import-input').addEventListener('change', e => {
const file = e.target.files?.[0];
if (file) _importFile(file);
e.target.value = ''; // reset so same file can be re-selected
});
document.getElementById('rk-filters').addEventListener('click', e => {
const chip = e.target.closest('.rk-chip');
if (!chip) return;
const { filter, val } = chip.dataset;
chip.closest('.rk-filter-group').querySelectorAll('.rk-chip')
.forEach(c => c.classList.remove('active'));
chip.classList.add('active');
if (filter === 'difficulty') _difficulty = val;
if (filter === 'terrain') _terrain = val;
if (filter === 'sort') _sortBy = val;
if (filter === 'mine') _onlyMine = chip.classList.contains('active') && val === 'mine';
_applyFilter();
});
}
// ----------------------------------------------------------
// View-Toggle
// ----------------------------------------------------------
function _switchView(mode) {
_viewMode = mode;
document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list');
document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map');
const layout = document.querySelector('.rk-layout');
const grid = document.getElementById('rk-grid');
if (mode === 'map') {
if (grid) grid.style.display = 'none';
// Alten Map-Container entfernen falls vorhanden
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
// Als fixed Overlay direkt in <body> — kein Konflikt mit .rk-layout overflow:hidden
const mapH = window.innerHeight - 160;
const sec = document.createElement('div');
sec.id = 'rk-map-section';
sec.className = 'rk-map-section';
sec.innerHTML = `
<div class="rk-map-bar">
<button class="btn btn-secondary btn-sm" id="rk-map-back" title="Zurück zur Liste">${UI.icon('arrow-left')}</button>
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
placeholder="🔍 Ort suchen…" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">${UI.icon('map-pin')}</button>
</div>
<div id="rk-search-map" style="flex:1;width:100%"></div>
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
`;
document.body.appendChild(sec);
document.getElementById('rk-map-back')?.addEventListener('click', () => _switchView('list'));
// Wie _initMiniMaps: pollen bis window.L bereit ist
_pollAndInitSearchMap();
} else {
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
if (grid) grid.style.display = '';
}
}
// ----------------------------------------------------------
// Suchkarte
// ----------------------------------------------------------
function _pollAndInitSearchMap() {
if (window.L) { _initSearchMap(); return; }
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 40) {
clearInterval(poll);
if (window.L) _initSearchMap();
}
}, 100);
}
function _initSearchMap() {
if (!document.getElementById('rk-search-map')) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
const zoom = _userPos ? 13 : 6;
_searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap);
setTimeout(() => _searchMap?.invalidateSize(), 100);
setTimeout(() => _searchMap?.invalidateSize(), 600);
_renderRoutesOnMap();
// Standort-Button
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
_userPos = pos;
_searchMap.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
});
// Geocoding-Suche
const locInput = document.getElementById('rk-map-loc');
let _geoDebounce;
locInput?.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
clearTimeout(_geoDebounce);
_geocodeAndFly(locInput.value.trim());
});
locInput?.addEventListener('input', () => {
clearTimeout(_geoDebounce);
const q = locInput.value.trim();
if (q.length < 3) return;
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
});
}
async function _geocodeAndFly(query) {
if (!query || !_searchMap) return;
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const data = await r.json();
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
const { lat, lon, boundingbox } = data[0];
if (boundingbox) {
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
{ maxZoom: 14 });
} else {
_searchMap.setView([+lat, +lon], 13);
}
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
}
function _renderRoutesOnMap() {
if (!_searchMap || !window.L) return;
// Alte Linien entfernen
_searchLines.forEach(({ line }) => line.remove());
_searchLines.clear();
const hint = document.getElementById('rk-map-hint');
_data.forEach(route => {
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
if (pts.length < 2) return;
const line = L.polyline(pts, {
color: '#C4843A', weight: 4, opacity: 0.75,
}).addTo(_searchMap);
// Start-/End-Marker
const startM = L.circleMarker(pts[0], {
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
const endM = L.circleMarker(pts[pts.length - 1], {
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
// Tooltip mit Namen und Distanz
const tip = `<b>${_esc(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
// Hover-Highlight
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
const onClick = () => {
if (hint) hint.textContent = `Lädt „${route.name}"…`;
_openDetail(route.id).finally(() => {
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
});
};
line.on('click', onClick);
startM.on('click', onClick);
_searchLines.set(route.id, { line, startM, endM });
});
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
if (_data.length && _searchLines.size && !_userPos) {
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
if (allPts.length) {
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
catch {}
}
}
}
// ----------------------------------------------------------
// Daten
// ----------------------------------------------------------
async function _loadData() {
try {
_data = await API.routes.list();
// "Meine Routen"-Filter nur zeigen wenn eingeloggt
if (_appState.user) {
document.getElementById('rk-mine-group')?.style.setProperty('display', '');
}
_applyFilter();
} catch (err) {
document.getElementById('rk-grid').innerHTML =
`<p style="color:var(--c-danger);padding:var(--space-6)">Fehler: ${_esc(err.message)}</p>`;
}
}
// ----------------------------------------------------------
// Filter
// ----------------------------------------------------------
const DOG_ORDER = { premium: 4, sehr_gut: 3, gut: 2, eingeschränkt: 1 };
function _applyFilter() {
let list = [..._data];
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)
list = list.filter(r => r.user_id === _appState.user.id);
if (_sortBy === 'distance') list.sort((a,b) => (b.distanz_km||0) - (a.distanz_km||0));
else if (_sortBy === 'rating') list.sort((a,b) => (b.bewertung||0) - (a.bewertung||0));
else if (_sortBy === 'dog') list.sort((a,b) =>
(DOG_ORDER[b.hunde_tauglichkeit]||0) - (DOG_ORDER[a.hunde_tauglichkeit]||0));
_filtered = list;
_renderGrid();
if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap();
}
// ----------------------------------------------------------
// Grid
// ----------------------------------------------------------
function _renderGrid() {
const grid = document.getElementById('rk-grid');
if (!grid) return;
if (!_filtered.length) {
if (_data.length) {
// Filter aktiv aber kein Ergebnis
grid.innerHTML = `<div class="rk-empty">
<div class="rk-empty-icon">🔍</div>
<p>Keine Routen passen zu deinen Filtern.</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');
_applyFilter();
});
} else {
// Noch gar keine Routen
grid.innerHTML = `<div class="rk-empty rk-empty--onboarding">
<div class="rk-empty-icon">🥾</div>
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3>
<p class="rk-empty-text">Zeichne deine Lieblingsstrecken auf — mit Streckendaten, Fotos und Hundetauglichkeit.</p>
<div class="rk-empty-features">
<div class="rk-empty-feature">${UI.icon('map-trifold')}<span>GPS-Aufzeichnung</span></div>
<div class="rk-empty-feature">${UI.icon('camera')}<span>Fotos entlang der Strecke</span></div>
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
<div class="rk-empty-feature">${UI.icon('download-simple')}<span>GPX-Download für Navi</span></div>
<div class="rk-empty-feature">${UI.icon('map-pin')}<span>Restaurants & Parkplätze</span></div>
<div class="rk-empty-feature">${UI.icon('lock')}<span>Privat oder öffentlich</span></div>
</div>
<button class="btn btn-primary btn-lg" id="rk-empty-rec">${UI.icon('path')} Erste Route aufzeichnen</button>
</div>`;
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 privBadge = !r.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : '';
const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || '';
const terrain = TERRAIN_LABEL[r.untergrund] || '';
const paws = HUNDE_LABEL[r.hunde_tauglichkeit] || '';
const dist = r.distanz_km ? `${r.distanz_km.toFixed(1)} km` : '';
const dur = r.dauer_min ? _fmtDur(r.dauer_min) : '';
const firstPhoto = (r.foto_urls || [])[0];
const previewContent = firstPhoto
? `<img src="${_esc(firstPhoto)}" style="width:100%;height:100%;object-fit:cover">`
: `<div class="rk-mini-map" data-id="${r.id}"
data-track='${JSON.stringify(r.preview_track||[])}'
style="width:100%;height:100%"></div>`;
return `
<div class="rk-card" data-id="${r.id}">
<div class="rk-card-preview">${previewContent}</div>
<div class="rk-card-body">
<div class="rk-card-name">${_esc(r.name)}</div>
<div class="rk-card-stats">
${dist ? `<span>${UI.icon('map-trifold')} ${dist}</span>` : ''}
${dur ? `<span>${UI.icon('timer')} ${dur}</span>` : ''}
${terrain ? `<span>${terrain}</span>` : ''}
${paws ? `<span title="Hundetauglichkeit">${paws}</span>` : ''}
</div>
<div class="rk-card-tags">
${privBadge}
${diffLabel ? `<span class="rk-badge rk-badge--${r.schwierigkeit}">${diffLabel}</span>` : ''}
${r.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
${r.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine</span>` : ''}
</div>
<div class="rk-card-footer">
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
<div class="rk-card-actions">
<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
</div>
</div>
</div>
</div>`;
}
function _starsHTML(id, avg, count) {
const stars = [1,2,3,4,5].map(n =>
`<span class="rk-star${n<=Math.round(avg)?' filled':''}" data-id="${id}" data-val="${n}">★</span>`
).join('');
return stars + (count > 0 ? `<span class="rk-star-count">${avg.toFixed(1)} (${count})</span>` : '');
}
// ----------------------------------------------------------
// Mini-Karten (OSM-Tiles via Leaflet, lazy per IntersectionObserver)
// ----------------------------------------------------------
function _initMiniMaps() {
const init = () => {
const obs = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
obs.unobserve(entry.target);
_buildMiniMap(entry.target);
});
}, { rootMargin: '150px' });
document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el));
};
if (window.L) { init(); return; }
// Leaflet noch am Laden — kurz pollen
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 30) { clearInterval(poll); if (window.L) init(); }
}, 100);
}
function _buildMiniMap(el) {
const track = JSON.parse(el.dataset.track || '[]');
const routeId = parseInt(el.dataset.id);
if (track.length < 2) {
el.innerHTML = '<div class="rk-preview-empty">🗺️</div>';
return;
}
const lls = track.map(p => [p.lat, p.lon]);
const m = L.map(el, {
zoomControl: false, attributionControl: false,
dragging: false, touchZoom: false, scrollWheelZoom: false,
doubleClickZoom: false, keyboard: false, boxZoom: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 17 }).addTo(m);
const poly = L.polyline(lls, { color: '#C4843A', weight: 3, opacity: 0.9 }).addTo(m);
L.circleMarker(lls[0], { radius: 5, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(m);
L.circleMarker(lls.at(-1), { radius: 5, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(m);
m.fitBounds(poly.getBounds(), { padding: [8, 8] });
_miniMaps.set(routeId, m);
}
// ----------------------------------------------------------
// SVG-Vorschau (Fallback, wird nicht mehr direkt genutzt)
// ----------------------------------------------------------
function _svgPreview(track) {
if (!track || track.length < 2)
return `<div class="rk-preview-empty">🗺️</div>`;
const lats = track.map(p=>p.lat), lons = track.map(p=>p.lon);
const minLat=Math.min(...lats), maxLat=Math.max(...lats);
const minLon=Math.min(...lons), maxLon=Math.max(...lons);
const latR=maxLat-minLat||0.0001, lonR=maxLon-minLon||0.0001;
const W=200,H=120,PAD=10;
const pts = track.map(p=>{
const x=(PAD+(p.lon-minLon)/lonR*(W-2*PAD)).toFixed(1);
const y=(H-PAD-(p.lat-minLat)/latR*(H-2*PAD)).toFixed(1);
return `${x},${y}`;
}).join(' ');
const [sx,sy]=pts.split(' ')[0].split(',');
const [ex,ey]=pts.split(' ').at(-1).split(',');
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;display:block">
<rect width="${W}" height="${H}" fill="#e8f0e8"/>
<polyline points="${pts}" fill="none" stroke="#C4843A" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
<circle cx="${sx}" cy="${sy}" r="5" fill="#22C55E" stroke="#fff" stroke-width="1.5"/>
<circle cx="${ex}" cy="${ey}" r="5" fill="#EF4444" stroke="#fff" stroke-width="1.5"/>
</svg>`;
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _openDetail(routeId) {
let route;
try { route = await API.routes.get(routeId); }
catch (err) { UI.toast.error(err.message); return; }
const isOwn = _appState.user?.id === route.user_id;
const track = route.gps_track || [];
const photos = route.foto_urls || [];
const paws = HUNDE_LABEL[route.hunde_tauglichkeit] || '';
const photoGallery = photos.length ? `
<div class="rk-photo-gallery">
${photos.map(u => `<img src="${_esc(u)}" class="rk-photo-thumb" onclick="window.open('${_esc(u)}','_blank')">`).join('')}
${isOwn ? `<label class="rk-photo-add" title="Foto hinzufügen">
<span>+</span>
<input type="file" id="rk-photo-input" accept="image/*" style="display:none">
</label>` : ''}
</div>` :
isOwn ? `<label class="rk-photo-add-empty">
${UI.icon('camera')} Foto hinzufügen
<input type="file" id="rk-photo-input" accept="image/*" style="display:none">
</label>` : '';
const body = `
<div id="rk-detail-map" style="height:200px;border-radius:var(--radius-md);
margin-bottom:var(--space-3);background:var(--c-surface-2)"></div>
${photoGallery}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin:var(--space-3) 0">
${route.distanz_km ? `<span class="rk-badge rk-badge--info">${UI.icon('map-trifold')} ${route.distanz_km.toFixed(2)} km</span>` : ''}
${route.dauer_min ? `<span class="rk-badge rk-badge--info">${UI.icon('timer')} ${_fmtDur(route.dauer_min)}</span>` : ''}
${route.schwierigkeit ? `<span class="rk-badge rk-badge--${route.schwierigkeit}">${DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit}</span>` : ''}
${route.untergrund ? `<span class="rk-badge">${TERRAIN_LABEL[route.untergrund]||route.untergrund}</span>` : ''}
${paws ? `<span class="rk-badge rk-badge--dog" title="Hundetauglichkeit">${paws}</span>` : ''}
${route.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
${route.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine empfohlen</span>` : ''}
${!route.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : ''}
</div>
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${_esc(route.beschreibung)}</p>` : ''}
<div id="rk-nearby" class="rk-nearby-section">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt Orte entlang der Route…</div>
</div>
<p style="color:var(--c-text-muted);font-size:0.75rem;margin-top:var(--space-2)">
${track.length} GPS-Punkte · von ${_esc(route.user_name||'Anonym')}
${route.bewertung ? ` · ${UI.icon('star')} ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''}
</p>
`;
const footer = `
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
</button>
<button type="button" class="btn btn-ghost" id="rd-del" style="color:var(--c-danger)">${UI.icon('trash')}</button>` : ''}
<button type="button" class="btn btn-primary flex-1" id="rd-close">Schließen</button>
`;
UI.modal.open({ title: `🥾 ${_esc(route.name)}`, body, footer });
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-gpx')?.addEventListener('click', () => _downloadGpxDirect(route));
// Sichtbarkeit toggle
document.getElementById('rd-vis')?.addEventListener('click', async () => {
try {
await API.routes.update(route.id, { is_public: !route.is_public });
route.is_public = !route.is_public;
const btn = document.getElementById('rd-vis');
if (btn) btn.innerHTML = route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich';
const r = _data.find(x => x.id === route.id);
if (r) r.is_public = route.is_public;
_applyFilter();
UI.toast.success(route.is_public ? 'Route ist jetzt öffentlich.' : 'Route ist jetzt privat.');
} catch (err) { UI.toast.error(err.message); }
});
// Löschen
document.getElementById('rd-del')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Route löschen?', message: `${route.name}" wird dauerhaft entfernt.`,
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.routes.delete(route.id);
_data = _data.filter(r => r.id !== route.id);
UI.modal.close();
_applyFilter();
UI.toast.success('Route gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto-Upload
document.getElementById('rk-photo-input')?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await API.routes.addPhoto(route.id, file);
route.foto_urls = res.foto_urls;
UI.toast.success('Foto gespeichert!');
// Reload detail
UI.modal.close();
setTimeout(() => _openDetail(route.id), 200);
} catch (err) { UI.toast.error(err.message); }
});
// Mini-Map
setTimeout(() => {
const el = document.getElementById('rk-detail-map');
if (!el || !track.length) return;
if (window.L) _buildDetailMap(el, track);
}, 80);
// Nearby POIs laden
if (track.length >= 2) {
_loadNearbyPois(track).then(pois => _renderNearby(pois));
} else {
const nb = document.getElementById('rk-nearby');
if (nb) nb.innerHTML = '';
}
}
function _buildDetailMap(el, track) {
const m = L.map(el, { zoomControl: false, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(m);
const lls = track.map(p => [p.lat, p.lon]);
const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m);
L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m);
m.fitBounds(poly.getBounds(), { padding:[10,10] });
}
// ----------------------------------------------------------
// Nearby POIs
// ----------------------------------------------------------
// Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt
function _isNearTrack(poi, track, maxMeters) {
const R = 6371000;
const plat = poi.lat * Math.PI / 180;
const plon = poi.lon * Math.PI / 180;
for (const pt of track) {
const dlat = plat - pt.lat * Math.PI / 180;
const dlon = plon - pt.lon * Math.PI / 180;
const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon;
if (R * Math.sqrt(a) <= maxMeters) return true;
}
return false;
}
async function _loadNearbyPois(track) {
const lats = track.map(p => p.lat), lons = track.map(p => p.lon);
const south = Math.min(...lats), north = Math.max(...lats);
const west = Math.min(...lons), east = Math.max(...lons);
// Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach
const pad = 0.0015;
const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad };
const results = [];
await Promise.all(NEARBY_TYPES.map(async ({ type, icon, label }) => {
try {
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
pois
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
} catch {}
}));
return results;
}
function _renderNearby(pois) {
const el = document.getElementById('rk-nearby');
if (!el) return;
if (!pois.length) { el.innerHTML = ''; return; }
// Gruppieren nach Typ
const byType = {};
pois.forEach(p => {
const key = p._label;
if (!byType[key]) byType[key] = { icon: p._icon, label: key, items: [] };
byType[key].items.push(p);
});
el.innerHTML = `
<div class="rk-nearby-title">${UI.icon('map-pin')} Entlang der Route</div>
${Object.values(byType).map(group => `
<div class="rk-nearby-group">
<div class="rk-nearby-group-label">${group.icon} ${_esc(group.label)} (${group.items.length})</div>
${group.items.slice(0, 5).map(p => `
<div class="rk-nearby-item">
<span class="rk-nearby-name">${_esc(p.name || group.label)}</span>
${p.opening_hours ? `<span class="rk-nearby-detail">${UI.icon('clock')} ${_esc(p.opening_hours)}</span>` : ''}
${p.phone ? `<a href="tel:${_esc(p.phone)}" class="rk-nearby-detail rk-nearby-phone">${UI.icon('phone')} ${_esc(p.phone)}</a>` : ''}
</div>
`).join('')}
${group.items.length > 5 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 0">+${group.items.length-5} weitere</div>` : ''}
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
// Bewerten
// ----------------------------------------------------------
async function _rateRoute(id, wertung) {
try {
const res = await API.routes.rate(id, wertung);
const r = _data.find(x => x.id === id);
if (r) { r.bewertung = res.bewertung; r.anz_bewertungen = res.anz_bewertungen; }
_applyFilter();
UI.toast.success(`Bewertet: ${wertung}`);
} catch (err) { UI.toast.error(err.message || 'Fehler beim Bewerten.'); }
}
// ----------------------------------------------------------
// GPX
// ----------------------------------------------------------
async function _downloadGpx(id) {
try {
const route = await API.routes.get(id);
_downloadGpxDirect(route);
} catch (err) { UI.toast.error(err.message); }
}
function _downloadGpxDirect(route) {
const track = route.gps_track || [];
if (!track.length) { UI.toast.warning('Keine GPS-Daten.'); return; }
const pts = track.map(p => ` <trkpt lat="${p.lat}" lon="${p.lon}"></trkpt>`).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Ban Yaro" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>${_esc(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="${_esc(name)}" required maxlength="120">
</div>
<div class="form-group">
<label class="form-label">Beschreibung</label>
<textarea class="form-input" id="ri-desc" rows="2" placeholder="Kurze Beschreibung der Route…"></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-input" id="ri-diff">
<option value="leicht">🟢 Leicht</option>
<option value="mittel">🟡 Mittel</option>
<option value="anspruchsvoll">🔴 Anspruchsvoll</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-input" id="ri-terrain">
<option value=""> wählen </option>
<option value="wald">🌲 Wald</option>
<option value="asphalt">🛣️ Asphalt</option>
<option value="wiese">🌿 Wiese</option>
<option value="mix">🔀 Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-selector" id="ri-paws">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">🐾 Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut">🐾🐾 Gut</button>
<button type="button" class="rk-paw-btn active" data-val="sehr_gut">🐾🐾🐾 Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium">🐾🐾🐾🐾 Premium</button>
</div>
</div>
<div style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-leine"> Leine empfohlen
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-schatten"> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" id="ri-public"> Öffentlich
</label>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-ghost" id="ri-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="ri-save">${UI.icon('floppy-disk')} Route speichern</button>
`;
UI.modal.open({ title: '📥 Route importieren', body, footer });
document.getElementById('ri-cancel')?.addEventListener('click', UI.modal.close);
// Paw-Selector
let _selPaw = 'sehr_gut';
document.getElementById('ri-paws')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('#ri-paws .rk-paw-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_selPaw = btn.dataset.val;
});
document.getElementById('ri-save')?.addEventListener('click', async () => {
const nameVal = document.getElementById('ri-name')?.value.trim();
if (!nameVal) { UI.toast.error('Bitte einen Namen eingeben.'); return; }
const saveBtn = document.getElementById('ri-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
try {
// Track auf max 2000 Punkte reduzieren (API-Limit / Performance)
const reducedTrack = _simplifyPreview(track, 2000);
await API.routes.create({
name: nameVal,
beschreibung: document.getElementById('ri-desc')?.value.trim() || null,
gps_track: reducedTrack,
distanz_km: Math.round(distanz_km * 100) / 100,
dauer_min: dauer_min || null,
schwierigkeit: document.getElementById('ri-diff')?.value || 'leicht',
untergrund: document.getElementById('ri-terrain')?.value || null,
schatten: document.getElementById('ri-schatten')?.checked,
leine_empfohlen: document.getElementById('ri-leine')?.checked,
is_public: document.getElementById('ri-public')?.checked,
hunde_tauglichkeit: _selPaw,
});
UI.modal.close();
UI.toast.success('Route importiert! 🥾');
_loadData();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Speichern.');
saveBtn.disabled = false;
saveBtn.innerHTML = UI.icon('floppy-disk') + ' Route speichern';
}
});
}
function _simplifyPreview(track, maxPts) {
if (track.length <= maxPts) return track;
const step = track.length / maxPts;
return Array.from({ length: maxPts }, (_, i) => track[Math.round(i * step)]);
}
return { init, refresh, onDogChange };
})();