banyaro/backend/static/js/pages/routes.js
rene ebe4ce20cf Sprint 10: OSM-POI-Cache, Karten-Clustering, Routen-Redesign
Karte (map.js):
- OSM Overpass API: Restaurants, Tierärzte, Parkplätze, Bänke, Wasserstellen
- Leaflet.markercluster für alle OSM-Layer
- Standort-Dot mit GPS-Genauigkeitskreis, Wake-Lock bei Aufzeichnung
- Community-Pins setzen/löschen, Meldungen, Crosshair-Placement
- Layer-Sichtbarkeit in localStorage (by_map_visible_v1)

Routen (routes.js + routen.py):
- Komoot-Stil: SVG-Track-Preview, Foto-Upload, Nearby-POIs im Detail-Modal
- Neue Felder: is_public, hunde_tauglichkeit, foto_urls
- Rate-Endpoint (POST /api/routes/{id}/rate)
- Foto-Upload (POST /api/routes/{id}/photo)
- Fix: json_extract $[-1] → $[#-1] (SQLite-kompatibler Pfad für letztes Element)

Backend (osm.py, database.py, scheduler.py):
- /api/osm/pois: OSM-Overpass-Cache mit Tile-Logik (14 Tage TTL)
- /api/osm/user-poi: Community-Marker CRUD
- /api/osm/report: Marker als ungültig melden
- Neue Tabellen: osm_pois, osm_tiles, user_map_pois, osm_reports
- Giftköder-Archiv-Job (täglich 03:00, soft-delete nach Ablauf)
- Giftköder-Archiv-Job als APScheduler-CronJob

UI: Orte-Menüpunkt entfernt (in Karte integriert), APP_VER auf 62
2026-04-15 16:30:10 +02:00

830 lines
36 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;
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();
try { _userPos = await API.getLocation(); } catch {}
_loadData();
}
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">
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
📥 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">🔴 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-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();
});
}
// ----------------------------------------------------------
// 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();
}
// ----------------------------------------------------------
// 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"><span>🗺️</span><span>GPS-Aufzeichnung</span></div>
<div class="rk-empty-feature"><span>📷</span><span>Fotos entlang der Strecke</span></div>
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
<div class="rk-empty-feature"><span>⬇️</span><span>GPX-Download für Navi</span></div>
<div class="rk-empty-feature"><span>📍</span><span>Restaurants & Parkplätze</span></div>
<div class="rk-empty-feature"><span>🔒</span><span>Privat oder öffentlich</span></div>
</div>
<button class="btn btn-primary btn-lg" id="rk-empty-rec">🔴 Erste Route aufzeichnen</button>
</div>`;
document.getElementById('rk-empty-rec')?.addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
});
}
return;
}
grid.innerHTML = _filtered.map(r => _cardHTML(r)).join('');
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">🔒 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 preview = _svgPreview(r.preview_track || []);
const firstPhoto = (r.foto_urls || [])[0];
return `
<div class="rk-card" data-id="${r.id}">
<div class="rk-card-preview">
${firstPhoto
? `<img src="${_esc(firstPhoto)}" style="width:100%;height:100%;object-fit:cover">`
: preview}
</div>
<div class="rk-card-body">
<div class="rk-card-name">${_esc(r.name)}</div>
<div class="rk-card-stats">
${dist ? `<span>🗺️ ${dist}</span>` : ''}
${dur ? `<span>⏱ ${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">🌳 Schatten</span>' : ''}
${r.leine_empfohlen ? '<span class="rk-badge">🔗 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">von ${_esc(r.user_name||'Anonym')}</span>
<button class="rk-dl-btn" data-id="${r.id}">⬇ 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>` : '');
}
// ----------------------------------------------------------
// SVG-Vorschau
// ----------------------------------------------------------
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">
📷 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">🗺️ ${route.distanz_km.toFixed(2)} km</span>` : ''}
${route.dauer_min ? `<span class="rk-badge rk-badge--info">⏱ ${_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">🌳 Schatten</span>' : ''}
${route.leine_empfohlen ? '<span class="rk-badge">🔗 Leine empfohlen</span>' : ''}
${!route.is_public ? '<span class="rk-badge rk-badge--private">🔒 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 ? ` · ⭐ ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''}
</p>
`;
const footer = `
<button type="button" class="btn btn-secondary" id="rd-gpx">⬇ GPX</button>
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
${route.is_public?'🔒 Privat':'🌍 Öffentlich'}
</button>
<button type="button" class="btn btn-ghost" id="rd-del" style="color:var(--c-danger)">🗑</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.textContent = route.is_public ? '🔒 Privat' : '🌍 Ö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
// ----------------------------------------------------------
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);
// Etwas aufweiten (ca. 300m)
const pad = 0.003;
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.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">📍 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">🕐 ${_esc(p.opening_hours)}</span>` : ''}
${p.phone ? `<a href="tel:${_esc(p.phone)}" class="rk-nearby-detail rk-nearby-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>📍 ${track.length} Punkte</span>
<span>🗺️ ${distanz_km.toFixed(2)} km</span>
${dauer_min ? `<span>⏱ ${_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" checked> Ö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">💾 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.textContent = '💾 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 };
})();