/* ============================================================ 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 _weatherLoaded = false; let _placingMarker = false; let _tempMarker = null; let _tileLayer = null; let _usingVector = false; // true wenn Vektor-Basemap (PMTiles) statt OSM-Raster let _engineGL = false; // true wenn MapLibre GL statt Leaflet (zentrale Karte) let _followGps = false; // Karte folgt dem Standort (Standort-Button an, Drag aus) let _maplibreLoaded = false; let _glLayersReady = false; // GL: POI-Sources/Layer angelegt? let _pmtilesProtoReg = false; // pmtiles-Protokoll bei MapLibre registriert? let _themeObserver = 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: [], hundesalon: [], hundeschule: [], poison: [], muell: [], dog_park: [], wasser: [], bank: [], giftkoeder: [], gefahr: [], parkplatz: [], treffpunkt: [], community: [], zuechter: [], hotel: [], }; const VISIBLE_KEY = 'by_map_visible_v1'; const _MAP_POI_KEY = 'by_map_pois_cache'; 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: 'Café & 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 }, hundesalon: { icon: '', label: 'Hundesalon', color: '#EC4899', z: 25 }, 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 }, hotel: { icon: '', label: 'Hotel', color: '#0369a1', z: 20 }, }; // Frontend-Layer → Backend-Typ Mapping const OSM_LAYER_MAP = { muell: 'waste_basket', dog_park: 'dog_park', wasser: 'drinking_water', tierarzt: 'tierarzt', hundesalon: 'hundesalon', shop: 'shop', restaurant: 'restaurant', bank: 'bank', giftkoeder: 'giftkoeder', kotbeutel: 'kotbeutel', gefahr: 'gefahr', parkplatz: 'parkplatz', treffpunkt: 'treffpunkt', community: 'sonstiges', hotel: 'hotel', }; // 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, }; // Orts-Suche let _searchTimer = null; let _searchMarker = null; let _overpassTimer = null; let _overpassActive = false; let _scanQueued = false; // Scan-Anfrage während laufendem Scan → danach nachholen 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); _engineGL = _useGL(); if (_engineGL) { await loadMapLibre(); _initMapGL(); // MapLibre-GL-Karte (GPU) } else { await _loadLeaflet(); _initMap(); // Leaflet-Raster (Default), sofort mit Deutschland-Mitte starten } _ensureFollowBtn(); // Crosshair-Button (Karte folgt Standort, wie in Routen) _startLocationTracking(); _loadAll(); _offerResume(); // unterbrochene Aufzeichnung anbieten // Standort im Hintergrund holen — bei Erfolg zur Position fliegen API.getLocation().then(pos => { _userPos = pos; if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; } _mapFlyTo(pos.lat, pos.lon, 14, { duration: 1.2 }); _weatherLoaded = true; _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(() => { _mapResize(); _scheduleOsmLoad(); }, 150); setTimeout(() => _mapResize(), 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
Mein Standort
Ort suchen
Marker setzen
${(!_useGL() || _offlineTilesEnabled()) ? `
Karte offline speichern
` : ''} ${App.hasPro(_appState?.user) ? `
Regenradar
Temperatur
` : ''}
·
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(); }); // Speed Dial const _sdEl = document.getElementById('map-speed-dial'); document.getElementById('map-sd-trigger')?.addEventListener('click', e => { e.stopPropagation(); _sdEl?.classList.toggle('open'); }); // Klick auf Karte / außerhalb schließt Speed Dial document.getElementById('central-map')?.addEventListener('pointerdown', () => { _sdEl?.classList.remove('open'); }); document.getElementById('map-locate-btn').addEventListener('click', () => { _sdEl?.classList.remove('open'); if (_userPos) { _mapSetView(_userPos.lat, _userPos.lon, 16); // Follow-Mode (René 2026-06-08): Karte wandert ab jetzt mit dem Standort; // manuelles Verschieben beendet das Folgen (dragstart-Listener im Map-Init). _followGps = true; _startCompass(); // User-Geste → iOS-Kompass-Permission _updateFollowBtn(); UI.toast.info('Karte folgt deinem Standort — zum Beenden Karte verschieben.'); } else { UI.toast.error('Standort noch nicht verfügbar.'); } }); document.getElementById('map-pin-btn').addEventListener('click', () => { _sdEl?.classList.remove('open'); _togglePlacementMode(); }); document.getElementById('map-offline-btn')?.addEventListener('click', () => { _sdEl?.classList.remove('open'); if (_engineGL) _openOfflineModal(); // GL: Verwaltung (speichern/anzeigen/löschen) else _cacheTiles(); // Leaflet: OSM-Raster → SW-Cache }); document.getElementById('map-radar-btn')?.addEventListener('click', () => { _sdEl?.classList.remove('open'); _toggleRadar(); }); document.getElementById('map-temp-btn')?.addEventListener('click', () => { _sdEl?.classList.remove('open'); _toggleTemp(); }); // Suche — FAB öffnet Panel document.getElementById('map-search-btn')?.addEventListener('click', () => { document.getElementById('map-speed-dial')?.classList.remove('open'); const wrap = document.getElementById('map-search-wrap'); const isOpen = wrap?.classList.contains('active'); if (isOpen) { _clearSearch(); } else { wrap?.classList.add('active'); setTimeout(() => document.getElementById('map-search-input')?.focus(), 60); document.getElementById('map-search-btn')?.classList.add('active'); } }); const searchInput = document.getElementById('map-search-input'); const searchResults = document.getElementById('map-search-results'); searchInput?.addEventListener('input', () => { const q = searchInput.value.trim(); clearTimeout(_searchTimer); if (q.length < 2) { searchResults.style.display = 'none'; return; } _searchTimer = setTimeout(() => _runSearch(q), 400); }); searchInput?.addEventListener('keydown', e => { if (e.key === 'Escape') _clearSearch(); }); document.getElementById('map-search-clear')?.addEventListener('click', _clearSearch); // Klick auf Karte schließt Ergebnisse (aber behält Marker) document.getElementById('central-map')?.addEventListener('pointerdown', () => { searchResults.style.display = 'none'; searchInput?.blur(); }); } // ---------------------------------------------------------- // REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.) // ---------------------------------------------------------- let _radarLayer = null; let _radarActive = false; let _radarTimer = null; let _tempLayer = null; let _tempActive = false; let _tempUrl = null; // GL: zum Re-Add nach Theme-Wechsel (setStyle löscht Raster-Layer) let _tempMaxZoom = 18; let _tempMarkers = []; let _tempDebounce = null; // Regenradar-Zeitleiste (RainViewer: ~2h Vergangenheit + ~30min Nowcast, 10-Min-Schritte) let _radarFrames = []; // [{ path, time }] let _radarHost = 'https://tilecache.rainviewer.com'; let _radarIdx = null; // aktueller Frame let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit) let _radarPlaying = false; let _radarPlayTimer = null; let _radarLayerKind = null; // 'rv' (RainViewer-PNG) | 'dwd' (pmtiles) — für sauberen Layer-Wechsel let _rdrPendingIdx = null; // Regler-Entprellung: zuletzt gewünschter Frame let _rdrRaf = null; // requestAnimationFrame-Handle für die Koaleszenz async function _toggleRadar() { if (!App.hasPro(_appState?.user)) { UI.toast.info('Regenradar ist ein Pro-Feature.'); return; } const btn = document.getElementById('map-radar-btn'); if (_radarActive) { _radarActive = false; _radarPause(); if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; } if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; } _rdrPendingIdx = null; clearInterval(_radarTimer); document.getElementById('map-radar-timeline')?.remove(); btn?.classList.remove('active'); return; } _radarActive = true; btn?.classList.add('active'); if (_map && _map.getZoom() > 7) _map.setZoom(7); await _loadRadar(); _radarTimer = setInterval(_loadRadar, 5 * 60 * 1000); // Frames frisch halten } async function _toggleTemp() { if (!App.hasPro(_appState?.user)) { UI.toast.info('Temperatur-Layer ist ein Pro-Feature.'); return; } const btn = document.getElementById('map-temp-btn'); if (_tempActive) { _tempActive = false; if (_tempLayer) { _wxRemoveRaster(_tempLayer); _tempLayer = null; } _tempMarkers.forEach(m => m.remove()); _tempMarkers = []; clearTimeout(_tempDebounce); _mapOffMove(_debounceTempLabels); document.getElementById('map-temp-legend')?.remove(); btn?.classList.remove('active'); return; } _tempActive = true; btn?.classList.add('active'); try { const cfg = await API.get('/weather/layer-tiles?layer=temp_new'); _tempUrl = cfg.url; _tempMaxZoom = cfg.maxNativeZoom ?? 18; _tempLayer = _wxAddRaster('temp', _tempUrl, 1.0, _tempMaxZoom); _showTempLegend(); _mapOnMove(_debounceTempLabels); await _loadTempLabels(); } catch { _tempActive = false; btn?.classList.remove('active'); UI.toast.error('Temperatur-Layer nicht verfügbar.'); } } function _debounceTempLabels() { clearTimeout(_tempDebounce); _tempDebounce = setTimeout(_loadTempLabels, 600); } function _tempColor(t) { if (t <= -10) return '#0033cc'; if (t <= 0) return '#0099ff'; if (t <= 10) return '#00cc88'; if (t <= 15) return '#88cc00'; if (t <= 20) return '#ffcc00'; if (t <= 25) return '#ff8800'; if (t <= 30) return '#ff3300'; return '#990000'; } async function _loadTempLabels() { if (!_tempActive || !_map) return; const bounds = _map.getBounds(); const n = bounds.getNorth(), s = bounds.getSouth(); const e = bounds.getEast(), w = bounds.getWest(); // 3×3 Raster const rows = 3, cols = 3; const points = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const lat = s + (n - s) * (r + 0.5) / rows; const lon = w + (e - w) * (c + 0.5) / cols; points.push([lat, lon]); } } const results = await Promise.all(points.map(([lat, lon]) => fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' }) .then(r => r.json()) .then(d => ({ lat, lon, t: d.current?.temperature_2m })) .catch(() => null) )); // Alte Marker entfernen _tempMarkers.forEach(m => m.remove()); _tempMarkers = []; results.filter(Boolean).forEach(({ lat, lon, t }) => { if (t == null) return; const temp = Math.round(t); const color = _tempColor(temp); const html = `
${temp}°
`; _tempMarkers.push(_wxAddTempMarker(lat, lon, html)); }); } function _showTempLegend() { const existing = document.getElementById('map-temp-legend'); if (existing) return; const steps = [ { c: '#0000cc', v: '−20°' }, { c: '#0055ff', v: '−10°' }, { c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' }, { c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' }, { c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' }, { c: '#990000', v: '35°' }, ]; const gradient = steps.map(s => s.c).join(','); const labels = steps.map(s => `${s.v}` ).join(''); const el = document.createElement('div'); el.id = 'map-temp-legend'; el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%); z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px; min-width:220px;pointer-events:none`; el.innerHTML = `
${labels}
`; document.getElementById('central-map')?.appendChild(el); } // DWD-Regenvorhersage (Settings-Toggle, Default AN) — nur im GL-Modus sinnvoll // (PMTiles-Protokoll) und innerhalb der DE1200-Abdeckung. function _dwdEnabled() { try { return localStorage.getItem('by_dwd_radar') !== '0'; } catch (e) { return true; } } function _mapInDwdCoverage() { try { const c = _map.getCenter(); // beide Engines: {lat, lng} return c.lng >= 1.5 && c.lng <= 18.7 && c.lat >= 45.7 && c.lat <= 56.2; } catch (e) { return false; } } async function _loadRadar() { if (!_radarActive || !_map) return; try { // Cache-Buster: sonst liefert der Service-Worker u. U. einen alten RainViewer-Stand // (Frames hingen ~50 min nach → DWD-Frische-Check fiel durch, Gerätetest 2026-06-09). const resp = await fetch(`https://api.rainviewer.com/public/weather-maps.json?_t=${Date.now()}`, { cache: 'no-store' }); const data = await resp.json(); const past = data.radar?.past || [], nowcast = data.radar?.nowcast || []; if (!past.length && !nowcast.length) return; _radarHost = data.host || _radarHost; const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`; // Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nächste 2 h (DWD/Nowcast) const WINDOW = 2 * 60 * 60; // 2 h je Seite const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (Geräteuhr ist zuverlässig) // Vergangenheit: RainViewer der letzten 2 h let pastFrames = past .filter(f => f.time >= nowSec - WINDOW && f.time <= nowSec) .map(f => ({ url: rvUrl(f), time: f.time })); // "Jetzt" + Zukunft — Default: RainViewer-Nowcast (~30 min) let nowFrame = null; let futureFrames = nowcast .filter(f => f.time > nowSec && f.time <= nowSec + WINDOW) .map(f => ({ url: rvUrl(f), time: f.time })); // DWD-Vorhersage (0–120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md) if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) { try { const r = await fetch('/radar/manifest.json', { cache: 'no-store' }); if (r.ok) { const man = await r.json(); const runT = Math.floor(Date.parse(man.run_time_utc) / 1000); // Frische des DWD-Laufs gegen die ECHTZEIT prüfen (< 30 min) — NICHT gegen den // jüngsten RainViewer-Frame, der deutlich nachhängen kann (sonst fällt DWD raus). if (man.frames?.length && Math.abs(nowSec - runT) < 1800) { const dwd = man.frames.map(fr => ({ url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`, time: runT + fr.lead_min * 60, lead: fr.lead_min, dwd: true, })); nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt" futureFrames = dwd.filter(f => f.lead > 0 && f.time <= runT + WINDOW); pastFrames = pastFrames.filter(f => f.time < runT); // Überlappung mit DWD-"jetzt" vermeiden } } } catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ } } // Kein DWD-"jetzt"? → jüngsten Vergangenheits-Frame (sonst ältesten Zukunfts-Frame) als "jetzt" if (!nowFrame) { if (pastFrames.length) nowFrame = pastFrames.pop(); else if (futureFrames.length) nowFrame = futureFrames.shift(); } if (!nowFrame) return; _radarFrames = [...pastFrames, nowFrame, ...futureFrames]; _radarNowIdx = pastFrames.length; // "jetzt" liegt direkt nach der Vergangenheit if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx; _showRadarFrame(_radarIdx); _buildRadarTimeline(); } catch { /* still */ } } // Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu. function _showRadarFrame(idx) { if (!_radarActive || !_radarFrames[idx]) return; _radarIdx = idx; const f = _radarFrames[idx]; const url = f.url; const kind = f.dwd ? 'dwd' : 'rv'; const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar'); // setTiles nur innerhalb DESSELBEN Quell-Typs (png↔png bzw. pmtiles↔pmtiles). // Beim Wechsel RainViewer↔DWD den Layer komplett neu aufbauen — sonst bleiben die // alten Kacheln stehen (DWD "neutralisiert" die RainViewer-Wolken nicht). if (src && src.setTiles && kind === _radarLayerKind) { src.setTiles([url]); } else { if (_radarLayer) _wxRemoveRaster(_radarLayer); _radarLayer = _wxAddRaster('radar', url, 0.7, 7); _radarLayerKind = kind; } _updateRadarTimelineUI(); } // Slider-Position (0–1000) ↔ Frame-Index. "jetzt" liegt fix bei 500 (Mitte): // Vergangenheit nutzt die linke, Vorhersage die rechte Hälfte — unabhängig von der // Frame-Anzahl je Seite. So sitzt "jetzt" optisch mittig (Fangpunkt). const RDR_MID = 500, RDR_SNAP = 28; function _radarPosToIdx(pos) { const now = _radarNowIdx, last = _radarFrames.length - 1; if (pos <= RDR_MID) return now > 0 ? Math.round((pos / RDR_MID) * now) : 0; const fut = last - now; return fut > 0 ? now + Math.round(((pos - RDR_MID) / RDR_MID) * fut) : now; } function _radarIdxToPos(idx) { const now = _radarNowIdx, last = _radarFrames.length - 1; if (idx <= now) return now > 0 ? Math.round((idx / now) * RDR_MID) : RDR_MID; const fut = last - now; return fut > 0 ? RDR_MID + Math.round(((idx - now) / fut) * RDR_MID) : RDR_MID; } function _buildRadarTimeline() { if (!_radarFrames.length) return; let el = document.getElementById('map-radar-timeline'); if (!el) { el = document.createElement('div'); el.id = 'map-radar-timeline'; el.className = 'map-radar-timeline'; el.innerHTML = `
`; document.getElementById('central-map')?.appendChild(el); el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay); el.querySelector('#rdr-slider').addEventListener('input', e => { let pos = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück if (Math.abs(pos - RDR_MID) <= RDR_SNAP) { pos = RDR_MID; e.target.value = RDR_MID; } // Fangpunkt "jetzt" _radarPause(); // Entprellen: pro Animationsframe nur EIN setTiles, egal wie schnell gezogen wird // (sonst bricht jeder neue Frame die laufenden Kachel-Requests ab → AbortError-Spam). _rdrPendingIdx = _radarPosToIdx(pos); if (_rdrRaf == null) { _rdrRaf = requestAnimationFrame(() => { _rdrRaf = null; if (_rdrPendingIdx != null) { _showRadarFrame(_rdrPendingIdx); _rdrPendingIdx = null; } }); } }); } // Breite an die Status-Pill angleichen → gleiche linke + rechte Kante. const pill = document.querySelector('.map-statusbar'); if (pill && pill.offsetWidth > 60) el.style.width = pill.offsetWidth + 'px'; _updateRadarTimelineUI(); } function _updateRadarTimelineUI() { const slider = document.getElementById('rdr-slider'); const timeEl = document.getElementById('rdr-time'); const playBtn = document.getElementById('rdr-play'); if (slider) slider.value = _radarIdxToPos(_radarIdx); if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`); const f = _radarFrames[_radarIdx]; if (timeEl && f) { const d = new Date(f.time * 1000); const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60); const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`); timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text) } } function _toggleRadarPlay() { _radarPlaying ? _radarPause() : _radarPlay(); } function _radarPlay() { if (_radarFrames.length < 2) return; _radarPlaying = true; _updateRadarTimelineUI(); clearInterval(_radarPlayTimer); _radarPlayTimer = setInterval(() => { let next = _radarIdx + 1; if (next >= _radarFrames.length) next = 0; // Loop ans Ende → zurück zum Anfang _showRadarFrame(next); }, 500); } function _radarPause() { _radarPlaying = false; clearInterval(_radarPlayTimer); _radarPlayTimer = null; _updateRadarTimelineUI(); } // ---------------------------------------------------------- // 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); } _addBasemap(); // Theme-Wechsel → Basemap aktualisieren (Vektor: Layer neu bauen / Raster: CSS-Filter) _themeObserver = new MutationObserver(() => _onThemeChange()); _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _onThemeChange); setTimeout(() => _map.invalidateSize(), 100); setTimeout(() => _map.invalidateSize(), 600); window.addEventListener('resize', () => _map.invalidateSize()); _map.on('moveend zoomend', () => { _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(true); }); _map.on('dragstart', () => { _followGps = false; _updateFollowBtn(); }); // manuelles Verschieben beendet Follow 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'); }); } // ========================================================== // MapLibre-GL-Engine (zentrale Karte) — GPU/Worker, performant. // Flag-gated; Raster-Leaflet bleibt Default. [lng,lat]-Reihenfolge! // ========================================================== // Flag: ?mapgl=1/0 → localStorage 'by_map_gl'. Default: auf allen deployten Hosts AN // (Prod banyaro.app/.de + staging.banyaro.app); localhost/LAN bleibt OSM-Raster (keine // Tiles lokal). by_map_gl=0 erzwingt Leaflet-Fallback. Freigegeben für Prod 2026-06-05. function _useGL() { try { const u = new URLSearchParams(location.search); if (u.has('mapgl')) localStorage.setItem('by_map_gl', u.get('mapgl') === '0' ? '0' : '1'); const flag = localStorage.getItem('by_map_gl'); if (flag === '1') return true; if (flag === '0') return false; return /(^|\.)banyaro\.(app|de)$/.test(location.hostname); } catch (e) { return false; } } // Offline-Vektorkacheln-Flag — zentrale Logik in boot.js BY.offlineTiles(). // Steuert nur die Button-Sichtbarkeit: im GL-Modus ohne byt://-Quelle wäre der Download nutzlos. function _offlineTilesEnabled() { try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; } } // ---------------------------------------------------------- // Follow-Button auf der Karte (Wunsch René 2026-06-06: „wie in Routen") — // Crosshair links unter den Zoom-Reglern (unterhalb des Offline-Puls-Icons). // Ein Tipp = zentrieren + folgen; Karte ziehen = Folgen pausiert (Button grau). // ---------------------------------------------------------- function _updateFollowBtn() { const b = document.getElementById('map-follow-btn'); if (b) b.style.color = _followGps ? 'var(--c-primary)' : 'var(--c-text-secondary, #9ca3af)'; } // Kompass → Blickrichtungs-Kegel am Standort-Punkt (René 2026-06-06: „fühlt sich an, // als ob es nicht weiß, wo es hinschaut"). iOS verlangt requestPermission in einer // User-Geste → Start über Follow-/Standort-Button. Karte bleibt genordet (dragRotate // aus), daher gilt: Bildschirm-Rotation des Kegels = Kompass-Heading direkt. let _compassOn = false, _compassRaf = null; async function _startCompass() { if (_compassOn || !window.DeviceOrientationEvent) return; _compassOn = true; if (typeof DeviceOrientationEvent.requestPermission === 'function') { try { await DeviceOrientationEvent.requestPermission(); } catch (e) {} } let smoothed = null; window.addEventListener('deviceorientation', e => { let raw = null; if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS else if (e.alpha != null && e.absolute !== false) raw = 360 - e.alpha; // Android if (raw == null) return; // Glätten über den kürzesten Winkelweg (sonst springt der Kegel bei 359↔0) if (smoothed == null) smoothed = raw; else { const d = ((raw - smoothed + 540) % 360) - 180; smoothed = (smoothed + d * 0.25 + 360) % 360; } if (_compassRaf) return; _compassRaf = requestAnimationFrame(() => { _compassRaf = null; const cone = document.querySelector('#central-map .loc-heading'); if (cone) { cone.style.display = 'block'; cone.style.transform = `rotate(${smoothed}deg)`; } }); }, true); } function _ensureFollowBtn() { const host = document.getElementById('central-map'); if (!host || document.getElementById('map-follow-btn')) { _updateFollowBtn(); return; } const b = document.createElement('button'); b.id = 'map-follow-btn'; b.type = 'button'; b.title = 'Karte folgt deinem Standort'; // FIXED + safe-area wie das Offline-Puls-Icon (+110px) — Crosshair sitzt in der // Lücke ZWISCHEN Zoom-Reglern und Offline-Icon (René 2026-06-06). b.style.cssText = 'position:fixed;left:10px;top:calc(env(safe-area-inset-top, 0px) + 70px);' + 'z-index:500;width:36px;height:36px;border-radius:50%;border:none;' + 'background:var(--c-surface,#fff);box-shadow:0 2px 8px rgba(0,0,0,.3);' + 'display:flex;align-items:center;justify-content:center;cursor:pointer'; b.innerHTML = ``; b.addEventListener('click', () => { if (!_userPos) { UI.toast.error('Standort noch nicht verfügbar.'); return; } _followGps = true; _startCompass(); // User-Geste → iOS-Kompass-Permission _mapSetView(_userPos.lat, _userPos.lon, Math.max(14, Math.round(_mapGetZoom()))); _updateFollowBtn(); }); host.appendChild(b); _updateFollowBtn(); } function loadMapLibre() { if (_maplibreLoaded) return Promise.resolve(); const v = '?v=' + (window.APP_VER || ''); if (!document.querySelector('link[href*="maplibre-gl.css"]')) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = '/js/vendor/maplibre-gl.css'; document.head.appendChild(l); } const seq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => { if ((src.includes('maplibre-gl.js') && window.maplibregl) || (src.includes('pmtiles.js') && window.pmtiles) || (src.includes('map-gl-style') && window.MapGLStyle) || (src.includes('map-offline') && window.MapOffline) || (src.includes('map-gl-markers') && window.MapGLMarkers)) return res(); const s = document.createElement('script'); s.src = src + v; s.onload = res; s.onerror = rej; document.head.appendChild(s); })), Promise.resolve()); return seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-offline.js', '/js/map-gl-markers.js']).then(() => { if (!(window.maplibregl && window.pmtiles && window.MapGLStyle && window.MapGLMarkers)) throw new Error('MapLibre nicht geladen'); if (!_pmtilesProtoReg) { const proto = new pmtiles.Protocol(); maplibregl.addProtocol('pmtiles', proto.tile); try { window.MapOffline && MapOffline.registerProtocol(); } catch (e) {} _pmtilesProtoReg = true; } _maplibreLoaded = true; }); } // ---- Engine-neutrale Facade (kapselt [lat,lon]↔[lng,lat] an EINER Stelle) ---- function _mapFlyTo(lat, lon, zoom, opts) { if (!_map) return; if (_engineGL) _map.flyTo({ center: [lon, lat], zoom, duration: opts && opts.duration ? opts.duration * 1000 : 1200 }); else _map.flyTo([lat, lon], zoom, opts); } function _mapSetView(lat, lon, zoom) { if (!_map) return; if (_engineGL) _map.jumpTo({ center: [lon, lat], zoom }); else _map.setView([lat, lon], zoom); } function _mapGetZoom() { return _map ? _map.getZoom() : 0; } function _mapResize() { if (!_map) return; if (_engineGL) _map.resize(); else _map.invalidateSize(); } function _mapGetCenter() { if (!_map) return null; const c = _map.getCenter(); // beide Engines: {lat, lng} return { lat: c.lat, lon: c.lng }; } // Bounds mit 15%-Padding (ersetzt Leaflets bounds.pad(0.15)) → {north,south,east,west} function _mapPaddedBounds(pad) { pad = pad == null ? 0.15 : pad; const b = _map.getBounds(); let n = b.getNorth(), s = b.getSouth(), e = b.getEast(), w = b.getWest(); const dLat = (n - s) * pad, dLon = (e - w) * pad; return { north: n + dLat, south: s - dLat, east: e + dLon, west: w - dLon }; } // ---- Engine-neutrale Wetter-Helfer (Raster-Overlay + Move-Listener + Temp-Marker) ---- // Raster-Overlay (Radar/Temp). handle = Leaflet-Layer | GL-Layer-ID-String. function _wxAddRaster(key, url, opacity, maxNativeZoom) { if (_engineGL) { const id = 'wx-' + key; _wxRemoveRaster(id); _map.addSource(id, { type: 'raster', tiles: [url], tileSize: 256, maxzoom: maxNativeZoom || 18 }); // Unter die POI-Marker/Cluster einfügen (sonst verdeckt das Overlay die Marker). let beforeId; const _ls = (_map.getStyle().layers || []); for (let i = 0; i < _ls.length; i++) { if (/^(cl-|clsym-|pt-|danger)/.test(_ls[i].id)) { beforeId = _ls[i].id; break; } } _map.addLayer({ id: id, type: 'raster', source: id, paint: { 'raster-opacity': opacity } }, beforeId); return id; } return window.L.tileLayer(url, { opacity: opacity, tileSize: 256, maxNativeZoom: maxNativeZoom || 18, maxZoom: 18 }).addTo(_map); } function _wxRemoveRaster(handle) { if (!handle || !_map) return; if (typeof handle === 'string') { if (_map.getLayer(handle)) _map.removeLayer(handle); if (_map.getSource(handle)) _map.removeSource(handle); } else if (handle.remove) { handle.remove(); } } function _mapOnMove(fn) { if (_engineGL) _map.on('moveend', fn); else _map.on('moveend zoomend', fn); } function _mapOffMove(fn) { if (_engineGL) _map.off('moveend', fn); else _map.off('moveend zoomend', fn); } // Temp-Pill an lat/lon. html = der innere Pill-
. Beide Engines: .remove() vorhanden. function _wxAddTempMarker(lat, lon, html) { if (_engineGL) { const wrap = document.createElement('div'); wrap.innerHTML = html; wrap.style.pointerEvents = 'none'; return new maplibregl.Marker({ element: wrap.firstElementChild || wrap, anchor: 'center' }) .setLngLat([lon, lat]).addTo(_map); } const icon = window.L.divIcon({ className: '', html: html, iconSize: null, iconAnchor: [20, 10] }); return window.L.marker([lat, lon], { icon: icon, zIndexOffset: 500, interactive: false }).addTo(_map); } function _initMapGL() { const el = document.getElementById('central-map'); if (!el || !window.maplibregl || _map) return; _engineGL = true; _covOn = false; // Bereiche-Layer-Status gehört zur Karten-Instanz const center = _userPos ? [_userPos.lon, _userPos.lat] : [8.6821, 50.1109]; // Frankfurt [lng,lat] const zoom = _userPos ? 14 : 10; _map = new maplibregl.Map({ container: 'central-map', style: MapGLStyle.build({ dark: _isDarkMode() }), center, zoom, attributionControl: false, maxZoom: 19, dragRotate: false, pitchWithRotate: false, }); // setTiles bricht beim schnellen Regler-Ziehen laufende Kachel-Requests ab → harmloser // AbortError. Eigener error-Handler verschluckt ihn, lässt echte Fehler aber durch. _map.on('error', (e) => { const err = e && e.error; const msg = (err && ((err.name || '') + ' ' + (err.message || ''))) || String(e || ''); if (/abort/i.test(msg)) return; console.warn('MapLibre:', err || e); }); // Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste). _map.touchZoomRotate.disableRotation(); _map.touchPitch.disable(); // Pinch bleibt in der Karte (verhindert iOS-Page-Zoom), ohne globales user-scalable=no. el.style.touchAction = 'none'; _map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); _map.addControl(new maplibregl.AttributionControl({ compact: true, customAttribution: '© OpenStreetMap contributors', })); MapGLStyle.collapseAttribution(_map); // nur ⓘ, nicht ausgeschrieben if (!_userPos) { _frankfurtTimer = setTimeout(() => _mapFlyTo(50.1109, 8.6821, 14, { duration: 2.5 }), 1200); } // Theme-Wechsel → Style neu setzen (Sources/Layer danach neu anlegen). _themeObserver = new MutationObserver(() => _onThemeChangeGL()); _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _onThemeChangeGL); _map.on('load', () => { _initPoiLayersGL(); _updateZoomDisplay(); _scheduleOsmLoad(); }); _map.on('moveend', () => { _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(true); document.getElementById('map-crosshair')?.classList.remove('dragging'); }); _map.on('movestart', () => { document.getElementById('map-crosshair')?.classList.add('dragging'); }); _map.on('dragstart', () => { _followGps = false; _updateFollowBtn(); }); // manuelles Verschieben beendet Follow window.addEventListener('resize', _mapResize); setTimeout(_mapResize, 100); setTimeout(_mapResize, 600); } function _onThemeChangeGL() { if (!_map || !_engineGL) return; _glLayersReady = false; _map.setStyle(MapGLStyle.build({ dark: _isDarkMode() })); // setStyle entfernt eigene Sources/Layer → nach Style-Load neu anlegen + Daten neu setzen. // (DOM-basierte maplibregl.Marker — Standort/Temp-Pillen/Rec-Dot — überleben setStyle.) _map.once('styledata', () => { _initPoiLayersGL(); Object.keys(TYPEN).forEach(_glPushLayer); // Wetter-Raster + Rec-Track waren Style-Layer → neu anlegen, falls aktiv. if (_radarActive) _loadRadar(); if (_tempActive && _tempUrl) _tempLayer = _wxAddRaster('temp', _tempUrl, 1.0, _tempMaxZoom); if (_recActive && _recTrack.length) _recTrackGL(); _scheduleOsmLoad(); }); } // GL-Datenmodell: POI-DATEN (nicht Marker) pro Kategorie. own = eigene Orte/Giftköder/ // Züchter (aus _loadAll), osm = Scan-Ergebnisse. Beim Setzen werden beide gemerged. let _glOsm = {}; let _glOwn = {}; function _glPushLayer(key) { if (!_engineGL || !window.MapGLMarkers) return; MapGLMarkers.setLayer(key, (_glOwn[key] || []).concat(_glOsm[key] || [])); } function _iconNameOf(t) { const m = /#([a-z0-9-]+)"/.exec(t && t.icon || ''); return m ? m[1] : null; } function _initPoiLayersGL() { if (!_map || !_engineGL || !window.MapGLMarkers || _glLayersReady) return; _glLayersReady = true; const types = {}; Object.keys(TYPEN).forEach(k => { types[k] = { color: TYPEN[k].color, iconName: _iconNameOf(TYPEN[k]), danger: DANGER_RADIUS[k] !== undefined }; }); MapGLMarkers.init(_map, { types, dangerKeys: Object.keys(DANGER_RADIUS), dangerRadiusM: 100, onClick: (props) => { if (props._kind === 'poison_alarm') { App.navigate('poison'); return true; } if (props._kind === 'place') { UI.toast.info(`${props.name || ''}${props.adresse ? ' · ' + props.adresse : ''}`.trim() || 'Eigener Ort'); return true; } return false; }, popupHTML: (props, key) => _buildPoiPopupHTML(props, key), popupWire: (props, key, close) => _wirePoiPopup(props, key, close), }); } // Popup-HTML für GL (spiegelt _showMarkerPopup; Züchter separat). function _buildPoiPopupHTML(props, layerKey) { const t = TYPEN[layerKey] || {}; if (props._kind === 'breeder') { const rasse = props.rasse_text ? `
${UI.escape(props.rasse_text)}
` : ''; const stadt = props.stadt ? `
${UI.escape(props.stadt)}
` : ''; return `
${t.icon || ''} ${UI.escape(props.zwingername || '')}
${rasse}${stadt}
`; } const label = props.name || t.label || ''; const isOwn = props.source === 'user' && (props.own === true || props.own === 'true' || props.own === 1); const isUser = props.source === 'user'; const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon']; const dogBtn = (props.source === 'osm' && DOG_TYPES.includes(layerKey)) ? `
Hund willkommen?
` : ''; const actionBtn = isOwn ? `` : ``; const openHours = props.opening_hours ? `
${UI.escape(String(props.opening_hours))}
` : ''; const phone = props.phone ? `
${UI.escape(String(props.phone))}
` : ''; const website = props.website ? `
Website
` : ''; return `
${t.icon || ''} ${UI.escape(String(label))}
${props.notiz ? `
${UI.escape(String(props.notiz))}
` : ''} ${openHours}${phone}${website}
${isUser ? ` Community-Pin${props.username ? ' · ' + UI.escape(String(props.username)) + '' : ''}` : ' OpenStreetMap'}
${dogBtn}${actionBtn}
`; } function _wirePoiPopup(props, layerKey, close) { if (props._kind === 'breeder') { document.getElementById('breeder-profile-btn')?.addEventListener('click', () => { close(); App.navigate('breeder', true, { zwingername: props.zwingername }); }); return; } const isOwn = props.source === 'user' && (props.own === true || props.own === 'true' || props.own === 1); document.getElementById('mp-action')?.addEventListener('click', () => { close(); if (isOwn) _deleteUserPoi(props.user_poi_id, null, layerKey); else _showReportDialog({ source: props.source, id: props.id, user_poi_id: props.user_poi_id, lat: props.lat, lon: props.lon }); }); const sendDog = async (welcome) => { const yes = document.getElementById('mp-dogyes'), no = document.getElementById('mp-dogno'); if (yes) yes.disabled = true; if (no) no.disabled = true; try { const r = await API.post('/osm-contrib/dog-friendly', { osm_id: props.id, osm_type: 'node', poi_type: layerKey, lat: props.lat, lon: props.lon, welcome, // Live-Präsenz-Beleg: wer am Ort steht, darf auch ohne aufgezeichnete Tour bewerten user_lat: _userPos?.lat ?? null, user_lon: _userPos?.lon ?? null, }); UI.toast.success((welcome ? 'Hund willkommen' : 'Hund nicht willkommen') + (r.submitted ? ' — eingetragen 🐾' : ' — wird übertragen 🐾')); close(); } catch (e) { UI.toast.error(e?.message || 'Konnte nicht eintragen.'); if (yes) yes.disabled = false; if (no) no.disabled = false; } }; document.getElementById('mp-dogyes')?.addEventListener('click', () => sendDog(true)); document.getElementById('mp-dogno')?.addEventListener('click', () => sendDog(false)); } // ---------------------------------------------------------- // Standort-Tracking — pulsierender blauer Punkt // ---------------------------------------------------------- function _startLocationTracking() { if (!navigator.geolocation || !_map) return; if (_engineGL) { _watchId = navigator.geolocation.watchPosition( pos => { const { latitude: lat, longitude: lon } = pos.coords; _userPos = { lat, lon }; if (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); } if (_locationMarker) { _locationMarker.setLngLat([lon, lat]); } else { const elx = document.createElement('div'); elx.className = 'loc-icon'; elx.innerHTML = '
'; _locationMarker = new maplibregl.Marker({ element: elx, anchor: 'center' }) .setLngLat([lon, lat]).addTo(_map); } // Pan nur bei brauchbarem Fix — ungenaue Positionen (>75 m) lassen die // Karte sonst zappeln („weiß nicht, wo es ist", René 2026-06-06). if (_followGps && !_recActive && (pos.coords.accuracy ?? 999) < 75) { _map.easeTo({ center: [lon, lat], duration: 600 }); } }, () => {}, { enableHighAccuracy: true, maximumAge: 2000, timeout: 15000 } ); return; } if (!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 (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(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); } if (_followGps && !_recActive && (pos.coords.accuracy ?? 999) < 75) _map.panTo([lat, lon]); }, () => {}, { enableHighAccuracy: true, maximumAge: 2000, 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 _isDarkMode() { const t = document.documentElement.getAttribute('data-theme'); if (t === 'dark') return true; if (t === 'light') return false; return window.matchMedia('(prefers-color-scheme: dark)').matches; } const _OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const _DARK_FILTER = 'invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85)'; function _buildTileLayer() { return L.tileLayer(_OSM_URL, { maxZoom: 19 }); } // Basemap hinzufügen: Vektor-PMTiles (Feature-Flag) mit sauberem Raster-Fallback. // Marker/Cluster/Overlays/Scan bleiben in beiden Fällen identisch. function _addBasemap() { const _addRaster = () => { _usingVector = false; _tileLayer = _buildTileLayer(); _tileLayer.addTo(_map); _tileLayer.on('load', _applyTileTheme); _applyTileTheme(); }; // ui.js exponiert UI als globales const (bare 'UI'), NICHT als window.UI! if (typeof UI !== 'undefined' && UI.map && UI.map.vectorEnabled && UI.map.vectorEnabled()) { UI.map.vectorLayer({ dark: _isDarkMode() }).then(layer => { if (!_map) return; _usingVector = true; _tileLayer = layer; layer.addTo(_map); _applyTileTheme(); // no-op bei Vektor (Theme steckt in den Tile-Farben) if (!_map._byVectorAttr) { _map._byVectorAttr = L.control.attribution({ prefix: false }).addTo(_map) .addAttribution('© OpenStreetMap contributors'); } }).catch(err => { console.warn('Vektor-Basemap nicht verfügbar — Fallback auf Raster:', err); if (_map) _addRaster(); }); } else { _addRaster(); } } // Theme-Wechsel: Vektor → Layer mit passendem Flavor neu bauen; Raster → CSS-Filter. function _onThemeChange() { if (_usingVector && _map && _tileLayer) { UI.map.vectorLayer({ dark: _isDarkMode() }).then(layer => { if (!_map) return; if (_tileLayer) _map.removeLayer(_tileLayer); _tileLayer = layer; layer.addTo(_map); }).catch(() => {}); } else { _applyTileTheme(); } } function _applyTileTheme() { if (!_map || _usingVector) return; // bei Vektor kein CSS-Filter (würde doppelt abdunkeln) const tilePaneEl = _map.getPane('tilePane'); if (tilePaneEl) tilePaneEl.style.filter = _isDarkMode() ? _DARK_FILTER : ''; } 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 = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; } else if (z < 14) { el.textContent = `Z${z}`; el.title = 'Ab Z14: alle Layer'; el.style.opacity = '0.7'; } else { el.textContent = `Z${z}`; el.title = ''; 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 // ---------------------------------------------------------- // Bewegungs-Gate (René 2026-06-06): Der Follow-Mode pannt alle paar Sekunden → // jedes moveend triggerte den Scanner LAUFEND. Scans aus Kartenbewegung laufen // erst, wenn sich das Zentrum ≥ 20 % der Viewport-Breite bewegt hat oder der // Zoom wechselt. Alle anderen Trigger (Marker gespeichert, Layer-Toggle, Retry, // Init) scannen weiter ungebremst (fromMove=false). let _lastScanCenter = null, _lastScanZoom = null; function _viewChangedEnough() { try { const zoom = Math.round(_mapGetZoom()); if (_lastScanZoom !== zoom) return true; if (!_lastScanCenter) return true; const c = _map.getCenter(); const b = _map.getBounds(); const viewM = _haversineRec(b.getSouth(), b.getWest(), b.getSouth(), b.getEast()); const movedM = _haversineRec(_lastScanCenter.lat, _lastScanCenter.lng, c.lat, c.lng); return movedM >= viewM * 0.2; } catch (e) { return true; } } function _scheduleOsmLoad(fromMove = false) { clearTimeout(_overpassTimer); _overpassTimer = setTimeout(() => { if (fromMove && !_viewChangedEnough()) return; try { _lastScanCenter = _map.getCenter(); _lastScanZoom = Math.round(_mapGetZoom()); } catch (e) {} _loadOsmLayers(); }, 600); } // OSM-Marker-Zählung (ohne eigene Orte), engine-neutral. function _osmCountOf(k) { if (_engineGL) return (_glOsm[k] || []).length; return (_layers[k] || []).filter(m => !m._ownPlace).length; } function _osmTotalCount() { if (_engineGL) return Object.values(_glOsm).reduce((a, arr) => a + (arr ? arr.length : 0), 0); return Object.values(_layers).flat().filter(m => !m._ownPlace).length; } // OSM-Marker eines Layers leeren (engine-neutral, eigene Orte bleiben). function _clearOsmLayer(k) { if (_engineGL) { _glOsm[k] = []; _glPushLayer(k); return; } _layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove()); _clusterGroups[k]?.clearLayers(); _layers[k] = _layers[k].filter(m => m._ownPlace); } async function _loadOsmLayers() { if (!_map) return; // Läuft schon ein Scan? Anfrage vormerken (nicht verwerfen) → wird danach nachgeholt. // Sonst gehen bei schnellen Zoom-/Pan-Folgen (z.B. Z16→Z13→Z14) Scans verloren → keine Marker. if (_overpassActive) { _scanQueued = true; return; } if (!_engineGL && !window.L) return; // MapLibre hat fraktionalen Zoom (z.B. 13.7) — auf ganze Stufe runden, damit die // Schwellen (10/14) der angezeigten Zoomstufe (Statusleiste rundet ebenso) entsprechen. // Sonst verschwinden Marker bei angezeigtem Z14, wenn der echte Zoom 13.x ist. const zoom = Math.round(_mapGetZoom()); // Unter Zoom 10: alles ausblenden if (zoom < 10) { Object.keys(OSM_LAYER_MAP).forEach(_clearOsmLayer); _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(_clearOsmLayer); } _overpassActive = true; // GL: KEIN resize() im Scan — MapLibre würde dadurch move/moveend feuern → // triggert den Scan erneut → Endlosschleife. invalidateSize ist eine Leaflet-Eigenheit. if (!_engineGL) _mapResize(); let bbox; if (_engineGL) { const p = _mapPaddedBounds(0.15); bbox = { south: p.south, west: p.west, north: p.north, east: p.east }; } else { const b = _map.getBounds().pad(0.15); 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 (engine-neutral) function _replaceOsmMarkers(layerKey, pois) { if (_engineGL) { _glOsm[layerKey] = pois || []; _glPushLayer(layerKey); return; } const cluster = _getCluster(layerKey); 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); const t = TYPEN[layerKey]; const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t)); cluster.addLayers(newMarkers); _layers[layerKey].push(...newMarkers); if (_visible[layerKey] !== false && _map && !_map.hasLayer(cluster)) { cluster.addTo(_map); } } // POIs holen — WICHTIG: r.ok prüfen! Der SW antwortet offline auf nicht-cachebare // API-GETs mit 503 + JSON-Body ({detail:…}) → r.json() wirft NICHT, der Erfolgs-Pfad // liefe mit einem Objekt statt Array weiter und ersetzte die Marker durch nichts. const _fetchPois = async (params) => { const r = await fetch(`/api/osm/pois?${params}`); if (!r.ok) throw new Error(`pois ${r.status}`); const pois = await r.json(); return Array.isArray(pois) ? pois : []; }; // 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 _fetchPois(params); _replaceOsmMarkers(layerKey, pois); return pois.length; } catch { // Offline: gespeicherte Region-POIs aus IndexedDB (MapOffline.downloadAround // legt sie beim Region-Download mit ab) statt leerer Karte. try { const off = window.MapOffline ? await MapOffline.pois(osmType, bbox) : []; if (off.length) { _replaceOsmMarkers(layerKey, off); return off.length; } } catch (e) {} 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 _fetchPois(params); const osmCount = _osmCountOf(layerKey); if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois); _done++; const pct = Math.round(20 + _done / _total * 80); const total = _osmTotalCount(); _setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct); return pois.length; } catch { _done++; const pct = Math.round(20 + _done / _total * 80); const total = _osmTotalCount(); _setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct); return _osmCountOf(layerKey); } }); try { await Promise.all(freshTasks); } finally { _overpassActive = false; // Während des Scans kam eine neue Anfrage (Karte bewegt) → jetzt nachholen, // damit die zuletzt sichtbare Ansicht garantiert gescannt wird. if (_scanQueued) { _scanQueued = false; _scheduleOsmLoad(); } } const totalLoaded = _osmTotalCount(); 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-Overpass-Fetch läuft noch — bis zu 8× nachfragen // Overpass für alle Layer sequential: bis zu ~4min → Retries müssen das abdecken if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) { _autoRetryCount++; // 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s const delays = [10000, 20000, 35000, 50000, 70000, 90000, 120000, 150000]; const delay = delays[_autoRetryCount - 1] || 120000; _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 ? `` : ``; // "Hund willkommen?" — 👍/👎 (dog=yes/no) bei OSM-POIs, wo's Sinn ergibt. // dog=no nötig, weil Pächter wechseln und ein Ort nicht mehr hundefreundlich wird. const DOG_TYPES = ['restaurant', 'hotel', 'shop', 'tierarzt', 'hundesalon']; const dogBtn = (poi.source === 'osm' && DOG_TYPES.includes(layerKey)) ? `
Hund willkommen?
` : ''; 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'}
${dogBtn}${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); }); const _sendDog = async (welcome) => { const yes = document.getElementById('mp-dogyes'); const no = document.getElementById('mp-dogno'); if (yes) yes.disabled = true; if (no) no.disabled = true; try { const r = await API.post('/osm-contrib/dog-friendly', { osm_id: poi.id, osm_type: 'node', poi_type: layerKey, lat: poi.lat, lon: poi.lon, welcome, // Live-Präsenz-Beleg: wer am Ort steht, darf auch ohne aufgezeichnete Tour bewerten user_lat: _userPos?.lat ?? null, user_lon: _userPos?.lon ?? null, }); UI.toast.success((welcome ? 'Hund willkommen' : 'Hund nicht willkommen') + (r.submitted ? ' — eingetragen 🐾' : ' — wird übertragen 🐾')); marker.closePopup(); } catch (e) { UI.toast.error(e?.message || 'Konnte nicht eintragen.'); if (yes) yes.disabled = false; if (no) no.disabled = false; } }; document.getElementById('mp-dogyes')?.addEventListener('click', () => _sendDog(true)); document.getElementById('mp-dogno')?.addEventListener('click', () => _sendDog(false)); }, 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: 'gefahr', icon: '', label: 'Gefahr', color: '#F59E0B' }, { type: 'freilauf', icon: '', label: 'Freilauf', color: '#22C55E' }, { type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' }, { type: 'restaurant', icon: '', label: 'Restaurant', color: '#F97316' }, { type: 'shop', icon: '', label: 'Shop', color: '#3B82F6' }, { type: 'tierarzt', icon: '', label: 'Tierarzt', color: '#EF4444' }, { type: 'hundesalon', icon: '', label: 'Hundesalon', color: '#EC4899' }, { type: 'hundeschule', icon: '', label: 'Hundeschule', color: '#8B5CF6' }, { 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: '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(); if (_engineGL) { const dot = document.createElement('div'); dot.style.cssText = 'width:20px;height:20px;border-radius:50%;background:#F59E0B;opacity:.7;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)'; _tempMarker = new maplibregl.Marker({ element: dot, anchor: 'center' }) .setLngLat([latlng.lng, latlng.lat]).addTo(_map); } else { _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; } // res.ok prüfen: SW antwortet offline mit 503+JSON → json() wirft nicht, // sonst Erfolgs-Toast obwohl nichts gemeldet wurde. (202 = offline gequeued = ok.) if (!res.ok) throw new Error(`report ${res.status}`); 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(); if (_engineGL) { const drop = arr => (arr || []).filter(p => String(p.user_poi_id) !== String(poiId)); _glOsm[layerKey] = drop(_glOsm[layerKey]); _glOwn[layerKey] = drop(_glOwn[layerKey]); _glPushLayer(layerKey); } else { _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; if (_engineGL) { _glOwn = {}; // eigene-Orte-Daten leeren Object.keys(TYPEN).forEach(_glPushLayer); // OSM-Scan-Daten bleiben } else { Object.values(_clusterGroups).forEach(cg => cg.clearLayers()); Object.values(_layers).flat().filter(m => m._ownPlace).forEach(m => { m._dangerCircle?.remove(); m.remove(); }); (_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(), ]); // Offline-Fallback PRO QUELLE (nicht alles-oder-nichts): Der SW cached /api/places und // /api/breeder/map-markers (feste URLs), aber /api/poison?lat=… ändert sich mit jeder // Position → Cache-Miss → vorher verschwanden offline ausgerechnet die GIFTKÖDER, // während places aus dem SW-Cache kam und den allFailed-Fallback verhinderte // (Gerätetest 2026-06-07). Jede Quelle fällt einzeln auf den letzten guten Stand zurück. let cached = null; try { cached = JSON.parse(localStorage.getItem(_MAP_POI_KEY) || 'null'); } catch {} const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected'); const placesVal = places.status === 'fulfilled' ? places.value : (cached?.places || []); let poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : (cached?.poison || []); const breederVal = breederList.status === 'fulfilled' ? breederList.value : (cached?.breeders || []); // Giftköder zusätzlich aus dem Offline-Region-Snapshot (deckt vorab gespeicherte // Gegenden ab, wo der localStorage-Stand der letzten Position nicht hinreicht). if (poisonList.status === 'rejected' && window.MapOffline?.alerts) { try { const c = _map ? _map.getCenter() : (_userPos ? { lat: _userPos.lat, lng: _userPos.lon } : null); if (c) { const off = await MapOffline.alerts('poison', { south: c.lat - 0.5, north: c.lat + 0.5, west: c.lng - 0.7, east: c.lng + 0.7 }); const seen = new Set(poisonVal.map(p => p.id)); poisonVal = poisonVal.concat(off.filter(p => !seen.has(p.id))); } } catch {} } if (allFailed && (placesVal.length || poisonVal.length || breederVal.length)) { UI.toast.info('Offline — Karte zeigt zuletzt geladene Daten.'); } _addPlaces(placesVal); _addPoison(poisonVal); _addBreeders(breederVal); if (places.status === 'fulfilled' || poisonList.status === 'fulfilled' || breederList.status === 'fulfilled') { try { localStorage.setItem(_MAP_POI_KEY, JSON.stringify({ ts: Date.now(), places: placesVal, poison: poisonVal, breeders: breederVal, })); } catch {} } _scheduleOsmLoad(); } function _addPlaces(places) { if (_engineGL) { const touched = new Set(); places.forEach(place => { if (!TYPEN[place.typ]) return; (_glOwn[place.typ] = _glOwn[place.typ] || []).push({ lat: place.lat, lon: place.lon, name: place.name, adresse: place.adresse, _kind: 'place', source: 'place', }); touched.add(place.typ); }); touched.forEach(_glPushLayer); return; } 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 (_engineGL) { items.forEach(p => { (_glOwn.poison = _glOwn.poison || []).push({ lat: p.lat, lon: p.lon, name: 'Giftk\u00f6der-Alarm', beschreibung: p.beschreibung, _kind: 'poison_alarm', source: 'poison', }); }); _glPushLayer('poison'); return; } 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 (_engineGL) { breeders.forEach(b => { if (b.location_lat == null || b.location_lng == null) return; (_glOwn.zuechter = _glOwn.zuechter || []).push({ lat: b.location_lat, lon: b.location_lng, _kind: 'breeder', source: 'breeder', zwingername: b.zwingername, rasse_text: b.rasse_text, stadt: b.stadt, }); }); _glPushLayer('zuechter'); return; } 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(UI.escape(b.zwingername), { direction: 'top', offset: [0, -16] }); marker.on('click', () => { const rasseText = b.rasse_text ? `
${UI.escape(b.rasse_text)}
` : ''; const stadtText = b.stadt ? `
${UI.escape(b.stadt)}
` : ''; marker.bindPopup(`
${t.icon} ${UI.escape(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]; if (_engineGL) { keys.forEach(k => { const on = _visible[layer]; _visible[k] = on; if (window.MapGLMarkers) MapGLMarkers.setVisible(k, on); if (k === 'poison' && _map && _map.getLayer && _map.getLayer('danger-fill')) { const vis = on ? 'visible' : 'none'; ['danger-fill', 'danger-line'].forEach(id => { if (_map.getLayer(id)) _map.setLayoutProperty(id, 'visibility', vis); }); } }); return; } 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; } // GL-Modus: Gebiet um die KARTENMITTE budget-getrieben (~5 MB) speichern — Stadt klein, // Land groß (Ring-Wachstum in MapOffline). Kartenmitte statt GPS, damit man eine entfernte // Gegend (Urlaubsort) vorab speichern kann. docs/OFFLINE_MAPS_PLAN.md async function _downloadVectorRegion() { if (!_map || !window.MapOffline) return; const btn = document.getElementById('map-offline-btn'); if (btn?.classList.contains('loading')) return; // läuft bereits const c = _map.getCenter(); btn?.classList.add('loading'); _setOsmStatus('Offline: 0 MB…'); try { const res = await MapOffline.downloadAround(c.lat, c.lng, { budgetMB: 5, onProgress: p => { _setOsmStatus(`Offline: ${(p.bytes / 1048576).toFixed(1)} / ${Math.round(p.budget / 1048576)} MB…`); } }); _setOsmStatus(''); UI.toast.success(`Gegend offline gespeichert — ~${res.radiusKm} km Umkreis, ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.`); window.OfflineIndicator?.refresh(); // Pfoten-Segment 5 sofort grün if (_covOn) _setCoverage(true); // Bereiche-Layer aktualisieren } catch (e) { _setOsmStatus(''); UI.toast.error('Offline-Download fehlgeschlagen — bitte erneut versuchen.'); } finally { btn?.classList.remove('loading'); } } // Bereichsauswahl: den SICHTBAREN Karten-Ausschnitt komplett speichern (z.B. fürs // Urlaubsziel: hinzoomen/-schieben, speichern). Cap 40 MB, Zu-groß-Schutz in MapOffline. async function _downloadViewport() { if (!_map || !window.MapOffline) return; const btn = document.getElementById('map-offline-btn'); if (btn?.classList.contains('loading')) return; const p = _mapPaddedBounds(0.02); btn?.classList.add('loading'); _setOsmStatus('Offline: 0 MB…'); try { const res = await MapOffline.downloadBbox( { south: p.south, west: p.west, north: p.north, east: p.east }, { capMB: 40, onProgress: pr => { _setOsmStatus(`Offline: ${(pr.bytes / 1048576).toFixed(1)} MB (${Math.round(pr.done / pr.total * 100)} %)…`); } }); _setOsmStatus(''); UI.toast.success(`Ausschnitt offline gespeichert — ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.` + `${res.capped ? ' (40-MB-Limit erreicht)' : ''}`); window.OfflineIndicator?.refresh(); if (_covOn) _setCoverage(true); } catch (e) { _setOsmStatus(''); UI.toast.error(e?.message?.includes('zu groß') ? e.message : 'Offline-Download fehlgeschlagen — bitte erneut versuchen.'); } finally { btn?.classList.remove('loading'); } } // ---------------------------------------------------------- // Offline-Bereiche-Layer (gespeicherte z14-Kacheln) + Verwaltungs-Modal // ---------------------------------------------------------- let _covOn = false; async function _setCoverage(on) { if (!_engineGL || !_map || !window.MapOffline) return false; if (!on) { try { if (_map.getLayer('by-off-cov-line')) _map.removeLayer('by-off-cov-line'); if (_map.getLayer('by-off-cov')) _map.removeLayer('by-off-cov'); if (_map.getSource('by-off-cov')) _map.removeSource('by-off-cov'); } catch (e) {} _covOn = false; return false; } const gj = await MapOffline.coverage().catch(() => null); if (!gj || !gj.features.length) { UI.toast.info('Noch keine Offline-Bereiche gespeichert.'); return false; } // Funkloch-Gebiete orange, manuell gespeicherte blau (Wunsch René 2026-06-08). const covColor = ['match', ['get', 'kind'], 'funkloch', '#f59e0b', '#3b82f6']; if (_map.getSource('by-off-cov')) { _map.getSource('by-off-cov').setData(gj); } else { _map.addSource('by-off-cov', { type: 'geojson', data: gj }); _map.addLayer({ id: 'by-off-cov', type: 'fill', source: 'by-off-cov', paint: { 'fill-color': covColor, 'fill-opacity': 0.15 } }); _map.addLayer({ id: 'by-off-cov-line', type: 'line', source: 'by-off-cov', paint: { 'line-color': covColor, 'line-opacity': 0.35, 'line-width': 0.5 } }); } _covOn = true; return true; } // Verwaltungs-Modal am Offline-Button: Stats + Gebiet speichern / Bereiche anzeigen / Löschen. async function _openOfflineModal() { if (!window.MapOffline) return; let s = { regions: [] }; try { s = await MapOffline.stats(); } catch (e) {} const regions = s.regions || []; const totalBytes = s.totalBytes || regions.reduce((a, r) => a + (r.bytes || 0), 0); const totalPois = regions.reduce((a, r) => a + (r.pois || 0), 0); UI.modal.open({ title: '🗺️ Offline-Karten', body: `

${regions.length ? `${regions.length} ${regions.length === 1 ? 'Gebiet' : 'Gebiete'} gespeichert — ~${(totalBytes / 1048576).toFixed(1)} MB, ${totalPois} Marker.` : 'Noch kein Gebiet gespeichert. Karte und Marker bleiben damit auch im Funkloch verfügbar.'}

manuell gespeichert  ·  Funkloch (automatisch)

${regions.length ? `` : ''}
${(s.deadzones || []).length ? `
Funkloch-Gebiete (${s.deadzones.length}) — werden automatisch aktuell gehalten
${s.deadzones.map(z => `
📡 ${new Date(z.ts).toLocaleDateString('de-DE')} · ${z.lat.toFixed(3)}, ${z.lon.toFixed(3)} · ${z.filled ? 'geladen' : 'ausstehend'}
`).join('')}
` : ''} `, footer: ``, }); document.getElementById('off-dl')?.addEventListener('click', () => { UI.modal.close(); _downloadVectorRegion(); }); document.getElementById('off-bbox')?.addEventListener('click', () => { UI.modal.close(); _downloadViewport(); }); // Ent-Funklochen: Zone aus dem Gedächtnis nehmen (✕) — lädt nicht mehr automatisch. document.querySelectorAll('.off-zone-del').forEach(b => b.addEventListener('click', async e => { const ts = Number(e.currentTarget.dataset.ts); e.currentTarget.closest('.off-zone-row')?.remove(); await MapOffline.removeDeadZone(ts).catch(() => {}); UI.toast.success('Funkloch-Gebiet entfernt — wird nicht mehr automatisch geladen.'); window.OfflineIndicator?.refresh(); })); document.getElementById('off-cov')?.addEventListener('click', async () => { UI.modal.close(); await _setCoverage(!_covOn); }); document.getElementById('off-clear')?.addEventListener('click', async e => { const btn = e.currentTarget; if (btn.dataset.confirm !== '1') { // Zweiklick statt confirm-Modal im Modal btn.dataset.confirm = '1'; btn.innerHTML = `${UI.icon('trash')} Wirklich alles löschen?`; return; } // SELEKTIV löschen (René 2026-06-08, spart Vorladezeit): Standort-Gebiet + Korridore // der gespeicherten Routen bleiben einfach stehen statt löschen-und-neu-laden. // (Korridor-Keep kommt primär aus der Region-Meta; API-Tracks sind Ergänzung.) let keepTracks = []; try { keepTracks = ((await API.routes.list()) || []) .map(r => ({ name: r.name, track: r.preview_track })) .filter(o => (o.track || []).length >= 2); } catch (e) {} // Position: GPS-Fix, sonst letzte bekannte Position (wetter.js et al.) let center = _userPos ? { lat: _userPos.lat, lon: _userPos.lon } : null; if (!center) { try { const p = JSON.parse(localStorage.getItem('by_last_position') || 'null'); if (p?.lat != null) center = { lat: p.lat, lon: p.lon }; } catch (e) {} } const sum = await MapOffline.clear({ center, keepTracks }).catch(() => null); _setCoverage(false); UI.modal.close(); // Sichtbarkeit, WAS behalten wurde — Diagnose-Hilfe für Gerätetests. const kept = []; if (sum?.standort) kept.push('Standort'); if (sum?.korridore) kept.push(`${sum.korridore} Route${sum.korridore === 1 ? '' : 'n'}`); if (sum?.funkloch) kept.push(`${sum.funkloch} Funkloch-Gebiet${sum.funkloch === 1 ? '' : 'e'}`); UI.toast.success(kept.length ? `Manuelle Gebiete gelöscht — behalten: ${kept.join(', ')}.` : 'Offline-Karten gelöscht.'); // Sicherheitsnetz: falls am Standort nichts zu behalten war (z.B. nie geladen), // Grundversorgung jetzt herstellen. if (_userPos && navigator.onLine) { try { const r = await MapOffline.ensureHomeArea(_userPos.lat, _userPos.lon); if (r) UI.toast.info('Dein Standort-Gebiet wurde neu geladen — offline weiter verfügbar.'); } catch (e) {} } window.OfflineIndicator?.refresh(); }); } 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(); } // Aufzeichnung gedrosselt nach localStorage sichern (Sicherheitsnetz gegen // Datenverlust bei Reload/Crash). force=true schreibt sofort. let _recPersistAt = 0; function _persistRec(force) { const now = Date.now(); if (!force && now - _recPersistAt < 8000) return; _recPersistAt = now; window.RecStore?.save({ source: 'map', track: _recTrack, distKm: _recDistKm, startTime: _recStartTime }); } // Aufzeichnung endgültig abgeschlossen (gespeichert/verworfen): Speicher // leeren, Guard lösen und einen ggf. aufgeschobenen Update-Reload nachholen. function _recDone() { window.RecStore?.clear(); window._byRecording = false; window._byReloadIfPending?.(); } // Unterbrochene Aufzeichnung (Reload/Crash/Update) zum Fortsetzen anbieten. let _resumeOffered = false; async function _offerResume() { if (_recActive || _resumeOffered) return; const saved = window.RecStore?.load(); if (!saved || saved.source !== 'map' || !Array.isArray(saved.track) || saved.track.length < 2) return; if (Date.now() - (saved.ts || 0) > 6 * 3600 * 1000) { window.RecStore?.clear(); return; } // > 6h alt _resumeOffered = true; const km = (saved.distKm || 0).toFixed(2); const ok = await UI.modal.confirm({ title: 'Aufzeichnung fortsetzen?', message: `Eine unterbrochene Aufzeichnung wurde gefunden (${km} km, ${saved.track.length} Punkte). Möchtest du sie fortsetzen?`, confirmText: 'Fortsetzen', cancelText: 'Später', }); // Nur explizites Fortsetzen resumt; sonst Track behalten (erneut anbieten / // Staleness räumt nach 6h auf) — kein versehentlicher Datenverlust. if (ok) _startRecording(saved); } async function _startRecording(resume) { if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; } if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; } window._byRecording = true; // Guard: Update-Reload wird aufgeschoben _recActive = true; _recPaused = false; _recTrack = (resume && Array.isArray(resume.track)) ? resume.track.slice() : []; _recDistKm = resume?.distKm || 0; _recStartTime = resume?.startTime || 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); _followGps = true; // Aufzeichnung startet im Follow-Mode (Drag pausiert, Standort-Button reaktiviert) _updateFollowBtn(); _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 }); // Funkloch-Gedächtnis: Position melden — Tile-Fetch-Fehler bei aktivem GPS // markieren die Gegend als „Offline nötig" (lokal, map-offline.js). window.MapOffline?.setGps({ lat, lon }); _persistRec(); _updateRecMap(lat, lon); _updateRecStatus(); }, () => {}, { enableHighAccuracy: true, maximumAge: 0, timeout: 10000 } ); // Fortgesetzte Aufzeichnung: bestehenden Track sofort einzeichnen if (resume && _recTrack.length && _map) { const last = _recTrack[_recTrack.length - 1]; if (_engineGL) { _recTrackGL(); _updateRecMarker(last.lat, last.lon); _map.panTo([last.lon, last.lat]); } else if (window.L) { _recPolyline = L.polyline(_recTrack.map(p => [p.lat, p.lon]), { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_map); _updateRecMarker(last.lat, last.lon); _map.panTo([last.lat, last.lon]); } _updateRecStatus(); } _persistRec(true); UI.toast.success(resume ? 'Aufzeichnung fortgesetzt.' : '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) || _wakeLock) return; try { _wakeLock = await navigator.wakeLock.request('screen'); _wakeLock.addEventListener('release', () => { _wakeLock = null; // OS hat Lock entzogen → sofort neu anfordern wenn noch aufzeichnet if (_recActive) _acquireWakeLock(); }); } 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)); } // GL: Track-Linie aus dem vollen _recTrack (geojson line source) setzen. function _recTrackGL() { const geo = { type: 'Feature', geometry: { type: 'LineString', coordinates: _recTrack.map(p => [p.lon, p.lat]) } }; if (!_map.getSource('rectrack')) { _map.addSource('rectrack', { type: 'geojson', data: geo }); _map.addLayer({ id: 'rectrack', type: 'line', source: 'rectrack', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#EF4444', 'line-width': 5, 'line-opacity': 0.9 } }); } else { _map.getSource('rectrack').setData(geo); } } function _updateRecMarker(lat, lon) { if (_engineGL) { if (!_recMarker) { const d = document.createElement('div'); d.style.cssText = 'width:16px;height:16px;border-radius:50%;background:#fff;border:3px solid #EF4444;box-shadow:0 1px 4px rgba(0,0,0,.4)'; _recMarker = new maplibregl.Marker({ element: d, anchor: 'center' }).setLngLat([lon, lat]).addTo(_map); } else { _recMarker.setLngLat([lon, lat]); } } else { if (!_recMarker) { _recMarker = L.circleMarker([lat, lon], { radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3 }).addTo(_map); } else { _recMarker.setLatLng([lat, lon]); } } } // Track + Marker entfernen (engine-neutral). function _recCleanupMap() { if (_engineGL && _map) { if (_map.getLayer && _map.getLayer('rectrack')) _map.removeLayer('rectrack'); if (_map.getSource && _map.getSource('rectrack')) _map.removeSource('rectrack'); } else if (_recPolyline) { _recPolyline.remove(); } _recPolyline = null; if (_recMarker) { _recMarker.remove(); _recMarker = null; } } function _updateRecMap(lat, lon) { if (!_map) return; if (_engineGL) { _recTrackGL(); _updateRecMarker(lat, lon); if (_followGps) _map.panTo([lon, lat]); // MapLibre: [lng,lat] — Drag pausiert Follow return; } if (!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); } _updateRecMarker(lat, lon); if (_followGps) _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; window.MapOffline?.setGps(null); // Funkloch-Erkennung nur bei aktiver Aufzeichnung _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.'); _recCleanupMap(); _recDone(); return; } // Guard bleibt aktiv bis gespeichert/verworfen — der Track liegt jetzt im // Save-Modal UND (als Netz) in RecStore. _persistRec(true); 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 dogs = _appState?.dogs || []; const activeDogId = _appState?.activeDog?.id; const dogPickerHtml = dogs.length > 1 ? `
${dogs.map(d => { const checked = d.id === activeDogId; const av = d.foto_url ? `` : ``; return ``; }).join('')}
` : ''; const body = `

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

${dogPickerHtml}
`; 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(); _recCleanupMap(); _recDone(); }); // Hund-Checkbox Toggle-Styling document.querySelectorAll('.rec-dog-cb').forEach(cb => { const label = cb.closest('label'); const update = () => { label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)'; label.style.background = cb.checked ? 'var(--c-primary-subtle)' : ''; label.style.color = cb.checked ? 'var(--c-primary)' : ''; }; update(); cb.addEventListener('change', update); }); 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); const dogIds = [...document.querySelectorAll('.rec-dog-cb:checked')].map(c => parseInt(c.value)); 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', dog_ids: dogIds.length ? dogIds : null, }); UI.modal.close(); _recCleanupMap(); _recDone(); 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 = ``; // precip_prob = Höchstwert der nächsten 3 Stunden → Fenster im Pill kennzeichnen. const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}% (3h)${w.next_rain_time ? ` ab ${w.next_rain_time}` : ''}` : ''; const warning = w.rain_warning_time ? ` · ⚠ ab ${w.rain_warning_time}` : ''; let zecken = ''; if (w.zecken_warnung) { const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E'; zecken = ` · `; } info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`; info.classList.remove('map-weather-chip--hidden'); sep.classList.remove('map-weather-chip--hidden'); } catch { /* still */ } } // ---------------------------------------------------------- // Orts-Suche (Nominatim-Proxy) // ---------------------------------------------------------- async function _runSearch(q) { const resultsEl = document.getElementById('map-search-results'); if (!resultsEl) return; resultsEl.innerHTML = '
Suche…
'; resultsEl.style.display = ''; try { const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`); if (!data.length) { resultsEl.innerHTML = '
Keine Ergebnisse
'; return; } resultsEl.innerHTML = data.map((r, i) => `
${UI.escape(r.name)}
${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''}
` ).join(''); resultsEl.querySelectorAll('.map-search-item').forEach(el => { el.addEventListener('pointerdown', e => { e.stopPropagation(); const r = data[+el.dataset.i]; _flyToResult(r); document.getElementById('map-search-input').value = r.name; document.getElementById('map-search-clear').style.display = ''; resultsEl.style.display = 'none'; }); }); } catch { resultsEl.innerHTML = '
Suche nicht verfügbar
'; } } function _flyToResult(r) { if (!_map) return; _searchMarker?.remove(); if (_engineGL) { _mapFlyTo(r.lat, r.lon, 15, { duration: 1.0 }); const pin = document.createElement('div'); pin.style.cssText = 'width:30px;height:30px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);background:#C4843A;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.4)'; _searchMarker = new maplibregl.Marker({ element: pin, anchor: 'bottom' }) .setLngLat([r.lon, r.lat]).addTo(_map); const popup = new maplibregl.Popup({ maxWidth: '240px' }).setLngLat([r.lon, r.lat]) .setHTML(`
${UI.escape(r.name)}
${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''} `) .addTo(_map); setTimeout(() => { document.getElementById('search-marker-close')?.addEventListener('click', () => { _clearSearch(); popup.remove(); }); }, 50); return; } if (!window.L) return; _map.flyTo([r.lat, r.lon], 15, { duration: 1.0 }); _searchMarker = L.marker([r.lat, r.lon], { icon: L.divIcon({ className: '', html: `
`, iconSize: [32, 32], iconAnchor: [16, 32], }), zIndexOffset: 1000, }) .addTo(_map) .bindPopup(`
${UI.escape(r.name)}
${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''} `, { maxWidth: 240 }) .openPopup(); setTimeout(() => { document.getElementById('search-marker-close')?.addEventListener('click', () => { _clearSearch(); _searchMarker?.closePopup(); }); }, 50); } function _clearSearch() { const input = document.getElementById('map-search-input'); const results = document.getElementById('map-search-results'); const wrap = document.getElementById('map-search-wrap'); const btn = document.getElementById('map-search-btn'); if (input) { input.value = ''; input.blur(); } if (results) results.style.display = 'none'; wrap?.classList.remove('active'); btn?.classList.remove('active'); _searchMarker?.remove(); _searchMarker = null; clearTimeout(_searchTimer); } return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive }; })();