`;
}
- function _formatDuration(min) {
- if (min < 60) return `${min} min`;
- return `${Math.floor(min / 60)}h ${min % 60 ? (min % 60) + 'min' : ''}`.trim();
+ function _starsHTML(id, avg, count) {
+ const stars = [1,2,3,4,5].map(n =>
+ `
` : '');
}
// ----------------------------------------------------------
- // Detail-Modal mit vollständiger Polyline
+ // SVG-Vorschau
+ // ----------------------------------------------------------
+ function _svgPreview(track) {
+ if (!track || track.length < 2)
+ return `
`;
+ const lats = track.map(p=>p.lat), lons = track.map(p=>p.lon);
+ const minLat=Math.min(...lats), maxLat=Math.max(...lats);
+ const minLon=Math.min(...lons), maxLon=Math.max(...lons);
+ const latR=maxLat-minLat||0.0001, lonR=maxLon-minLon||0.0001;
+ const W=200,H=120,PAD=10;
+ const pts = track.map(p=>{
+ const x=(PAD+(p.lon-minLon)/lonR*(W-2*PAD)).toFixed(1);
+ const y=(H-PAD-(p.lat-minLat)/latR*(H-2*PAD)).toFixed(1);
+ return `${x},${y}`;
+ }).join(' ');
+ const [sx,sy]=pts.split(' ')[0].split(',');
+ const [ex,ey]=pts.split(' ').at(-1).split(',');
+ return `
`;
+ }
+
+ // ----------------------------------------------------------
+ // Detail-Modal
// ----------------------------------------------------------
async function _openDetail(routeId) {
let route;
- try {
- route = await API.routes.get(routeId);
- } catch (err) {
- UI.toast.error(err.message);
- return;
- }
+ try { route = await API.routes.get(routeId); }
+ catch (err) { UI.toast.error(err.message); return; }
- const isOwn = _appState.user?.id === route.user_id;
- const ug = route.untergrund ? (UNTERGRUND[route.untergrund] || route.untergrund) : null;
- const track = route.gps_track || [];
+ 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 ? `
+
` : '';
- // Mini-Map als div mit ID, wird nach open() befüllt
const body = `
-
- ${route.distanz_km ? `
🗺️ ${route.distanz_km.toFixed(2)} km ` : ''}
- ${route.dauer_min ? `
⏱ ${_formatDuration(route.dauer_min)} ` : ''}
- ${route.schwierigkeit ? `
${route.schwierigkeit} ` : ''}
- ${ug ? `
${ug} ` : ''}
- ${route.schatten ? '
🌳 Schatten ' : ''}
- ${route.leine_empfohlen ? '
🔗 Leine empfohlen ' : ''}
+
+ ${photoGallery}
+
+ ${route.distanz_km ? `🗺️ ${route.distanz_km.toFixed(2)} km ` : ''}
+ ${route.dauer_min ? `⏱ ${_fmtDur(route.dauer_min)} ` : ''}
+ ${route.schwierigkeit ? `${DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit} ` : ''}
+ ${route.untergrund ? `${TERRAIN_LABEL[route.untergrund]||route.untergrund} ` : ''}
+ ${paws ? `${paws} ` : ''}
+ ${route.schatten ? '🌳 Schatten ' : ''}
+ ${route.leine_empfohlen ? '🔗 Leine empfohlen ' : ''}
+ ${!route.is_public ? '🔒 Privat ' : ''}
- ${route.beschreibung ? `
${_esc(route.beschreibung)}
` : ''}
-
- ${track.length} GPS-Punkte · von ${_esc(route.user_name || 'Unbekannt')}
+ ${route.beschreibung ? `
${_esc(route.beschreibung)}
` : ''}
+
+
Lädt Orte entlang der Route…
+
+
+ ${track.length} GPS-Punkte · von ${_esc(route.user_name||'Anonym')}
+ ${route.bewertung ? ` · ⭐ ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''}
`;
- const footer = isOwn ? `
-
Schließen
-
Löschen
- ` : `
+ const footer = `
+
⬇ GPX
+ ${isOwn ? `
+ ${route.is_public?'🔒 Privat':'🌍 Öffentlich'}
+
+
🗑 ` : ''}
Schließen
`;
- UI.modal.open({ title: `🥾 ${route.name}`, body, footer });
+ UI.modal.open({ title: `🥾 ${_esc(route.name)}`, body, footer });
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
- document.getElementById('rd-delete')?.addEventListener('click', async () => {
+ 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,
@@ -339,245 +394,437 @@ window.Page_routes = (() => {
await API.routes.delete(route.id);
_data = _data.filter(r => r.id !== route.id);
UI.modal.close();
- _renderList();
- _renderPolylines();
+ _applyFilter();
UI.toast.success('Route gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
- // Mini-Map mit Polyline nach DOM-Einfüge
+ // 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('route-detail-map');
- if (!el || !window.L || !track.length) return;
- const detailMap = L.map('route-detail-map', { zoomControl: false, attributionControl: false });
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(detailMap);
- const latlngs = track.map(p => [p.lat, p.lon]);
- const polyline = L.polyline(latlngs, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(detailMap);
- // Start/End-Marker
- L.circleMarker(latlngs[0], { radius: 7, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1 }).addTo(detailMap);
- L.circleMarker(latlngs[latlngs.length-1], { radius: 7, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1 }).addTo(detailMap);
- detailMap.fitBounds(polyline.getBounds(), { padding: [10, 10] });
- }, 100);
+ 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] });
}
// ----------------------------------------------------------
- // GPS-AUFZEICHNUNG
+ // Nearby POIs
// ----------------------------------------------------------
- function _startRecording() {
- if (!_appState.user) {
- UI.toast.warning('Bitte zuerst anmelden.');
- App.navigate('settings');
- return;
- }
- if (!navigator.geolocation) {
- UI.toast.error('GPS wird von diesem Browser nicht unterstützt.');
- return;
- }
+ 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 };
- _recording = true;
- _track = [];
- _distanceKm = 0;
- _startTime = Date.now();
-
- // UI umschalten
- document.getElementById('rec-idle').style.display = 'none';
- document.getElementById('rec-active').style.display = '';
- document.getElementById('rec-stats').style.display = '';
-
- // Stopuhr
- _timerInt = setInterval(_updateRecTimer, 1000);
-
- // GPS-Tracking
- _watchId = navigator.geolocation.watchPosition(
- pos => _onGpsPoint(pos.coords.latitude, pos.coords.longitude),
- err => UI.toast.warning('GPS-Fehler: ' + err.message),
- { enableHighAccuracy: true, maximumAge: 0, timeout: 15000 }
- );
-
- // Buttons binden
- document.getElementById('rec-stop-btn').onclick = _stopRecording;
- document.getElementById('rec-pause-btn').onclick = _togglePause;
-
- UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
+ 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;
}
- let _paused = false;
-
- function _togglePause() {
- _paused = !_paused;
- const btn = document.getElementById('rec-pause-btn');
- if (btn) btn.textContent = _paused ? '▶ Weiter' : '⏸ Pause';
- if (_paused && _timerInt) clearInterval(_timerInt);
- if (!_paused) _timerInt = setInterval(_updateRecTimer, 1000);
- }
-
- function _onGpsPoint(lat, lon) {
- if (_paused) return;
- const pt = { lat, lon };
- if (_track.length > 0) {
- const prev = _track[_track.length - 1];
- _distanceKm += _haversine(prev.lat, prev.lon, lat, lon) / 1000;
- }
- _track.push(pt);
- _updateRecStats();
- _updateRecMap(lat, lon);
- }
-
- function _updateRecMap(lat, lon) {
- if (!_recMap || !window.L) return;
- const latlng = [lat, lon];
- if (!_recPolyline) {
- _recPolyline = L.polyline([latlng], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
- } else {
- _recPolyline.addLatLng(latlng);
- }
- if (!_recMarker) {
- _recMarker = L.circleMarker(latlng, {
- radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3,
- }).addTo(_recMap);
- } else {
- _recMarker.setLatLng(latlng);
- }
- _recMap.setView(latlng);
- }
-
- function _updateRecStats() {
- const dist = document.getElementById('rec-dist');
- const pts = document.getElementById('rec-pts');
- if (dist) dist.textContent = _distanceKm.toFixed(2);
- if (pts) pts.textContent = _track.length;
- }
-
- function _updateRecTimer() {
- const el = document.getElementById('rec-time');
+ function _renderNearby(pois) {
+ const el = document.getElementById('rk-nearby');
if (!el) return;
- const secs = Math.floor((Date.now() - _startTime) / 1000);
- const mm = String(Math.floor(secs / 60)).padStart(2, '0');
- const ss = String(secs % 60).padStart(2, '0');
- el.textContent = `${mm}:${ss}`;
+ 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 = `
+
📍 Entlang der Route
+ ${Object.values(byType).map(group => `
+
+
${group.icon} ${_esc(group.label)} (${group.items.length})
+ ${group.items.slice(0, 5).map(p => `
+
+
${_esc(p.name || group.label)}
+ ${p.opening_hours ? `
🕐 ${_esc(p.opening_hours)} ` : ''}
+ ${p.phone ? `
📞 ${_esc(p.phone)} ` : ''}
+
+ `).join('')}
+ ${group.items.length > 5 ? `
+${group.items.length-5} weitere
` : ''}
+
+ `).join('')}
+ `;
}
- function _stopRecording() {
- if (_watchId !== null) { navigator.geolocation.clearWatch(_watchId); _watchId = null; }
- if (_timerInt) { clearInterval(_timerInt); _timerInt = null; }
- _recording = false;
- _paused = false;
+ // ----------------------------------------------------------
+ // 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.'); }
+ }
- if (_track.length < 2) {
- UI.toast.warning('Zu wenige GPS-Punkte — bitte etwas länger gehen.');
- _resetRecUI();
+ // ----------------------------------------------------------
+ // GPX
+ // ----------------------------------------------------------
+ async function _downloadGpx(id) {
+ try {
+ const route = await API.routes.get(id);
+ _downloadGpxDirect(route);
+ } catch (err) { UI.toast.error(err.message); }
+ }
+
+ function _downloadGpxDirect(route) {
+ const track = route.gps_track || [];
+ if (!track.length) { UI.toast.warning('Keine GPS-Daten.'); return; }
+ const pts = track.map(p => `
`).join('\n');
+ const gpx = `
+
+ ${_esc(route.name)} \n${pts}\n
+ `;
+ const blob = new Blob([gpx], { type: 'application/gpx+xml' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${route.name.replace(/[^a-z0-9äöü]/gi,'_')}.gpx`;
+ a.click();
+ URL.revokeObjectURL(url);
+ UI.toast.success('GPX heruntergeladen!');
+ }
+
+ function _fmtDur(min) {
+ if (min < 60) return `${min} min`;
+ return `${Math.floor(min/60)}h ${min%60?(min%60)+'min':''}`.trim();
+ }
+
+ // ----------------------------------------------------------
+ // Import: GPX / KML / TCX
+ // ----------------------------------------------------------
+ async function _importFile(file) {
+ const text = await file.text();
+ const ext = file.name.split('.').pop().toLowerCase();
+ let parsed;
+ try {
+ if (ext === 'gpx') parsed = _parseGpx(text, file.name);
+ else if (ext === 'kml') parsed = _parseKml(text, file.name);
+ else if (ext === 'tcx') parsed = _parseTcx(text, file.name);
+ else { UI.toast.error('Format nicht unterstützt. Bitte GPX, KML oder TCX.'); return; }
+ } catch (err) {
+ UI.toast.error(`Datei konnte nicht gelesen werden: ${err.message}`);
return;
}
-
- const dauer = Math.floor((Date.now() - _startTime) / 1000 / 60);
- _showSaveForm(_track, _distanceKm, dauer);
+ if (!parsed || parsed.track.length < 2) {
+ UI.toast.error('Keine GPS-Punkte in der Datei gefunden.');
+ return;
+ }
+ _openImportModal(parsed);
}
- function _resetRecUI() {
- document.getElementById('rec-idle').style.display = '';
- document.getElementById('rec-active').style.display = 'none';
- document.getElementById('rec-stats').style.display = 'none';
- if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
- if (_recMarker) { _recMarker.remove(); _recMarker = null; }
- _track = []; _distanceKm = 0;
+ function _parseGpx(text, filename) {
+ const doc = new DOMParser().parseFromString(text, 'application/xml');
+ const ns = doc.documentElement.getAttribute('xmlns') || '';
+ const resolve = tag => {
+ // Namespace-aware oder fallback
+ const els = doc.getElementsByTagNameNS(ns, tag);
+ return els.length ? els : doc.getElementsByTagName(tag);
+ };
+
+ // Track-Punkte aus
+ const trkpts = resolve('trkpt');
+ const track = [];
+ let startTime = null, endTime = null;
+
+ for (const pt of trkpts) {
+ const lat = parseFloat(pt.getAttribute('lat'));
+ const lon = parseFloat(pt.getAttribute('lon'));
+ if (isNaN(lat) || isNaN(lon)) continue;
+ const point = { lat, lon };
+
+ // Elevation (optional)
+ const eleEl = pt.getElementsByTagName('ele')[0] || pt.getElementsByTagNameNS(ns,'ele')[0];
+ if (eleEl) point.ele = parseFloat(eleEl.textContent);
+
+ // Timestamp (optional)
+ const timeEl = pt.getElementsByTagName('time')[0] || pt.getElementsByTagNameNS(ns,'time')[0];
+ if (timeEl) {
+ const t = new Date(timeEl.textContent);
+ if (!startTime) startTime = t;
+ endTime = t;
+ }
+ track.push(point);
+ }
+
+ // Falls keine → Route-Punkte versuchen
+ if (!track.length) {
+ const rtepts = resolve('rtept');
+ for (const pt of rtepts) {
+ const lat = parseFloat(pt.getAttribute('lat'));
+ const lon = parseFloat(pt.getAttribute('lon'));
+ if (!isNaN(lat) && !isNaN(lon)) track.push({ lat, lon });
+ }
+ }
+
+ // Name aus Datei
+ const nameEl = resolve('name')[0];
+ const name = nameEl?.textContent?.trim() ||
+ filename.replace(/\.gpx$/i, '').replace(/_/g,' ');
+
+ const dauer_min = (startTime && endTime)
+ ? Math.round((endTime - startTime) / 60000) : null;
+
+ return { track, name, dauer_min, source: 'GPX' };
+ }
+
+ function _parseKml(text, filename) {
+ const doc = new DOMParser().parseFromString(text, 'application/xml');
+ const track = [];
+
+ // enthält "lon,lat,ele lon,lat,ele …" oder newline-getrennt
+ const coordEls = doc.getElementsByTagName('coordinates');
+ for (const el of coordEls) {
+ const raw = el.textContent.trim().replace(/\n/g, ' ');
+ for (const tuple of raw.split(/\s+/)) {
+ const parts = tuple.split(',');
+ if (parts.length >= 2) {
+ const lon = parseFloat(parts[0]);
+ const lat = parseFloat(parts[1]);
+ if (!isNaN(lat) && !isNaN(lon)) {
+ const point = { lat, lon };
+ if (parts[2]) point.ele = parseFloat(parts[2]);
+ track.push(point);
+ }
+ }
+ }
+ }
+
+ const nameEl = doc.getElementsByTagName('name')[0];
+ const name = nameEl?.textContent?.trim() ||
+ filename.replace(/\.kml$/i, '').replace(/_/g,' ');
+
+ return { track, name, dauer_min: null, source: 'KML' };
+ }
+
+ function _parseTcx(text, filename) {
+ const doc = new DOMParser().parseFromString(text, 'application/xml');
+ const track = [];
+ let startTime = null, endTime = null;
+
+ const trackpoints = doc.getElementsByTagName('Trackpoint');
+ for (const tp of trackpoints) {
+ const latEl = tp.getElementsByTagName('LatitudeDegrees')[0];
+ const lonEl = tp.getElementsByTagName('LongitudeDegrees')[0];
+ if (!latEl || !lonEl) continue;
+ const lat = parseFloat(latEl.textContent);
+ const lon = parseFloat(lonEl.textContent);
+ if (isNaN(lat) || isNaN(lon)) continue;
+
+ const point = { lat, lon };
+ const altEl = tp.getElementsByTagName('AltitudeMeters')[0];
+ if (altEl) point.ele = parseFloat(altEl.textContent);
+
+ const timeEl = tp.getElementsByTagName('Time')[0];
+ if (timeEl) {
+ const t = new Date(timeEl.textContent);
+ if (!startTime) startTime = t;
+ endTime = t;
+ }
+ track.push(point);
+ }
+
+ const nameEl = doc.getElementsByTagName('Name')[0];
+ const name = nameEl?.textContent?.trim() ||
+ filename.replace(/\.tcx$/i, '').replace(/_/g,' ');
+
+ const dauer_min = (startTime && endTime)
+ ? Math.round((endTime - startTime) / 60000) : null;
+
+ return { track, name, dauer_min, source: 'TCX' };
+ }
+
+ // Haversine (client-seitig für Import-Stats)
+ function _calcDistance(track) {
+ const R2RAD = Math.PI / 180;
+ let dist = 0;
+ for (let i = 1; i < track.length; i++) {
+ const a = track[i-1], b = track[i];
+ const dlat = (b.lat - a.lat) * R2RAD;
+ const dlon = (b.lon - a.lon) * R2RAD;
+ const sin2 = Math.sin(dlat/2)**2 +
+ Math.cos(a.lat*R2RAD)*Math.cos(b.lat*R2RAD)*Math.sin(dlon/2)**2;
+ dist += 2 * 6371000 * Math.asin(Math.sqrt(sin2));
+ }
+ return dist / 1000; // km
}
// ----------------------------------------------------------
- // Speicher-Formular nach Aufzeichnung
+ // Import-Modal
// ----------------------------------------------------------
- function _showSaveForm(track, distKm, dauMin) {
- const schwOpts = SCHWIERIGKEIT
- .map(s => `${s.charAt(0).toUpperCase() + s.slice(1)} `)
- .join('');
- const ugOpts = Object.entries(UNTERGRUND)
- .map(([v, l]) => `${l} `)
- .join('');
+ function _openImportModal(parsed) {
+ const { track, name, dauer_min, source } = parsed;
+ const distanz_km = _calcDistance(track);
+ const preview = _svgPreview(_simplifyPreview(track, 60));
const body = `
-
- 🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
-
-
`;
const footer = `
- Verwerfen
- 💾 Route speichern
+ Abbrechen
+ 💾 Route speichern
`;
- UI.modal.open({ title: '🥾 Route benennen', body, footer });
+ UI.modal.open({ title: '📥 Route importieren', body, footer });
- document.getElementById('rs-discard')?.addEventListener('click', () => {
- UI.modal.close();
- _resetRecUI();
+ 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('route-save-form')?.addEventListener('submit', async e => {
- e.preventDefault();
- const btn = document.querySelector('[form="route-save-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
- const fd = UI.formData(e.target);
+ 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; }
- 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,
- };
-
- const saved = await API.routes.create(payload);
- // Für die Liste: start_lat/lon aus Track ergänzen
- saved.start_lat = track[0].lat;
- saved.start_lon = track[0].lon;
- _data.unshift(saved);
+ 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();
- _resetRecUI();
- _renderList();
- _renderPolylines();
- _switchTab('entdecken');
- UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`);
- });
+ 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 };
})();
diff --git a/backend/static/sw.js b/backend/static/sw.js
index 9c80bfb..c9f63de 100644
--- a/backend/static/sw.js
+++ b/backend/static/sw.js
@@ -1,10 +1,11 @@
/* ============================================================
BAN YARO — Service Worker
- Offline-Cache + Push Notifications
+ Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
-const CACHE_VERSION = 'by-v33';
+const CACHE_VERSION = 'by-v62';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
+const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
// index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [
@@ -14,6 +15,9 @@ const STATIC_ASSETS = [
'/js/api.js',
'/js/ui.js',
'/js/app.js',
+ '/js/leaflet.markercluster.js',
+ '/css/MarkerCluster.css',
+ '/css/MarkerCluster.Default.css',
'/manifest.json',
'/icons/icon-192.png',
];
@@ -30,13 +34,15 @@ self.addEventListener('install', event => {
});
// ----------------------------------------------------------
-// ACTIVATE — alte Caches aufräumen
+// ACTIVATE — alte Caches aufräumen (CACHE_TILES behalten)
// ----------------------------------------------------------
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
- keys.filter(k => k !== CACHE_STATIC).map(k => caches.delete(k))
+ keys
+ .filter(k => k !== CACHE_STATIC && k !== CACHE_TILES)
+ .map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
@@ -59,6 +65,38 @@ self.addEventListener('fetch', event => {
return;
}
+ // OSM-Kartenkacheln: eigener persistenter Cache
+ if (url.hostname.endsWith('tile.openstreetmap.org')) {
+ event.respondWith(
+ caches.open(CACHE_TILES).then(cache =>
+ cache.match(event.request).then(cached => {
+ if (cached) return cached;
+ return fetch(event.request).then(response => {
+ if (response.ok) cache.put(event.request, response.clone());
+ return response;
+ });
+ })
+ ).catch(() => new Response('', { status: 503 }))
+ );
+ return;
+ }
+
+ // Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer)
+ if (url.pathname.startsWith('/js/pages/')) {
+ event.respondWith(
+ fetch(event.request)
+ .then(response => {
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE_STATIC).then(c => c.put(event.request, clone));
+ }
+ return response;
+ })
+ .catch(() => caches.match(event.request))
+ );
+ return;
+ }
+
// Navigation (index.html): immer Network-First
if (event.request.mode === 'navigate') {
event.respondWith(
@@ -93,6 +131,48 @@ self.addEventListener('fetch', event => {
);
});
+// ----------------------------------------------------------
+// MESSAGE — Tile-Vorausladung (Offline-Speicherung)
+// ----------------------------------------------------------
+self.addEventListener('message', event => {
+ if (event.data?.type !== 'CACHE_TILES') return;
+
+ const urls = event.data.urls || [];
+ const source = event.source;
+ let done = 0;
+ const total = urls.length;
+
+ if (total === 0) {
+ source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: 0, total: 0 });
+ return;
+ }
+
+ caches.open(CACHE_TILES).then(cache => {
+ const queue = [...urls];
+
+ function fetchBatch() {
+ if (queue.length === 0) {
+ source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: total, total });
+ return;
+ }
+ const batch = queue.splice(0, 8);
+ Promise.all(batch.map(url =>
+ cache.match(url).then(cached => {
+ if (cached) { done++; return; }
+ return fetch(url, { mode: 'cors' })
+ .then(r => { if (r.ok) cache.put(url, r); done++; })
+ .catch(() => { done++; });
+ })
+ )).then(() => {
+ source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done, total });
+ setTimeout(fetchBatch, 30);
+ });
+ }
+
+ fetchBatch();
+ });
+});
+
// ----------------------------------------------------------
// PUSH NOTIFICATIONS
// ----------------------------------------------------------