/** * 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]; // Determine refresh status for sidebar display let refreshClass = ''; let refreshStatusHtml = ''; if (isRefreshing) { const state = this._progressState[incident.id]; const step = state ? state.step : 'researching'; const isQueued = (step === 'queued'); if (isQueued) { refreshClass = ' queued-item'; const pos = state && state._queuePos ? ' (#' + state._queuePos + ')' : ''; refreshStatusHtml = ''; } else { refreshClass = ' refreshing-item'; const label = this._getStepLabel(step); refreshStatusHtml = ''; } } return `
${this.escape(incident.title)}
${incident.article_count} Artikel · ${this.escape(creator)}
${refreshStatusHtml}
${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. */ // === Progress State (per-incident) === _progressState: {}, // { incidentId: { step, isFirst, startTime, minimized } } _progressTimerInterval: null, _getStepOrder() { return ['queued', 'researching', 'deep_researching', 'analyzing', 'factchecking']; }, _getStepLabel(step) { const map = { queued: 'In Warteschlange', researching: 'Recherchiert...', deep_researching: 'Tiefenrecherche...', analyzing: 'Analysiert...', factchecking: 'Faktencheck...', cancelling: 'Wird abgebrochen...', }; return map[step] || step; }, showProgress(status, extra = {}, incidentId = null, isFirstRefresh = false) { if (!incidentId) incidentId = App.currentIncidentId; if (!incidentId) return; // Init state for this incident if (!this._progressState[incidentId]) { this._progressState[incidentId] = { step: 'queued', isFirst: isFirstRefresh, startTime: null, minimized: false }; } const state = this._progressState[incidentId]; state.step = status; if (isFirstRefresh) state.isFirst = true; // Start timer on first non-queued status if (status !== 'queued' && !state.startTime) { if (extra.started_at) { const serverStart = typeof parseUTC === 'function' ? parseUTC(extra.started_at) : new Date(extra.started_at); state.startTime = serverStart ? serverStart.getTime() : Date.now(); } else { state.startTime = Date.now(); } } // Start global timer interval if not running if (!this._progressTimerInterval) { this._progressTimerInterval = setInterval(() => this._tickProgressTimers(), 1000); } // Store queue position if (status === 'queued' && extra.queue_position) { state._queuePos = extra.queue_position; } // Update sidebar status for ALL incidents (not just current) this._updateSidebarRefreshStatus(incidentId, status, extra); // Only show popup/mini UI for current incident if (incidentId !== App.currentIncidentId) return; if (false) { // popup always shown initially state.minimized = true; } if (state.minimized) { this._showMiniProgress(status, state); return; } this._showPopupProgress(status, extra, state); }, _showPopupProgress(status, extra, state) { const overlay = document.getElementById('progress-overlay'); const popup = document.getElementById('progress-popup'); if (!overlay || !popup) return; overlay.style.display = 'flex'; this._initClickOutside(); // Blocking (no close) for first refresh if (state.isFirst) { overlay.classList.add('blocking'); // Apply blur to grid const grid = document.querySelector('.grid-stack'); if (grid) grid.classList.add('blurred'); } else { overlay.classList.remove('blocking'); } // Minimize button: only for updates (not first) const minBtn = document.getElementById('progress-popup-minimize'); if (minBtn) minBtn.style.display = state.isFirst ? 'none' : ''; // Title const titleEl = document.getElementById('progress-popup-title'); if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft'; // Multi-pass info const passEl = document.getElementById('progress-popup-pass'); if (passEl) { if (extra.research_pass && extra.research_total_passes) { passEl.textContent = 'Durchlauf ' + extra.research_pass + '/' + extra.research_total_passes; passEl.style.display = ''; } else { passEl.style.display = 'none'; } } // Update checklist const stepOrder = this._getStepOrder(); const currentIdx = stepOrder.indexOf(status === 'deep_researching' ? 'researching' : status); const items = document.querySelectorAll('.progress-check-item'); // Map checklist items to step indices: queued=0, researching=1, analyzing=3, factchecking=4 const checkStepMap = { queued: 0, researching: 1, analyzing: 3, factchecking: 4 }; items.forEach(item => { const step = item.dataset.step; const stepIdx = checkStepMap[step] !== undefined ? checkStepMap[step] : -1; const icon = item.querySelector('.progress-check-icon'); const detail = item.querySelector('.progress-check-detail'); item.classList.remove('active', 'done', 'error'); if (stepIdx < currentIdx || (step === 'queued' && currentIdx > 0)) { item.classList.add('done'); if (icon) icon.innerHTML = '\u2713'; } else if (stepIdx === currentIdx || (step === 'researching' && (status === 'researching' || status === 'deep_researching'))) { item.classList.add('active'); if (icon) icon.innerHTML = '
'; if (detail && extra.detail) detail.textContent = extra.detail; else if (detail) detail.textContent = ''; } else { if (icon) icon.innerHTML = '\u25cb'; if (detail) detail.textContent = ''; } }); // Cancel button const cancelBtn = document.getElementById('progress-cancel-btn'); if (cancelBtn) { cancelBtn.style.display = ''; cancelBtn.textContent = 'Abbrechen'; cancelBtn.disabled = false; } // Hide complete summary const summaryEl = document.getElementById('progress-complete-summary'); if (summaryEl) summaryEl.style.display = 'none'; // Hide mini bar const mini = document.getElementById('progress-mini'); if (mini) mini.style.display = 'none'; // Lock action buttons during first refresh this._lockActionsIfFirst(state.isFirst); }, _lockActionsIfFirst(isFirst) { const actions = document.querySelector('.incident-header-actions'); if (!actions) return; if (isFirst) { actions.classList.add('first-refresh-locked'); } else { actions.classList.remove('first-refresh-locked'); } }, _showMiniProgress(status, state) { const mini = document.getElementById('progress-mini'); if (!mini) return; mini.style.display = 'flex'; const textEl = document.getElementById('progress-mini-text'); if (textEl) textEl.textContent = this._getStepLabel(status); // Hide popup const overlay = document.getElementById('progress-overlay'); if (overlay) overlay.style.display = 'none'; }, minimizeProgress(incidentId) { if (!incidentId) incidentId = App.currentIncidentId; const state = this._progressState[incidentId]; if (!state) return; state.minimized = true; state._userOpenedPopup = false; this._showMiniProgress(state.step, state); }, openProgressPopup(incidentId) { if (!incidentId) incidentId = App.currentIncidentId; const state = this._progressState[incidentId]; if (!state) return; state.minimized = false; state._userOpenedPopup = true; this._showPopupProgress(state.step, {}, state); }, showProgressComplete(data, incidentId) { if (!incidentId) incidentId = App.currentIncidentId; const state = this._progressState[incidentId]; // Calculate total time let totalTimeStr = ''; if (state && state.startTime) { const elapsed = Math.floor((Date.now() - state.startTime) / 1000); const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; totalTimeStr = mins + ':' + String(secs).padStart(2, '0'); } if (incidentId === App.currentIncidentId) { // Remove blur const grid = document.querySelector('.grid-stack'); if (grid) grid.classList.remove('blurred'); const overlay = document.getElementById('progress-overlay'); if (overlay) { overlay.style.display = 'flex'; overlay.classList.remove('blocking'); } // Mark all steps done document.querySelectorAll('.progress-check-item').forEach(item => { item.classList.remove('active', 'error'); item.classList.add('done'); const icon = item.querySelector('.progress-check-icon'); if (icon) icon.innerHTML = '\u2713'; }); // Show 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\u00e4tigt'); if (data.contradicted_count > 0) parts.push(data.contradicted_count + ' widerlegt'); const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen'; const summaryEl = document.getElementById('progress-complete-summary'); if (summaryEl) { summaryEl.innerHTML = '\u2713 Abgeschlossen: ' + summaryText + (totalTimeStr ? 'Gesamtzeit: ' + totalTimeStr + '' : ''); summaryEl.style.display = 'block'; } // Update title const titleEl = document.getElementById('progress-popup-title'); if (titleEl) titleEl.textContent = 'Abgeschlossen'; // Hide cancel, show minimize const cancelBtn = document.getElementById('progress-cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; const minBtn = document.getElementById('progress-popup-minimize'); if (minBtn) minBtn.style.display = ''; // Hide mini bar const mini = document.getElementById('progress-mini'); if (mini) mini.style.display = 'none'; } // Remove sidebar refresh status this._removeSidebarRefreshStatus(incidentId); // Clean up state after delay setTimeout(() => { this.hideProgress(incidentId); }, 5000); }, showProgressError(errorMsg, willRetry = false, delay = 0, incidentId = null) { if (!incidentId) incidentId = App.currentIncidentId; if (incidentId !== App.currentIncidentId) return; const overlay = document.getElementById('progress-overlay'); if (overlay) overlay.style.display = 'flex'; // Mark current step as error const state = this._progressState[incidentId]; if (state) { const items = document.querySelectorAll('.progress-check-item.active'); items.forEach(item => { item.classList.remove('active'); item.classList.add('error'); const icon = item.querySelector('.progress-check-icon'); if (icon) icon.innerHTML = '\u2717'; }); } const titleEl = document.getElementById('progress-popup-title'); if (titleEl) { titleEl.textContent = willRetry ? 'Fehlgeschlagen \u2014 erneuter Versuch in ' + delay + 's...' : 'Fehlgeschlagen: ' + errorMsg; } const cancelBtn = document.getElementById('progress-cancel-btn'); if (cancelBtn) cancelBtn.style.display = 'none'; if (!willRetry) { this._removeSidebarRefreshStatus(incidentId); setTimeout(() => this.hideProgress(incidentId), 6000); } }, hideProgress(incidentId) { if (!incidentId) incidentId = App.currentIncidentId; // Remove blur const grid = document.querySelector('.grid-stack'); if (grid) grid.classList.remove('blurred'); if (incidentId === App.currentIncidentId) { const overlay = document.getElementById('progress-overlay'); if (overlay) { overlay.style.display = 'none'; overlay.classList.remove('blocking'); } const mini = document.getElementById('progress-mini'); if (mini) mini.style.display = 'none'; } // Unlock action buttons this._lockActionsIfFirst(false); // Remove sidebar status this._removeSidebarRefreshStatus(incidentId); // Clean up state delete this._progressState[incidentId]; // Stop timer if no more active refreshes if (Object.keys(this._progressState).length === 0 && this._progressTimerInterval) { clearInterval(this._progressTimerInterval); this._progressTimerInterval = null; } }, _tickProgressTimers() { for (const [id, state] of Object.entries(this._progressState)) { if (!state.startTime) continue; const elapsed = Math.max(0, Math.floor((Date.now() - state.startTime) / 1000)); const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; const timeStr = mins + ':' + String(secs).padStart(2, '0'); if (parseInt(id) === App.currentIncidentId) { // Update popup timer const timerEl = document.getElementById('progress-popup-timer'); if (timerEl) timerEl.textContent = timeStr; // Update mini timer const miniTimer = document.getElementById('progress-mini-timer'); if (miniTimer) miniTimer.textContent = timeStr; } // Update sidebar timer for this incident const sidebarTimer = document.getElementById('sidebar-refresh-timer-' + id); if (sidebarTimer) sidebarTimer.textContent = timeStr; } }, // === Sidebar Refresh Status === _updateSidebarRefreshStatus(incidentId, status, extra) { const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]'); if (!item) return; const isQueued = (status === 'queued'); // Add appropriate class item.classList.remove('refreshing-item', 'queued-item'); item.classList.add(isQueued ? 'queued-item' : 'refreshing-item'); // Add or update status text below meta let statusEl = document.getElementById('sidebar-refresh-' + incidentId); if (!statusEl) { const textCol = item.querySelector('div[style*="flex:1"]'); if (!textCol) return; statusEl = document.createElement('div'); statusEl.id = 'sidebar-refresh-' + incidentId; textCol.appendChild(statusEl); } if (isQueued) { const pos = (extra && extra.queue_position) ? extra.queue_position : ((this._progressState[incidentId] || {})._queuePos || ''); // Store queue position in state for renderIncidentItem const pState = this._progressState[incidentId]; if (pState && pos) pState._queuePos = pos; statusEl.className = 'incident-refresh-status queued-status'; statusEl.innerHTML = 'Warteschlange' + (pos ? ' (#' + pos + ')' : '') + ''; } else { statusEl.className = 'incident-refresh-status'; const label = this._getStepLabel(status); statusEl.innerHTML = '' + label + ''; } }, _removeSidebarRefreshStatus(incidentId) { const statusEl = document.getElementById('sidebar-refresh-' + incidentId); if (statusEl) statusEl.remove(); const item = document.querySelector('.incident-item[data-id="' + incidentId + '"]'); if (item) item.classList.remove('refreshing-item', 'queued-item'); }, _reindexQueuePositions() { // Collect all queued incidents and renumber sequentially const queued = []; for (const [id, state] of Object.entries(this._progressState)) { if (state && state.step === 'queued') queued.push({ id: Number(id), pos: state._queuePos || 999 }); } queued.sort((a, b) => a.pos - b.pos); queued.forEach((item, idx) => { const newPos = idx + 1; const state = this._progressState[item.id]; if (state) state._queuePos = newPos; const statusEl = document.getElementById('sidebar-refresh-' + item.id); if (statusEl) statusEl.innerHTML = 'Warteschlange (#' + newPos + ')'; }); }, // === Click-outside to auto-minimize popup === _initClickOutside() { if (this._clickOutsideInit) return; this._clickOutsideInit = true; document.addEventListener('click', (e) => { const overlay = document.getElementById('progress-overlay'); if (!overlay || overlay.style.display === 'none') return; const popup = document.getElementById('progress-popup'); if (!popup) return; // Ignore clicks inside the popup itself if (popup.contains(e.target)) return; // Ignore clicks on the mini bar const mini = document.getElementById('progress-mini'); if (mini && mini.contains(e.target)) return; // Don't minimize during first refresh (blocking) const currentId = App.currentIncidentId; const state = this._progressState[currentId]; if (state && state.isFirst) return; // Auto-minimize if (state && !state.minimized) { this.minimizeProgress(currentId); } }); }, /** * 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, '
    '); // Markdown-Tabellen rendern html = html.replace(/(?:^|
    )((?:\|.+\|(?:
    |$))+)/g, function(match, tableBlock) { var rows = tableBlock.split('
    ').filter(function(r) { return r.trim().length > 0; }); if (rows.length < 2) return match; var isSep = function(r) { return /^\|[\s\-:|]+\|$/.test(r.trim()); }; if (!isSep(rows[1])) return match; var parseRow = function(r) { return r.split('|').slice(1, -1).map(function(c) { return c.trim(); }); }; var headerCells = parseRow(rows[0]); var thead = '' + headerCells.map(function(c) { return '' + c + ''; }).join('') + ''; var tbody = '' + rows.slice(2).map(function(r) { if (isSep(r)) return ''; var cells = parseRow(r); return '' + cells.map(function(c) { return '' + c + ''; }).join('') + ''; }).join('') + ''; return '
    ' + thead + tbody + '
    '; }); // Inline-Zitate [1], [2], [1383a] etc. als klickbare Links rendern if (sources.length > 0) { html = html.replace(/\[(\d+[a-z]?)\]/g, (match, num) => { // Exakte Suche (auch mit Buchstaben-Suffix) let src = sources.find(s => String(s.nr) === num || Number(s.nr) === Number(num)); // Fallback: Bei Suffix wie "1383a" auf Basisnummer 1383 zurueckfallen if ((!src || !src.url) && /[a-z]$/.test(num)) { const baseNum = num.replace(/[a-z]$/, ''); const baseSrc = sources.find(s => String(s.nr) === baseNum || Number(s.nr) === Number(baseNum)); if (baseSrc && baseSrc.url) src = baseSrc; } 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', 'telegram': 'Telegram', '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; }, };