banyaro/backend/static/js/pages/map.js
rene 1fdba57365 Feature: UX-Fixes — Zahnrad weg, POI-Kombi-Typen, exp-fab-Position, Welten-Config in DB (SW by-v653)
- worlds-settings Zahnrad komplett entfernt (war auf Mobile sichtbar, auf Desktop schon hidden)
- exp-fab: bottom jetzt calc(--nav-bottom-height + --safe-bottom + --space-2) — kein Overlap mit worlds-back auf iPhone
- Karte POI: neue Typen bank, bank_kotbeutel, bank_kotbeutel_abfall, kotbeutel_abfall (Backend + Frontend)
- Welten-Chip-Config: GET/PUT /profile/world-config, Spalte users.world_config TEXT (Migration), Sync bei Init + Speichern
2026-05-03 19:50:04 +02:00

1628 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
BAN YARO — Zentrale Karte
Layer: eigene Orte, Giftköder, OSM-POIs, Community-Pins
Features: Clustering, Standort-Dot, Gefahren-Radius
============================================================ */
window.Page_map = (() => {
let _container = null;
let _appState = null;
let _map = null;
let _leafletLoaded = false;
let _userPos = null;
let _placingMarker = false;
let _tempMarker = null;
// Standort-Tracking
let _locationMarker = null;
let _locationAccuracy = null;
let _watchId = null;
// GPS-Aufzeichnung
let _recActive = false;
let _recPaused = false;
let _wakeLock = null;
let _recTrack = [];
let _recDistKm = 0;
let _recStartTime = null;
let _recTimerInt = null;
let _recPolyline = null;
let _pocketOverlay = null;
let _pocketHideTimer = null;
let _recMarker = null;
let _recWatchId = null;
// Cluster-Gruppen pro Layer (für OSM-Marker)
let _clusterGroups = {};
// Layer-Marker (Arrays von Leaflet-Markern)
let _layers = {
restaurant: [],
freilauf: [],
shop: [],
kotbeutel: [],
tierarzt: [],
hundeschule: [],
poison: [],
muell: [],
dog_park: [],
wasser: [],
bank: [],
giftkoeder: [],
gefahr: [],
parkplatz: [],
treffpunkt: [],
community: [],
zuechter: [],
};
const VISIBLE_KEY = 'by_map_visible_v1';
let _visible = {};
// Gespeicherten Zustand laden, Fallback: alles sichtbar
(() => {
const saved = (() => { try { return JSON.parse(localStorage.getItem(VISIBLE_KEY) || 'null'); } catch { return null; } })();
Object.keys(_layers).forEach(k => {
_visible[k] = saved ? (saved[k] !== false) : true;
});
})();
function _saveVisible() {
try { localStorage.setItem(VISIBLE_KEY, JSON.stringify(_visible)); } catch {}
}
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: '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 },
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 },
};
// Frontend-Layer → Backend-Typ Mapping
const OSM_LAYER_MAP = {
muell: 'waste_basket',
dog_park: 'dog_park',
wasser: 'drinking_water',
tierarzt: 'tierarzt',
shop: 'shop',
restaurant: 'restaurant',
bank: 'bank',
giftkoeder: 'giftkoeder',
kotbeutel: 'kotbeutel',
gefahr: 'gefahr',
parkplatz: 'parkplatz',
treffpunkt: 'treffpunkt',
community: 'sonstiges',
};
// Gefahren-Radius-Kreis: prominente rote Fläche
const DANGER_RADIUS = { poison: 100, giftkoeder: 100 };
// Layer die schon ab Zoom 10 geladen werden (nicht erst ab 14)
const EARLY_LAYERS = new Set(['giftkoeder']);
const DANGER_CIRCLE_STYLE = {
color: '#DC2626', fillColor: '#DC2626',
fillOpacity: 0.12, weight: 2, dashArray: null,
interactive: false,
};
let _overpassTimer = null;
let _overpassActive = false;
let _ringClosing = false;
let _frankfurtTimer = null;
let _autoRetryCount = 0; // begrenzt Auto-Retry auf max 3x pro Kartenposition
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
Object.assign(_container.style, { padding: '0', overflow: 'hidden', position: 'relative', gap: '0' });
_render();
// Alle-Button Initialzustand
const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder');
document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit);
await _loadLeaflet();
_initMap(); // sofort mit Deutschland-Mitte starten
_startLocationTracking();
_loadAll();
// Standort im Hintergrund holen — bei Erfolg zur Position fliegen
API.getLocation().then(pos => {
_userPos = pos;
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
_loadWeather(pos.lat, pos.lon);
}).catch(() => {
const btn = document.getElementById('map-locate-btn');
if (btn) {
btn.title = 'Standort nicht verfügbar';
btn.style.opacity = '0.55';
btn.innerHTML = '<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(() => { _map?.invalidateSize(); _scheduleOsmLoad(); }, 150);
setTimeout(() => _map?.invalidateSize(), 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>
</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>
<div class="map-fabs">
<button class="map-fab 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>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span>
<span id="map-osm-status"></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();
});
document.getElementById('map-locate-btn').addEventListener('click', () => {
if (_userPos) {
_map?.setView([_userPos.lat, _userPos.lon], 16);
} else {
UI.toast.error('Standort noch nicht verfügbar.');
}
});
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
}
// ----------------------------------------------------------
// Leaflet + MarkerCluster laden
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded) return;
// Leaflet-Basis: nur laden wenn noch nicht vorhanden (diary.js kann es vorgeladen haben)
if (!window.L) {
const lCss = document.createElement('link');
lCss.rel = 'stylesheet'; lCss.href = '/css/leaflet.css';
document.head.appendChild(lCss);
await new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = resolve;
document.head.appendChild(s);
});
}
// MarkerCluster: separat prüfen — diary.js lädt Leaflet ohne MarkerCluster
if (!window.L.markerClusterGroup) {
['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(f => {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = `/css/${f}`;
document.head.appendChild(l);
});
await new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.markercluster.js'; s.onload = resolve;
document.head.appendChild(s);
});
}
_leafletLoaded = true;
}
// ----------------------------------------------------------
// Karte initialisieren
// ----------------------------------------------------------
function _initMap() {
const el = document.getElementById('central-map');
if (!el || !window.L || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [50.1109, 8.6821]; // Frankfurt
const zoom = _userPos ? 14 : 10;
_map = L.map('central-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
if (!_userPos) {
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
}
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
setTimeout(() => _map.invalidateSize(), 100);
setTimeout(() => _map.invalidateSize(), 600);
window.addEventListener('resize', () => _map.invalidateSize());
_map.on('moveend zoomend', () => { _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(); });
setTimeout(() => { _updateZoomDisplay(); _scheduleOsmLoad(); }, 800);
// Fadenkreuz-Animation beim Kartenverschieben
_map.on('movestart', () => {
document.getElementById('map-crosshair')?.classList.add('dragging');
});
_map.on('moveend', () => {
document.getElementById('map-crosshair')?.classList.remove('dragging');
});
}
// ----------------------------------------------------------
// Standort-Tracking — pulsierender blauer Punkt
// ----------------------------------------------------------
function _startLocationTracking() {
if (!navigator.geolocation || !_map || !window.L) return;
const icon = L.divIcon({
className: 'loc-icon',
html: '<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 (_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(255,255,255,0.8)">${n}</div>`,
iconSize: [36, 36], iconAnchor: [18, 18],
});
},
});
if (_visible[layerKey] !== false) {
_clusterGroups[layerKey].addTo(_map);
}
}
return _clusterGroups[layerKey];
}
function _updateZoomDisplay() {
if (!_map) return;
const z = Math.round(_map.getZoom());
const el = document.getElementById('map-zoom-info');
if (!el) return;
if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; }
else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; }
else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; }
}
function _setOsmStatus(text, pct = null) {
const el = document.getElementById('map-osm-status');
const statusbar = document.getElementById('map-statusbar');
if (el) el.textContent = text;
_updateScanRing(text ? pct : null);
_updateScanDog(text ? pct : null);
if (pct === 100 && statusbar) {
statusbar.classList.add('scan-done');
setTimeout(() => statusbar.classList.remove('scan-done'), 2200);
}
}
function _injectDogStyles() {
if (document.getElementById('by-dog-style')) return;
const s = document.createElement('style');
s.id = 'by-dog-style';
s.textContent = [
'@keyframes by-sniff{0%,100%{transform:translateY(0) rotate(0deg)}30%{transform:translateY(2.5px) rotate(-1.5deg)}70%{transform:translateY(1px) rotate(1deg)}}',
'@keyframes by-wander{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}45%{transform:translateX(5px)}68%{transform:translateX(-5px)}85%{transform:translateX(7px)}}',
'@keyframes by-wag{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}',
'#map-scan-dog{animation:by-wander 1.75s ease-in-out infinite;transition:opacity .5s ease;color:#C4843A;position:absolute;pointer-events:none;z-index:1003;width:42px;height:32px}',
'#map-scan-dog svg{display:block;animation:by-sniff .42s ease-in-out infinite}',
'#map-scan-dog .by-tail{transform-box:fill-box;transform-origin:0% 100%;animation:by-wag .32s ease-in-out infinite}',
'#map-statusbar{transition:background .35s ease,color .35s ease,border-color .35s ease}',
'#map-statusbar.scan-done{background:#22C55E!important;color:#fff!important;border-color:#16A34A!important}',
].join('');
document.head.appendChild(s);
}
function _updateScanDog(pct) {
_injectDogStyles();
const statusbar = document.getElementById('map-statusbar');
if (!statusbar) return;
const mapEl = statusbar.closest('.map-main') || statusbar.parentElement;
if (!mapEl) return;
let dog = document.getElementById('map-scan-dog');
if (pct === null) {
if (_ringClosing) return;
if (dog) { dog.style.opacity = '0'; setTimeout(() => dog?.remove(), 550); }
return;
}
if (!dog) {
dog = document.createElement('div');
dog.id = 'map-scan-dog';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '42');
svg.setAttribute('height', '32');
svg.setAttribute('viewBox', '0 0 54 40');
svg.innerHTML = `
<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);
}
async function _loadOsmLayers() {
if (!_map || !window.L || _overpassActive) return;
const zoom = _map.getZoom();
// Unter Zoom 10: alles ausblenden
if (zoom < 10) {
Object.keys(OSM_LAYER_MAP).forEach(k => {
_layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove());
_clusterGroups[k]?.clearLayers();
_layers[k] = _layers[k].filter(m => m._ownPlace);
});
_setOsmStatus('');
return;
}
// Zoom 1013: normale OSM-Layer ausblenden, EARLY_LAYERS behalten/laden
if (zoom < 14) {
Object.keys(OSM_LAYER_MAP).filter(k => !EARLY_LAYERS.has(k)).forEach(k => {
_layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove());
_clusterGroups[k]?.clearLayers();
_layers[k] = _layers[k].filter(m => m._ownPlace);
});
}
_overpassActive = true;
_map.invalidateSize();
const b = _map.getBounds().pad(0.15);
const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
// Welche Layer bei diesem Zoom geladen werden
const activeLayers = zoom >= 14
? Object.entries(OSM_LAYER_MAP)
: Object.entries(OSM_LAYER_MAP).filter(([k]) => EARLY_LAYERS.has(k));
// OSM-Marker eines Layers ersetzen, eigene Orte behalten
function _replaceOsmMarkers(layerKey, pois) {
const cluster = _getCluster(layerKey);
// Alte OSM-Marker entfernen
const oldOsm = _layers[layerKey].filter(m => !m._ownPlace);
oldOsm.forEach(m => m._dangerCircle?.remove());
cluster.removeLayers(oldOsm);
_layers[layerKey] = _layers[layerKey].filter(m => m._ownPlace);
// Neue Marker erstellen und in Cluster packen
const t = TYPEN[layerKey];
const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t));
cluster.addLayers(newMarkers);
_layers[layerKey].push(...newMarkers);
// Sicherstellen dass der Cluster auf der Karte ist (kann durch vorherigen Toggle fehlen)
if (_visible[layerKey] !== false && _map && !_map.hasLayer(cluster)) {
cluster.addTo(_map);
}
}
// Phase 1: sofort DB-Daten zeigen (fast=true)
_setOsmStatus('Lade…');
const fastTasks = activeLayers.map(async ([layerKey, osmType]) => {
const params = new URLSearchParams({ type: osmType, fast: 'true', ...bbox });
try {
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
_replaceOsmMarkers(layerKey, pois);
return pois.length;
} catch { return 0; }
});
const fastCounts = await Promise.all(fastTasks);
const fastTotal = fastCounts.reduce((a, b) => a + b, 0);
if (fastTotal > 0) _setOsmStatus(`${fastTotal} aus Datenbank`, 20);
// Phase 2: Overpass für fehlende Tiles — mit %-Fortschritt
let _done = 0;
const _total = activeLayers.length;
_setOsmStatus('Scanne…', 20);
const freshTasks = activeLayers.map(async ([layerKey, osmType]) => {
const params = new URLSearchParams({ type: osmType, ...bbox });
try {
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length;
if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois);
_done++;
const pct = Math.round(20 + _done / _total * 80);
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
return pois.length;
} catch {
_done++;
const pct = Math.round(20 + _done / _total * 80);
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
return _layers[layerKey].filter(m => !m._ownPlace).length;
}
});
await Promise.all(freshTasks);
_overpassActive = false;
const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false);
if (totalLoaded > 0 && allHidden) {
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
}
// Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) {
_autoRetryCount++;
const delay = _autoRetryCount * 30000; // 30s, 60s, 90s
_setOsmStatus(`Neue Umgebung Daten werden geladen…`);
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
}
}
// ----------------------------------------------------------
// Spezielles Giftköder-Icon (pulsierend)
// ----------------------------------------------------------
function _poisonDivIcon() {
return L.divIcon({
className: '',
html: `<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(255,255,255,0.7)">${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>`;
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>
${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);
});
}, 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;
}
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' },
{ 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: 'kotbeutel_abfall', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel + Mülleimer', color: '#5a8a6a' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'bank_kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Bank + Kotbeutel', color: '#7a6030' },
{ type: 'bank_kotbeutel_abfall', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Bank + Kotbeutel + Mülleimer', color: '#4a5a2a' },
{ type: 'drinking_water', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ 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();
_tempMarker = L.circleMarker([latlng.lat, latlng.lng], {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
let _selectedType = '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</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}" 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;
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
_selectedType = btn.dataset.type;
});
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;
UI.modal.close();
await _saveUserPoi({ type: _selectedType, 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; }
const data = await res.json();
if (data.status === 'bereits_gemeldet') {
UI.toast.info('Du hast diesen Marker bereits gemeldet.');
} else {
UI.toast.success('Meldung eingereicht. Danke!');
}
} catch { UI.toast.error('Fehler beim Melden.'); }
});
}
async function _deleteUserPoi(poiId, marker, layerKey) {
try {
const res = await fetch(`/api/osm/user-poi/${poiId}`, {
method: 'DELETE', credentials: 'include',
});
if (!res.ok) throw new Error();
_clusterGroups[layerKey]?.removeLayer(marker);
marker._dangerCircle?.remove();
_layers[layerKey] = _layers[layerKey].filter(m => m !== marker);
UI.toast.success('Marker gelöscht.');
} catch { UI.toast.error('Fehler beim Löschen.'); }
}
// ----------------------------------------------------------
// Eigene Orte + Giftköder laden
// ----------------------------------------------------------
async function _loadAll() {
// Falls Overpass-Job steckengeblieben: zurücksetzen
_overpassActive = false;
// Cluster-Gruppen leeren (OSM-Marker)
Object.values(_clusterGroups).forEach(cg => cg.clearLayers());
// Eigene-Orte-Marker direkt von Karte entfernen
Object.values(_layers).flat().filter(m => m._ownPlace).forEach(m => {
m._dangerCircle?.remove();
m.remove();
});
// Giftköder-Kreise
(_layers.poison || []).forEach(m => m._dangerCircle?.remove());
Object.keys(_layers).forEach(k => { _layers[k] = []; });
const [places, poisonList, breederList] = await Promise.allSettled([
API.places.list(),
_userPos ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) : Promise.resolve([]),
API.breeder.mapMarkers(),
]);
if (places.status === 'fulfilled') _addPlaces(places.value);
if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
_scheduleOsmLoad();
}
function _addPlaces(places) {
if (!_map || !window.L) return;
places.forEach(place => {
const t = TYPEN[place.typ];
if (!t) return;
const m = _createSimpleMarker(place.lat, place.lon, t, place.name,
() => UI.toast.info(`${t.icon} ${place.name}${place.adresse ? ' \u00b7 ' + place.adresse : ''}`));
m._ownPlace = true;
_layers[place.typ]?.push(m);
if (!_visible[place.typ]) m.setOpacity(0);
});
}
function _addPoison(items) {
if (!_map || !window.L) return;
items.forEach(p => {
const tooltip = `Giftk\u00f6der-Alarm${p.beschreibung ? ': ' + p.beschreibung : ''}`;
const m = L.marker([p.lat, p.lon], { icon: _poisonDivIcon(), zIndexOffset: 100 })
.addTo(_map)
.bindTooltip(tooltip, { direction: 'top', offset: [0, -24] })
.on('click', () => App.navigate('poison'));
m._ownPlace = true;
m._dangerCircle = _addDangerCircle(p.lat, p.lon);
_layers.poison.push(m);
if (!_visible.poison) {
m.setOpacity(0);
m._dangerCircle.setStyle({ opacity: 0, fillOpacity: 0 });
}
});
}
function _addBreeders(breeders) {
if (!_map || !window.L) return;
const t = TYPEN.zuechter;
const cluster = _getCluster('zuechter');
const markers = [];
breeders.forEach(b => {
// Ohne Koordinaten: stillen Skip
if (b.location_lat == null || b.location_lng == null) return;
const icon = L.divIcon({
className: '',
html: `<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(255,255,255,0.7)">${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(_esc(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">${_esc(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${_esc(b.stadt)}</div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${_esc(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(255,255,255,0.7)">${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];
keys.forEach(k => {
const on = _visible[layer];
_visible[k] = on;
if (_clusterGroups[k]) {
on ? _clusterGroups[k].addTo(_map) : _clusterGroups[k].remove();
}
(_layers[k] || []).forEach(m => {
if (m._ownPlace) m.setOpacity?.(on ? 1 : 0);
if (m._dangerCircle) {
m._dangerCircle.setStyle(on
? { opacity: 1, fillOpacity: 0.12 }
: { opacity: 0, fillOpacity: 0 }
);
}
});
});
}
// ----------------------------------------------------------
// Offline-Kacheln vorladen
// ----------------------------------------------------------
function _tileCoords(lat, lon, zoom) {
const n = Math.pow(2, zoom);
const x = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
function _collectTileUrls(bounds, minZoom, maxZoom) {
const urls = [];
const subdomains = ['a', 'b', 'c'];
for (let z = minZoom; z <= maxZoom; z++) {
const sw = _tileCoords(bounds.getSouth(), bounds.getWest(), z);
const ne = _tileCoords(bounds.getNorth(), bounds.getEast(), z);
for (let x = sw.x; x <= ne.x; x++) {
for (let y = ne.y; y <= sw.y; y++) {
const s = subdomains[Math.abs(x + y) % 3];
urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`);
}
}
}
return urls;
}
async function _cacheTiles() {
if (!_map) return;
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {
UI.toast.warning('Service Worker nicht bereit \u2014 bitte Seite neu laden.');
return;
}
const bounds = _map.getBounds();
// Padding: 1 Kachel nach außen auf Zoom 14
const padded = bounds.pad(0.15);
// Zoom 1215 innerhalb der aktuellen Kartenansicht
const urls = _collectTileUrls(padded, 12, 15);
if (urls.length === 0) {
UI.toast.info('Keine Kacheln im Bereich.');
return;
}
if (urls.length > 800) {
UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`);
return;
}
const btn = document.getElementById('map-offline-btn');
if (btn) btn.classList.add('loading');
_setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`);
// Progress via postMessage vom SW
const onMessage = evt => {
if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return;
const { done, total } = evt.data;
if (done >= total) {
navigator.serviceWorker.removeEventListener('message', onMessage);
if (btn) btn.classList.remove('loading');
_setOsmStatus('');
UI.toast.success(`${total} Kacheln offline gespeichert!`);
} else {
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
}
};
navigator.serviceWorker.addEventListener('message', onMessage);
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
}
// ----------------------------------------------------------
// Pocket-Modus Overlay
// ----------------------------------------------------------
function _showPocketOverlay() {
if (_pocketOverlay) return;
const el = document.createElement('div');
el.id = 'pocket-overlay';
el.innerHTML = `
<div class="po-status" id="po-status">GPS läuft</div>
<div class="po-time" id="po-time">0:00</div>
<div class="po-dist" id="po-dist">0.00 km</div>
<div class="po-hint">Tippen für Steuerung</div>
<div class="po-controls" id="po-controls">
<button class="po-btn" id="po-pause">⏸ Pause</button>
<button class="po-btn po-btn--stop" id="po-stop">⏹ Stopp</button>
</div>
`;
el.style.cssText = `
position:fixed;inset:0;z-index:9998;background:#000;
display:flex;flex-direction:column;align-items:center;justify-content:center;
color:#fff;font-family:inherit;user-select:none;
`;
el.querySelector('.po-status').style.cssText =
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
el.querySelector('.po-time').style.cssText =
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
el.querySelector('.po-dist').style.cssText =
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
el.querySelector('.po-hint').style.cssText =
'font-size:0.75rem;color:#444;margin-top:2.5rem';
const ctrl = el.querySelector('.po-controls');
ctrl.style.cssText =
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
el.querySelectorAll('.po-btn').forEach(b => {
b.style.cssText =
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
});
el.querySelector('.po-btn--stop').style.cssText +=
'border-color:#c0392b;color:#e74c3c';
// Tippen → Controls 4s einblenden, dann ausblenden
el.addEventListener('click', e => {
if (e.target.closest('#po-controls')) return;
ctrl.style.display = 'flex';
el.querySelector('.po-hint').style.color = '#666';
clearTimeout(_pocketHideTimer);
_pocketHideTimer = setTimeout(() => {
ctrl.style.display = 'none';
el.querySelector('.po-hint').style.color = '#444';
}, 4000);
});
el.querySelector('#po-pause').addEventListener('click', () => {
_togglePause();
el.querySelector('#po-pause').textContent =
_recPaused ? '▶ Weiter' : '⏸ Pause';
el.querySelector('#po-status').textContent =
_recPaused ? 'Pausiert' : 'GPS läuft';
});
el.querySelector('#po-stop').addEventListener('click', () => {
_hidePocketOverlay();
_stopRecording();
});
document.body.appendChild(el);
_pocketOverlay = el;
}
function _hidePocketOverlay() {
clearTimeout(_pocketHideTimer);
_pocketOverlay?.remove();
_pocketOverlay = null;
}
function _updatePocketOverlay() {
if (!_pocketOverlay) return;
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const timeEl = _pocketOverlay.querySelector('#po-time');
const distEl = _pocketOverlay.querySelector('#po-dist');
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
}
// ----------------------------------------------------------
// GPS-Aufzeichnung
// ----------------------------------------------------------
function _toggleRecording() {
if (!_recActive) _startRecording();
else _stopRecording();
}
async function _startRecording() {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden.');
App.navigate('settings');
return;
}
if (!navigator.geolocation) {
UI.toast.error('GPS nicht verfügbar.');
return;
}
_recActive = true;
_recPaused = false;
_recTrack = [];
_recDistKm = 0;
_recStartTime = Date.now();
// FAB umschalten
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.innerHTML = '<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 });
_updateRecMap(lat, lon);
_updateRecStatus();
},
() => {},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
);
UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
// Pocket-Modus aktivieren wenn in Einstellungen eingeschaltet
if (localStorage.getItem('by_pocket_mode') === 'true') {
setTimeout(_showPocketOverlay, 800); // kurz warten damit Toast sichtbar war
}
}
async function _onVisibilityChange() {
if (_recActive && document.visibilityState === 'visible' && !_wakeLock) {
await _acquireWakeLock();
}
}
async function _acquireWakeLock() {
if (!('wakeLock' in navigator)) return;
try {
_wakeLock = await navigator.wakeLock.request('screen');
_wakeLock.addEventListener('release', () => { _wakeLock = null; });
} catch {}
}
function _releaseWakeLock() {
_wakeLock?.release();
_wakeLock = null;
}
function _togglePause() {
_recPaused = !_recPaused;
const btn = document.getElementById('rec-panel-pause');
if (btn) btn.textContent = _recPaused ? '▶ Weiter' : '⏸ Pause';
const panel = document.getElementById('map-rec-panel');
panel?.classList.toggle('paused', _recPaused);
}
function _haversineRec(lat1, lon1, lat2, lon2) {
const R = 6371000;
const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function _updateRecMap(lat, lon) {
if (!_map || !window.L) return;
const ll = [lat, lon];
if (!_recPolyline) {
_recPolyline = L.polyline([ll], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_map);
} else {
_recPolyline.addLatLng(ll);
}
if (!_recMarker) {
_recMarker = L.circleMarker(ll, {
radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3,
}).addTo(_map);
} else {
_recMarker.setLatLng(ll);
}
_map.panTo(ll);
}
function _updateRecStatus() {
const secs = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(secs / 60)).padStart(2, '0');
const ss = String(secs % 60).padStart(2, '0');
const pace = _recDistKm > 0.05
? (() => { const pSec = secs / _recDistKm / 60; const pm = Math.floor(pSec); const ps = String(Math.round((pSec-pm)*60)).padStart(2,'0'); return `${pm}:${ps}`; })()
: ':';
const distEl = document.getElementById('rec-stat-dist');
const timeEl = document.getElementById('rec-stat-time');
const paceEl = document.getElementById('rec-stat-pace');
if (distEl) distEl.textContent = _recDistKm.toFixed(2);
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (paceEl) paceEl.textContent = pace;
_updatePocketOverlay();
}
function _stopRecording() {
if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; }
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
_recActive = false;
_releaseWakeLock();
_hidePocketOverlay();
document.removeEventListener('visibilitychange', _onVisibilityChange);
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.innerHTML = '<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.');
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
return;
}
const dauMin = Math.max(1, Math.floor((Date.now() - _recStartTime) / 1000 / 60));
_showRecSaveModal(_recTrack, _recDistKm, dauMin);
}
async function _prefillRouteName(track, distKm) {
const nameInput = document.querySelector('#rec-save-form [name="name"]');
if (!nameInput || nameInput.value) return;
const pt = track[0];
const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
const km = distKm.toFixed(1);
let ort = '';
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' });
const data = await r.json();
const a = data.address || {};
ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || '';
} catch {}
if (!nameInput.value) nameInput.value = ort
? `Gassirunde ${ort} · ${date} · ${km} km`
: `Gassirunde · ${date} · ${km} km`;
}
function _showRecSaveModal(track, distKm, dauMin) {
const body = `
<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">
<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 style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<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 style="color:var(--c-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();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
});
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const saved = await API.routes.create({
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
distanz_km: Math.round(distKm * 100) / 100,
dauer_min: dauMin,
schwierigkeit: fd.schwierigkeit || 'leicht',
untergrund: fd.untergrund || null,
schatten: 'schatten' in fd,
leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
});
UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
if (saved.is_valid === false) {
UI.toast.warning(`Route „${saved.name}" gespeichert — wird nicht für Statistiken gewertet (Geschwindigkeit zu hoch).`);
} else {
UI.toast.success(`Route „${saved.name}" gespeichert!`);
}
});
});
}
// ----------------------------------------------------------
// WETTER-CHIP
// ----------------------------------------------------------
async function _loadWeather(lat, lon) {
const info = document.getElementById('map-weather-info');
const sep = document.getElementById('map-weather-sep');
if (!info) return;
try {
const w = await API.weather.get(lat, lon);
const temp = w.temp_c != null ? `${Math.round(w.temp_c)}°` : '';
const icon = `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:-2px"><use href="/icons/phosphor.svg#${w.icon}"></use></svg>`;
const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : '';
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}${zecken}`;
info.classList.remove('map-weather-chip--hidden');
sep.classList.remove('map-weather-chip--hidden');
} catch { /* still */ }
}
return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive };
})();