/** * Parst einen Zeitstring vom Server in ein Date-Objekt. * Timestamps mit 'Z' oder '+' werden direkt geparst (echtes UTC/Offset). * Timestamps ohne Zeitzonen-Info werden als Europe/Berlin interpretiert, * da die DB alle Zeiten in Lokalzeit speichert. */ function parseUTC(dateStr) { if (!dateStr) return null; try { if (dateStr.endsWith('Z') || dateStr.includes('+')) { const d = new Date(dateStr); return isNaN(d.getTime()) ? null : d; } // DB-Timestamps sind Europe/Berlin Lokalzeit. // Aktuellen Berlin-UTC-Offset ermitteln und anwenden. const normalized = dateStr.replace(' ', 'T'); const naive = new Date(normalized + 'Z'); // als UTC parsen if (isNaN(naive.getTime())) return null; // Berlin-Offset fuer diesen Zeitpunkt bestimmen const berlinStr = naive.toLocaleString('sv-SE', { timeZone: 'Europe/Berlin' }); const berlinAsUTC = new Date(berlinStr.replace(' ', 'T') + 'Z'); const offsetMs = naive.getTime() - berlinAsUTC.getTime(); const d = new Date(naive.getTime() + offsetMs); return isNaN(d.getTime()) ? null : d; } catch (e) { return null; } } /** * UI-Komponenten für das Dashboard. */ const UI = { /** * Sidebar-Eintrag für eine Lage rendern. */ renderIncidentItem(incident, isActive) { const isRefreshing = App._refreshingIncidents && App._refreshingIncidents.has(incident.id); const dotClass = isRefreshing ? 'refreshing' : (incident.status === 'active' ? 'active' : 'archived'); const activeClass = isActive ? 'active' : ''; const creator = (incident.created_by_username || '').split('@')[0]; return `
${this.escape(incident.title)}
${incident.article_count} Artikel · ${this.escape(creator)}
${incident.visibility === 'private' ? 'PRIVAT' : ''} ${incident.refresh_mode === 'auto' ? '' : ''}
`; }, /** * Faktencheck-Eintrag rendern. */ factCheckLabels: { confirmed: 'Bestätigt durch mehrere Quellen', unconfirmed: 'Nicht unabhängig bestätigt', contradicted: 'Widerlegt', developing: 'Faktenlage noch im Fluss', established: 'Gesicherter Fakt (3+ Quellen)', disputed: 'Umstrittener Sachverhalt', unverified: 'Nicht unabhängig verifizierbar', }, factCheckTooltips: { confirmed: 'Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.', established: 'Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.', developing: 'Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.', unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.', unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.', disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.', contradicted: 'Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.', }, factCheckChipLabels: { confirmed: 'Bestätigt', unconfirmed: 'Unbestätigt', contradicted: 'Widerlegt', developing: 'Unklar', established: 'Gesichert', disputed: 'Umstritten', unverified: 'Ungeprüft', }, factCheckIcons: { confirmed: '✓', unconfirmed: '?', contradicted: '✗', developing: '↻', established: '✓', disputed: '⚠', unverified: '?', }, /** * Faktencheck-Filterleiste rendern. */ renderFactCheckFilters(factchecks) { // Welche Stati kommen tatsächlich vor + Zähler const statusCounts = {}; factchecks.forEach(fc => { statusCounts[fc.status] = (statusCounts[fc.status] || 0) + 1; }); const statusOrder = ['confirmed', 'established', 'developing', 'unconfirmed', 'unverified', 'disputed', 'contradicted']; const usedStatuses = statusOrder.filter(s => statusCounts[s]); if (usedStatuses.length <= 1) return ''; const items = usedStatuses.map(status => { const icon = this.factCheckIcons[status] || '?'; const chipLabel = this.factCheckChipLabels[status] || status; const tooltip = this.factCheckTooltips[status] || ''; const count = statusCounts[status]; return ``; }).join(''); return `
${items}
`; }, renderFactCheck(fc) { const urls = (fc.evidence || '').match(/https?:\/\/[^\s,)]+/g) || []; const count = urls.length; return `
${this.factCheckLabels[fc.status] || fc.status}
${this.escape(fc.claim)}
${count} Quelle${count !== 1 ? 'n' : ''}
${this.renderEvidence(fc.evidence || '')}
`; }, /** * Evidence mit erklärenden Text UND Quellen-Chips rendern. */ renderEvidence(text) { if (!text) return 'Keine Belege'; const urls = text.match(/https?:\/\/[^\s,)]+/g) || []; if (urls.length === 0) { return `${this.escape(text)}`; } // Erklärenden Text extrahieren (URLs entfernen) let explanation = text; urls.forEach(url => { explanation = explanation.replace(url, '').trim(); }); // Aufräumen: Klammern, mehrfache Kommas/Leerzeichen explanation = explanation.replace(/\(\s*\)/g, ''); explanation = explanation.replace(/,\s*,/g, ','); explanation = explanation.replace(/\s+/g, ' ').trim(); explanation = explanation.replace(/[,.:;]+$/, '').trim(); // Chips für jede URL const chips = urls.map(url => { let label; try { label = new URL(url).hostname.replace('www.', ''); } catch { label = url; } return `${this.escape(label)}`; }).join(''); const explanationHtml = explanation ? `${this.escape(explanation)}` : ''; return `${explanationHtml}
${chips}
`; }, /** * Toast-Benachrichtigung anzeigen. */ _toastTimers: new Map(), showToast(message, type = 'info', duration = 5000) { const container = document.getElementById('toast-container'); // Duplikat? Bestehenden Toast neu animieren const existing = Array.from(container.children).find( t => t.dataset.msg === message && t.dataset.type === type ); if (existing) { clearTimeout(this._toastTimers.get(existing)); // Kurz rausschieben, dann neu reingleiten existing.style.transition = 'none'; existing.style.opacity = '0'; existing.style.transform = 'translateX(100%)'; void existing.offsetWidth; // Reflow erzwingen existing.style.transition = 'all 0.3s ease'; existing.style.opacity = '1'; existing.style.transform = 'translateX(0)'; const timer = setTimeout(() => { existing.style.opacity = '0'; existing.style.transform = 'translateX(100%)'; setTimeout(() => { existing.remove(); this._toastTimers.delete(existing); }, 300); }, duration); this._toastTimers.set(existing, timer); return; } const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.setAttribute('role', 'status'); toast.dataset.msg = message; toast.dataset.type = type; toast.innerHTML = `${this.escape(message)}`; container.appendChild(toast); const timer = setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(100%)'; toast.style.transition = 'all 0.3s ease'; setTimeout(() => { toast.remove(); this._toastTimers.delete(toast); }, 300); }, duration); this._toastTimers.set(toast, timer); }, _progressStartTime: null, _progressTimer: null, /** * Fortschrittsanzeige einblenden und Status setzen. */ showProgress(status, extra = {}) { const bar = document.getElementById('progress-bar'); if (!bar) return; bar.style.display = 'block'; bar.classList.remove('progress-bar--complete', 'progress-bar--error'); const steps = { queued: { active: 0, label: 'In Warteschlange...' }, researching: { active: 1, label: 'Recherchiert Quellen...' }, deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' }, analyzing: { active: 2, label: 'Analysiert Meldungen...' }, factchecking: { active: 3, label: 'Faktencheck läuft...' }, cancelling: { active: 0, label: 'Wird abgebrochen...' }, }; const step = steps[status] || steps.queued; // Queue-Position anzeigen let labelText = step.label; if (status === 'queued' && extra.queue_position > 1) { labelText = `In Warteschlange (Position ${extra.queue_position})...`; } else if (extra.detail) { labelText = extra.detail; } // Timer starten beim Übergang von queued zu aktivem Status if (step.active > 0 && !this._progressStartTime) { if (extra.started_at) { // Echte Startzeit vom Server verwenden const serverStart = parseUTC(extra.started_at); this._progressStartTime = serverStart ? serverStart.getTime() : Date.now(); } else { this._progressStartTime = Date.now(); } this._startProgressTimer(); } const stepIds = ['step-researching', 'step-analyzing', 'step-factchecking']; stepIds.forEach((id, i) => { const el = document.getElementById(id); if (!el) return; el.className = 'progress-step'; if (i + 1 < step.active) el.classList.add('done'); else if (i + 1 === step.active) el.classList.add('active'); }); const fill = document.getElementById('progress-fill'); const percent = step.active === 0 ? 5 : Math.round((step.active / 3) * 100); if (fill) { fill.style.width = percent + '%'; } // ARIA-Werte auf der Progressbar aktualisieren bar.setAttribute('aria-valuenow', String(percent)); bar.setAttribute('aria-valuetext', labelText); const label = document.getElementById('progress-label'); if (label) label.textContent = labelText; // Cancel-Button sichtbar machen const cancelBtn = document.getElementById('progress-cancel-btn'); if (cancelBtn) cancelBtn.style.display = ''; }, /** * Timer-Intervall starten (1x pro Sekunde). */ _startProgressTimer() { if (this._progressTimer) return; const timerEl = document.getElementById('progress-timer'); if (!timerEl) return; this._progressTimer = setInterval(() => { if (!this._progressStartTime) return; const elapsed = Math.max(0, Math.floor((Date.now() - this._progressStartTime) / 1000)); const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; timerEl.textContent = `${mins}:${String(secs).padStart(2, '0')}`; }, 1000); }, /** * Abschluss-Animation: Grüner Balken mit Summary-Text. */ showProgressComplete(data) { const bar = document.getElementById('progress-bar'); if (!bar) return; // Timer stoppen this._stopProgressTimer(); // Alle Steps auf done ['step-researching', 'step-analyzing', 'step-factchecking'].forEach(id => { const el = document.getElementById(id); if (el) { el.className = 'progress-step done'; } }); // Fill auf 100% const fill = document.getElementById('progress-fill'); if (fill) fill.style.width = '100%'; // Complete-Klasse bar.classList.remove('progress-bar--error'); bar.classList.add('progress-bar--complete'); // Label mit Summary const parts = []; if (data.new_articles > 0) { parts.push(`${data.new_articles} neue Artikel`); } if (data.confirmed_count > 0) { parts.push(`${data.confirmed_count} Fakten bestätigt`); } if (data.contradicted_count > 0) { parts.push(`${data.contradicted_count} widerlegt`); } const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen'; const label = document.getElementById('progress-label'); if (label) label.textContent = `Abgeschlossen: ${summaryText}`; // Cancel-Button ausblenden const cancelBtn = document.getElementById('progress-cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; bar.setAttribute('aria-valuenow', '100'); bar.setAttribute('aria-valuetext', 'Abgeschlossen'); }, /** * Fehler-Zustand: Roter Balken mit Fehlermeldung. */ showProgressError(errorMsg, willRetry = false, delay = 0) { const bar = document.getElementById('progress-bar'); if (!bar) return; bar.style.display = 'block'; // Timer stoppen this._stopProgressTimer(); // Error-Klasse bar.classList.remove('progress-bar--complete'); bar.classList.add('progress-bar--error'); const label = document.getElementById('progress-label'); if (label) { label.textContent = willRetry ? `Fehlgeschlagen \u2014 erneuter Versuch in ${delay}s...` : `Fehlgeschlagen: ${errorMsg}`; } // Cancel-Button ausblenden const cancelBtn = document.getElementById('progress-cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; // Bei finalem Fehler nach 6s ausblenden if (!willRetry) { setTimeout(() => this.hideProgress(), 6000); } }, /** * Timer-Intervall stoppen und zurücksetzen. */ _stopProgressTimer() { if (this._progressTimer) { clearInterval(this._progressTimer); this._progressTimer = null; } this._progressStartTime = null; const timerEl = document.getElementById('progress-timer'); if (timerEl) timerEl.textContent = ''; }, /** * Fortschrittsanzeige ausblenden. */ hideProgress() { const bar = document.getElementById('progress-bar'); if (bar) { bar.style.display = 'none'; bar.classList.remove('progress-bar--complete', 'progress-bar--error'); } this._stopProgressTimer(); }, /** * Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern. */ renderSummary(summary, sourcesJson, incidentType) { if (!summary) return 'Noch keine Zusammenfassung.'; let sources = []; try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {} // Markdown-Rendering let html = this.escape(summary); // ## Überschriften html = html.replace(/^## (.+)$/gm, '

$1

'); // **Fettdruck** html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // Listen (- Item) html = html.replace(/^- (.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>\n?)+/gs, ''); // Zeilenumbrüche (aber nicht nach Headings/Listen) html = html.replace(/\n(?!<)/g, '
    '); // Überflüssige
    nach Block-Elementen entfernen + doppelte
    zusammenfassen html = html.replace(/<\/h3>(
    )+/g, ''); html = html.replace(/<\/ul>(
    )+/g, ''); html = html.replace(/(
    ){2,}/g, '
    '); // Inline-Zitate [1], [2] etc. als klickbare Links rendern if (sources.length > 0) { html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => { const src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num)); if (src && src.url) { return `[${num}]`; } return match; }); } return `
    ${html}
    `; }, /** * Quellenübersicht für eine Lage rendern. */ renderSourceOverview(articles) { if (!articles || articles.length === 0) return ''; // Nach Quelle aggregieren const sourceMap = {}; articles.forEach(a => { const name = a.source || 'Unbekannt'; if (!sourceMap[name]) { sourceMap[name] = { count: 0, languages: new Set(), urls: [] }; } sourceMap[name].count++; sourceMap[name].languages.add(a.language || 'de'); if (a.source_url) sourceMap[name].urls.push(a.source_url); }); const sources = Object.entries(sourceMap) .sort((a, b) => b[1].count - a[1].count); // Sprach-Statistik const langCount = {}; articles.forEach(a => { const lang = (a.language || 'de').toUpperCase(); langCount[lang] = (langCount[lang] || 0) + 1; }); const langChips = Object.entries(langCount) .sort((a, b) => b[1] - a[1]) .map(([lang, count]) => `${lang} ${count}`) .join(''); let html = `
    `; html += `${articles.length} Artikel aus ${sources.length} Quellen`; html += `
    ${langChips}
    `; html += `
    `; html += '
    '; sources.forEach(([name, data]) => { const langs = [...data.languages].map(l => l.toUpperCase()).join('/'); html += `
    ${this.escape(name)} ${langs} ${data.count}
    `; }); html += '
    '; return html; }, /** * Kategorie-Labels. */ _categoryLabels: { 'nachrichtenagentur': 'Agentur', 'oeffentlich-rechtlich': 'ÖR', 'qualitaetszeitung': 'Qualität', 'behoerde': 'Behörde', 'fachmedien': 'Fach', 'think-tank': 'Think Tank', 'international': 'Intl.', 'regional': 'Regional', 'boulevard': 'Boulevard', 'sonstige': 'Sonstige', }, /** * Domain-Gruppe rendern (aufklappbar mit Feeds). */ renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) { const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || ''; const feedCount = feeds.filter(f => f.source_type !== 'excluded').length; const hasMultiple = feedCount > 1; const displayName = (domain && !domain.startsWith('_single_')) ? domain : (feeds[0]?.name || 'Unbekannt'); const escapedDomain = this.escape(domain); if (isExcluded) { // Ausgeschlossene Domain const notesHtml = excludedNotes ? ` ${this.escape(excludedNotes)}` : ''; return `
    ${this.escape(displayName)}${notesHtml}
    Ausgeschlossen
    `; } // Aktive Domain-Gruppe const toggleAttr = hasMultiple ? `onclick="App.toggleGroup('${escapedDomain}')" role="button" tabindex="0" aria-expanded="false"` : ''; const toggleIcon = hasMultiple ? '' : ''; let feedRows = ''; if (hasMultiple) { const realFeeds = feeds.filter(f => f.source_type !== 'excluded'); feedRows = `
    `; realFeeds.forEach((feed, i) => { const isLast = i === realFeeds.length - 1; const connector = isLast ? '\u2514\u2500' : '\u251C\u2500'; const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web'; const urlDisplay = feed.url ? this._shortenUrl(feed.url) : ''; feedRows += `
    ${connector} ${this.escape(feed.name)} ${typeLabel} ${this.escape(urlDisplay)} ${!feed.is_global ? ` ` : 'Grundquelle'}
    `; }); feedRows += '
    '; } const feedCountBadge = feedCount > 0 ? `${feedCount} Feed${feedCount !== 1 ? 's' : ''}` : ''; // Info-Button mit Tooltip (Typ, Sprache, Ausrichtung) let infoButtonHtml = ''; const firstFeed = feeds[0] || {}; const hasInfo = firstFeed.language || firstFeed.bias; if (hasInfo) { const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal' }; const lines = []; lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt')); if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language); if (firstFeed.bias) lines.push('Ausrichtung: ' + firstFeed.bias); const tooltipText = this.escape(lines.join('\n')); infoButtonHtml = ` `; } return `
    ${toggleIcon}
    ${this.escape(displayName)}${infoButtonHtml}
    ${catLabel} ${feedCountBadge}
    ${!isGlobal && !hasMultiple && feeds[0]?.id ? `` : ''} ${!isGlobal ? `` : ''}
    ${feedRows}
    `; }, /** * URL kürzen für die Anzeige in Feed-Zeilen. */ _shortenUrl(url) { try { const u = new URL(url); let path = u.pathname; if (path.length > 40) path = path.substring(0, 37) + '...'; return u.hostname + path; } catch { return url.length > 50 ? url.substring(0, 47) + '...' : url; } }, /** * Leaflet-Karte mit Locations rendern. */ _map: null, _mapCluster: null, _mapCategoryLayers: {}, _mapLegendControl: null, _pendingLocations: null, // Farbige Marker-Icons nach Kategorie (inline SVG, keine externen Ressourcen) _markerIcons: null, _createSvgIcon(fillColor, strokeColor) { const svg = `` + `` + `` + `` + ``; return L.divIcon({ html: svg, className: 'map-marker-svg', iconSize: [28, 42], iconAnchor: [14, 42], popupAnchor: [0, -36], }); }, _initMarkerIcons() { if (this._markerIcons || typeof L === 'undefined') return; this._markerIcons = { primary: this._createSvgIcon('#dc3545', '#a71d2a'), secondary: this._createSvgIcon('#f39c12', '#c47d0a'), tertiary: this._createSvgIcon('#2a81cb', '#1a5c8f'), mentioned: this._createSvgIcon('#7b7b7b', '#555555'), }; }, _defaultCategoryLabels: { primary: 'Hauptgeschehen', secondary: 'Reaktionen', tertiary: 'Beteiligte', mentioned: 'Erwaehnt', }, _categoryColors: { primary: '#cb2b3e', secondary: '#f39c12', tertiary: '#2a81cb', mentioned: '#7b7b7b', }, _activeCategoryLabels: null, renderMap(locations, categoryLabels) { const container = document.getElementById('map-container'); const emptyEl = document.getElementById('map-empty'); const statsEl = document.getElementById('map-stats'); if (!container) return; // Leaflet noch nicht geladen? Locations merken und spaeter rendern if (typeof L === 'undefined') { this._pendingLocations = locations; // Statistik trotzdem anzeigen if (locations && locations.length > 0) { const totalArticles = locations.reduce((s, l) => s + l.article_count, 0); if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`; if (emptyEl) emptyEl.style.display = 'none'; } return; } if (!locations || locations.length === 0) { if (emptyEl) emptyEl.style.display = 'flex'; if (statsEl) statsEl.textContent = ''; if (this._map) { this._map.remove(); this._map = null; this._mapCluster = null; } return; } if (emptyEl) emptyEl.style.display = 'none'; // Statistik const totalArticles = locations.reduce((s, l) => s + l.article_count, 0); if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`; // Container-Hoehe sicherstellen (Leaflet braucht px-Hoehe) const gsItem = container.closest('.grid-stack-item'); if (gsItem) { const headerEl = container.closest('.map-card')?.querySelector('.card-header'); const headerH = headerEl ? headerEl.offsetHeight : 40; const available = gsItem.offsetHeight - headerH - 4; container.style.height = Math.max(available, 200) + 'px'; } else if (container.offsetHeight < 50) { container.style.height = '300px'; } // Karte initialisieren oder updaten if (!this._map) { this._map = L.map(container, { zoomControl: true, attributionControl: true, minZoom: 2, maxBounds: [[-85, -180], [85, 180]], maxBoundsViscosity: 1.0, }).setView([51.1657, 10.4515], 5); // Deutschland-Zentrum this._applyMapTiles(); this._mapCluster = L.markerClusterGroup({ maxClusterRadius: 40, iconCreateFunction: function(cluster) { const count = cluster.getChildCount(); let size = 'small'; if (count >= 10) size = 'medium'; if (count >= 50) size = 'large'; return L.divIcon({ html: '
    ' + count + '
    ', className: 'map-cluster map-cluster-' + size, iconSize: L.point(40, 40), }); }, }); this._map.addLayer(this._mapCluster); } else { this._mapCluster.clearLayers(); this._mapCategoryLayers = {}; } // Marker hinzufuegen const bounds = []; this._initMarkerIcons(); // Dynamische Labels verwenden (API > Default) const catLabels = categoryLabels || this._activeCategoryLabels || this._defaultCategoryLabels; this._activeCategoryLabels = catLabels; const usedCategories = new Set(); locations.forEach(loc => { const cat = loc.category || 'mentioned'; usedCategories.add(cat); const icon = (this._markerIcons && this._markerIcons[cat]) ? this._markerIcons[cat] : undefined; const markerOpts = icon ? { icon } : {}; const marker = L.marker([loc.lat, loc.lon], markerOpts); // Popup-Inhalt const catLabel = catLabels[cat] || this._defaultCategoryLabels[cat] || cat; const catColor = this._categoryColors[cat] || '#7b7b7b'; let popupHtml = `
    `; popupHtml += `
    ${this.escape(loc.location_name)}`; if (loc.country_code) popupHtml += ` ${this.escape(loc.country_code)}`; popupHtml += `
    `; popupHtml += `
    ${catLabel}
    `; popupHtml += `
    ${loc.article_count} Artikel
    `; popupHtml += `
    `; const maxShow = 5; loc.articles.slice(0, maxShow).forEach(art => { const headline = this.escape(art.headline || 'Ohne Titel'); const source = this.escape(art.source || ''); if (art.source_url) { popupHtml += `${headline} ${source}`; } else { popupHtml += `
    ${headline} ${source}
    `; } }); if (loc.articles.length > maxShow) { popupHtml += `
    +${loc.articles.length - maxShow} weitere
    `; } popupHtml += `
    `; marker.bindPopup(popupHtml, { maxWidth: 300, className: 'map-popup-container' }); if (!this._mapCategoryLayers[cat]) this._mapCategoryLayers[cat] = L.featureGroup(); this._mapCategoryLayers[cat].addLayer(marker); this._mapCluster.addLayer(marker); bounds.push([loc.lat, loc.lon]); }); // Ansicht auf Marker zentrieren if (bounds.length > 0) { if (bounds.length === 1) { this._map.setView(bounds[0], 8); } else { this._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 }); } } // Legende mit Checkbox-Filter if (this._map) { const existingLegend = document.querySelector('.map-legend-ctrl'); if (existingLegend) existingLegend.remove(); if (this._mapLegendControl) { try { this._map.removeControl(this._mapLegendControl); } catch(e) {} } const legend = L.control({ position: 'bottomright' }); const self2 = this; const legendLabels = catLabels; legend.onAdd = function() { const div = L.DomUtil.create('div', 'map-legend-ctrl'); L.DomEvent.disableClickPropagation(div); let html = 'Filter'; ['primary', 'secondary', 'tertiary', 'mentioned'].forEach(cat => { if (usedCategories.has(cat) && legendLabels[cat]) { html += ''; } }); div.innerHTML = html; div.addEventListener('change', function(e) { const cb = e.target; if (!cb.dataset.mapCat) return; self2._toggleMapCategory(cb.dataset.mapCat, cb.checked); }); return div; }; legend.addTo(this._map); this._mapLegendControl = legend; } // Resize-Fix fuer gridstack (mehrere Versuche, da Container-Hoehe erst spaeter steht) const self = this; [100, 300, 800].forEach(delay => { setTimeout(() => { if (!self._map) return; self._map.invalidateSize(); if (bounds.length === 1) { self._map.setView(bounds[0], 8); } else if (bounds.length > 1) { self._map.fitBounds(bounds, { padding: [30, 30], maxZoom: 12 }); } }, delay); }); }, _applyMapTiles() { if (!this._map) return; // Alte Tile-Layer entfernen this._map.eachLayer(layer => { if (layer instanceof L.TileLayer) this._map.removeLayer(layer); }); // Deutsche OSM-Kacheln: deutsche Ortsnamen, einheitlich fuer beide Themes const tileUrl = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png'; const attribution = '© OpenStreetMap'; L.tileLayer(tileUrl, { attribution, maxZoom: 18, noWrap: true }).addTo(this._map); }, updateMapTheme() { this._applyMapTiles(); }, invalidateMap() { if (this._map) this._map.invalidateSize(); }, retryPendingMap() { if (this._pendingLocations && typeof L !== 'undefined') { const locs = this._pendingLocations; this._pendingLocations = null; this.renderMap(locs, this._activeCategoryLabels); } }, _mapFullscreen: false, _mapOriginalParent: null, toggleMapFullscreen() { const overlay = document.getElementById('map-fullscreen-overlay'); const fsContainer = document.getElementById('map-fullscreen-container'); const mapContainer = document.getElementById('map-container'); const statsEl = document.getElementById('map-stats'); const fsStatsEl = document.getElementById('map-fullscreen-stats'); if (!this._mapFullscreen) { // Save original parent and height this._mapOriginalParent = mapContainer.parentElement; this._savedMapHeight = mapContainer.style.height || mapContainer.offsetHeight + 'px'; // Move entire map-container into fullscreen overlay fsContainer.appendChild(mapContainer); mapContainer.style.height = '100%'; if (statsEl && fsStatsEl) { fsStatsEl.textContent = statsEl.textContent; } overlay.classList.add('active'); this._mapFullscreen = true; // Escape key to close this._mapFsKeyHandler = (e) => { if (e.key === 'Escape') this.toggleMapFullscreen(); }; document.addEventListener('keydown', this._mapFsKeyHandler); setTimeout(() => { if (this._map) this._map.invalidateSize(); }, 100); } else { // Exit fullscreen: move map-container back to original parent overlay.classList.remove('active'); if (this._mapOriginalParent) { this._mapOriginalParent.appendChild(mapContainer); } // Restore saved height mapContainer.style.height = this._savedMapHeight || ''; this._mapFullscreen = false; if (this._mapFsKeyHandler) { document.removeEventListener('keydown', this._mapFsKeyHandler); this._mapFsKeyHandler = null; } const self = this; [100, 300, 600].forEach(delay => { setTimeout(() => { if (self._map) self._map.invalidateSize(); }, delay); }); } }, _mapFsKeyHandler: null, _toggleMapCategory(cat, visible) { const layers = this._mapCategoryLayers[cat]; if (!layers || !this._mapCluster) return; layers.eachLayer(marker => { if (visible) { this._mapCluster.addLayer(marker); } else { this._mapCluster.removeLayer(marker); } }); }, /** * HTML escapen. */ escape(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, };