/* ============================================================ BAN YARO — Zentrale Karte Layer: eigene Orte, Giftköder, OSM-POIs, Community-Pins Features: Clustering, Standort-Dot, Gefahren-Radius ============================================================ */ window.Page_map = (() => { let _container = null; let _appState = null; let _map = null; let _leafletLoaded = false; let _userPos = null; let _placingMarker = false; let _tempMarker = null; // Standort-Tracking let _locationMarker = null; let _locationAccuracy = null; let _watchId = null; // GPS-Aufzeichnung let _recActive = false; let _recPaused = false; let _wakeLock = null; let _recTrack = []; let _recDistKm = 0; let _recStartTime = null; let _recTimerInt = null; let _recPolyline = null; let _pocketOverlay = null; let _pocketHideTimer = null; let _recMarker = null; let _recWatchId = null; // Cluster-Gruppen pro Layer (für OSM-Marker) let _clusterGroups = {}; // Layer-Marker (Arrays von Leaflet-Markern) let _layers = { restaurant: [], freilauf: [], shop: [], kotbeutel: [], tierarzt: [], hundeschule: [], poison: [], muell: [], dog_park: [], wasser: [], bank: [], giftkoeder: [], gefahr: [], parkplatz: [], treffpunkt: [], community: [], zuechter: [], }; const VISIBLE_KEY = 'by_map_visible_v1'; let _visible = {}; // Gespeicherten Zustand laden, Fallback: alles sichtbar (() => { const saved = (() => { try { return JSON.parse(localStorage.getItem(VISIBLE_KEY) || 'null'); } catch { return null; } })(); Object.keys(_layers).forEach(k => { _visible[k] = saved ? (saved[k] !== false) : true; }); })(); function _saveVisible() { try { localStorage.setItem(VISIBLE_KEY, JSON.stringify(_visible)); } catch {} } // z: zIndexOffset — höher = weiter oben bei Überlappung const TYPEN = { restaurant: { icon: '', label: 'Restaurant', color: '#F97316', z: 10 }, freilauf: { icon: '', label: 'Freilauf', color: '#22C55E', z: 20 }, shop: { icon: '', label: 'Shop', color: '#3B82F6', z: 15 }, kotbeutel: { icon: '', label: 'Kotbeutel', color: '#84A98C', z: 5 }, tierarzt: { icon: '', label: 'Tierarzt', color: '#EF4444', z: 40 }, hundeschule: { icon: '', label: 'Hundeschule', color: '#8B5CF6', z: 30 }, poison: { icon: '', label: 'Giftköder', color: '#DC2626', z: 100 }, muell: { icon: '', label: 'Mülleimer', color: '#6B7280', z: -20 }, dog_park: { icon: '', label: 'Hundewiese', color: '#15803D', z: 5 }, wasser: { icon: '', label: 'Wasserstelle', color: '#0EA5E9', z: 35 }, bank: { icon: '', label: 'Bank', color: '#92400E', z: -30 }, giftkoeder: { icon: '', label: 'Giftköder', color: '#DC2626', z: 80 }, gefahr: { icon: '', label: 'Gefahr', color: '#F59E0B', z: 60 }, parkplatz: { icon: '', label: 'Parkplatz', color: '#2563EB', z: 5 }, treffpunkt: { icon: '', label: 'Treffpunkt', color: '#7C3AED', z: 25 }, community: { icon: '', label: 'Sonstiges', color: '#F59E0B', z: 30 }, zuechter: { icon: '', label: 'Züchter', color: '#7C3AED', z: 50 }, }; // Frontend-Layer → Backend-Typ Mapping const OSM_LAYER_MAP = { muell: 'waste_basket', dog_park: 'dog_park', wasser: 'drinking_water', tierarzt: 'tierarzt', shop: 'shop', restaurant: 'restaurant', bank: 'bank', giftkoeder: 'giftkoeder', kotbeutel: 'kotbeutel', gefahr: 'gefahr', parkplatz: 'parkplatz', treffpunkt: 'treffpunkt', community: 'sonstiges', }; // Gefahren-Radius-Kreis: prominente rote Fläche const DANGER_RADIUS = { poison: 100, giftkoeder: 100 }; // Layer die schon ab Zoom 10 geladen werden (nicht erst ab 14) const EARLY_LAYERS = new Set(['giftkoeder']); const DANGER_CIRCLE_STYLE = { color: '#DC2626', fillColor: '#DC2626', fillOpacity: 0.12, weight: 2, dashArray: null, interactive: false, }; let _overpassTimer = null; let _overpassActive = false; let _ringClosing = false; let _frankfurtTimer = null; let _autoRetryCount = 0; // begrenzt Auto-Retry auf max 3x pro Kartenposition // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- async function init(container, appState) { _container = container; _appState = appState; Object.assign(_container.style, { padding: '0', overflow: 'hidden', position: 'relative', gap: '0' }); _render(); // Alle-Button Initialzustand const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit); await _loadLeaflet(); _initMap(); // sofort mit Deutschland-Mitte starten _startLocationTracking(); _loadAll(); // Standort im Hintergrund holen — bei Erfolg zur Position fliegen API.getLocation().then(pos => { _userPos = pos; if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; } _map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 }); _loadWeather(pos.lat, pos.lon); }).catch(() => { const btn = document.getElementById('map-locate-btn'); if (btn) { btn.title = 'Standort nicht verfügbar'; btn.style.opacity = '0.55'; btn.innerHTML = ''; } }); } function refresh() { // Leaflet kennt die Container-Größe nach Seitenwechsel nicht — neu berechnen setTimeout(() => { _map?.invalidateSize(); _scheduleOsmLoad(); }, 150); setTimeout(() => _map?.invalidateSize(), 600); _loadAll(); } function onDogChange() {} // ---------------------------------------------------------- // RENDER // ---------------------------------------------------------- function _render() { _container.innerHTML = `
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => ` `).join('')}
Karte verschieben · Pin landet genau hier
·
0.00 km
00:00 Zeit
–:–– min/km
Bildschirm bleibt aktiv — GPS läuft
`; const legendBtns = Object.keys(TYPEN).filter(k => k !== 'giftkoeder').length + 1; // +1 Alle-Btn document.getElementById('map-legend') ?.style.setProperty('--map-legend-cols', Math.ceil(legendBtns / 2)); document.getElementById('map-legend').addEventListener('click', e => { const btn = e.target.closest('.map-legend-btn'); if (!btn) return; // "Alle"-Button if (btn.id === 'map-legend-all') { const anyOn = Object.entries(_visible) .some(([k, v]) => v && k !== 'giftkoeder'); // Wenn irgendetwas sichtbar → alles aus; sonst alles an const newState = !anyOn; Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').forEach(([k]) => { _visible[k] = newState; document.querySelector(`.map-legend-btn[data-layer="${k}"]`) ?.classList.toggle('active', newState); _applyVisibility(k); }); btn.classList.toggle('all-off', !newState); _saveVisible(); return; } const layer = btn.dataset.layer; _visible[layer] = !_visible[layer]; btn.classList.toggle('active', _visible[layer]); _applyVisibility(layer); // Alle-Button-Zustand aktualisieren const anyOn = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOn); _saveVisible(); }); document.getElementById('map-locate-btn').addEventListener('click', () => { if (_userPos) { _map?.setView([_userPos.lat, _userPos.lon], 16); } else { UI.toast.error('Standort noch nicht verfügbar.'); } }); document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); } // ---------------------------------------------------------- // Leaflet + MarkerCluster laden // ---------------------------------------------------------- async function _loadLeaflet() { if (_leafletLoaded) return; // Leaflet-Basis: nur laden wenn noch nicht vorhanden (diary.js kann es vorgeladen haben) if (!window.L) { const lCss = document.createElement('link'); lCss.rel = 'stylesheet'; lCss.href = '/css/leaflet.css'; document.head.appendChild(lCss); await new Promise(resolve => { const s = document.createElement('script'); s.src = '/js/leaflet.js'; s.onload = resolve; document.head.appendChild(s); }); } // MarkerCluster: separat prüfen — diary.js lädt Leaflet ohne MarkerCluster if (!window.L.markerClusterGroup) { ['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(f => { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = `/css/${f}`; document.head.appendChild(l); }); await new Promise(resolve => { const s = document.createElement('script'); s.src = '/js/leaflet.markercluster.js'; s.onload = resolve; document.head.appendChild(s); }); } _leafletLoaded = true; } // ---------------------------------------------------------- // Karte initialisieren // ---------------------------------------------------------- function _initMap() { const el = document.getElementById('central-map'); if (!el || !window.L || _map) return; const center = _userPos ? [_userPos.lat, _userPos.lon] : [50.1109, 8.6821]; // Frankfurt const zoom = _userPos ? 14 : 10; _map = L.map('central-map', { zoomControl: true, attributionControl: false }) .setView(center, zoom); if (!_userPos) { _frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200); } L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); setTimeout(() => _map.invalidateSize(), 100); setTimeout(() => _map.invalidateSize(), 600); window.addEventListener('resize', () => _map.invalidateSize()); _map.on('moveend zoomend', () => { _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(); }); setTimeout(() => { _updateZoomDisplay(); _scheduleOsmLoad(); }, 800); // Fadenkreuz-Animation beim Kartenverschieben _map.on('movestart', () => { document.getElementById('map-crosshair')?.classList.add('dragging'); }); _map.on('moveend', () => { document.getElementById('map-crosshair')?.classList.remove('dragging'); }); } // ---------------------------------------------------------- // Standort-Tracking — pulsierender blauer Punkt // ---------------------------------------------------------- function _startLocationTracking() { if (!navigator.geolocation || !_map || !window.L) return; const icon = L.divIcon({ className: 'loc-icon', html: '
', iconSize: [24, 24], iconAnchor: [12, 12], }); _watchId = navigator.geolocation.watchPosition( pos => { const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords; _userPos = { lat, lon }; if (_locationMarker) { _locationMarker.setLatLng([lat, lon]); _locationAccuracy?.setLatLng([lat, lon]).setRadius(acc); } else { _locationAccuracy = L.circle([lat, lon], { radius: acc, color: '#3B82F6', fillColor: '#3B82F6', fillOpacity: 0.1, weight: 1, interactive: false, }).addTo(_map); _locationMarker = L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false, }).addTo(_map); } }, () => {}, { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 } ); } // ---------------------------------------------------------- // Cluster-Gruppe holen / erstellen // ---------------------------------------------------------- function _getCluster(layerKey) { if (!_clusterGroups[layerKey]) { _clusterGroups[layerKey] = L.markerClusterGroup({ maxClusterRadius: 50, spiderfyOnMaxZoom: true, showCoverageOnHover: false, animate: true, chunkedLoading: true, iconCreateFunction: cluster => { const t = TYPEN[layerKey]; const n = cluster.getChildCount(); return L.divIcon({ className: '', html: `
${n}
`, iconSize: [36, 36], iconAnchor: [18, 18], }); }, }); if (_visible[layerKey] !== false) { _clusterGroups[layerKey].addTo(_map); } } return _clusterGroups[layerKey]; } function _updateZoomDisplay() { if (!_map) return; const z = Math.round(_map.getZoom()); const el = document.getElementById('map-zoom-info'); if (!el) return; if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; } else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; } else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; } } function _setOsmStatus(text, pct = null) { const el = document.getElementById('map-osm-status'); const statusbar = document.getElementById('map-statusbar'); if (el) el.textContent = text; _updateScanRing(text ? pct : null); _updateScanDog(text ? pct : null); if (pct === 100 && statusbar) { statusbar.classList.add('scan-done'); setTimeout(() => statusbar.classList.remove('scan-done'), 2200); } } function _injectDogStyles() { if (document.getElementById('by-dog-style')) return; const s = document.createElement('style'); s.id = 'by-dog-style'; s.textContent = [ '@keyframes by-sniff{0%,100%{transform:translateY(0) rotate(0deg)}30%{transform:translateY(2.5px) rotate(-1.5deg)}70%{transform:translateY(1px) rotate(1deg)}}', '@keyframes by-wander{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}45%{transform:translateX(5px)}68%{transform:translateX(-5px)}85%{transform:translateX(7px)}}', '@keyframes by-wag{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}', '#map-scan-dog{animation:by-wander 1.75s ease-in-out infinite;transition:opacity .5s ease;color:#C4843A;position:absolute;pointer-events:none;z-index:1003;width:42px;height:32px}', '#map-scan-dog svg{display:block;animation:by-sniff .42s ease-in-out infinite}', '#map-scan-dog .by-tail{transform-box:fill-box;transform-origin:0% 100%;animation:by-wag .32s ease-in-out infinite}', '#map-statusbar{transition:background .35s ease,color .35s ease,border-color .35s ease}', '#map-statusbar.scan-done{background:#22C55E!important;color:#fff!important;border-color:#16A34A!important}', ].join(''); document.head.appendChild(s); } function _updateScanDog(pct) { _injectDogStyles(); const statusbar = document.getElementById('map-statusbar'); if (!statusbar) return; const mapEl = statusbar.closest('.map-main') || statusbar.parentElement; if (!mapEl) return; let dog = document.getElementById('map-scan-dog'); if (pct === null) { if (_ringClosing) return; if (dog) { dog.style.opacity = '0'; setTimeout(() => dog?.remove(), 550); } return; } if (!dog) { dog = document.createElement('div'); dog.id = 'map-scan-dog'; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '42'); svg.setAttribute('height', '32'); svg.setAttribute('viewBox', '0 0 54 40'); svg.innerHTML = ` `; dog.appendChild(svg); mapEl.appendChild(dog); } const sr = statusbar.getBoundingClientRect(); const mr = mapEl.getBoundingClientRect(); dog.style.left = (sr.left - mr.left + sr.width - 36) + 'px'; dog.style.top = (sr.top - mr.top - 35) + 'px'; dog.style.opacity = '1'; if (pct >= 100) { setTimeout(() => { const d = document.getElementById('map-scan-dog'); if (d) { d.style.opacity = '0'; setTimeout(() => d?.remove(), 550); } }, 500); } } function _updateScanRing(pct) { const statusbar = document.getElementById('map-statusbar'); if (!statusbar) return; const mapEl = statusbar.closest('.map-main') || statusbar.parentElement; if (!mapEl) return; let svg = document.getElementById('map-scan-ring'); // Ring ausblenden / entfernen if (pct === null) { if (_ringClosing) return; if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); } statusbar.style.border = ''; return; } // SVG einmalig erzeugen if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.id = 'map-scan-ring'; svg.setAttribute('overflow', 'visible'); svg.style.cssText = 'position:absolute;pointer-events:none;z-index:1002;transition:opacity 0.55s ease'; const path = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); path.id = 'map-scan-ring-rect'; path.setAttribute('fill', 'none'); path.setAttribute('stroke', '#C4843A'); path.setAttribute('stroke-width', '3'); path.setAttribute('stroke-linecap', 'round'); svg.appendChild(path); mapEl.appendChild(svg); } // Position relativ zum Map-Container berechnen const sr = statusbar.getBoundingClientRect(); const mr = mapEl.getBoundingClientRect(); const w = sr.width; const h = sr.height; const r = h / 2; // border-radius-full = Hälfte der Höhe const p = 2; // Abstand zur inneren Kante // Umfang der Pill: gerades Stück + zwei Halbkreise const perim = 2 * (w - h) + Math.PI * h; // Natürlicher SVG-Start: linkes Ende der oberen Geraden // 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2 // dashoffset = perim - S verschiebt den Dash-Start genau dorthin const S = (w - h) / 2; const progress = Math.min(100, Math.max(0, pct)); const progressLen = progress * perim / 100; svg.style.left = (sr.left - mr.left - p) + 'px'; svg.style.top = (sr.top - mr.top - p) + 'px'; svg.style.width = (w + p * 2) + 'px'; svg.style.height = (h + p * 2) + 'px'; svg.style.opacity = '1'; const rect = document.getElementById('map-scan-ring-rect'); rect.setAttribute('x', String(p)); rect.setAttribute('y', String(p)); rect.setAttribute('width', String(w)); rect.setAttribute('height', String(h)); rect.setAttribute('rx', String(r)); rect.setAttribute('ry', String(r)); rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`); rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2)); // Original-Rahmen verstecken während Ring aktiv ist statusbar.style.border = 'none'; if (progress >= 100) { _ringClosing = true; setTimeout(() => { const s = document.getElementById('map-scan-ring'); if (s) s.style.opacity = '0'; statusbar.style.border = ''; setTimeout(() => { s?.remove(); _ringClosing = false; }, 600); }, 500); } } // ---------------------------------------------------------- // OSM-Layer laden // ---------------------------------------------------------- function _scheduleOsmLoad() { clearTimeout(_overpassTimer); _overpassTimer = setTimeout(_loadOsmLayers, 600); } async function _loadOsmLayers() { if (!_map || !window.L || _overpassActive) return; const zoom = _map.getZoom(); // Unter Zoom 10: alles ausblenden if (zoom < 10) { Object.keys(OSM_LAYER_MAP).forEach(k => { _layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove()); _clusterGroups[k]?.clearLayers(); _layers[k] = _layers[k].filter(m => m._ownPlace); }); _setOsmStatus(''); return; } // Zoom 10–13: normale OSM-Layer ausblenden, EARLY_LAYERS behalten/laden if (zoom < 14) { Object.keys(OSM_LAYER_MAP).filter(k => !EARLY_LAYERS.has(k)).forEach(k => { _layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove()); _clusterGroups[k]?.clearLayers(); _layers[k] = _layers[k].filter(m => m._ownPlace); }); } _overpassActive = true; _map.invalidateSize(); const b = _map.getBounds().pad(0.15); const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }; // Welche Layer bei diesem Zoom geladen werden const activeLayers = zoom >= 14 ? Object.entries(OSM_LAYER_MAP) : Object.entries(OSM_LAYER_MAP).filter(([k]) => EARLY_LAYERS.has(k)); // OSM-Marker eines Layers ersetzen, eigene Orte behalten function _replaceOsmMarkers(layerKey, pois) { const cluster = _getCluster(layerKey); // Alte OSM-Marker entfernen const oldOsm = _layers[layerKey].filter(m => !m._ownPlace); oldOsm.forEach(m => m._dangerCircle?.remove()); cluster.removeLayers(oldOsm); _layers[layerKey] = _layers[layerKey].filter(m => m._ownPlace); // Neue Marker erstellen und in Cluster packen const t = TYPEN[layerKey]; const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t)); cluster.addLayers(newMarkers); _layers[layerKey].push(...newMarkers); // Sicherstellen dass der Cluster auf der Karte ist (kann durch vorherigen Toggle fehlen) if (_visible[layerKey] !== false && _map && !_map.hasLayer(cluster)) { cluster.addTo(_map); } } // Phase 1: sofort DB-Daten zeigen (fast=true) _setOsmStatus('Lade…'); const fastTasks = activeLayers.map(async ([layerKey, osmType]) => { const params = new URLSearchParams({ type: osmType, fast: 'true', ...bbox }); try { const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); _replaceOsmMarkers(layerKey, pois); return pois.length; } catch { return 0; } }); const fastCounts = await Promise.all(fastTasks); const fastTotal = fastCounts.reduce((a, b) => a + b, 0); if (fastTotal > 0) _setOsmStatus(`${fastTotal} aus Datenbank`, 20); // Phase 2: Overpass für fehlende Tiles — mit %-Fortschritt let _done = 0; const _total = activeLayers.length; _setOsmStatus('Scanne…', 20); const freshTasks = activeLayers.map(async ([layerKey, osmType]) => { const params = new URLSearchParams({ type: osmType, ...bbox }); try { const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length; if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois); _done++; const pct = Math.round(20 + _done / _total * 80); const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length; _setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct); return pois.length; } catch { _done++; const pct = Math.round(20 + _done / _total * 80); const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length; _setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct); return _layers[layerKey].filter(m => !m._ownPlace).length; } }); await Promise.all(freshTasks); _overpassActive = false; const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length; const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false); if (totalLoaded > 0 && allHidden) { _setOsmStatus('Layer deaktiviert — Liste antippen', 100); } // Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) { _autoRetryCount++; const delay = _autoRetryCount * 30000; // 30s, 60s, 90s _setOsmStatus(`Neue Umgebung – Daten werden geladen…`); setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay); } } // ---------------------------------------------------------- // Spezielles Giftköder-Icon (pulsierend) // ---------------------------------------------------------- function _poisonDivIcon() { return L.divIcon({ className: '', html: `
`, iconSize: [48, 48], iconAnchor: [24, 24], }); } function _addDangerCircle(lat, lon) { return L.circle([lat, lon], { radius: 100, ...DANGER_CIRCLE_STYLE }).addTo(_map); } // ---------------------------------------------------------- // OSM-Marker erstellen (geht in Cluster, NICHT direkt auf Karte) // ---------------------------------------------------------- function _createOsmMarker(poi, layerKey, t) { const isPoison = DANGER_RADIUS[layerKey] !== undefined; const icon = isPoison ? _poisonDivIcon() : L.divIcon({ className: '', html: `
${t.icon}
`, iconSize: [32, 32], iconAnchor: [16, 16], }); const label = poi.name || t.label; const marker = L.marker([poi.lat, poi.lon], { icon, zIndexOffset: t.z ?? 0 }) .bindTooltip(label, { direction: 'top', offset: [0, -16] }); marker.on('click', () => _showMarkerPopup(marker, poi, layerKey, t, label)); if (isPoison) { marker._dangerCircle = _addDangerCircle(poi.lat, poi.lon); } return marker; } function _showMarkerPopup(marker, poi, layerKey, t, label) { const isOwn = poi.source === 'user' && poi.own; const isUser = poi.source === 'user'; const actionBtn = isOwn ? `` : ``; const openHours = poi.opening_hours ? `
${poi.opening_hours}
` : ''; const phone = poi.phone ? `` : ''; const website = poi.website ? `` : ''; marker.bindPopup(`
${t.icon} ${label}
${poi.notiz ? `
${poi.notiz}
` : ''} ${openHours}${phone}${website}
${isUser ? ` Community-Pin${poi.username ? ' · ' + poi.username + '' : ''}` : ' OpenStreetMap'}
${actionBtn}
`, { maxWidth: 260 }).openPopup(); setTimeout(() => { document.getElementById('mp-action')?.addEventListener('click', () => { marker.closePopup(); if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey); else _showReportDialog(poi); }); }, 50); } // ---------------------------------------------------------- // Marker setzen (Placement-Mode) // ---------------------------------------------------------- function _togglePlacementMode() { if (!_appState?.user) { App.navigate('welcome'); return; } _placingMarker = !_placingMarker; const btn = document.getElementById('map-pin-btn'); if (_placingMarker) { btn?.classList.add('active'); btn && (btn.textContent = '\u2715'); // Fadenkreuz + Bestätigen-Leiste einblenden document.getElementById('map-crosshair')?.classList.add('active'); document.getElementById('map-place-bar')?.classList.add('active'); document.getElementById('map-place-confirm').onclick = () => { const center = _map.getCenter(); _exitPlacementMode(); _confirmPlacement(center); }; document.getElementById('map-place-cancel').onclick = _exitPlacementMode; } else { _exitPlacementMode(); } } function _exitPlacementMode() { _placingMarker = false; const btn = document.getElementById('map-pin-btn'); btn?.classList.remove('active'); btn && (btn.innerHTML = ''); document.getElementById('map-crosshair')?.classList.remove('active', 'dragging'); document.getElementById('map-place-bar')?.classList.remove('active'); _tempMarker?.remove(); _tempMarker = null; } // Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv) const PIN_TYPES = [ { type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626', exclusive: true }, { type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' }, { type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' }, { type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' }, { type: 'drinking_water',icon: '', label: 'Wasserstelle',color: '#0EA5E9' }, { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, { type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' }, { type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' }, { type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' }, ]; function _confirmPlacement(latlng) { _tempMarker?.remove(); _tempMarker = L.circleMarker([latlng.lat, latlng.lng], { radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6, }).addTo(_map); let _selectedTypes = new Set(['giftkoeder']); UI.modal.open({ title: ' Marker setzen', body: `
${PIN_TYPES.map(p => ` `).join('')}
`, footer: ` `, }); document.querySelector('.poi-type-grid')?.addEventListener('click', e => { const btn = e.target.closest('.poi-type-btn'); if (!btn) return; const t = btn.dataset.type; if (btn.dataset.excl) { _selectedTypes = new Set([t]); document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t)); } else { if (_selectedTypes.has('giftkoeder')) { _selectedTypes.delete('giftkoeder'); document.querySelector('[data-excl="1"]')?.classList.remove('selected'); } if (_selectedTypes.has(t)) { if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); } } else { _selectedTypes.add(t); btn.classList.add('selected'); } } }); document.getElementById('poi-cancel')?.addEventListener('click', () => { UI.modal.close(); _exitPlacementMode(); }); document.getElementById('poi-save')?.addEventListener('click', async () => { const name = document.getElementById('poi-name').value.trim() || null; const notiz = document.getElementById('poi-notiz').value.trim() || null; const type = [..._selectedTypes].join(','); UI.modal.close(); await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz }); _exitPlacementMode(); }); } async function _saveUserPoi(data) { try { const res = await fetch('/api/osm/user-poi', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(data), }); if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; } if (!res.ok) throw new Error(); UI.toast.success('Marker gespeichert!'); _scheduleOsmLoad(); } catch { UI.toast.error('Fehler beim Speichern.'); } } // ---------------------------------------------------------- // Melden / Löschen // ---------------------------------------------------------- function _showReportDialog(poi) { UI.modal.open({ title: 'Marker melden', body: `

Warum ist dieser Marker falsch?

`, }); document.getElementById('report-options')?.addEventListener('click', async e => { const btn = e.target.closest('[data-grund]'); if (!btn) return; UI.modal.close(); try { const body = { type: poi.source === 'user' ? 'user_poi' : 'osm', grund: btn.dataset.grund, osm_id: poi.source === 'osm' ? poi.id : undefined, user_poi_id: poi.source === 'user' ? poi.user_poi_id : undefined, }; const res = await fetch('/api/osm/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body), }); if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; } const data = await res.json(); if (data.status === 'bereits_gemeldet') { UI.toast.info('Du hast diesen Marker bereits gemeldet.'); } else { UI.toast.success('Meldung eingereicht. Danke!'); } } catch { UI.toast.error('Fehler beim Melden.'); } }); } async function _deleteUserPoi(poiId, marker, layerKey) { try { const res = await fetch(`/api/osm/user-poi/${poiId}`, { method: 'DELETE', credentials: 'include', }); if (!res.ok) throw new Error(); _clusterGroups[layerKey]?.removeLayer(marker); marker._dangerCircle?.remove(); _layers[layerKey] = _layers[layerKey].filter(m => m !== marker); UI.toast.success('Marker gelöscht.'); } catch { UI.toast.error('Fehler beim Löschen.'); } } // ---------------------------------------------------------- // Eigene Orte + Giftköder laden // ---------------------------------------------------------- async function _loadAll() { // Falls Overpass-Job steckengeblieben: zurücksetzen _overpassActive = false; // Cluster-Gruppen leeren (OSM-Marker) Object.values(_clusterGroups).forEach(cg => cg.clearLayers()); // Eigene-Orte-Marker direkt von Karte entfernen Object.values(_layers).flat().filter(m => m._ownPlace).forEach(m => { m._dangerCircle?.remove(); m.remove(); }); // Giftköder-Kreise (_layers.poison || []).forEach(m => m._dangerCircle?.remove()); Object.keys(_layers).forEach(k => { _layers[k] = []; }); const [places, poisonList, breederList] = await Promise.allSettled([ API.places.list(), _userPos ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) : Promise.resolve([]), API.breeder.mapMarkers(), ]); if (places.status === 'fulfilled') _addPlaces(places.value); if (poisonList.status === 'fulfilled') _addPoison(poisonList.value); if (breederList.status === 'fulfilled') _addBreeders(breederList.value); _scheduleOsmLoad(); } function _addPlaces(places) { if (!_map || !window.L) return; places.forEach(place => { const t = TYPEN[place.typ]; if (!t) return; const m = _createSimpleMarker(place.lat, place.lon, t, place.name, () => UI.toast.info(`${t.icon} ${place.name}${place.adresse ? ' \u00b7 ' + place.adresse : ''}`)); m._ownPlace = true; _layers[place.typ]?.push(m); if (!_visible[place.typ]) m.setOpacity(0); }); } function _addPoison(items) { if (!_map || !window.L) return; items.forEach(p => { const tooltip = `Giftk\u00f6der-Alarm${p.beschreibung ? ': ' + p.beschreibung : ''}`; const m = L.marker([p.lat, p.lon], { icon: _poisonDivIcon(), zIndexOffset: 100 }) .addTo(_map) .bindTooltip(tooltip, { direction: 'top', offset: [0, -24] }) .on('click', () => App.navigate('poison')); m._ownPlace = true; m._dangerCircle = _addDangerCircle(p.lat, p.lon); _layers.poison.push(m); if (!_visible.poison) { m.setOpacity(0); m._dangerCircle.setStyle({ opacity: 0, fillOpacity: 0 }); } }); } function _addBreeders(breeders) { if (!_map || !window.L) return; const t = TYPEN.zuechter; const cluster = _getCluster('zuechter'); const markers = []; breeders.forEach(b => { // Ohne Koordinaten: stillen Skip if (b.location_lat == null || b.location_lng == null) return; const icon = L.divIcon({ className: '', html: `
${t.icon}
`, iconSize: [32, 32], iconAnchor: [16, 16], }); const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 }) .bindTooltip(_esc(b.zwingername), { direction: 'top', offset: [0, -16] }); marker.on('click', () => { const rasseText = b.rasse_text ? `
${_esc(b.rasse_text)}
` : ''; const stadtText = b.stadt ? `
${_esc(b.stadt)}
` : ''; marker.bindPopup(`
${t.icon} ${_esc(b.zwingername)}
${rasseText}${stadtText}
`, { maxWidth: 260 }).openPopup(); setTimeout(() => { document.getElementById('breeder-profile-btn')?.addEventListener('click', () => { marker.closePopup(); App.navigate('breeder', true, { zwingername: b.zwingername }); }); }, 50); }); markers.push(marker); _layers.zuechter.push(marker); }); cluster.addLayers(markers); if (_visible.zuechter !== false && _map && !_map.hasLayer(cluster)) { cluster.addTo(_map); } } function _createSimpleMarker(lat, lon, t, tooltip, onClick) { const icon = L.divIcon({ className: '', html: `
${t.icon}
`, iconSize: [32, 32], iconAnchor: [16, 16], }); return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 }) .addTo(_map) .bindTooltip(tooltip, { direction: 'top', offset: [0, -16] }) .on('click', onClick); } // ---------------------------------------------------------- // Layer ein/ausblenden // ---------------------------------------------------------- function _applyVisibility(layer) { // poison-Toggle steuert auch giftkoeder-Community-Pins mit const keys = layer === 'poison' ? ['poison', 'giftkoeder'] : [layer]; keys.forEach(k => { const on = _visible[layer]; _visible[k] = on; if (_clusterGroups[k]) { on ? _clusterGroups[k].addTo(_map) : _clusterGroups[k].remove(); } (_layers[k] || []).forEach(m => { if (m._ownPlace) m.setOpacity?.(on ? 1 : 0); if (m._dangerCircle) { m._dangerCircle.setStyle(on ? { opacity: 1, fillOpacity: 0.12 } : { opacity: 0, fillOpacity: 0 } ); } }); }); } // ---------------------------------------------------------- // Offline-Kacheln vorladen // ---------------------------------------------------------- function _tileCoords(lat, lon, zoom) { const n = Math.pow(2, zoom); const x = Math.floor((lon + 180) / 360 * n); const latRad = lat * Math.PI / 180; const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n); return { x, y }; } function _collectTileUrls(bounds, minZoom, maxZoom) { const urls = []; const subdomains = ['a', 'b', 'c']; for (let z = minZoom; z <= maxZoom; z++) { const sw = _tileCoords(bounds.getSouth(), bounds.getWest(), z); const ne = _tileCoords(bounds.getNorth(), bounds.getEast(), z); for (let x = sw.x; x <= ne.x; x++) { for (let y = ne.y; y <= sw.y; y++) { const s = subdomains[Math.abs(x + y) % 3]; urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`); } } } return urls; } async function _cacheTiles() { if (!_map) return; if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) { UI.toast.warning('Service Worker nicht bereit \u2014 bitte Seite neu laden.'); return; } const bounds = _map.getBounds(); // Padding: 1 Kachel nach außen auf Zoom 14 const padded = bounds.pad(0.15); // Zoom 12–15 innerhalb der aktuellen Kartenansicht const urls = _collectTileUrls(padded, 12, 15); if (urls.length === 0) { UI.toast.info('Keine Kacheln im Bereich.'); return; } if (urls.length > 800) { UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`); return; } const btn = document.getElementById('map-offline-btn'); if (btn) btn.classList.add('loading'); _setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`); // Progress via postMessage vom SW const onMessage = evt => { if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return; const { done, total } = evt.data; if (done >= total) { navigator.serviceWorker.removeEventListener('message', onMessage); if (btn) btn.classList.remove('loading'); _setOsmStatus(''); UI.toast.success(`${total} Kacheln offline gespeichert!`); } else { _setOsmStatus(`Offline: ${done} / ${total} Kacheln…`); } }; navigator.serviceWorker.addEventListener('message', onMessage); navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls }); } // ---------------------------------------------------------- // Pocket-Modus Overlay // ---------------------------------------------------------- function _showPocketOverlay() { if (_pocketOverlay) return; const el = document.createElement('div'); el.id = 'pocket-overlay'; el.innerHTML = `
GPS läuft
0:00
0.00 km
Tippen für Steuerung
`; el.style.cssText = ` position:fixed;inset:0;z-index:9998;background:#000; display:flex;flex-direction:column;align-items:center;justify-content:center; color:#fff;font-family:inherit;user-select:none; `; el.querySelector('.po-status').style.cssText = 'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase'; el.querySelector('.po-time').style.cssText = 'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1'; el.querySelector('.po-dist').style.cssText = 'font-size:1.5rem;color:#aaa;margin-top:0.5rem'; el.querySelector('.po-hint').style.cssText = 'font-size:0.75rem;color:#444;margin-top:2.5rem'; const ctrl = el.querySelector('.po-controls'); ctrl.style.cssText = 'display:none;gap:1rem;margin-top:2rem;flex-direction:row'; el.querySelectorAll('.po-btn').forEach(b => { b.style.cssText = 'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' + 'background:#111;color:#fff;font-size:1rem;cursor:pointer'; }); el.querySelector('.po-btn--stop').style.cssText += 'border-color:#c0392b;color:#e74c3c'; // Tippen → Controls 4s einblenden, dann ausblenden el.addEventListener('click', e => { if (e.target.closest('#po-controls')) return; ctrl.style.display = 'flex'; el.querySelector('.po-hint').style.color = '#666'; clearTimeout(_pocketHideTimer); _pocketHideTimer = setTimeout(() => { ctrl.style.display = 'none'; el.querySelector('.po-hint').style.color = '#444'; }, 4000); }); el.querySelector('#po-pause').addEventListener('click', () => { _togglePause(); el.querySelector('#po-pause').textContent = _recPaused ? '▶ Weiter' : '⏸ Pause'; el.querySelector('#po-status').textContent = _recPaused ? 'Pausiert' : 'GPS läuft'; }); el.querySelector('#po-stop').addEventListener('click', () => { _hidePocketOverlay(); _stopRecording(); }); document.body.appendChild(el); _pocketOverlay = el; } function _hidePocketOverlay() { clearTimeout(_pocketHideTimer); _pocketOverlay?.remove(); _pocketOverlay = null; } function _updatePocketOverlay() { if (!_pocketOverlay) return; const elapsed = Math.floor((Date.now() - _recStartTime) / 1000); const mm = String(Math.floor(elapsed / 60)).padStart(1, '0'); const ss = String(elapsed % 60).padStart(2, '0'); const timeEl = _pocketOverlay.querySelector('#po-time'); const distEl = _pocketOverlay.querySelector('#po-dist'); if (timeEl) timeEl.textContent = `${mm}:${ss}`; if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`; } // ---------------------------------------------------------- // GPS-Aufzeichnung // ---------------------------------------------------------- function _toggleRecording() { if (!_recActive) _startRecording(); else _stopRecording(); } async function _startRecording() { if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; } if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; } _recActive = true; _recPaused = false; _recTrack = []; _recDistKm = 0; _recStartTime = Date.now(); // FAB umschalten const btn = document.getElementById('map-rec-btn'); if (btn) { btn.innerHTML = ''; btn.classList.add('recording'); } // Aufzeichnungs-Panel einblenden const panel = document.getElementById('map-rec-panel'); if (panel) panel.classList.add('active'); document.getElementById('rec-panel-pause').onclick = _togglePause; document.getElementById('rec-panel-stop').onclick = _stopRecording; // Wake Lock — Bildschirm wach halten await _acquireWakeLock(); const hint = document.getElementById('map-rec-hint'); if (hint) hint.textContent = _wakeLock ? 'Bildschirm bleibt aktiv — GPS läuft' : 'Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen'; // Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern document.addEventListener('visibilitychange', _onVisibilityChange); _recTimerInt = setInterval(_updateRecStatus, 1000); _recWatchId = navigator.geolocation.watchPosition( pos => { if (_recPaused) return; const { latitude: lat, longitude: lon } = pos.coords; if (_recTrack.length > 0) { const prev = _recTrack[_recTrack.length - 1]; const d = _haversineRec(prev.lat, prev.lon, lat, lon); if (d < 3) return; _recDistKm += d / 1000; } _recTrack.push({ lat, lon }); _updateRecMap(lat, lon); _updateRecStatus(); }, () => {}, { enableHighAccuracy: true, maximumAge: 0, timeout: 10000 } ); UI.toast.success('Aufzeichnung gestartet — los geht\'s!'); // Pocket-Modus aktivieren wenn in Einstellungen eingeschaltet if (localStorage.getItem('by_pocket_mode') === 'true') { setTimeout(_showPocketOverlay, 800); // kurz warten damit Toast sichtbar war } } async function _onVisibilityChange() { if (_recActive && document.visibilityState === 'visible' && !_wakeLock) { await _acquireWakeLock(); } } async function _acquireWakeLock() { if (!('wakeLock' in navigator)) return; try { _wakeLock = await navigator.wakeLock.request('screen'); _wakeLock.addEventListener('release', () => { _wakeLock = null; }); } catch {} } function _releaseWakeLock() { _wakeLock?.release(); _wakeLock = null; } function _togglePause() { _recPaused = !_recPaused; const btn = document.getElementById('rec-panel-pause'); if (btn) btn.textContent = _recPaused ? '▶ Weiter' : '⏸ Pause'; const panel = document.getElementById('map-rec-panel'); panel?.classList.toggle('paused', _recPaused); } function _haversineRec(lat1, lon1, lat2, lon2) { const R = 6371000; const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180; const dp = (lat2 - lat1) * Math.PI / 180; const dl = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2; return 2 * R * Math.asin(Math.sqrt(a)); } function _updateRecMap(lat, lon) { if (!_map || !window.L) return; const ll = [lat, lon]; if (!_recPolyline) { _recPolyline = L.polyline([ll], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_map); } else { _recPolyline.addLatLng(ll); } if (!_recMarker) { _recMarker = L.circleMarker(ll, { radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3, }).addTo(_map); } else { _recMarker.setLatLng(ll); } _map.panTo(ll); } function _updateRecStatus() { const secs = Math.floor((Date.now() - _recStartTime) / 1000); const mm = String(Math.floor(secs / 60)).padStart(2, '0'); const ss = String(secs % 60).padStart(2, '0'); const pace = _recDistKm > 0.05 ? (() => { const pSec = secs / _recDistKm / 60; const pm = Math.floor(pSec); const ps = String(Math.round((pSec-pm)*60)).padStart(2,'0'); return `${pm}:${ps}`; })() : '–:––'; const distEl = document.getElementById('rec-stat-dist'); const timeEl = document.getElementById('rec-stat-time'); const paceEl = document.getElementById('rec-stat-pace'); if (distEl) distEl.textContent = _recDistKm.toFixed(2); if (timeEl) timeEl.textContent = `${mm}:${ss}`; if (paceEl) paceEl.textContent = pace; _updatePocketOverlay(); } function _stopRecording() { if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; } if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; } _recActive = false; _releaseWakeLock(); _hidePocketOverlay(); document.removeEventListener('visibilitychange', _onVisibilityChange); const btn = document.getElementById('map-rec-btn'); if (btn) { btn.innerHTML = ''; btn.classList.remove('recording'); } const panel = document.getElementById('map-rec-panel'); if (panel) panel.classList.remove('active', 'paused'); if (_recTrack.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte — bitte etwas länger laufen.'); if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } if (_recMarker) { _recMarker.remove(); _recMarker = null; } return; } const dauMin = Math.max(1, Math.floor((Date.now() - _recStartTime) / 1000 / 60)); _showRecSaveModal(_recTrack, _recDistKm, dauMin); } async function _prefillRouteName(track, distKm) { const nameInput = document.querySelector('#rec-save-form [name="name"]'); if (!nameInput || nameInput.value) return; const pt = track[0]; const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); const km = distKm.toFixed(1); let ort = ''; try { const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' }); const data = await r.json(); const a = data.address || {}; ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || ''; } catch {} if (!nameInput.value) nameInput.value = ort ? `Gassirunde ${ort} · ${date} · ${km} km` : `Gassirunde · ${date} · ${km} km`; } function _showRecSaveModal(track, distKm, dauMin) { const body = `

${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min

`; const footer = ` `; UI.modal.open({ title: ' Route benennen', body, footer }); _prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet document.getElementById('rec-paw-select')?.addEventListener('click', e => { const btn = e.target.closest('.rk-paw-btn'); if (!btn) return; document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected')); btn.classList.add('selected'); document.getElementById('rec-paw-val').value = btn.dataset.val; }); document.getElementById('rms-discard')?.addEventListener('click', () => { UI.modal.close(); if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } if (_recMarker) { _recMarker.remove(); _recMarker = null; } }); document.getElementById('rec-save-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="rec-save-form"][type="submit"]'); const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { const saved = await API.routes.create({ name: fd.name?.trim(), beschreibung: fd.beschreibung || null, gps_track: track, distanz_km: Math.round(distKm * 100) / 100, dauer_min: dauMin, schwierigkeit: fd.schwierigkeit || 'leicht', untergrund: fd.untergrund || null, schatten: 'schatten' in fd, leine_empfohlen: 'leine_empfohlen' in fd, is_public: 'is_public' in fd, hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', }); UI.modal.close(); if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } if (_recMarker) { _recMarker.remove(); _recMarker = null; } if (saved.is_valid === false) { UI.toast.warning(`Route „${saved.name}" gespeichert — wird nicht für Statistiken gewertet (Geschwindigkeit zu hoch).`); } else { UI.toast.success(`Route „${saved.name}" gespeichert!`); } }); }); } // ---------------------------------------------------------- // WETTER-CHIP // ---------------------------------------------------------- async function _loadWeather(lat, lon) { const info = document.getElementById('map-weather-info'); const sep = document.getElementById('map-weather-sep'); if (!info) return; try { const w = await API.weather.get(lat, lon); const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '–'; const icon = ``; const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : ''; let zecken = ''; if (w.zecken_warnung) { const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E'; zecken = ` · `; } info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${zecken}`; info.classList.remove('map-weather-chip--hidden'); sep.classList.remove('map-weather-chip--hidden'); } catch { /* still */ } } return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive }; })();