/* ============================================================
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 = `
. 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))
? `
` : '';
const actionBtn = isOwn
? `
`
: `
`;
const openHours = props.opening_hours ? `
${UI.escape(String(props.opening_hours))}
` : '';
const phone = props.phone ? `
` : '';
const website = props.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))
? `
`
: '';
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: `
`,
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 ? `
` : '';
const body = `
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
`;
const footer = `
`;
UI.modal.open({ title: '
Route benennen', body, footer });
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('rec-paw-val').value = btn.dataset.val;
});
document.getElementById('rms-discard')?.addEventListener('click', () => {
UI.modal.close();
_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 };
})();