banyaro/backend/static/js/pages/map.js

1329 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ============================================================
BAN YARO — Zentrale Karte
Layer: eigene Orte, Giftköder, OSM-POIs, Community-Pins
Features: Clustering, Standort-Dot, Gefahren-Radius
============================================================ */
window.Page_map = (() => {
let _container = null;
let _appState = null;
let _map = null;
let _leafletLoaded = false;
let _userPos = null;
let _placingMarker = false;
let _tempMarker = null;
// Standort-Tracking
let _locationMarker = null;
let _locationAccuracy = null;
let _watchId = null;
// GPS-Aufzeichnung
let _recActive = false;
let _recPaused = false;
let _wakeLock = null;
let _recTrack = [];
let _recDistKm = 0;
let _recStartTime = null;
let _recTimerInt = null;
let _recPolyline = null;
let _pocketOverlay = null;
let _pocketHideTimer = null;
let _recMarker = null;
let _recWatchId = null;
// Cluster-Gruppen pro Layer (für OSM-Marker)
let _clusterGroups = {};
// Layer-Marker (Arrays von Leaflet-Markern)
let _layers = {
restaurant: [],
freilauf: [],
shop: [],
kotbeutel: [],
tierarzt: [],
hundeschule: [],
poison: [],
muell: [],
dog_park: [],
wasser: [],
bank: [],
giftkoeder: [],
gefahr: [],
parkplatz: [],
treffpunkt: [],
community: [],
};
const VISIBLE_KEY = 'by_map_visible_v1';
let _visible = {};
// Gespeicherten Zustand laden, Fallback: alles sichtbar
(() => {
const saved = (() => { try { return JSON.parse(localStorage.getItem(VISIBLE_KEY) || 'null'); } catch { return null; } })();
Object.keys(_layers).forEach(k => {
_visible[k] = saved ? (saved[k] !== false) : true;
});
})();
function _saveVisible() {
try { localStorage.setItem(VISIBLE_KEY, JSON.stringify(_visible)); } catch {}
}
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#6B7280', z: -20 },
dog_park: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chair"></use></svg>', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
};
// 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;
// ----------------------------------------------------------
// 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);
try { _userPos = await API.getLocation(); } catch {}
await _loadLeaflet();
_initMap();
_startLocationTracking();
_loadAll();
}
function refresh() { _loadAll(); }
function onDogChange() {}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="map-full-layout">
<div class="map-legend" id="map-legend">
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
</button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
${t.icon} <span class="map-legend-label">${t.label}</span>
</button>
`).join('')}
</div>
<div id="central-map" class="map-full"></div>
<!-- Fadenkreuz-Overlay (nur im Placement-Modus sichtbar) -->
<div class="map-crosshair" id="map-crosshair">
<div class="map-crosshair-pin"><svg class="ph-icon" aria-hidden="true" style="width:28px;height:28px"><use href="/icons/phosphor.svg#map-pin"></use></svg></div>
<div class="map-crosshair-shadow"></div>
</div>
<div class="map-place-bar" id="map-place-bar">
<span class="map-place-hint">Karte verschieben · Pin landet genau hier</span>
<div class="map-place-btns">
<button class="btn btn-secondary" id="map-place-cancel">Abbrechen</button>
<button class="btn btn-primary" id="map-place-confirm"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Hier platzieren</button>
</div>
</div>
<div class="map-fabs">
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg></button>
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg></button>
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span>
<span id="map-osm-status"></span>
</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>
`;
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-offline-btn').addEventListener('click', _cacheTiles);
document.getElementById('map-rec-btn').addEventListener('click', _toggleRecording);
}
// ----------------------------------------------------------
// Leaflet + MarkerCluster laden
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
// Leaflet CSS
const lCss = document.createElement('link');
lCss.rel = 'stylesheet'; lCss.href = '/css/leaflet.css';
document.head.appendChild(lCss);
// Leaflet JS
await new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.js'; s.onload = resolve;
document.head.appendChild(s);
});
// MarkerCluster CSS
['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(f => {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = `/css/${f}`;
document.head.appendChild(l);
});
// MarkerCluster JS
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] : [51.1657, 10.4515];
const zoom = _userPos ? 14 : 6;
_map = L.map('central-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
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', () => { _updateZoomDisplay(); _scheduleOsmLoad(); });
setTimeout(() => { _updateZoomDisplay(); _scheduleOsmLoad(); }, 800);
// Fadenkreuz-Animation beim Kartenverschieben
_map.on('movestart', () => {
document.getElementById('map-crosshair')?.classList.add('dragging');
});
_map.on('moveend', () => {
document.getElementById('map-crosshair')?.classList.remove('dragging');
});
}
// ----------------------------------------------------------
// Standort-Tracking — pulsierender blauer Punkt
// ----------------------------------------------------------
function _startLocationTracking() {
if (!navigator.geolocation || !_map || !window.L) return;
const icon = L.divIcon({
className: 'loc-icon',
html: '<div class="loc-dot"></div><div class="loc-ring"></div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
});
_watchId = navigator.geolocation.watchPosition(
pos => {
const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords;
_userPos = { lat, lon };
if (_locationMarker) {
_locationMarker.setLatLng([lat, lon]);
_locationAccuracy?.setLatLng([lat, lon]).setRadius(acc);
} else {
_locationAccuracy = L.circle([lat, lon], {
radius: acc, color: '#3B82F6', fillColor: '#3B82F6',
fillOpacity: 0.1, weight: 1, interactive: false,
}).addTo(_map);
_locationMarker = L.marker([lat, lon], {
icon, zIndexOffset: 500, interactive: false,
}).addTo(_map);
}
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 }
);
}
// ----------------------------------------------------------
// Cluster-Gruppe holen / erstellen
// ----------------------------------------------------------
function _getCluster(layerKey) {
if (!_clusterGroups[layerKey]) {
_clusterGroups[layerKey] = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
animate: true,
chunkedLoading: true,
iconCreateFunction: cluster => {
const t = TYPEN[layerKey];
const n = cluster.getChildCount();
return L.divIcon({
className: '',
html: `<div style="background:${t.color};color:#fff;font-size:13px;font-weight:700;
width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 6px rgba(0,0,0,0.4);
border:2px solid rgba(255,255,255,0.8)">${n}</div>`,
iconSize: [36, 36], iconAnchor: [18, 18],
});
},
});
if (_visible[layerKey] !== false) {
_clusterGroups[layerKey].addTo(_map);
}
}
return _clusterGroups[layerKey];
}
function _updateZoomDisplay() {
if (!_map) return;
const z = Math.round(_map.getZoom());
const el = document.getElementById('map-zoom-info');
if (!el) return;
if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; }
else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; }
else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; }
}
function _setOsmStatus(text) {
const el = document.getElementById('map-osm-status');
if (el) el.textContent = text;
}
// ----------------------------------------------------------
// 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;
const b = _map.getBounds();
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);
}
// Phase 1: sofort DB-Daten zeigen (fast=true)
_setOsmStatus('Lade\u2026');
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`);
// Phase 2: Overpass für fehlende Tiles — mit %-Fortschritt
let _done = 0;
const _total = activeLayers.length;
_setOsmStatus(fastTotal > 0 ? `${fastTotal} gefunden \u00b7 Scanne 0\u202f%` : 'Scanne 0\u202f%');
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(_done / _total * 100);
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
_setOsmStatus(pct < 100
? `${total} gefunden \u00b7 Scanne ${pct}\u202f%`
: `${total} Marker`
);
return pois.length;
} catch {
_done++;
return _layers[layerKey].filter(m => !m._ownPlace).length;
}
});
await Promise.all(freshTasks);
_overpassActive = false;
}
// ----------------------------------------------------------
// Spezielles Giftköder-Icon (pulsierend)
// ----------------------------------------------------------
function _poisonDivIcon() {
return L.divIcon({
className: '',
html: `<div class="poison-marker">
<div class="poison-ring"></div>
<div class="poison-ring"></div>
<div class="poison-dot"><svg class="ph-icon" aria-hidden="true" style="width:20px;height:20px"><use href="/icons/phosphor.svg#skull"></use></svg></div>
</div>`,
iconSize: [48, 48],
iconAnchor: [24, 24],
});
}
function _addDangerCircle(lat, lon) {
return L.circle([lat, lon], { radius: 100, ...DANGER_CIRCLE_STYLE }).addTo(_map);
}
// ----------------------------------------------------------
// OSM-Marker erstellen (geht in Cluster, NICHT direkt auf Karte)
// ----------------------------------------------------------
function _createOsmMarker(poi, layerKey, t) {
const isPoison = DANGER_RADIUS[layerKey] !== undefined;
const icon = isPoison
? _poisonDivIcon()
: L.divIcon({
className: '',
html: `<div style="background:${t.color};color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const label = poi.name || t.label;
const marker = L.marker([poi.lat, poi.lon], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(label, { direction: 'top', offset: [0, -16] });
marker.on('click', () => _showMarkerPopup(marker, poi, layerKey, t, label));
if (isPoison) {
marker._dangerCircle = _addDangerCircle(poi.lat, poi.lon);
}
return marker;
}
function _showMarkerPopup(marker, poi, layerKey, t, label) {
const isOwn = poi.source === 'user' && poi.own;
const isUser = poi.source === 'user';
const actionBtn = isOwn
? `<button class="btn btn-danger btn-sm" id="mp-action">Löschen</button>`
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
const openHours = poi.opening_hours
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
const phone = poi.phone
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">${poi.phone}</a></div>` : '';
const website = poi.website
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg> Website</a></div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${label}</div>
${poi.notiz ? `<div style="font-size:12px;color:#666;margin-bottom:6px">${poi.notiz}</div>` : ''}
${openHours}${phone}${website}
<div style="font-size:11px;color:#999;margin-bottom:10px">
${isUser
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>
${actionBtn}
</div>
`, { maxWidth: 260 }).openPopup();
setTimeout(() => {
document.getElementById('mp-action')?.addEventListener('click', () => {
marker.closePopup();
if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey);
else _showReportDialog(poi);
});
}, 50);
}
// ----------------------------------------------------------
// Marker setzen (Placement-Mode)
// ----------------------------------------------------------
function _togglePlacementMode() {
_placingMarker = !_placingMarker;
const btn = document.getElementById('map-pin-btn');
if (_placingMarker) {
btn?.classList.add('active');
btn && (btn.textContent = '\u2715');
// Fadenkreuz + Bestätigen-Leiste einblenden
document.getElementById('map-crosshair')?.classList.add('active');
document.getElementById('map-place-bar')?.classList.add('active');
document.getElementById('map-place-confirm').onclick = () => {
const center = _map.getCenter();
_exitPlacementMode();
_confirmPlacement(center);
};
document.getElementById('map-place-cancel').onclick = _exitPlacementMode;
} else {
_exitPlacementMode();
}
}
function _exitPlacementMode() {
_placingMarker = false;
const btn = document.getElementById('map-pin-btn');
btn?.classList.remove('active');
btn && (btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>');
document.getElementById('map-crosshair')?.classList.remove('active', 'dragging');
document.getElementById('map-place-bar')?.classList.remove('active');
_tempMarker?.remove();
_tempMarker = null;
}
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
{ 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: 'drinking_water', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
_tempMarker?.remove();
_tempMarker = L.circleMarker([latlng.lat, latlng.lng], {
radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6,
}).addTo(_map);
let _selectedType = 'giftkoeder';
UI.modal.open({
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
<label class="form-label">Typ auswählen</label>
<div class="poi-type-grid">
${PIN_TYPES.map(p => `
<button type="button" class="poi-type-btn${p.type === 'giftkoeder' ? ' selected' : ''}"
data-type="${p.type}" style="--pt-color:${p.color}">
<span class="poi-type-icon">${p.icon}</span>
<span class="poi-type-label">${p.label}</span>
</button>
`).join('')}
</div>
</div>
<div>
<label class="form-label">Name (optional)</label>
<input class="form-input" id="poi-name" placeholder="z.B. Brunnen im Stadtpark">
</div>
<div>
<label class="form-label">Notiz (optional)</label>
<input class="form-input" id="poi-notiz" placeholder="Weitere Infos\u2026">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" id="poi-cancel">Abbrechen</button>
<button class="btn btn-primary" id="poi-save">Speichern</button>
`,
});
document.querySelector('.poi-type-grid')?.addEventListener('click', e => {
const btn = e.target.closest('.poi-type-btn');
if (!btn) return;
document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
_selectedType = btn.dataset.type;
});
document.getElementById('poi-cancel')?.addEventListener('click', () => {
UI.modal.close();
_exitPlacementMode();
});
document.getElementById('poi-save')?.addEventListener('click', async () => {
const name = document.getElementById('poi-name').value.trim() || null;
const notiz = document.getElementById('poi-notiz').value.trim() || null;
UI.modal.close();
await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz });
_exitPlacementMode();
});
}
async function _saveUserPoi(data) {
try {
const res = await fetch('/api/osm/user-poi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
if (!res.ok) throw new Error();
UI.toast.success('Marker gespeichert!');
_scheduleOsmLoad();
} catch {
UI.toast.error('Fehler beim Speichern.');
}
}
// ----------------------------------------------------------
// Melden / Löschen
// ----------------------------------------------------------
function _showReportDialog(poi) {
UI.modal.open({
title: 'Marker melden',
body: `
<p class="text-secondary">Warum ist dieser Marker falsch?</p>
<div class="flex flex-col gap-2" id="report-options">
<button class="btn btn-secondary" data-grund="existiert_nicht">Existiert nicht mehr</button>
<button class="btn btn-secondary" data-grund="falsche_position">Falsche Position</button>
<button class="btn btn-secondary" data-grund="spam">Spam / Missbrauch</button>
<button class="btn btn-secondary" data-grund="sonstiges">Sonstiges</button>
</div>
`,
});
document.getElementById('report-options')?.addEventListener('click', async e => {
const btn = e.target.closest('[data-grund]');
if (!btn) return;
UI.modal.close();
try {
const body = {
type: poi.source === 'user' ? 'user_poi' : 'osm',
grund: btn.dataset.grund,
osm_id: poi.source === 'osm' ? poi.id : undefined,
user_poi_id: poi.source === 'user' ? poi.user_poi_id : undefined,
};
const res = await fetch('/api/osm/report', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify(body),
});
if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; }
const data = await res.json();
if (data.status === 'bereits_gemeldet') {
UI.toast.info('Du hast diesen Marker bereits gemeldet.');
} else {
UI.toast.success('Meldung eingereicht. Danke!');
}
} catch { UI.toast.error('Fehler beim Melden.'); }
});
}
async function _deleteUserPoi(poiId, marker, layerKey) {
try {
const res = await fetch(`/api/osm/user-poi/${poiId}`, {
method: 'DELETE', credentials: 'include',
});
if (!res.ok) throw new Error();
_clusterGroups[layerKey]?.removeLayer(marker);
marker._dangerCircle?.remove();
_layers[layerKey] = _layers[layerKey].filter(m => m !== marker);
UI.toast.success('Marker gelöscht.');
} catch { UI.toast.error('Fehler beim Löschen.'); }
}
// ----------------------------------------------------------
// Eigene Orte + Giftköder laden
// ----------------------------------------------------------
async function _loadAll() {
// 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] = await Promise.allSettled([
API.places.list(),
_userPos ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) : Promise.resolve([]),
]);
if (places.status === 'fulfilled') _addPlaces(places.value);
if (poisonList.status === 'fulfilled') _addPoison(poisonList.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 _createSimpleMarker(lat, lon, t, tooltip, onClick) {
const icon = L.divIcon({
className: '',
html: `<div style="background:${t.color};color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 })
.addTo(_map)
.bindTooltip(tooltip, { direction: 'top', offset: [0, -16] })
.on('click', onClick);
}
// ----------------------------------------------------------
// Layer ein/ausblenden
// ----------------------------------------------------------
function _applyVisibility(layer) {
// poison-Toggle steuert auch giftkoeder-Community-Pins mit
const keys = layer === 'poison' ? ['poison', 'giftkoeder'] : [layer];
keys.forEach(k => {
const on = _visible[layer];
_visible[k] = on;
if (_clusterGroups[k]) {
on ? _clusterGroups[k].addTo(_map) : _clusterGroups[k].remove();
}
(_layers[k] || []).forEach(m => {
if (m._ownPlace) m.setOpacity?.(on ? 1 : 0);
if (m._dangerCircle) {
m._dangerCircle.setStyle(on
? { opacity: 1, fillOpacity: 0.12 }
: { opacity: 0, fillOpacity: 0 }
);
}
});
});
}
// ----------------------------------------------------------
// Offline-Kacheln vorladen
// ----------------------------------------------------------
function _tileCoords(lat, lon, zoom) {
const n = Math.pow(2, zoom);
const x = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
function _collectTileUrls(bounds, minZoom, maxZoom) {
const urls = [];
const subdomains = ['a', 'b', 'c'];
for (let z = minZoom; z <= maxZoom; z++) {
const sw = _tileCoords(bounds.getSouth(), bounds.getWest(), z);
const ne = _tileCoords(bounds.getNorth(), bounds.getEast(), z);
for (let x = sw.x; x <= ne.x; x++) {
for (let y = ne.y; y <= sw.y; y++) {
const s = subdomains[Math.abs(x + y) % 3];
urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`);
}
}
}
return urls;
}
async function _cacheTiles() {
if (!_map) return;
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {
UI.toast.warning('Service Worker nicht bereit \u2014 bitte Seite neu laden.');
return;
}
const bounds = _map.getBounds();
// Padding: 1 Kachel nach außen auf Zoom 14
const padded = bounds.pad(0.15);
// Zoom 1215 innerhalb der aktuellen Kartenansicht
const urls = _collectTileUrls(padded, 12, 15);
if (urls.length === 0) {
UI.toast.info('Keine Kacheln im Bereich.');
return;
}
if (urls.length > 800) {
UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`);
return;
}
const btn = document.getElementById('map-offline-btn');
if (btn) btn.classList.add('loading');
_setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`);
// Progress via postMessage vom SW
const onMessage = evt => {
if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return;
const { done, total } = evt.data;
if (done >= total) {
navigator.serviceWorker.removeEventListener('message', onMessage);
if (btn) btn.classList.remove('loading');
_setOsmStatus('');
UI.toast.success(`${total} Kacheln offline gespeichert!`);
} else {
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
}
};
navigator.serviceWorker.addEventListener('message', onMessage);
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
}
// ----------------------------------------------------------
// Pocket-Modus Overlay
// ----------------------------------------------------------
function _showPocketOverlay() {
if (_pocketOverlay) return;
const el = document.createElement('div');
el.id = 'pocket-overlay';
el.innerHTML = `
<div class="po-status" id="po-status">GPS läuft</div>
<div class="po-time" id="po-time">0:00</div>
<div class="po-dist" id="po-dist">0.00 km</div>
<div class="po-hint">Tippen für Steuerung</div>
<div class="po-controls" id="po-controls">
<button class="po-btn" id="po-pause">⏸ Pause</button>
<button class="po-btn po-btn--stop" id="po-stop">⏹ Stopp</button>
</div>
`;
el.style.cssText = `
position:fixed;inset:0;z-index:9998;background:#000;
display:flex;flex-direction:column;align-items:center;justify-content:center;
color:#fff;font-family:inherit;user-select:none;
`;
el.querySelector('.po-status').style.cssText =
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
el.querySelector('.po-time').style.cssText =
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
el.querySelector('.po-dist').style.cssText =
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
el.querySelector('.po-hint').style.cssText =
'font-size:0.75rem;color:#444;margin-top:2.5rem';
const ctrl = el.querySelector('.po-controls');
ctrl.style.cssText =
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
el.querySelectorAll('.po-btn').forEach(b => {
b.style.cssText =
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
});
el.querySelector('.po-btn--stop').style.cssText +=
'border-color:#c0392b;color:#e74c3c';
// Tippen → Controls 4s einblenden, dann ausblenden
el.addEventListener('click', e => {
if (e.target.closest('#po-controls')) return;
ctrl.style.display = 'flex';
el.querySelector('.po-hint').style.color = '#666';
clearTimeout(_pocketHideTimer);
_pocketHideTimer = setTimeout(() => {
ctrl.style.display = 'none';
el.querySelector('.po-hint').style.color = '#444';
}, 4000);
});
el.querySelector('#po-pause').addEventListener('click', () => {
_togglePause();
el.querySelector('#po-pause').textContent =
_recPaused ? '▶ Weiter' : '⏸ Pause';
el.querySelector('#po-status').textContent =
_recPaused ? 'Pausiert' : 'GPS läuft';
});
el.querySelector('#po-stop').addEventListener('click', () => {
_hidePocketOverlay();
_stopRecording();
});
document.body.appendChild(el);
_pocketOverlay = el;
}
function _hidePocketOverlay() {
clearTimeout(_pocketHideTimer);
_pocketOverlay?.remove();
_pocketOverlay = null;
}
function _updatePocketOverlay() {
if (!_pocketOverlay) return;
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const timeEl = _pocketOverlay.querySelector('#po-time');
const distEl = _pocketOverlay.querySelector('#po-dist');
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
}
// ----------------------------------------------------------
// GPS-Aufzeichnung
// ----------------------------------------------------------
function _toggleRecording() {
if (!_recActive) _startRecording();
else _stopRecording();
}
async function _startRecording() {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden.');
App.navigate('settings');
return;
}
if (!navigator.geolocation) {
UI.toast.error('GPS nicht verfügbar.');
return;
}
_recActive = true;
_recPaused = false;
_recTrack = [];
_recDistKm = 0;
_recStartTime = Date.now();
// FAB umschalten
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>'; btn.classList.add('recording'); }
// Aufzeichnungs-Panel einblenden
const panel = document.getElementById('map-rec-panel');
if (panel) panel.classList.add('active');
document.getElementById('rec-panel-pause').onclick = _togglePause;
document.getElementById('rec-panel-stop').onclick = _stopRecording;
// Wake Lock — Bildschirm wach halten
await _acquireWakeLock();
const hint = document.getElementById('map-rec-hint');
if (hint) hint.textContent = _wakeLock
? 'Bildschirm bleibt aktiv — GPS läuft'
: 'Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
// Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern
document.addEventListener('visibilitychange', _onVisibilityChange);
_recTimerInt = setInterval(_updateRecStatus, 1000);
_recWatchId = navigator.geolocation.watchPosition(
pos => {
if (_recPaused) return;
const { latitude: lat, longitude: lon } = pos.coords;
if (_recTrack.length > 0) {
const prev = _recTrack[_recTrack.length - 1];
const d = _haversineRec(prev.lat, prev.lon, lat, lon);
if (d < 3) return;
_recDistKm += d / 1000;
}
_recTrack.push({ lat, lon });
_updateRecMap(lat, lon);
_updateRecStatus();
},
() => {},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
);
UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
// Pocket-Modus aktivieren wenn in Einstellungen eingeschaltet
if (localStorage.getItem('by_pocket_mode') === 'true') {
setTimeout(_showPocketOverlay, 800); // kurz warten damit Toast sichtbar war
}
}
async function _onVisibilityChange() {
if (_recActive && document.visibilityState === 'visible' && !_wakeLock) {
await _acquireWakeLock();
}
}
async function _acquireWakeLock() {
if (!('wakeLock' in navigator)) return;
try {
_wakeLock = await navigator.wakeLock.request('screen');
_wakeLock.addEventListener('release', () => { _wakeLock = null; });
} catch {}
}
function _releaseWakeLock() {
_wakeLock?.release();
_wakeLock = null;
}
function _togglePause() {
_recPaused = !_recPaused;
const btn = document.getElementById('rec-panel-pause');
if (btn) btn.textContent = _recPaused ? '▶ Weiter' : '⏸ Pause';
const panel = document.getElementById('map-rec-panel');
panel?.classList.toggle('paused', _recPaused);
}
function _haversineRec(lat1, lon1, lat2, lon2) {
const R = 6371000;
const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function _updateRecMap(lat, lon) {
if (!_map || !window.L) return;
const ll = [lat, lon];
if (!_recPolyline) {
_recPolyline = L.polyline([ll], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_map);
} else {
_recPolyline.addLatLng(ll);
}
if (!_recMarker) {
_recMarker = L.circleMarker(ll, {
radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3,
}).addTo(_map);
} else {
_recMarker.setLatLng(ll);
}
_map.panTo(ll);
}
function _updateRecStatus() {
const secs = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(secs / 60)).padStart(2, '0');
const ss = String(secs % 60).padStart(2, '0');
const pace = _recDistKm > 0.05
? (() => { const pSec = secs / _recDistKm / 60; const pm = Math.floor(pSec); const ps = String(Math.round((pSec-pm)*60)).padStart(2,'0'); return `${pm}:${ps}`; })()
: ':';
const distEl = document.getElementById('rec-stat-dist');
const timeEl = document.getElementById('rec-stat-time');
const paceEl = document.getElementById('rec-stat-pace');
if (distEl) distEl.textContent = _recDistKm.toFixed(2);
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (paceEl) paceEl.textContent = pace;
_updatePocketOverlay();
}
function _stopRecording() {
if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; }
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
_recActive = false;
_releaseWakeLock();
_hidePocketOverlay();
document.removeEventListener('visibilitychange', _onVisibilityChange);
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>'; btn.classList.remove('recording'); }
const panel = document.getElementById('map-rec-panel');
if (panel) panel.classList.remove('active', 'paused');
if (_recTrack.length < 2) {
UI.toast.warning('Zu wenige GPS-Punkte — bitte etwas länger laufen.');
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
return;
}
const dauMin = Math.max(1, Math.floor((Date.now() - _recStartTime) / 1000 / 60));
_showRecSaveModal(_recTrack, _recDistKm, dauMin);
}
async function _prefillRouteName(track, distKm) {
const nameInput = document.querySelector('#rec-save-form [name="name"]');
if (!nameInput || nameInput.value) return;
const pt = track[0];
const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
const km = distKm.toFixed(1);
let ort = '';
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' });
const data = await r.json();
const a = data.address || {};
ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || '';
} catch {}
if (!nameInput.value) nameInput.value = ort
? `Gassirunde ${ort} · ${date} · ${km} km`
: `Gassirunde · ${date} · ${km} km`;
}
function _showRecSaveModal(track, distKm, dauMin) {
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="rec-save-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name"
placeholder="Wird automatisch ermittelt…" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">
<option value="leicht">Leicht</option>
<option value="mittel">Mittel</option>
<option value="anspruchsvoll">Anspruchsvoll</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
<option value="wald">Wald</option>
<option value="asphalt">Asphalt</option>
<option value="wiese">Wiese</option>
<option value="mix">Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-select" id="rec-paw-select">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Premium</button>
</div>
<input type="hidden" name="hunde_tauglichkeit" id="rec-paw-val" value="sehr_gut">
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree"></use></svg> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leine empfohlen
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public" checked> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
</label>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rms-discard">Verwerfen</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
`;
UI.modal.open({ title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Route benennen', body, footer });
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
if (!btn) return;
document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('rec-paw-val').value = btn.dataset.val;
});
document.getElementById('rms-discard')?.addEventListener('click', () => {
UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
});
document.getElementById('rec-save-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="rec-save-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const saved = await API.routes.create({
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
distanz_km: Math.round(distKm * 100) / 100,
dauer_min: dauMin,
schwierigkeit: fd.schwierigkeit || 'leicht',
untergrund: fd.untergrund || null,
schatten: 'schatten' in fd,
leine_empfohlen: 'leine_empfohlen' in fd,
is_public: 'is_public' in fd,
hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut',
});
UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
UI.toast.success(`Route „${saved.name}" gespeichert!`);
});
});
}
return { init, refresh, onDogChange, startRecording: _startRecording };
})();