banyaro/backend/static/js/pages/map.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

1949 lines
85 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
BAN YARO — Zentrale Karte
Layer: eigene Orte, Giftköder, OSM-POIs, Community-Pins
Features: Clustering, Standort-Dot, Gefahren-Radius
============================================================ */
window.Page_map = (() => {
let _container = null;
let _appState = null;
let _map = null;
let _leafletLoaded = false;
let _userPos = null;
let _weatherLoaded = false;
let _placingMarker = false;
let _tempMarker = null;
let _tileLayer = null;
let _themeObserver = null;
// Standort-Tracking
let _locationMarker = null;
let _locationAccuracy = null;
let _watchId = null;
// GPS-Aufzeichnung
let _recActive = false;
let _recPaused = false;
let _wakeLock = null;
let _recTrack = [];
let _recDistKm = 0;
let _recStartTime = null;
let _recTimerInt = null;
let _recPolyline = null;
let _pocketOverlay = null;
let _pocketHideTimer = null;
let _recMarker = null;
let _recWatchId = null;
// Cluster-Gruppen pro Layer (für OSM-Marker)
let _clusterGroups = {};
// Layer-Marker (Arrays von Leaflet-Markern)
let _layers = {
restaurant: [],
freilauf: [],
shop: [],
kotbeutel: [],
tierarzt: [],
hundesalon: [],
hundeschule: [],
poison: [],
muell: [],
dog_park: [],
wasser: [],
bank: [],
giftkoeder: [],
gefahr: [],
parkplatz: [],
treffpunkt: [],
community: [],
zuechter: [],
};
const VISIBLE_KEY = 'by_map_visible_v1';
const _MAP_POI_KEY = 'by_map_pois_cache';
let _visible = {};
// Gespeicherten Zustand laden, Fallback: alles sichtbar
(() => {
const saved = (() => { try { return JSON.parse(localStorage.getItem(VISIBLE_KEY) || 'null'); } catch { return null; } })();
Object.keys(_layers).forEach(k => {
_visible[k] = saved ? (saved[k] !== false) : true;
});
})();
function _saveVisible() {
try { localStorage.setItem(VISIBLE_KEY, JSON.stringify(_visible)); } catch {}
}
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Café & Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundesalon: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scissors"></use></svg>', label: 'Hundesalon', color: '#EC4899', z: 25 },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280', z: -20 },
dog_park: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chair"></use></svg>', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hotel', color: '#0369a1', z: 20 },
};
// Frontend-Layer → Backend-Typ Mapping
const OSM_LAYER_MAP = {
muell: 'waste_basket',
dog_park: 'dog_park',
wasser: 'drinking_water',
tierarzt: 'tierarzt',
hundesalon: 'hundesalon',
shop: 'shop',
restaurant: 'restaurant',
bank: 'bank',
giftkoeder: 'giftkoeder',
kotbeutel: 'kotbeutel',
gefahr: 'gefahr',
parkplatz: 'parkplatz',
treffpunkt: 'treffpunkt',
community: 'sonstiges',
hotel: 'hotel',
};
// Gefahren-Radius-Kreis: prominente rote Fläche
const DANGER_RADIUS = { poison: 100, giftkoeder: 100 };
// Layer die schon ab Zoom 10 geladen werden (nicht erst ab 14)
const EARLY_LAYERS = new Set(['giftkoeder']);
const DANGER_CIRCLE_STYLE = {
color: '#DC2626', fillColor: '#DC2626',
fillOpacity: 0.12, weight: 2, dashArray: null,
interactive: false,
};
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 });
_weatherLoaded = true;
_loadWeather(pos.lat, pos.lon);
}).catch(() => {
const btn = document.getElementById('map-locate-btn');
if (btn) {
btn.title = 'Standort nicht verfügbar';
btn.style.opacity = '0.55';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin-slash"></use></svg>';
}
});
}
function refresh() {
// Leaflet kennt die Container-Größe nach Seitenwechsel nicht — neu berechnen
setTimeout(() => { _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>
<span class="map-legend-label">Filter</span>
</button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
${t.icon} <span class="map-legend-label">${t.label}</span>
</button>
`).join('')}
</div>
<div id="central-map" class="map-full"></div>
<!-- Fadenkreuz-Overlay (nur im Placement-Modus sichtbar) -->
<div class="map-crosshair" id="map-crosshair">
<div class="map-crosshair-pin"><svg class="ph-icon" aria-hidden="true" style="width:28px;height:28px"><use href="/icons/phosphor.svg#map-pin"></use></svg></div>
<div class="map-crosshair-shadow"></div>
</div>
<div class="map-place-bar" id="map-place-bar">
<span class="map-place-hint">Karte verschieben · Pin landet genau hier</span>
<div class="map-place-btns">
<button class="btn btn-secondary" id="map-place-cancel">Abbrechen</button>
<button class="btn btn-primary" id="map-place-confirm"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Hier platzieren</button>
</div>
</div>
<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>
${App.hasPro(_appState?.user) ? `
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></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" class="hidden"></span>
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span>
<span class="map-weather-chip--hidden" id="map-weather-info"></span>
</div>
<!-- Aufzeichnungs-Panel (erscheint beim Start) -->
<div class="map-rec-panel" id="map-rec-panel">
<div class="map-rec-stats">
<div class="map-rec-stat">
<span class="map-rec-val" id="rec-stat-dist">0.00</span>
<span class="map-rec-lbl">km</span>
</div>
<div class="map-rec-stat map-rec-stat--main">
<span class="map-rec-val" id="rec-stat-time">00:00</span>
<span class="map-rec-lbl">Zeit</span>
</div>
<div class="map-rec-stat">
<span class="map-rec-val" id="rec-stat-pace">:</span>
<span class="map-rec-lbl">min/km</span>
</div>
</div>
<div class="map-rec-actions">
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause">Pause</button>
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
</div>
<div class="map-rec-hint" id="map-rec-hint">
Bildschirm bleibt aktiv — GPS läuft
</div>
</div>
</div>
`;
const legendBtns = Object.keys(TYPEN).filter(k => k !== 'giftkoeder').length + 1; // +1 Alle-Btn
document.getElementById('map-legend')
?.style.setProperty('--map-legend-cols', Math.ceil(legendBtns / 2));
document.getElementById('map-legend').addEventListener('click', e => {
const btn = e.target.closest('.map-legend-btn');
if (!btn) return;
// "Alle"-Button
if (btn.id === 'map-legend-all') {
const anyOn = Object.entries(_visible)
.some(([k, v]) => v && k !== 'giftkoeder');
// Wenn irgendetwas sichtbar → alles aus; sonst alles an
const newState = !anyOn;
Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').forEach(([k]) => {
_visible[k] = newState;
document.querySelector(`.map-legend-btn[data-layer="${k}"]`)
?.classList.toggle('active', newState);
_applyVisibility(k);
});
btn.classList.toggle('all-off', !newState);
_saveVisible();
return;
}
const layer = btn.dataset.layer;
_visible[layer] = !_visible[layer];
btn.classList.toggle('active', _visible[layer]);
_applyVisibility(layer);
// Alle-Button-Zustand aktualisieren
const anyOn = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder');
document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOn);
_saveVisible();
});
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);
document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
}
// ----------------------------------------------------------
// REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.)
// ----------------------------------------------------------
let _radarLayer = null;
let _radarActive = false;
let _radarTimer = null;
let _tempLayer = null;
let _tempActive = false;
let _tempMarkers = [];
let _tempDebounce = null;
async function _toggleRadar() {
if (!App.hasPro(_appState?.user)) {
UI.toast.info('Regenradar ist ein Pro-Feature.');
return;
}
const btn = document.getElementById('map-radar-btn');
if (_radarActive) {
_radarActive = false;
if (_radarLayer) { _map.removeLayer(_radarLayer); _radarLayer = null; }
clearInterval(_radarTimer);
btn?.classList.remove('active');
return;
}
_radarActive = true;
btn?.classList.add('active');
if (_map && _map.getZoom() > 7) _map.setZoom(7);
await _loadRadar();
_radarTimer = setInterval(_loadRadar, 5 * 60 * 1000);
}
async function _toggleTemp() {
if (!App.hasPro(_appState?.user)) {
UI.toast.info('Temperatur-Layer ist ein Pro-Feature.');
return;
}
const btn = document.getElementById('map-temp-btn');
if (_tempActive) {
_tempActive = false;
if (_tempLayer) { _map.removeLayer(_tempLayer); _tempLayer = null; }
_tempMarkers.forEach(m => _map.removeLayer(m));
_tempMarkers = [];
clearTimeout(_tempDebounce);
_map.off('moveend zoomend', _debounceTempLabels);
document.getElementById('map-temp-legend')?.remove();
btn?.classList.remove('active');
return;
}
_tempActive = true;
btn?.classList.add('active');
try {
const cfg = await API.get('/weather/layer-tiles?layer=temp_new');
_tempLayer = window.L.tileLayer(cfg.url, {
opacity: 1.0,
tileSize: 256,
zIndex: 290,
maxNativeZoom: cfg.maxNativeZoom ?? 18,
maxZoom: 18,
attribution: 'Temp © <a href="https://openweathermap.org">OpenWeatherMap</a>',
}).addTo(_map);
_showTempLegend();
_map.on('moveend zoomend', _debounceTempLabels);
await _loadTempLabels();
} catch {
_tempActive = false;
btn?.classList.remove('active');
UI.toast.error('Temperatur-Layer nicht verfügbar.');
}
}
function _debounceTempLabels() {
clearTimeout(_tempDebounce);
_tempDebounce = setTimeout(_loadTempLabels, 600);
}
function _tempColor(t) {
if (t <= -10) return '#0033cc';
if (t <= 0) return '#0099ff';
if (t <= 10) return '#00cc88';
if (t <= 15) return '#88cc00';
if (t <= 20) return '#ffcc00';
if (t <= 25) return '#ff8800';
if (t <= 30) return '#ff3300';
return '#990000';
}
async function _loadTempLabels() {
if (!_tempActive || !_map) return;
const bounds = _map.getBounds();
const n = bounds.getNorth(), s = bounds.getSouth();
const e = bounds.getEast(), w = bounds.getWest();
// 3×3 Raster
const rows = 3, cols = 3;
const points = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const lat = s + (n - s) * (r + 0.5) / rows;
const lon = w + (e - w) * (c + 0.5) / cols;
points.push([lat, lon]);
}
}
const results = await Promise.all(points.map(([lat, lon]) =>
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}&current=temperature_2m&timezone=auto`, { cache: 'no-store' })
.then(r => r.json())
.then(d => ({ lat, lon, t: d.current?.temperature_2m }))
.catch(() => null)
));
// Alte Marker entfernen
_tempMarkers.forEach(m => _map.removeLayer(m));
_tempMarkers = [];
results.filter(Boolean).forEach(({ lat, lon, t }) => {
if (t == null) return;
const temp = Math.round(t);
const color = _tempColor(temp);
const icon = window.L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;font-size:12px;font-weight:800;
padding:2px 6px;border-radius:10px;white-space:nowrap;
box-shadow:0 1px 4px rgba(0,0,0,0.5);text-shadow:0 1px 2px rgba(0,0,0,0.4)">${temp}°</div>`,
iconSize: null,
iconAnchor: [20, 10],
});
const m = window.L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false });
m.addTo(_map);
_tempMarkers.push(m);
});
}
function _showTempLegend() {
const existing = document.getElementById('map-temp-legend');
if (existing) return;
const steps = [
{ c: '#0000cc', v: '20°' }, { c: '#0055ff', v: '10°' },
{ c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' },
{ c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' },
{ c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' },
{ c: '#990000', v: '35°' },
];
const gradient = steps.map(s => s.c).join(',');
const labels = steps.map(s =>
`<span style="flex:1;text-align:center;font-size:9px;color:#fff;text-shadow:0 0 3px #000">${s.v}</span>`
).join('');
const el = document.createElement('div');
el.id = 'map-temp-legend';
el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%);
z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px;
min-width:220px;pointer-events:none`;
el.innerHTML = `
<div style="height:10px;border-radius:3px;background:linear-gradient(to right,${gradient});margin-bottom:2px"></div>
<div style="display:flex">${labels}</div>`;
document.getElementById('central-map')?.appendChild(el);
}
async function _loadRadar() {
if (!_radarActive || !_map) return;
try {
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
const data = await resp.json();
const frames = [...(data.radar?.past || []), ...(data.radar?.nowcast || [])];
if (!frames.length) return;
const latest = frames[frames.length - 1].path;
const url = `https://tilecache.rainviewer.com${latest}/256/{z}/{x}/{y}/4/1_1.png`;
if (_radarLayer) _map.removeLayer(_radarLayer);
_radarLayer = window.L.tileLayer(url, {
opacity: 0.7,
tileSize: 256,
zIndex: 300,
maxNativeZoom: 7,
maxZoom: 18,
attribution: 'Radar © <a href="https://rainviewer.com">RainViewer</a>',
}).addTo(_map);
} catch { /* still */ }
}
// ----------------------------------------------------------
// 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);
}
_tileLayer = _buildTileLayer();
_tileLayer.addTo(_map);
// Sofort Dark-Filter anwenden wenn nötig (nach Tile-Load)
_tileLayer.on('load', _applyTileTheme);
_applyTileTheme();
// Theme-Wechsel → Filter aktualisieren
_themeObserver = new MutationObserver(() => _applyTileTheme());
_themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _applyTileTheme);
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 (!_weatherLoaded) { _weatherLoaded = true; _loadWeather(lat, lon); }
if (_locationMarker) {
_locationMarker.setLatLng([lat, lon]);
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
} else {
_locationAccuracy = L.circle([lat, lon], {
radius: acc, color: '#3B82F6', fillColor: '#3B82F6',
fillOpacity: 0.1, weight: 1, interactive: false,
}).addTo(_map);
_locationMarker = L.marker([lat, lon], {
icon, zIndexOffset: 500, interactive: false,
}).addTo(_map);
}
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }
);
}
// ----------------------------------------------------------
// Cluster-Gruppe holen / erstellen
// ----------------------------------------------------------
function _getCluster(layerKey) {
if (!_clusterGroups[layerKey]) {
_clusterGroups[layerKey] = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
animate: true,
chunkedLoading: true,
iconCreateFunction: cluster => {
const t = TYPEN[layerKey];
const n = cluster.getChildCount();
return L.divIcon({
className: '',
html: `<div style="background:${t.color};color:#fff;font-size:13px;font-weight:700;
width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4);
border:2px solid rgba(52,68,36,0.65)">${n}</div>`,
iconSize: [36, 36], iconAnchor: [18, 18],
});
},
});
if (_visible[layerKey] !== false) {
_clusterGroups[layerKey].addTo(_map);
}
}
return _clusterGroups[layerKey];
}
function _isDarkMode() {
const t = document.documentElement.getAttribute('data-theme');
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const _OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const _DARK_FILTER = 'invert(93%) hue-rotate(180deg) brightness(0.88) contrast(0.88) saturate(0.85)';
function _buildTileLayer() {
return L.tileLayer(_OSM_URL, { maxZoom: 19 });
}
function _applyTileTheme() {
if (!_map) return;
const tilePaneEl = _map.getPane('tilePane');
if (tilePaneEl) tilePaneEl.style.filter = _isDarkMode() ? _DARK_FILTER : '';
}
function _updateZoomDisplay() {
if (!_map) return;
const z = Math.round(_map.getZoom());
const el = document.getElementById('map-zoom-info');
if (!el) return;
if (z < 10) { el.textContent = `Z${z}`; el.title = 'Ab Z10: Giftköder'; el.style.opacity = '0.5'; }
else if (z < 14) { el.textContent = `Z${z}`; el.title = 'Ab Z14: alle Layer'; el.style.opacity = '0.7'; }
else { el.textContent = `Z${z}`; el.title = ''; el.style.opacity = '1'; }
}
function _setOsmStatus(text, pct = null) {
const el = document.getElementById('map-osm-status');
const statusbar = document.getElementById('map-statusbar');
if (el) el.textContent = text;
_updateScanRing(text ? pct : null);
_updateScanDog(text ? pct : null);
if (pct === 100 && statusbar) {
statusbar.classList.add('scan-done');
setTimeout(() => statusbar.classList.remove('scan-done'), 2200);
}
}
function _injectDogStyles() {
if (document.getElementById('by-dog-style')) return;
const s = document.createElement('style');
s.id = 'by-dog-style';
s.textContent = [
'@keyframes by-sniff{0%,100%{transform:translateY(0) rotate(0deg)}30%{transform:translateY(2.5px) rotate(-1.5deg)}70%{transform:translateY(1px) rotate(1deg)}}',
'@keyframes by-wander{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}45%{transform:translateX(5px)}68%{transform:translateX(-5px)}85%{transform:translateX(7px)}}',
'@keyframes by-wag{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}',
'#map-scan-dog{animation:by-wander 1.75s ease-in-out infinite;transition:opacity .5s ease;color:#C4843A;position:absolute;pointer-events:none;z-index:1003;width:42px;height:32px}',
'#map-scan-dog svg{display:block;animation:by-sniff .42s ease-in-out infinite}',
'#map-scan-dog .by-tail{transform-box:fill-box;transform-origin:0% 100%;animation:by-wag .32s ease-in-out infinite}',
'#map-statusbar{transition:background .35s ease,color .35s ease,border-color .35s ease}',
'#map-statusbar.scan-done{background:#22C55E!important;color:#fff!important;border-color:#16A34A!important}',
].join('');
document.head.appendChild(s);
}
function _updateScanDog(pct) {
_injectDogStyles();
const statusbar = document.getElementById('map-statusbar');
if (!statusbar) return;
const mapEl = statusbar.closest('.map-main') || statusbar.parentElement;
if (!mapEl) return;
let dog = document.getElementById('map-scan-dog');
if (pct === null) {
if (_ringClosing) return;
if (dog) { dog.style.opacity = '0'; setTimeout(() => dog?.remove(), 550); }
return;
}
if (!dog) {
dog = document.createElement('div');
dog.id = 'map-scan-dog';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '42');
svg.setAttribute('height', '32');
svg.setAttribute('viewBox', '0 0 54 40');
svg.innerHTML = `
<ellipse cx="33" cy="22" rx="14" ry="8" fill="currentColor"/>
<ellipse cx="16" cy="20" rx="6" ry="7" fill="currentColor"/>
<ellipse cx="8" cy="27" rx="7" ry="6" fill="currentColor"/>
<ellipse cx="14" cy="16" rx="3" ry="5" fill="currentColor" transform="rotate(15,14,16)" opacity=".85"/>
<ellipse cx="3" cy="30" rx="3.5" ry="2.5" fill="currentColor"/>
<ellipse cx="1.5" cy="29" rx="2" ry="1.5" fill="#7a4f2a"/>
<circle cx="9" cy="24" r="1.3" fill="white" opacity=".9"/>
<rect x="22" y="28" width="3" height="10" rx="1.5" fill="currentColor"/>
<rect x="29" y="29" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="37" y="28" width="3" height="9" rx="1.5" fill="currentColor"/>
<rect x="42" y="27" width="3" height="9" rx="1.5" fill="currentColor"/>
<g class="by-tail" transform="translate(47,17)">
<path d="M0,0 Q6,-10 4,-18" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round"/>
</g>
`;
dog.appendChild(svg);
mapEl.appendChild(dog);
}
const sr = statusbar.getBoundingClientRect();
const mr = mapEl.getBoundingClientRect();
dog.style.left = (sr.left - mr.left + sr.width - 36) + 'px';
dog.style.top = (sr.top - mr.top - 35) + 'px';
dog.style.opacity = '1';
if (pct >= 100) {
setTimeout(() => {
const d = document.getElementById('map-scan-dog');
if (d) { d.style.opacity = '0'; setTimeout(() => d?.remove(), 550); }
}, 500);
}
}
function _updateScanRing(pct) {
const statusbar = document.getElementById('map-statusbar');
if (!statusbar) return;
const mapEl = statusbar.closest('.map-main') || statusbar.parentElement;
if (!mapEl) return;
let svg = document.getElementById('map-scan-ring');
// Ring ausblenden / entfernen
if (pct === null) {
if (_ringClosing) return;
if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); }
statusbar.style.border = '';
return;
}
// SVG einmalig erzeugen
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'map-scan-ring';
svg.setAttribute('overflow', 'visible');
svg.style.cssText = 'position:absolute;pointer-events:none;z-index:1002;transition:opacity 0.55s ease';
const path = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
path.id = 'map-scan-ring-rect';
path.setAttribute('fill', 'none');
path.setAttribute('stroke', '#C4843A');
path.setAttribute('stroke-width', '3');
path.setAttribute('stroke-linecap', 'round');
svg.appendChild(path);
mapEl.appendChild(svg);
}
// Position relativ zum Map-Container berechnen
const sr = statusbar.getBoundingClientRect();
const mr = mapEl.getBoundingClientRect();
const w = sr.width;
const h = sr.height;
const r = h / 2; // border-radius-full = Hälfte der Höhe
const p = 2; // Abstand zur inneren Kante
// Umfang der Pill: gerades Stück + zwei Halbkreise
const perim = 2 * (w - h) + Math.PI * h;
// Natürlicher SVG-Start: linkes Ende der oberen Geraden
// 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2
// dashoffset = perim - S verschiebt den Dash-Start genau dorthin
const S = (w - h) / 2;
const progress = Math.min(100, Math.max(0, pct));
const progressLen = progress * perim / 100;
svg.style.left = (sr.left - mr.left - p) + 'px';
svg.style.top = (sr.top - mr.top - p) + 'px';
svg.style.width = (w + p * 2) + 'px';
svg.style.height = (h + p * 2) + 'px';
svg.style.opacity = '1';
const rect = document.getElementById('map-scan-ring-rect');
rect.setAttribute('x', String(p));
rect.setAttribute('y', String(p));
rect.setAttribute('width', String(w));
rect.setAttribute('height', String(h));
rect.setAttribute('rx', String(r));
rect.setAttribute('ry', String(r));
rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`);
rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2));
// Original-Rahmen verstecken während Ring aktiv ist
statusbar.style.border = 'none';
if (progress >= 100) {
_ringClosing = true;
setTimeout(() => {
const s = document.getElementById('map-scan-ring');
if (s) s.style.opacity = '0';
statusbar.style.border = '';
setTimeout(() => { s?.remove(); _ringClosing = false; }, 600);
}, 500);
}
}
// ----------------------------------------------------------
// OSM-Layer laden
// ----------------------------------------------------------
function _scheduleOsmLoad() {
clearTimeout(_overpassTimer);
_overpassTimer = setTimeout(_loadOsmLayers, 600);
}
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(52,68,36,0.55)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const label = poi.name || t.label;
const marker = L.marker([poi.lat, poi.lon], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(label, { direction: 'top', offset: [0, -16] });
marker.on('click', () => _showMarkerPopup(marker, poi, layerKey, t, label));
if (isPoison) {
marker._dangerCircle = _addDangerCircle(poi.lat, poi.lon);
}
return marker;
}
function _showMarkerPopup(marker, poi, layerKey, t, label) {
const isOwn = poi.source === 'user' && poi.own;
const isUser = poi.source === 'user';
const actionBtn = isOwn
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
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;
}
// Einzelne Basis-Typen — Mehrfachauswahl möglich (außer giftkoeder = exklusiv)
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', exclusive: true },
{ type: 'gefahr', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B' },
{ type: 'freilauf', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'restaurant', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316' },
{ type: 'shop', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
{ type: 'tierarzt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
{ type: 'hundesalon', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scissors"></use></svg>', label: 'Hundesalon', color: '#EC4899' },
{ type: 'hundeschule', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C' },
{ type: 'bank', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#park"></use></svg>', label: 'Sitzbank', color: '#92400E' },
{ type: 'drinking_water',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle',color: '#0EA5E9' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
_tempMarker?.remove();
_tempMarker = L.circleMarker([latlng.lat, latlng.lng], {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
let _selectedTypes = new Set(['giftkoeder']);
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
<label class="form-label">Typ auswählen <span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">(Mehrfachauswahl möglich)</span></label>
<div class="poi-type-grid">
${PIN_TYPES.map(p => `
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
data-type="${p.type}" data-excl="${p.exclusive ? '1' : ''}" style="--pt-color:${p.color}">
<span class="poi-type-icon">${p.icon}</span>
<span class="poi-type-label">${p.label}</span>
</button>
`).join('')}
</div>
</div>
<div>
<label class="form-label">Name (optional)</label>
<input class="form-input" id="poi-name" placeholder="z.B. Brunnen im Stadtpark">
</div>
<div>
<label class="form-label">Notiz (optional)</label>
<input class="form-input" id="poi-notiz" placeholder="Weitere Infos\u2026">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="poi-cancel">Abbrechen</button>
<button class="btn btn-primary" id="poi-save">Speichern</button>
`,
});
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
const btn = e.target.closest('.poi-type-btn');
if (!btn) return;
const t = btn.dataset.type;
if (btn.dataset.excl) {
_selectedTypes = new Set([t]);
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.toggle('selected', b.dataset.type === t));
} else {
if (_selectedTypes.has('giftkoeder')) {
_selectedTypes.delete('giftkoeder');
document.querySelector('[data-excl="1"]')?.classList.remove('selected');
}
if (_selectedTypes.has(t)) {
if (_selectedTypes.size > 1) { _selectedTypes.delete(t); btn.classList.remove('selected'); }
} else {
_selectedTypes.add(t); btn.classList.add('selected');
}
}
});
document.getElementById('poi-cancel')?.addEventListener('click', () => {
UI.modal.close();
_exitPlacementMode();
});
document.getElementById('poi-save')?.addEventListener('click', async () => {
const name = document.getElementById('poi-name').value.trim() || null;
const notiz = document.getElementById('poi-notiz').value.trim() || null;
const type = [..._selectedTypes].join(',');
UI.modal.close();
await _saveUserPoi({ type, lat: latlng.lat, lon: latlng.lng, name, notiz });
_exitPlacementMode();
});
}
async function _saveUserPoi(data) {
try {
const res = await fetch('/api/osm/user-poi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
if (!res.ok) throw new Error();
UI.toast.success('Marker gespeichert!');
_scheduleOsmLoad();
} catch {
UI.toast.error('Fehler beim Speichern.');
}
}
// ----------------------------------------------------------
// Melden / Löschen
// ----------------------------------------------------------
function _showReportDialog(poi) {
UI.modal.open({
title: 'Marker melden',
body: `
<p class="text-secondary">Warum ist dieser Marker falsch?</p>
<div class="flex flex-col gap-2" id="report-options">
<button class="btn btn-secondary" data-grund="existiert_nicht">Existiert nicht mehr</button>
<button class="btn btn-secondary" data-grund="falsche_position">Falsche Position</button>
<button class="btn btn-secondary" data-grund="spam">Spam / Missbrauch</button>
<button class="btn btn-secondary" data-grund="sonstiges">Sonstiges</button>
</div>
`,
});
document.getElementById('report-options')?.addEventListener('click', async e => {
const btn = e.target.closest('[data-grund]');
if (!btn) return;
UI.modal.close();
try {
const body = {
type: poi.source === 'user' ? 'user_poi' : 'osm',
grund: btn.dataset.grund,
osm_id: poi.source === 'osm' ? poi.id : undefined,
user_poi_id: poi.source === 'user' ? poi.user_poi_id : undefined,
};
const res = await fetch('/api/osm/report', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify(body),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
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(),
]);
const allFailed = [places, poisonList, breederList].every(r => r.status === 'rejected');
if (allFailed) {
try {
const raw = localStorage.getItem(_MAP_POI_KEY);
if (raw) {
const cached = JSON.parse(raw);
_addPlaces(cached.places || []);
_addPoison(cached.poison || []);
_addBreeders(cached.breeders || []);
UI.toast.info('Offline — Karte zeigt gecachte Kacheln. POI-Daten eventuell veraltet.');
_scheduleOsmLoad();
return;
}
} catch {}
}
const placesVal = places.status === 'fulfilled' ? places.value : [];
const poisonVal = poisonList.status === 'fulfilled' ? poisonList.value : [];
const breederVal = breederList.status === 'fulfilled' ? breederList.value : [];
if (places.status === 'fulfilled') _addPlaces(placesVal);
if (poisonList.status === 'fulfilled') _addPoison(poisonVal);
if (breederList.status === 'fulfilled') _addBreeders(breederVal);
if (places.status === 'fulfilled' || poisonList.status === 'fulfilled' || breederList.status === 'fulfilled') {
try {
localStorage.setItem(_MAP_POI_KEY, JSON.stringify({
ts: Date.now(),
places: placesVal,
poison: poisonVal,
breeders: breederVal,
}));
} catch {}
}
_scheduleOsmLoad();
}
function _addPlaces(places) {
if (!_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(52,68,36,0.55)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(_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(52,68,36,0.55)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })
.addTo(_map)
.bindTooltip(tooltip, { direction: 'top', offset: [0, -16] })
.on('click', onClick);
}
// ----------------------------------------------------------
// Layer ein/ausblenden
// ----------------------------------------------------------
function _applyVisibility(layer) {
// poison-Toggle steuert auch giftkoeder-Community-Pins mit
const keys = layer === 'poison' ? ['poison', 'giftkoeder'] : [layer];
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) || _wakeLock) return;
try {
_wakeLock = await navigator.wakeLock.request('screen');
_wakeLock.addEventListener('release', () => {
_wakeLock = null;
// OS hat Lock entzogen → sofort neu anfordern wenn noch aufzeichnet
if (_recActive) _acquireWakeLock();
});
} catch {}
}
function _releaseWakeLock() {
_wakeLock?.release();
_wakeLock = null;
}
function _togglePause() {
_recPaused = !_recPaused;
const btn = document.getElementById('rec-panel-pause');
if (btn) btn.textContent = _recPaused ? '▶ Weiter' : '⏸ Pause';
const panel = document.getElementById('map-rec-panel');
panel?.classList.toggle('paused', _recPaused);
}
function _haversineRec(lat1, lon1, lat2, lon2) {
const R = 6371000;
const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
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 dogs = _appState?.dogs || [];
const activeDogId = _appState?.activeDog?.id;
const dogPickerHtml = dogs.length > 1 ? `
<div class="form-group">
<label class="form-label">Welche Hunde waren dabei?</label>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${dogs.map(d => {
const checked = d.id === activeDogId;
const av = d.foto_url
? `<img src="${UI.escape(d.foto_url)}" style="width:20px;height:20px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<svg class="ph-icon" style="width:14px;height:14px;flex-shrink:0"><use href="/icons/phosphor.svg#dog"></use></svg>`;
return `<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
class="rec-dog-cb hidden">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('')}
</div>
</div>` : '';
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="rec-save-form" autocomplete="off">
${dogPickerHtml}
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name"
placeholder="Wird automatisch ermittelt…" required>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">
<option value="leicht">Leicht</option>
<option value="mittel">Mittel</option>
<option value="anspruchsvoll">Anspruchsvoll</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
<option value="wald">Wald</option>
<option value="asphalt">Asphalt</option>
<option value="wiese">Wiese</option>
<option value="mix">Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-select" id="rec-paw-select">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Premium</button>
</div>
<input type="hidden" name="hunde_tauglichkeit" id="rec-paw-val" value="sehr_gut">
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree"></use></svg> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leine empfohlen
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
</label>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rms-discard">Verwerfen</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
`;
UI.modal.open({ title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Route benennen', body, footer });
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('rec-paw-val').value = btn.dataset.val;
});
document.getElementById('rms-discard')?.addEventListener('click', () => {
UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
});
// Hund-Checkbox Toggle-Styling
document.querySelectorAll('.rec-dog-cb').forEach(cb => {
const label = cb.closest('label');
const update = () => {
label.style.borderColor = cb.checked ? 'var(--c-primary)' : 'var(--c-border)';
label.style.background = cb.checked ? 'var(--c-primary-subtle)' : '';
label.style.color = cb.checked ? 'var(--c-primary)' : '';
};
update();
cb.addEventListener('change', update);
});
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
const fd = UI.formData(e.target);
const dogIds = [...document.querySelectorAll('.rec-dog-cb:checked')].map(c => parseInt(c.value));
await UI.asyncButton(btn, async () => {
const saved = await API.routes.create({
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
distanz_km: Math.round(distKm * 100) / 100,
dauer_min: dauMin,
schwierigkeit: fd.schwierigkeit || 'leicht',
untergrund: fd.untergrund || null,
schatten: 'schatten' in fd,
leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
dog_ids: dogIds.length ? dogIds : null,
});
UI.modal.close();
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.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
: '';
const warning = w.rain_warning_time
? ` · <span style="color:#f59e0b;font-weight:700">⚠ ab ${w.rain_warning_time}</span>`
: '';
let zecken = '';
if (w.zecken_warnung) {
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
}
info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`;
info.classList.remove('map-weather-chip--hidden');
sep.classList.remove('map-weather-chip--hidden');
} catch { /* still */ }
}
return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive };
})();