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).
1949 lines
85 KiB
JavaScript
1949 lines
85 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Zentrale Karte
|
||
Layer: eigene Orte, Giftköder, OSM-POIs, Community-Pins
|
||
Features: Clustering, Standort-Dot, Gefahren-Radius
|
||
============================================================ */
|
||
|
||
window.Page_map = (() => {
|
||
|
||
let _container = null;
|
||
let _appState = null;
|
||
let _map = null;
|
||
let _leafletLoaded = false;
|
||
let _userPos = null;
|
||
let _weatherLoaded = false;
|
||
let _placingMarker = false;
|
||
let _tempMarker = null;
|
||
let _tileLayer = null;
|
||
let _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)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' })
|
||
.then(r => r.json())
|
||
.then(d => ({ lat, lon, t: d.current?.temperature_2m }))
|
||
.catch(() => null)
|
||
));
|
||
|
||
// Alte Marker entfernen
|
||
_tempMarkers.forEach(m => _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 10–13: normale OSM-Layer ausblenden, EARLY_LAYERS behalten/laden
|
||
if (zoom < 14) {
|
||
Object.keys(OSM_LAYER_MAP).filter(k => !EARLY_LAYERS.has(k)).forEach(k => {
|
||
_layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove());
|
||
_clusterGroups[k]?.clearLayers();
|
||
_layers[k] = _layers[k].filter(m => m._ownPlace);
|
||
});
|
||
}
|
||
|
||
_overpassActive = true;
|
||
_map.invalidateSize();
|
||
const b = _map.getBounds().pad(0.15);
|
||
const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
|
||
|
||
// Welche Layer bei diesem Zoom geladen werden
|
||
const activeLayers = zoom >= 14
|
||
? Object.entries(OSM_LAYER_MAP)
|
||
: Object.entries(OSM_LAYER_MAP).filter(([k]) => EARLY_LAYERS.has(k));
|
||
|
||
// OSM-Marker eines Layers ersetzen, eigene Orte behalten
|
||
function _replaceOsmMarkers(layerKey, pois) {
|
||
const cluster = _getCluster(layerKey);
|
||
// Alte OSM-Marker entfernen
|
||
const oldOsm = _layers[layerKey].filter(m => !m._ownPlace);
|
||
oldOsm.forEach(m => m._dangerCircle?.remove());
|
||
cluster.removeLayers(oldOsm);
|
||
_layers[layerKey] = _layers[layerKey].filter(m => m._ownPlace);
|
||
// Neue Marker erstellen und in Cluster packen
|
||
const t = TYPEN[layerKey];
|
||
const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t));
|
||
cluster.addLayers(newMarkers);
|
||
_layers[layerKey].push(...newMarkers);
|
||
// Sicherstellen dass der Cluster auf der Karte ist (kann durch vorherigen Toggle fehlen)
|
||
if (_visible[layerKey] !== false && _map && !_map.hasLayer(cluster)) {
|
||
cluster.addTo(_map);
|
||
}
|
||
}
|
||
|
||
// Phase 1: sofort DB-Daten zeigen (fast=true)
|
||
_setOsmStatus('Lade…');
|
||
const fastTasks = activeLayers.map(async ([layerKey, osmType]) => {
|
||
const params = new URLSearchParams({ type: osmType, fast: 'true', ...bbox });
|
||
try {
|
||
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
|
||
_replaceOsmMarkers(layerKey, pois);
|
||
return pois.length;
|
||
} catch { return 0; }
|
||
});
|
||
const fastCounts = await Promise.all(fastTasks);
|
||
const fastTotal = fastCounts.reduce((a, b) => a + b, 0);
|
||
if (fastTotal > 0) _setOsmStatus(`${fastTotal} aus Datenbank`, 20);
|
||
|
||
// Phase 2: Overpass für fehlende Tiles — mit %-Fortschritt
|
||
let _done = 0;
|
||
const _total = activeLayers.length;
|
||
_setOsmStatus('Scanne…', 20);
|
||
|
||
const freshTasks = activeLayers.map(async ([layerKey, osmType]) => {
|
||
const params = new URLSearchParams({ type: osmType, ...bbox });
|
||
try {
|
||
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
|
||
const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length;
|
||
if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois);
|
||
_done++;
|
||
const pct = Math.round(20 + _done / _total * 80);
|
||
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
||
return pois.length;
|
||
} catch {
|
||
_done++;
|
||
const pct = Math.round(20 + _done / _total * 80);
|
||
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
||
return _layers[layerKey].filter(m => !m._ownPlace).length;
|
||
}
|
||
});
|
||
await Promise.all(freshTasks);
|
||
_overpassActive = false;
|
||
|
||
const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||
const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false);
|
||
if (totalLoaded > 0 && allHidden) {
|
||
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
||
}
|
||
|
||
// Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen
|
||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) {
|
||
_autoRetryCount++;
|
||
const delay = _autoRetryCount * 30000; // 30s, 60s, 90s
|
||
_setOsmStatus(`Neue Umgebung – Daten werden geladen…`);
|
||
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Spezielles Giftköder-Icon (pulsierend)
|
||
// ----------------------------------------------------------
|
||
function _poisonDivIcon() {
|
||
return L.divIcon({
|
||
className: '',
|
||
html: `<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 12–15 innerhalb der aktuellen Kartenansicht
|
||
const urls = _collectTileUrls(padded, 12, 15);
|
||
|
||
if (urls.length === 0) {
|
||
UI.toast.info('Keine Kacheln im Bereich.');
|
||
return;
|
||
}
|
||
|
||
if (urls.length > 800) {
|
||
UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`);
|
||
return;
|
||
}
|
||
|
||
const btn = document.getElementById('map-offline-btn');
|
||
if (btn) btn.classList.add('loading');
|
||
_setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`);
|
||
|
||
// Progress via postMessage vom SW
|
||
const onMessage = evt => {
|
||
if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return;
|
||
const { done, total } = evt.data;
|
||
if (done >= total) {
|
||
navigator.serviceWorker.removeEventListener('message', onMessage);
|
||
if (btn) btn.classList.remove('loading');
|
||
_setOsmStatus('');
|
||
UI.toast.success(`${total} Kacheln offline gespeichert!`);
|
||
} else {
|
||
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
|
||
}
|
||
};
|
||
navigator.serviceWorker.addEventListener('message', onMessage);
|
||
|
||
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// Pocket-Modus Overlay
|
||
// ----------------------------------------------------------
|
||
function _showPocketOverlay() {
|
||
if (_pocketOverlay) return;
|
||
const el = document.createElement('div');
|
||
el.id = 'pocket-overlay';
|
||
el.innerHTML = `
|
||
<div class="po-status" id="po-status">GPS läuft</div>
|
||
<div class="po-time" id="po-time">0:00</div>
|
||
<div class="po-dist" id="po-dist">0.00 km</div>
|
||
<div class="po-hint">Tippen für Steuerung</div>
|
||
<div class="po-controls" id="po-controls">
|
||
<button class="po-btn" id="po-pause">⏸ Pause</button>
|
||
<button class="po-btn po-btn--stop" id="po-stop">⏹ Stopp</button>
|
||
</div>
|
||
`;
|
||
el.style.cssText = `
|
||
position:fixed;inset:0;z-index:9998;background:#000;
|
||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
color:#fff;font-family:inherit;user-select:none;
|
||
`;
|
||
el.querySelector('.po-status').style.cssText =
|
||
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
|
||
el.querySelector('.po-time').style.cssText =
|
||
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
|
||
el.querySelector('.po-dist').style.cssText =
|
||
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
|
||
el.querySelector('.po-hint').style.cssText =
|
||
'font-size:0.75rem;color:#444;margin-top:2.5rem';
|
||
const ctrl = el.querySelector('.po-controls');
|
||
ctrl.style.cssText =
|
||
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
|
||
el.querySelectorAll('.po-btn').forEach(b => {
|
||
b.style.cssText =
|
||
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
|
||
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
|
||
});
|
||
el.querySelector('.po-btn--stop').style.cssText +=
|
||
'border-color:#c0392b;color:#e74c3c';
|
||
|
||
// Tippen → Controls 4s einblenden, dann ausblenden
|
||
el.addEventListener('click', e => {
|
||
if (e.target.closest('#po-controls')) return;
|
||
ctrl.style.display = 'flex';
|
||
el.querySelector('.po-hint').style.color = '#666';
|
||
clearTimeout(_pocketHideTimer);
|
||
_pocketHideTimer = setTimeout(() => {
|
||
ctrl.style.display = 'none';
|
||
el.querySelector('.po-hint').style.color = '#444';
|
||
}, 4000);
|
||
});
|
||
|
||
el.querySelector('#po-pause').addEventListener('click', () => {
|
||
_togglePause();
|
||
el.querySelector('#po-pause').textContent =
|
||
_recPaused ? '▶ Weiter' : '⏸ Pause';
|
||
el.querySelector('#po-status').textContent =
|
||
_recPaused ? 'Pausiert' : 'GPS läuft';
|
||
});
|
||
el.querySelector('#po-stop').addEventListener('click', () => {
|
||
_hidePocketOverlay();
|
||
_stopRecording();
|
||
});
|
||
|
||
document.body.appendChild(el);
|
||
_pocketOverlay = el;
|
||
}
|
||
|
||
function _hidePocketOverlay() {
|
||
clearTimeout(_pocketHideTimer);
|
||
_pocketOverlay?.remove();
|
||
_pocketOverlay = null;
|
||
}
|
||
|
||
function _updatePocketOverlay() {
|
||
if (!_pocketOverlay) return;
|
||
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
|
||
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
|
||
const ss = String(elapsed % 60).padStart(2, '0');
|
||
const timeEl = _pocketOverlay.querySelector('#po-time');
|
||
const distEl = _pocketOverlay.querySelector('#po-dist');
|
||
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
|
||
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// GPS-Aufzeichnung
|
||
// ----------------------------------------------------------
|
||
function _toggleRecording() {
|
||
if (!_recActive) _startRecording();
|
||
else _stopRecording();
|
||
}
|
||
|
||
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 };
|
||
|
||
})();
|