/* ============================================================
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: '', label: 'Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '', label: 'Kotbeutel', color: '#84A98C', z: 5 },
tierarzt: { icon: '', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundeschule: { icon: '', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '', label: 'Mülleimer', color: '#6B7280', z: -20 },
dog_park: { icon: '', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '', label: 'Züchter', color: '#7C3AED', z: 50 },
};
// 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 = '';
}
});
}
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 = `
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
`).join('')}
Karte verschieben · Pin landet genau hier
·
0.00
km
00:00
Zeit
–:––
min/km
Bildschirm bleibt aktiv — GPS läuft
`;
const legendBtns = Object.keys(TYPEN).filter(k => k !== 'giftkoeder').length + 1; // +1 Alle-Btn
document.getElementById('map-legend')
?.style.setProperty('--map-legend-cols', Math.ceil(legendBtns / 2));
document.getElementById('map-legend').addEventListener('click', e => {
const btn = e.target.closest('.map-legend-btn');
if (!btn) return;
// "Alle"-Button
if (btn.id === 'map-legend-all') {
const anyOn = Object.entries(_visible)
.some(([k, v]) => v && k !== 'giftkoeder');
// Wenn irgendetwas sichtbar → alles aus; sonst alles an
const newState = !anyOn;
Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').forEach(([k]) => {
_visible[k] = newState;
document.querySelector(`.map-legend-btn[data-layer="${k}"]`)
?.classList.toggle('active', newState);
_applyVisibility(k);
});
btn.classList.toggle('all-off', !newState);
_saveVisible();
return;
}
const layer = btn.dataset.layer;
_visible[layer] = !_visible[layer];
btn.classList.toggle('active', _visible[layer]);
_applyVisibility(layer);
// Alle-Button-Zustand aktualisieren
const anyOn = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder');
document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOn);
_saveVisible();
});
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: '',
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: `${n}
`,
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 = `
`;
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 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(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: ``,
iconSize: [48, 48],
iconAnchor: [24, 24],
});
}
function _addDangerCircle(lat, lon) {
return L.circle([lat, lon], { radius: 100, ...DANGER_CIRCLE_STYLE }).addTo(_map);
}
// ----------------------------------------------------------
// OSM-Marker erstellen (geht in Cluster, NICHT direkt auf Karte)
// ----------------------------------------------------------
function _createOsmMarker(poi, layerKey, t) {
const isPoison = DANGER_RADIUS[layerKey] !== undefined;
const icon = isPoison
? _poisonDivIcon()
: L.divIcon({
className: '',
html: `${t.icon}
`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const label = poi.name || t.label;
const marker = L.marker([poi.lat, poi.lon], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(label, { direction: 'top', offset: [0, -16] });
marker.on('click', () => _showMarkerPopup(marker, poi, layerKey, t, label));
if (isPoison) {
marker._dangerCircle = _addDangerCircle(poi.lat, poi.lon);
}
return marker;
}
function _showMarkerPopup(marker, poi, layerKey, t, label) {
const isOwn = poi.source === 'user' && poi.own;
const isUser = poi.source === 'user';
const actionBtn = isOwn
? ``
: ``;
const openHours = poi.opening_hours
? ` ${poi.opening_hours}
` : '';
const phone = poi.phone
? `` : '';
const website = poi.website
? `` : '';
marker.bindPopup(`
${t.icon} ${label}
${poi.notiz ? `
${poi.notiz}
` : ''}
${openHours}${phone}${website}
${isUser
? ` Community-Pin${poi.username ? ' · ' + poi.username + '' : ''}`
: ' OpenStreetMap'}
${actionBtn}
`, { maxWidth: 260 }).openPopup();
setTimeout(() => {
document.getElementById('mp-action')?.addEventListener('click', () => {
marker.closePopup();
if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey);
else _showReportDialog(poi);
});
}, 50);
}
// ----------------------------------------------------------
// Marker setzen (Placement-Mode)
// ----------------------------------------------------------
function _togglePlacementMode() {
if (!_appState?.user) { App.navigate('welcome'); return; }
_placingMarker = !_placingMarker;
const btn = document.getElementById('map-pin-btn');
if (_placingMarker) {
btn?.classList.add('active');
btn && (btn.textContent = '\u2715');
// Fadenkreuz + Bestätigen-Leiste einblenden
document.getElementById('map-crosshair')?.classList.add('active');
document.getElementById('map-place-bar')?.classList.add('active');
document.getElementById('map-place-confirm').onclick = () => {
const center = _map.getCenter();
_exitPlacementMode();
_confirmPlacement(center);
};
document.getElementById('map-place-cancel').onclick = _exitPlacementMode;
} else {
_exitPlacementMode();
}
}
function _exitPlacementMode() {
_placingMarker = false;
const btn = document.getElementById('map-pin-btn');
btn?.classList.remove('active');
btn && (btn.innerHTML = '');
document.getElementById('map-crosshair')?.classList.remove('active', 'dragging');
document.getElementById('map-place-bar')?.classList.remove('active');
_tempMarker?.remove();
_tempMarker = null;
}
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'waste_basket', icon: '', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'dog_park', icon: '', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
_tempMarker?.remove();
_tempMarker = L.circleMarker([latlng.lat, latlng.lng], {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
let _selectedTypes = new Set(['giftkoeder']);
UI.modal.open({
title: ' Marker setzen',
body: `
`,
footer: `
`,
});
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
const btn = e.target.closest('.poi-type-btn');
if (!btn) return;
const t = btn.dataset.type;
if (btn.dataset.excl) {
_selectedTypes = new Set([t]);
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t));
} else {
if (_selectedTypes.has('giftkoeder')) {
_selectedTypes.delete('giftkoeder');
document.querySelector('[data-excl="1"]')?.classList.remove('selected');
}
if (_selectedTypes.has(t)) {
if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); }
} else {
_selectedTypes.add(t); btn.classList.add('selected');
}
}
});
document.getElementById('poi-cancel')?.addEventListener('click', () => {
UI.modal.close();
_exitPlacementMode();
});
document.getElementById('poi-save')?.addEventListener('click', async () => {
const name = document.getElementById('poi-name').value.trim() || null;
const notiz = document.getElementById('poi-notiz').value.trim() || null;
const type = [..._selectedTypes].join(',');
UI.modal.close();
await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz });
_exitPlacementMode();
});
}
async function _saveUserPoi(data) {
try {
const res = await fetch('/api/osm/user-poi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
if (!res.ok) throw new Error();
UI.toast.success('Marker gespeichert!');
_scheduleOsmLoad();
} catch {
UI.toast.error('Fehler beim Speichern.');
}
}
// ----------------------------------------------------------
// Melden / Löschen
// ----------------------------------------------------------
function _showReportDialog(poi) {
UI.modal.open({
title: 'Marker melden',
body: `
Warum ist dieser Marker falsch?
`,
});
document.getElementById('report-options')?.addEventListener('click', async e => {
const btn = e.target.closest('[data-grund]');
if (!btn) return;
UI.modal.close();
try {
const body = {
type: poi.source === 'user' ? 'user_poi' : 'osm',
grund: btn.dataset.grund,
osm_id: poi.source === 'osm' ? poi.id : undefined,
user_poi_id: poi.source === 'user' ? poi.user_poi_id : undefined,
};
const res = await fetch('/api/osm/report', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify(body),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
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: `${t.icon}
`,
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 ? `${_esc(b.rasse_text)}
` : '';
const stadtText = b.stadt ? `${_esc(b.stadt)}
` : '';
marker.bindPopup(`
${t.icon} ${_esc(b.zwingername)}
${rasseText}${stadtText}
`, { maxWidth: 260 }).openPopup();
setTimeout(() => {
document.getElementById('breeder-profile-btn')?.addEventListener('click', () => {
marker.closePopup();
App.navigate('breeder', true, { zwingername: b.zwingername });
});
}, 50);
});
markers.push(marker);
_layers.zuechter.push(marker);
});
cluster.addLayers(markers);
if (_visible.zuechter !== false && _map && !_map.hasLayer(cluster)) {
cluster.addTo(_map);
}
}
function _createSimpleMarker(lat, lon, t, tooltip, onClick) {
const icon = L.divIcon({
className: '',
html: `${t.icon}
`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })
.addTo(_map)
.bindTooltip(tooltip, { direction: 'top', offset: [0, -16] })
.on('click', onClick);
}
// ----------------------------------------------------------
// Layer ein/ausblenden
// ----------------------------------------------------------
function _applyVisibility(layer) {
// poison-Toggle steuert auch giftkoeder-Community-Pins mit
const keys = layer === 'poison' ? ['poison', 'giftkoeder'] : [layer];
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 12–15 innerhalb der aktuellen Kartenansicht
const urls = _collectTileUrls(padded, 12, 15);
if (urls.length === 0) {
UI.toast.info('Keine Kacheln im Bereich.');
return;
}
if (urls.length > 800) {
UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`);
return;
}
const btn = document.getElementById('map-offline-btn');
if (btn) btn.classList.add('loading');
_setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`);
// Progress via postMessage vom SW
const onMessage = evt => {
if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return;
const { done, total } = evt.data;
if (done >= total) {
navigator.serviceWorker.removeEventListener('message', onMessage);
if (btn) btn.classList.remove('loading');
_setOsmStatus('');
UI.toast.success(`${total} Kacheln offline gespeichert!`);
} else {
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
}
};
navigator.serviceWorker.addEventListener('message', onMessage);
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
}
// ----------------------------------------------------------
// Pocket-Modus Overlay
// ----------------------------------------------------------
function _showPocketOverlay() {
if (_pocketOverlay) return;
const el = document.createElement('div');
el.id = 'pocket-overlay';
el.innerHTML = `
GPS läuft
0:00
0.00 km
Tippen für Steuerung
`;
el.style.cssText = `
position:fixed;inset:0;z-index:9998;background:#000;
display:flex;flex-direction:column;align-items:center;justify-content:center;
color:#fff;font-family:inherit;user-select:none;
`;
el.querySelector('.po-status').style.cssText =
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
el.querySelector('.po-time').style.cssText =
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
el.querySelector('.po-dist').style.cssText =
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
el.querySelector('.po-hint').style.cssText =
'font-size:0.75rem;color:#444;margin-top:2.5rem';
const ctrl = el.querySelector('.po-controls');
ctrl.style.cssText =
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
el.querySelectorAll('.po-btn').forEach(b => {
b.style.cssText =
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
});
el.querySelector('.po-btn--stop').style.cssText +=
'border-color:#c0392b;color:#e74c3c';
// Tippen → Controls 4s einblenden, dann ausblenden
el.addEventListener('click', e => {
if (e.target.closest('#po-controls')) return;
ctrl.style.display = 'flex';
el.querySelector('.po-hint').style.color = '#666';
clearTimeout(_pocketHideTimer);
_pocketHideTimer = setTimeout(() => {
ctrl.style.display = 'none';
el.querySelector('.po-hint').style.color = '#444';
}, 4000);
});
el.querySelector('#po-pause').addEventListener('click', () => {
_togglePause();
el.querySelector('#po-pause').textContent =
_recPaused ? '▶ Weiter' : '⏸ Pause';
el.querySelector('#po-status').textContent =
_recPaused ? 'Pausiert' : 'GPS läuft';
});
el.querySelector('#po-stop').addEventListener('click', () => {
_hidePocketOverlay();
_stopRecording();
});
document.body.appendChild(el);
_pocketOverlay = el;
}
function _hidePocketOverlay() {
clearTimeout(_pocketHideTimer);
_pocketOverlay?.remove();
_pocketOverlay = null;
}
function _updatePocketOverlay() {
if (!_pocketOverlay) return;
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const timeEl = _pocketOverlay.querySelector('#po-time');
const distEl = _pocketOverlay.querySelector('#po-dist');
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
}
// ----------------------------------------------------------
// GPS-Aufzeichnung
// ----------------------------------------------------------
function _toggleRecording() {
if (!_recActive) _startRecording();
else _stopRecording();
}
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 = ''; 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 = ''; 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 = `
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
`;
const footer = `
`;
UI.modal.open({ title: ' Route benennen', body, footer });
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('rec-paw-val').value = btn.dataset.val;
});
document.getElementById('rms-discard')?.addEventListener('click', () => {
UI.modal.close();
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 = ``;
const regen = w.precip_prob != null ? ` · 💧 ${w.precip_prob}%` : '';
let zecken = '';
if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
zecken = ` · `;
}
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 };
})();