banyaro/backend/static/js/pages/map.js
rene 2cdb743ce7 Selektives Loeschen: auch Funkloch-Gebiete bleiben + Keep-Set haertung
Rene: Funkloecher + Routen waren nach 'Alles loeschen' weiter weg.
- Funkloch-Regionen jetzt im Keep-Set (geloescht wird NUR Manuelles);
  Zonen behalten ihren Fuellstatus (Komplett-Wipe setzt weiter zurueck)
- Korridor-Migration beim Loeschen: keepTracks=[{name,track}] schreibt
  Tracks in Alt-Eintraege ohne r.track (Bestand vor v1236) bzw. legt
  fehlende Korridor-Regionen an — kein Warten auf Self-Healing
- clear() liefert Summary; Toast zeigt 'behalten: Standort, X Routen,
  Y Funkloch-Gebiete' — Diagnose-Sichtbarkeit fuer Geraetetests
Bump v1237
2026-06-06 13:55:37 +02:00

3055 lines
139 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — 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 _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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Café & Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundesalon: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scissors"></use></svg>', label: 'Hundesalon', color: '#EC4899', z: 25 },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280', z: -20 },
dog_park: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chair"></use></svg>', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', 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
}
_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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin-slash"></use></svg>';
}
});
}
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 = `
<div class="map-full-layout">
<div class="map-legend" id="map-legend">
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
<span class="map-legend-label">Filter</span>
</button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
${t.icon} <span class="map-legend-label">${t.label}</span>
</button>
`).join('')}
</div>
<div id="central-map" class="map-full"></div>
<!-- Fadenkreuz-Overlay (nur im Placement-Modus sichtbar) -->
<div class="map-crosshair" id="map-crosshair">
<div class="map-crosshair-pin"><svg class="ph-icon" aria-hidden="true" style="width:28px;height:28px"><use href="/icons/phosphor.svg#map-pin"></use></svg></div>
<div class="map-crosshair-shadow"></div>
</div>
<div class="map-place-bar" id="map-place-bar">
<span class="map-place-hint">Karte verschieben · Pin landet genau hier</span>
<div class="map-place-btns">
<button class="btn btn-secondary" id="map-place-cancel">Abbrechen</button>
<button class="btn btn-primary" id="map-place-confirm"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Hier platzieren</button>
</div>
</div>
<!-- Orts-Suche Panel (von oben einschiebend, geschlossen per default) -->
<div class="map-search-wrap" id="map-search-wrap">
<div class="map-search-row">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;flex-shrink:0;color:#888"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="map-search-input" class="map-search-input"
placeholder="Ort oder Adresse…" autocomplete="off" autocorrect="off" spellcheck="false">
<button class="map-search-clear" id="map-search-clear" aria-label="Suche schließen">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div class="map-search-results" id="map-search-results" style="display:none"></div>
</div>
<!-- Speed Dial -->
<div class="map-speed-dial" id="map-speed-dial">
<div class="map-sd-items">
<!-- DOM-Reihenfolge = Aufklappreihenfolge von unten nach oben -->
<div class="map-sd-item">
<span class="map-sd-label">Mein Standort</span>
<button class="map-sd-btn" id="map-locate-btn" title="Mein Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-sd-item">
<span class="map-sd-label">Ort suchen</span>
<button class="map-sd-btn" id="map-search-btn" title="Ort suchen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg></button>
</div>
<div class="map-sd-item">
<span class="map-sd-label">Marker setzen</span>
<button class="map-sd-btn map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
</div>
${(!_useGL() || _offlineTilesEnabled()) ? `
<div class="map-sd-item">
<span class="map-sd-label">Karte offline speichern</span>
<button class="map-sd-btn" id="map-offline-btn" title="Karte offline speichern"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-arrow-down"></use></svg></button>
</div>` : ''}
${App.hasPro(_appState?.user) ? `
<div class="map-sd-item">
<span class="map-sd-label">Regenradar</span>
<button class="map-sd-btn" id="map-radar-btn" title="Regenradar"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
</div>
<div class="map-sd-item">
<span class="map-sd-label">Temperatur</span>
<button class="map-sd-btn" id="map-temp-btn" title="Temperatur"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
</div>
` : ''}
</div>
<button class="map-fab map-sd-trigger" id="map-sd-trigger" title="Karten-Aktionen">
<svg class="ph-icon map-sd-icon-open" aria-hidden="true"><use href="/icons/phosphor.svg#dots-three-vertical"></use></svg>
<svg class="ph-icon map-sd-icon-close" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span>
<span id="map-osm-status" class="hidden"></span>
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span>
<span class="map-weather-chip--hidden" id="map-weather-info"></span>
</div>
<!-- Aufzeichnungs-Panel (erscheint beim Start) -->
<div class="map-rec-panel" id="map-rec-panel">
<div class="map-rec-stats">
<div class="map-rec-stat">
<span class="map-rec-val" id="rec-stat-dist">0.00</span>
<span class="map-rec-lbl">km</span>
</div>
<div class="map-rec-stat map-rec-stat--main">
<span class="map-rec-val" id="rec-stat-time">00:00</span>
<span class="map-rec-lbl">Zeit</span>
</div>
<div class="map-rec-stat">
<span class="map-rec-val" id="rec-stat-pace">:</span>
<span class="map-rec-lbl">min/km</span>
</div>
</div>
<div class="map-rec-actions">
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause">Pause</button>
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
</div>
<div class="map-rec-hint" id="map-rec-hint">
Bildschirm bleibt aktiv — GPS läuft
</div>
</div>
</div>
`;
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);
} 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;
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; }
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)}&current=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 = `<div style="background:${color};color:#fff;font-size:12px;font-weight:800;
padding:2px 6px;border-radius:10px;white-space:nowrap;
box-shadow:0 1px 4px rgba(0,0,0,0.5);text-shadow:0 1px 2px rgba(0,0,0,0.4)">${temp}°</div>`;
_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 =>
`<span style="flex:1;text-align:center;font-size:9px;color:#fff;text-shadow:0 0 3px #000">${s.v}</span>`
).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 = `
<div style="height:10px;border-radius:3px;background:linear-gradient(to right,${gradient});margin-bottom:2px"></div>
<div style="display:flex">${labels}</div>`;
document.getElementById('central-map')?.appendChild(el);
}
async function _loadRadar() {
if (!_radarActive || !_map) return;
try {
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
const data = await resp.json();
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
const frames = [...past, ...nowcast];
if (!frames.length) return;
_radarHost = data.host || _radarHost;
_radarFrames = frames.map(f => ({ path: f.path, time: f.time }));
_radarNowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
_showRadarFrame(_radarIdx);
_buildRadarTimeline();
} catch { /* still */ }
}
function _radarUrl(idx) {
return `${_radarHost}${_radarFrames[idx].path}/256/{z}/{x}/{y}/4/1_1.png`;
}
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
function _showRadarFrame(idx) {
if (!_radarActive || !_radarFrames[idx]) return;
_radarIdx = idx;
const url = _radarUrl(idx);
const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar');
if (src && src.setTiles) {
src.setTiles([url]);
} else {
if (_radarLayer) _wxRemoveRaster(_radarLayer);
_radarLayer = _wxAddRaster('radar', url, 0.7, 7);
}
_updateRadarTimelineUI();
}
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 = `
<button id="rdr-play" class="rdr-play" type="button" aria-label="Abspielen">
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#play"></use></svg>
</button>
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="${_radarFrames.length - 1}" value="${_radarIdx}" step="1" aria-label="Radar-Zeit">
<span id="rdr-time" class="rdr-time"></span>`;
document.getElementById('central-map')?.appendChild(el);
el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay);
el.querySelector('#rdr-slider').addEventListener('input', e => {
const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
_radarPause();
_showRadarFrame(idx);
});
} else {
el.querySelector('#rdr-slider').max = _radarFrames.length - 1;
}
// 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 = _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}`;
timeEl.classList.toggle('is-forecast', diffMin > 0);
}
}
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(); });
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; }
}
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-<div>. 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,
});
// 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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();
document.getElementById('map-crosshair')?.classList.remove('dragging');
});
_map.on('movestart', () => {
document.getElementById('map-crosshair')?.classList.add('dragging');
});
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 ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${UI.escape(props.rasse_text)}</div>` : '';
const stadt = props.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${UI.escape(props.stadt)}</div>` : '';
return `<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon || ''} ${UI.escape(props.zwingername || '')}</div>
${rasse}${stadt}<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button></div>`;
}
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))
? `<div style="margin-bottom:8px"><div style="font-size:11px;color:#666;margin-bottom:4px">Hund willkommen?</div>
<div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" id="mp-dogyes" style="flex:1" title="Hund willkommen"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#thumbs-up"></use></svg></button>
<button class="btn btn-secondary btn-sm" id="mp-dogno" style="flex:1" title="Hund nicht willkommen"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#thumbs-down"></use></svg></button>
</div></div>` : '';
const actionBtn = isOwn
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
const openHours = props.opening_hours ? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${UI.escape(String(props.opening_hours))}</div>` : '';
const phone = props.phone ? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${props.phone}" style="color:var(--c-primary);text-decoration:none">${UI.escape(String(props.phone))}</a></div>` : '';
const website = props.website ? `<div style="font-size:11px;margin-bottom:6px"><a href="${props.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg> Website</a></div>` : '';
return `<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon || ''} ${UI.escape(String(label))}</div>
${props.notiz ? `<div style="font-size:12px;color:#666;margin-bottom:6px">${UI.escape(String(props.notiz))}</div>` : ''}
${openHours}${phone}${website}
<div style="font-size:11px;color:#999;margin-bottom:10px">
${isUser ? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${props.username ? ' · <b>' + UI.escape(String(props.username)) + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>${dogBtn}${actionBtn}</div>`;
}
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,
});
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 = '<div class="loc-dot"></div><div class="loc-ring"></div>';
_locationMarker = new maplibregl.Marker({ element: elx, anchor: 'center' })
.setLngLat([lon, lat]).addTo(_map);
}
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }
);
return;
}
if (!window.L) return;
const icon = L.divIcon({
className: 'loc-icon',
html: '<div class="loc-dot"></div><div class="loc-ring"></div>',
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);
}
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }
);
}
// ----------------------------------------------------------
// Cluster-Gruppe holen / erstellen
// ----------------------------------------------------------
function _getCluster(layerKey) {
if (!_clusterGroups[layerKey]) {
_clusterGroups[layerKey] = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
animate: true,
chunkedLoading: true,
iconCreateFunction: cluster => {
const t = TYPEN[layerKey];
const n = cluster.getChildCount();
return L.divIcon({
className: '',
html: `<div style="background:${t.color};color:#fff;font-size:13px;font-weight:700;
width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4);
border:2px solid rgba(52,68,36,0.65)">${n}</div>`,
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('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = `
<ellipse cx="33" cy="22" rx="14" ry="8" fill="currentColor"/>
<ellipse cx="16" cy="20" rx="6" ry="7" fill="currentColor"/>
<ellipse cx="8" cy="27" rx="7" ry="6" fill="currentColor"/>
<ellipse cx="14" cy="16" rx="3" ry="5" fill="currentColor" transform="rotate(15,14,16)" opacity=".85"/>
<ellipse cx="3" cy="30" rx="3.5" ry="2.5" fill="currentColor"/>
<ellipse cx="1.5" cy="29" rx="2" ry="1.5" fill="#7a4f2a"/>
<circle cx="9" cy="24" r="1.3" fill="white" opacity=".9"/>
<rect x="22" y="28" width="3" height="10" rx="1.5" fill="currentColor"/>
<rect x="29" y="29" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="37" y="28" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="42" y="27" width="3" height="9" rx="1.5" fill="currentColor"/>
<g class="by-tail" transform="translate(47,17)">
<path d="M0,0 Q6,-10 4,-18" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round"/>
</g>
`;
dog.appendChild(svg);
mapEl.appendChild(dog);
}
const sr = statusbar.getBoundingClientRect();
const mr = mapEl.getBoundingClientRect();
dog.style.left = (sr.left - mr.left + sr.width - 36) + 'px';
dog.style.top = (sr.top - mr.top - 35) + 'px';
dog.style.opacity = '1';
if (pct >= 100) {
setTimeout(() => {
const d = document.getElementById('map-scan-dog');
if (d) { d.style.opacity = '0'; setTimeout(() => d?.remove(), 550); }
}, 500);
}
}
function _updateScanRing(pct) {
const statusbar = document.getElementById('map-statusbar');
if (!statusbar) return;
const mapEl = statusbar.closest('.map-main') || statusbar.parentElement;
if (!mapEl) return;
let svg = document.getElementById('map-scan-ring');
// Ring ausblenden / entfernen
if (pct === null) {
if (_ringClosing) return;
if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); }
statusbar.style.border = '';
return;
}
// SVG einmalig erzeugen
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'map-scan-ring';
svg.setAttribute('overflow', 'visible');
svg.style.cssText = 'position:absolute;pointer-events:none;z-index:1002;transition:opacity 0.55s ease';
const path = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
path.id = 'map-scan-ring-rect';
path.setAttribute('fill', 'none');
path.setAttribute('stroke', '#C4843A');
path.setAttribute('stroke-width', '3');
path.setAttribute('stroke-linecap', 'round');
svg.appendChild(path);
mapEl.appendChild(svg);
}
// Position relativ zum Map-Container berechnen
const sr = statusbar.getBoundingClientRect();
const mr = mapEl.getBoundingClientRect();
const w = sr.width;
const h = sr.height;
const r = h / 2; // border-radius-full = Hälfte der Höhe
const p = 2; // Abstand zur inneren Kante
// Umfang der Pill: gerades Stück + zwei Halbkreise
const perim = 2 * (w - h) + Math.PI * h;
// Natürlicher SVG-Start: linkes Ende der oberen Geraden
// 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2
// dashoffset = perim - S verschiebt den Dash-Start genau dorthin
const S = (w - h) / 2;
const progress = Math.min(100, Math.max(0, pct));
const progressLen = progress * perim / 100;
svg.style.left = (sr.left - mr.left - p) + 'px';
svg.style.top = (sr.top - mr.top - p) + 'px';
svg.style.width = (w + p * 2) + 'px';
svg.style.height = (h + p * 2) + 'px';
svg.style.opacity = '1';
const rect = document.getElementById('map-scan-ring-rect');
rect.setAttribute('x', String(p));
rect.setAttribute('y', String(p));
rect.setAttribute('width', String(w));
rect.setAttribute('height', String(h));
rect.setAttribute('rx', String(r));
rect.setAttribute('ry', String(r));
rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`);
rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2));
// Original-Rahmen verstecken während Ring aktiv ist
statusbar.style.border = 'none';
if (progress >= 100) {
_ringClosing = true;
setTimeout(() => {
const s = document.getElementById('map-scan-ring');
if (s) s.style.opacity = '0';
statusbar.style.border = '';
setTimeout(() => { s?.remove(); _ringClosing = false; }, 600);
}, 500);
}
}
// ----------------------------------------------------------
// OSM-Layer laden
// ----------------------------------------------------------
function _scheduleOsmLoad() {
clearTimeout(_overpassTimer);
_overpassTimer = setTimeout(_loadOsmLayers, 600);
}
// 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 1013: 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: `<div class="poison-marker">
<div class="poison-ring"></div>
<div class="poison-ring"></div>
<div class="poison-dot"><svg class="ph-icon" aria-hidden="true" style="width:20px;height:20px"><use href="/icons/phosphor.svg#skull"></use></svg></div>
</div>`,
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: `<div style="background:${t.color};color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
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
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
// "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))
? `<div style="margin-bottom:8px">
<div style="font-size:11px;color:#666;margin-bottom:4px">Hund willkommen?</div>
<div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm" id="mp-dogyes" style="flex:1" title="Hund willkommen">
<svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#thumbs-up"></use></svg>
</button>
<button class="btn btn-secondary btn-sm" id="mp-dogno" style="flex:1" title="Hund nicht willkommen">
<svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#thumbs-down"></use></svg>
</button>
</div>
</div>`
: '';
const openHours = poi.opening_hours
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
const phone = poi.phone
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">${poi.phone}</a></div>` : '';
const website = poi.website
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg> Website</a></div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${label}</div>
${poi.notiz ? `<div style="font-size:12px;color:#666;margin-bottom:6px">${poi.notiz}</div>` : ''}
${openHours}${phone}${website}
<div style="font-size:11px;color:#999;margin-bottom:10px">
${isUser
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>
${dogBtn}${actionBtn}
</div>
`, { 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,
});
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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>');
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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'gefahr', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B' },
{ type: 'freilauf', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'restaurant', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316' },
{ type: 'shop', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
{ type: 'tierarzt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
{ type: 'hundesalon', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scissors"></use></svg>', label: 'Hundesalon', color: '#EC4899' },
{ type: 'hundeschule', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', 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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
<label class="form-label">Typ auswählen <span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">(Mehrfachauswahl möglich)</span></label>
<div class="poi-type-grid">
${PIN_TYPES.map(p => `
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
data-type="${p.type}" data-excl="${p.exclusive ? '1' : ''}" style="--pt-color:${p.color}">
<span class="poi-type-icon">${p.icon}</span>
<span class="poi-type-label">${p.label}</span>
</button>
`).join('')}
</div>
</div>
<div>
<label class="form-label">Name (optional)</label>
<input class="form-input" id="poi-name" placeholder="z.B. Brunnen im Stadtpark">
</div>
<div>
<label class="form-label">Notiz (optional)</label>
<input class="form-input" id="poi-notiz" placeholder="Weitere Infos\u2026">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="poi-cancel">Abbrechen</button>
<button class="btn btn-primary" id="poi-save">Speichern</button>
`,
});
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: `
<p class="text-secondary">Warum ist dieser Marker falsch?</p>
<div class="flex flex-col gap-2" id="report-options">
<button class="btn btn-secondary" data-grund="existiert_nicht">Existiert nicht mehr</button>
<button class="btn btn-secondary" data-grund="falsche_position">Falsche Position</button>
<button class="btn btn-secondary" data-grund="spam">Spam / Missbrauch</button>
<button class="btn btn-secondary" data-grund="sonstiges">Sonstiges</button>
</div>
`,
});
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: `<div style="background:${t.color};color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
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 ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${UI.escape(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${UI.escape(b.stadt)}</div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${UI.escape(b.zwingername)}</div>
${rasseText}${stadtText}
<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button>
</div>
`, { 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: `<div style="background:${t.color};color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(52,68,36,0.55)">${t.icon}</div>`,
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: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
${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.'}
</p>
<div class="flex flex-col gap-2">
<button class="btn btn-primary" id="off-dl">${UI.icon('download-simple')} Dieses Gebiet speichern (~5 MB)</button>
<button class="btn btn-secondary" id="off-bbox">${UI.icon('squares-four')} Sichtbaren Ausschnitt speichern</button>
<button class="btn btn-secondary" id="off-cov">${UI.icon('stack')} Gespeicherte Bereiche ${_covOn ? 'ausblenden' : 'anzeigen'}</button>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0;text-align:center">
<span style="color:#3b82f6">■</span> manuell gespeichert &nbsp;·&nbsp;
<span style="color:#f59e0b">■</span> Funkloch (automatisch)
</p>
${regions.length ? `<button class="btn btn-secondary" id="off-clear" style="color:var(--c-danger)">${UI.icon('trash')} Alles löschen</button>` : ''}
</div>
${(s.deadzones || []).length ? `
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);margin-bottom:2px">
Funkloch-Gebiete (${s.deadzones.length}) — werden automatisch aktuell gehalten</div>
${s.deadzones.map(z => `
<div class="off-zone-row" style="display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:var(--text-xs);padding:4px 0;border-top:1px solid var(--c-border)">
<span style="color:var(--c-text-secondary)">📡 ${new Date(z.ts).toLocaleDateString('de-DE')} · ${z.lat.toFixed(3)}, ${z.lon.toFixed(3)} · ${z.filled ? 'geladen' : 'ausstehend'}</span>
<button type="button" class="btn btn-secondary off-zone-del" data-ts="${z.ts}"
title="Nicht mehr automatisch laden" style="padding:1px 8px;font-size:var(--text-xs)">✕</button>
</div>`).join('')}
</div>` : ''}
`,
footer: `<button class="btn btn-secondary" data-modal-close style="width:100%">Schließen</button>`,
});
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 1215 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 = `
<div class="po-status" id="po-status">GPS läuft</div>
<div class="po-time" id="po-time">0:00</div>
<div class="po-dist" id="po-dist">0.00 km</div>
<div class="po-hint">Tippen für Steuerung</div>
<div class="po-controls" id="po-controls">
<button class="po-btn" id="po-pause">⏸ Pause</button>
<button class="po-btn po-btn--stop" id="po-stop">⏹ Stopp</button>
</div>
`;
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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>'; btn.classList.add('recording'); }
// Aufzeichnungs-Panel einblenden
const panel = document.getElementById('map-rec-panel');
if (panel) panel.classList.add('active');
document.getElementById('rec-panel-pause').onclick = _togglePause;
document.getElementById('rec-panel-stop').onclick = _stopRecording;
// Wake Lock — Bildschirm wach halten
await _acquireWakeLock();
const hint = document.getElementById('map-rec-hint');
if (hint) hint.textContent = _wakeLock
? 'Bildschirm bleibt aktiv — GPS läuft'
: 'Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
// Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern
document.addEventListener('visibilitychange', _onVisibilityChange);
_recTimerInt = setInterval(_updateRecStatus, 1000);
_recWatchId = navigator.geolocation.watchPosition(
pos => {
if (_recPaused) return;
const { latitude: lat, longitude: lon } = pos.coords;
if (_recTrack.length > 0) {
const prev = _recTrack[_recTrack.length - 1];
const d = _haversineRec(prev.lat, prev.lon, lat, lon);
if (d < 3) return;
_recDistKm += d / 1000;
}
_recTrack.push({ lat, lon });
// 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);
_map.panTo([lon, lat]); // MapLibre: [lng,lat]
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);
_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 = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>'; 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 ? `
<div class="form-group">
<label class="form-label">Welche Hunde waren dabei?</label>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${dogs.map(d => {
const checked = d.id === activeDogId;
const av = d.foto_url
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
class="rec-dog-cb hidden">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('')}
</div>
</div>` : '';
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="rec-save-form" autocomplete="off">
${dogPickerHtml}
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name"
placeholder="Wird automatisch ermittelt…" required>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">
<option value="leicht">Leicht</option>
<option value="mittel">Mittel</option>
<option value="anspruchsvoll">Anspruchsvoll</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
<option value="wald">Wald</option>
<option value="asphalt">Asphalt</option>
<option value="wiese">Wiese</option>
<option value="mix">Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-select" id="rec-paw-select">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Premium</button>
</div>
<input type="hidden" name="hunde_tauglichkeit" id="rec-paw-val" value="sehr_gut">
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree"></use></svg> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leine empfohlen
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
</label>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rms-discard">Verwerfen</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
`;
UI.modal.open({ title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> 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 = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
// 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
? ` · <span style="color:#f59e0b;font-weight:700">⚠ ab ${w.rain_warning_time}</span>`
: '';
let zecken = '';
if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
}
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 = '<div class="map-search-loading">Suche…</div>';
resultsEl.style.display = '';
try {
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
if (!data.length) {
resultsEl.innerHTML = '<div class="map-search-empty">Keine Ergebnisse</div>';
return;
}
resultsEl.innerHTML = data.map((r, i) =>
`<div class="map-search-item" data-i="${i}">
<div class="map-search-item-name">${UI.escape(r.name)}</div>
${r.subtitle ? `<div class="map-search-item-sub">${UI.escape(r.subtitle)}</div>` : ''}
</div>`
).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 = '<div class="map-search-empty">Suche nicht verfügbar</div>';
}
}
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(`<div style="font-size:13px;font-weight:600">${UI.escape(r.name)}</div>
${r.subtitle ? `<div style="font-size:11px;color:#888">${UI.escape(r.subtitle)}</div>` : ''}
<button class="btn btn-secondary btn-sm" id="search-marker-close" style="margin-top:8px">Marker entfernen</button>`)
.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: `<div style="background:#C4843A;color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 8px rgba(0,0,0,0.4)">
<span style="transform:rotate(45deg)">
<svg style="width:16px;height:16px" viewBox="0 0 256 256" fill="currentColor">
<path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,48a32,32,0,1,1-32,32A32,32,0,0,1,128,64Zm0,144a80,80,0,0,1-56.37-23.37C74.18,170.06,98.65,160,128,160s53.82,10.06,56.37,24.63A80,80,0,0,1,128,208Z"/>
</svg>
</span></div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
}),
zIndexOffset: 1000,
})
.addTo(_map)
.bindPopup(`<div style="font-size:13px;font-weight:600">${UI.escape(r.name)}</div>
${r.subtitle ? `<div style="font-size:11px;color:#888">${UI.escape(r.subtitle)}</div>` : ''}
<button class="btn btn-secondary btn-sm" id="search-marker-close" style="margin-top:8px">
Marker entfernen
</button>`, { 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 };
})();