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
3055 lines
139 KiB
JavaScript
3055 lines
139 KiB
JavaScript
/* ============================================================
|
||
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)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' })
|
||
.then(r => r.json())
|
||
.then(d => ({ lat, lon, t: d.current?.temperature_2m }))
|
||
.catch(() => null)
|
||
));
|
||
|
||
// Alte Marker entfernen
|
||
_tempMarkers.forEach(m => m.remove());
|
||
_tempMarkers = [];
|
||
|
||
results.filter(Boolean).forEach(({ lat, lon, t }) => {
|
||
if (t == null) return;
|
||
const temp = Math.round(t);
|
||
const color = _tempColor(temp);
|
||
const html = `<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 10–13: normale OSM-Layer ausblenden, EARLY_LAYERS behalten/laden
|
||
if (zoom < 14) {
|
||
Object.keys(OSM_LAYER_MAP).filter(k => !EARLY_LAYERS.has(k)).forEach(_clearOsmLayer);
|
||
}
|
||
|
||
_overpassActive = true;
|
||
// GL: KEIN resize() im Scan — MapLibre würde dadurch move/moveend feuern →
|
||
// triggert den Scan erneut → Endlosschleife. invalidateSize ist eine Leaflet-Eigenheit.
|
||
if (!_engineGL) _mapResize();
|
||
let bbox;
|
||
if (_engineGL) {
|
||
const p = _mapPaddedBounds(0.15);
|
||
bbox = { south: p.south, west: p.west, north: p.north, east: p.east };
|
||
} else {
|
||
const b = _map.getBounds().pad(0.15);
|
||
bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
|
||
}
|
||
|
||
// Welche Layer bei diesem Zoom geladen werden
|
||
const activeLayers = zoom >= 14
|
||
? Object.entries(OSM_LAYER_MAP)
|
||
: Object.entries(OSM_LAYER_MAP).filter(([k]) => EARLY_LAYERS.has(k));
|
||
|
||
// OSM-Marker eines Layers ersetzen, eigene Orte behalten (engine-neutral)
|
||
function _replaceOsmMarkers(layerKey, pois) {
|
||
if (_engineGL) { _glOsm[layerKey] = pois || []; _glPushLayer(layerKey); return; }
|
||
const cluster = _getCluster(layerKey);
|
||
const oldOsm = _layers[layerKey].filter(m => !m._ownPlace);
|
||
oldOsm.forEach(m => m._dangerCircle?.remove());
|
||
cluster.removeLayers(oldOsm);
|
||
_layers[layerKey] = _layers[layerKey].filter(m => m._ownPlace);
|
||
const t = TYPEN[layerKey];
|
||
const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t));
|
||
cluster.addLayers(newMarkers);
|
||
_layers[layerKey].push(...newMarkers);
|
||
if (_visible[layerKey] !== false && _map && !_map.hasLayer(cluster)) {
|
||
cluster.addTo(_map);
|
||
}
|
||
}
|
||
|
||
// POIs holen — WICHTIG: r.ok prüfen! Der SW antwortet offline auf nicht-cachebare
|
||
// API-GETs mit 503 + JSON-Body ({detail:…}) → r.json() wirft NICHT, der Erfolgs-Pfad
|
||
// liefe mit einem Objekt statt Array weiter und ersetzte die Marker durch nichts.
|
||
const _fetchPois = async (params) => {
|
||
const r = await fetch(`/api/osm/pois?${params}`);
|
||
if (!r.ok) throw new Error(`pois ${r.status}`);
|
||
const pois = await r.json();
|
||
return Array.isArray(pois) ? pois : [];
|
||
};
|
||
|
||
// Phase 1: sofort DB-Daten zeigen (fast=true)
|
||
_setOsmStatus('Lade…');
|
||
const fastTasks = activeLayers.map(async ([layerKey, osmType]) => {
|
||
const params = new URLSearchParams({ type: osmType, fast: 'true', ...bbox });
|
||
try {
|
||
const pois = await _fetchPois(params);
|
||
_replaceOsmMarkers(layerKey, pois);
|
||
return pois.length;
|
||
} catch {
|
||
// Offline: gespeicherte Region-POIs aus IndexedDB (MapOffline.downloadAround
|
||
// legt sie beim Region-Download mit ab) statt leerer Karte.
|
||
try {
|
||
const off = window.MapOffline ? await MapOffline.pois(osmType, bbox) : [];
|
||
if (off.length) { _replaceOsmMarkers(layerKey, off); return off.length; }
|
||
} catch (e) {}
|
||
return 0;
|
||
}
|
||
});
|
||
const fastCounts = await Promise.all(fastTasks);
|
||
const fastTotal = fastCounts.reduce((a, b) => a + b, 0);
|
||
if (fastTotal > 0) _setOsmStatus(`${fastTotal} aus Datenbank`, 20);
|
||
|
||
// Phase 2: Overpass für fehlende Tiles — mit %-Fortschritt
|
||
let _done = 0;
|
||
const _total = activeLayers.length;
|
||
_setOsmStatus('Scanne…', 20);
|
||
|
||
const freshTasks = activeLayers.map(async ([layerKey, osmType]) => {
|
||
const params = new URLSearchParams({ type: osmType, ...bbox });
|
||
try {
|
||
const pois = await _fetchPois(params);
|
||
const osmCount = _osmCountOf(layerKey);
|
||
if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois);
|
||
_done++;
|
||
const pct = Math.round(20 + _done / _total * 80);
|
||
const total = _osmTotalCount();
|
||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
||
return pois.length;
|
||
} catch {
|
||
_done++;
|
||
const pct = Math.round(20 + _done / _total * 80);
|
||
const total = _osmTotalCount();
|
||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
||
return _osmCountOf(layerKey);
|
||
}
|
||
});
|
||
try {
|
||
await Promise.all(freshTasks);
|
||
} finally {
|
||
_overpassActive = false;
|
||
// Während des Scans kam eine neue Anfrage (Karte bewegt) → jetzt nachholen,
|
||
// damit die zuletzt sichtbare Ansicht garantiert gescannt wird.
|
||
if (_scanQueued) { _scanQueued = false; _scheduleOsmLoad(); }
|
||
}
|
||
|
||
const totalLoaded = _osmTotalCount();
|
||
const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false);
|
||
if (totalLoaded > 0 && allHidden) {
|
||
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
||
}
|
||
|
||
// Wenn 0 OSM-Marker: Hintergrund-Overpass-Fetch läuft noch — bis zu 8× nachfragen
|
||
// Overpass für alle Layer sequential: bis zu ~4min → Retries müssen das abdecken
|
||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) {
|
||
_autoRetryCount++;
|
||
// 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s
|
||
const delays = [10000, 20000, 35000, 50000, 70000, 90000, 120000, 150000];
|
||
const delay = delays[_autoRetryCount - 1] || 120000;
|
||
_setOsmStatus(`Neue Umgebung – Daten werden geladen…`);
|
||
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Spezielles Giftköder-Icon (pulsierend)
|
||
// ----------------------------------------------------------
|
||
function _poisonDivIcon() {
|
||
return L.divIcon({
|
||
className: '',
|
||
html: `<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 ·
|
||
<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 12–15 innerhalb der aktuellen Kartenansicht
|
||
const urls = _collectTileUrls(padded, 12, 15);
|
||
|
||
if (urls.length === 0) {
|
||
UI.toast.info('Keine Kacheln im Bereich.');
|
||
return;
|
||
}
|
||
|
||
if (urls.length > 800) {
|
||
UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`);
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('map-offline-btn');
|
||
if (btn) btn.classList.add('loading');
|
||
_setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`);
|
||
|
||
// Progress via postMessage vom SW
|
||
const onMessage = evt => {
|
||
if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return;
|
||
const { done, total } = evt.data;
|
||
if (done >= total) {
|
||
navigator.serviceWorker.removeEventListener('message', onMessage);
|
||
if (btn) btn.classList.remove('loading');
|
||
_setOsmStatus('');
|
||
UI.toast.success(`${total} Kacheln offline gespeichert!`);
|
||
} else {
|
||
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
|
||
}
|
||
};
|
||
navigator.serviceWorker.addEventListener('message', onMessage);
|
||
|
||
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Pocket-Modus Overlay
|
||
// ----------------------------------------------------------
|
||
function _showPocketOverlay() {
|
||
if (_pocketOverlay) return;
|
||
const el = document.createElement('div');
|
||
el.id = 'pocket-overlay';
|
||
el.innerHTML = `
|
||
<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 };
|
||
|
||
})();
|