diff --git a/requirements.txt b/requirements.txt index ddcb6b7..97c5f82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,6 @@ apscheduler==3.10.4 websockets python-multipart aiosmtplib +spacy>=3.7,<4.0 +geonamescache>=2.0 +geopy>=2.4 diff --git a/src/agents/geoparsing.py b/src/agents/geoparsing.py new file mode 100644 index 0000000..aed8538 --- /dev/null +++ b/src/agents/geoparsing.py @@ -0,0 +1,287 @@ +"""Geoparsing-Modul: NER-basierte Ortsextraktion und Geocoding fuer Artikel.""" +import asyncio +import logging +import re +from typing import Optional + +logger = logging.getLogger("osint.geoparsing") + +# Lazy-loaded spaCy-Modelle (erst beim ersten Aufruf geladen) +_nlp_de = None +_nlp_en = None + +# Stopwords: Entitaeten die von spaCy faelschlicherweise als Orte erkannt werden +LOCATION_STOPWORDS = { + "EU", "UN", "NATO", "WHO", "OSZE", "OPEC", "G7", "G20", "BRICS", + "Nato", "Eu", "Un", "Onu", + "Bundesregierung", "Bundestag", "Bundesrat", "Bundeskanzler", + "Kreml", "Weisses Haus", "White House", "Pentagon", "Elysee", + "Twitter", "Facebook", "Telegram", "Signal", "WhatsApp", + "Reuters", "AP", "AFP", "DPA", "dpa", + "Internet", "Online", "Web", +} + +# Maximale Textlaenge fuer NER-Verarbeitung +MAX_TEXT_LENGTH = 10000 + + +def _load_spacy_model(lang: str): + """Laedt ein spaCy-Modell lazy (nur beim ersten Aufruf).""" + global _nlp_de, _nlp_en + try: + import spacy + except ImportError: + logger.error("spaCy nicht installiert - pip install spacy") + return None + + if lang == "de" and _nlp_de is None: + try: + _nlp_de = spacy.load("de_core_news_sm", disable=["parser", "lemmatizer", "textcat"]) + logger.info("spaCy-Modell de_core_news_sm geladen") + except OSError: + logger.warning("spaCy-Modell de_core_news_sm nicht gefunden - python -m spacy download de_core_news_sm") + return None + elif lang == "en" and _nlp_en is None: + try: + _nlp_en = spacy.load("en_core_web_sm", disable=["parser", "lemmatizer", "textcat"]) + logger.info("spaCy-Modell en_core_web_sm geladen") + except OSError: + logger.warning("spaCy-Modell en_core_web_sm nicht gefunden - python -m spacy download en_core_web_sm") + return None + + return _nlp_de if lang == "de" else _nlp_en + + +def _extract_locations_from_text(text: str, language: str = "de") -> list[dict]: + """Extrahiert Ortsnamen aus Text via spaCy NER. + + Returns: + Liste von dicts: [{name: str, source_text: str}] + """ + if not text: + return [] + + text = text[:MAX_TEXT_LENGTH] + + nlp = _load_spacy_model(language) + if nlp is None: + # Fallback: anderes Modell versuchen + fallback = "en" if language == "de" else "de" + nlp = _load_spacy_model(fallback) + if nlp is None: + return [] + + doc = nlp(text) + + locations = [] + seen = set() + for ent in doc.ents: + if ent.label_ in ("LOC", "GPE"): + name = ent.text.strip() + # Filter: zu kurz, Stopword, oder nur Zahlen/Sonderzeichen + if len(name) < 2: + continue + if name in LOCATION_STOPWORDS: + continue + if re.match(r'^[\d\W]+$', name): + continue + + name_lower = name.lower() + if name_lower not in seen: + seen.add(name_lower) + # Kontext: 50 Zeichen um die Entitaet herum + start = max(0, ent.start_char - 25) + end = min(len(text), ent.end_char + 25) + source_text = text[start:end].strip() + locations.append({"name": name, "source_text": source_text}) + + return locations + + +# Geocoding-Cache (in-memory, lebt solange der Prozess laeuft) +_geocode_cache: dict[str, Optional[dict]] = {} + +# geonamescache-Instanz (lazy) +_gc = None + + +def _get_geonamescache(): + """Laedt geonamescache lazy.""" + global _gc + if _gc is None: + try: + import geonamescache + _gc = geonamescache.GeonamesCache() + logger.info("geonamescache geladen") + except ImportError: + logger.error("geonamescache nicht installiert - pip install geonamescache") + return None + return _gc + + +def _geocode_location(name: str) -> Optional[dict]: + """Geocoded einen Ortsnamen. Offline via geonamescache, Fallback Nominatim. + + Returns: + dict mit {lat, lon, country_code, normalized_name, confidence} oder None + """ + name_lower = name.lower().strip() + if name_lower in _geocode_cache: + return _geocode_cache[name_lower] + + result = _geocode_offline(name) + if result is None: + result = _geocode_nominatim(name) + + _geocode_cache[name_lower] = result + return result + + +def _geocode_offline(name: str) -> Optional[dict]: + """Versucht Geocoding ueber geonamescache (offline).""" + gc = _get_geonamescache() + if gc is None: + return None + + name_lower = name.lower().strip() + + # 1. Direkte Suche in Staedten + cities = gc.get_cities() + matches = [] + for gid, city in cities.items(): + city_name = city.get("name", "") + alt_names = city.get("alternatenames", "") + if city_name.lower() == name_lower: + matches.append(city) + elif name_lower in [n.strip().lower() for n in alt_names.split(",") if n.strip()]: + matches.append(city) + + if matches: + # Disambiguierung: groesste Stadt gewinnt + best = max(matches, key=lambda c: c.get("population", 0)) + return { + "lat": float(best["latitude"]), + "lon": float(best["longitude"]), + "country_code": best.get("countrycode", ""), + "normalized_name": best["name"], + "confidence": min(1.0, 0.6 + (best.get("population", 0) / 10_000_000)), + } + + # 2. Laendersuche + countries = gc.get_countries() + for code, country in countries.items(): + if country.get("name", "").lower() == name_lower: + # Hauptstadt-Koordinaten als Fallback + capital = country.get("capital", "") + if capital: + cap_result = _geocode_offline(capital) + if cap_result: + cap_result["normalized_name"] = country["name"] + cap_result["confidence"] = 0.5 # Land, nicht Stadt + return cap_result + + return None + + +def _geocode_nominatim(name: str) -> Optional[dict]: + """Fallback-Geocoding ueber Nominatim (1 Request/Sekunde).""" + try: + from geopy.geocoders import Nominatim + from geopy.exc import GeocoderTimedOut, GeocoderServiceError + except ImportError: + return None + + try: + geocoder = Nominatim(user_agent="aegissight-monitor/1.0", timeout=5) + location = geocoder.geocode(name, language="de", exactly_one=True) + if location: + # Country-Code aus Address extrahieren falls verfuegbar + raw = location.raw or {} + country_code = "" + if "address" in raw: + country_code = raw["address"].get("country_code", "").upper() + + return { + "lat": float(location.latitude), + "lon": float(location.longitude), + "country_code": country_code, + "normalized_name": location.address.split(",")[0] if location.address else name, + "confidence": 0.4, # Nominatim-Ergebnis = niedrigere Konfidenz + } + except (GeocoderTimedOut, GeocoderServiceError) as e: + logger.debug(f"Nominatim-Fehler fuer '{name}': {e}") + except Exception as e: + logger.debug(f"Geocoding-Fehler fuer '{name}': {e}") + + return None + + +async def geoparse_articles(articles: list[dict]) -> dict[int, list[dict]]: + """Geoparsing fuer eine Liste von Artikeln. + + Args: + articles: Liste von Artikel-Dicts (mit id, content_de, content_original, language, headline, headline_de) + + Returns: + dict[article_id -> list[{location_name, location_name_normalized, country_code, lat, lon, confidence, source_text}]] + """ + if not articles: + return {} + + result = {} + + for article in articles: + article_id = article.get("id") + if not article_id: + continue + + language = article.get("language", "de") + + # Text zusammenbauen: Headline + Content + text_parts = [] + if language == "de": + if article.get("headline_de"): + text_parts.append(article["headline_de"]) + elif article.get("headline"): + text_parts.append(article["headline"]) + if article.get("content_de"): + text_parts.append(article["content_de"]) + elif article.get("content_original"): + text_parts.append(article["content_original"]) + else: + if article.get("headline"): + text_parts.append(article["headline"]) + if article.get("content_original"): + text_parts.append(article["content_original"]) + + text = "\n".join(text_parts) + if not text.strip(): + continue + + # NER-Extraktion (CPU-bound, in Thread ausfuehren) + locations_raw = await asyncio.to_thread( + _extract_locations_from_text, text, language + ) + + if not locations_raw: + continue + + # Geocoding (enthaelt potentiell Netzwerk-Calls) + locations = [] + for loc in locations_raw: + geo = await asyncio.to_thread(_geocode_location, loc["name"]) + if geo: + locations.append({ + "location_name": loc["name"], + "location_name_normalized": geo["normalized_name"], + "country_code": geo["country_code"], + "lat": geo["lat"], + "lon": geo["lon"], + "confidence": geo["confidence"], + "source_text": loc.get("source_text", ""), + }) + + if locations: + result[article_id] = locations + + return result diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index 57b5957..501b1d5 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -708,6 +708,31 @@ class AgentOrchestrator: await db.commit() + # Geoparsing: Orte aus neuen Artikeln extrahieren und speichern + if new_articles_for_analysis: + try: + from agents.geoparsing import geoparse_articles + logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...") + geo_results = await geoparse_articles(new_articles_for_analysis) + geo_count = 0 + for art_id, locations in geo_results.items(): + for loc in locations: + await db.execute( + """INSERT INTO article_locations + (article_id, incident_id, location_name, location_name_normalized, + country_code, latitude, longitude, confidence, source_text, tenant_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (art_id, incident_id, loc["location_name"], loc["location_name_normalized"], + loc["country_code"], loc["lat"], loc["lon"], loc["confidence"], + loc.get("source_text", ""), tenant_id), + ) + geo_count += 1 + if geo_count > 0: + await db.commit() + logger.info(f"Geoparsing: {geo_count} Orte aus {len(geo_results)} Artikeln gespeichert") + except Exception as e: + logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}") + # Quellen-Statistiken aktualisieren if new_count > 0: try: diff --git a/src/database.py b/src/database.py index c03b5c0..eb4ff00 100644 --- a/src/database.py +++ b/src/database.py @@ -167,6 +167,22 @@ CREATE TABLE IF NOT EXISTS incident_subscriptions ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, incident_id) ); + +CREATE TABLE IF NOT EXISTS article_locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE, + incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE, + location_name TEXT NOT NULL, + location_name_normalized TEXT, + country_code TEXT, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + confidence REAL DEFAULT 0.0, + source_text TEXT, + tenant_id INTEGER REFERENCES organizations(id) +); +CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id); +CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id); """ @@ -366,6 +382,29 @@ async def init_db(): except Exception: pass + # Migration: article_locations-Tabelle (fuer bestehende DBs) + cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='article_locations'") + if not await cursor.fetchone(): + await db.executescript(""" + CREATE TABLE IF NOT EXISTS article_locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE, + incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE, + location_name TEXT NOT NULL, + location_name_normalized TEXT, + country_code TEXT, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + confidence REAL DEFAULT 0.0, + source_text TEXT, + tenant_id INTEGER REFERENCES organizations(id) + ); + CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id); + CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id); + """) + await db.commit() + logger.info("Migration: article_locations-Tabelle erstellt") + # Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min) await db.execute( """UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart', diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 6257ffe..e1a17c6 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -271,6 +271,55 @@ async def get_factchecks( return [dict(row) for row in rows] +@router.get("/{incident_id}/locations") +async def get_locations( + incident_id: int, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Geografische Orte einer Lage abrufen (aggregiert nach Ort).""" + tenant_id = current_user.get("tenant_id") + await _check_incident_access(db, incident_id, current_user["id"], tenant_id) + cursor = await db.execute( + """SELECT al.location_name, al.location_name_normalized, al.country_code, + al.latitude, al.longitude, al.confidence, + a.id as article_id, a.headline, a.headline_de, a.source, a.source_url + FROM article_locations al + JOIN articles a ON a.id = al.article_id + WHERE al.incident_id = ? + ORDER BY al.location_name_normalized, a.collected_at DESC""", + (incident_id,), + ) + rows = await cursor.fetchall() + + # Aggregierung nach normalisiertem Ortsnamen + Koordinaten + loc_map = {} + for row in rows: + row = dict(row) + key = (row["location_name_normalized"] or row["location_name"], round(row["latitude"], 2), round(row["longitude"], 2)) + if key not in loc_map: + loc_map[key] = { + "location_name": row["location_name_normalized"] or row["location_name"], + "lat": row["latitude"], + "lon": row["longitude"], + "country_code": row["country_code"], + "confidence": row["confidence"], + "article_count": 0, + "articles": [], + } + loc_map[key]["article_count"] += 1 + # Maximal 10 Artikel pro Ort mitliefern + if len(loc_map[key]["articles"]) < 10: + loc_map[key]["articles"].append({ + "id": row["article_id"], + "headline": row["headline_de"] or row["headline"], + "source": row["source"], + "source_url": row["source_url"], + }) + + return list(loc_map.values()) + + @router.get("/{incident_id}/refresh-log") async def get_refresh_log( incident_id: int, diff --git a/src/static/css/style.css b/src/static/css/style.css index 3d8e958..715cccc 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -4142,3 +4142,174 @@ a:hover { color: black !important; } } + +/* === Karten-Kachel (Leaflet) === */ +.map-card { + display: flex; + flex-direction: column; + height: 100%; +} +.map-card .card-header { + flex-shrink: 0; +} +.map-stats { + font-size: 12px; + color: var(--text-secondary); + font-family: var(--font-body); +} +.map-container { + flex: 1; + min-height: 200px; + position: relative; + z-index: 1; +} +.map-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; + color: var(--text-tertiary); + font-size: 13px; + font-family: var(--font-body); +} + +/* Leaflet-Popup-Overrides */ +.map-popup-container .leaflet-popup-content-wrapper { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); +} +.map-popup-container .leaflet-popup-tip { + background: var(--bg-card); + border: 1px solid var(--border); +} +.map-popup-container .leaflet-popup-content { + margin: 10px 12px; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.5; +} +.map-popup-container .leaflet-popup-close-button { + color: var(--text-secondary); +} +.map-popup-container .leaflet-popup-close-button:hover { + color: var(--text-primary); +} +.map-popup-title { + font-weight: 600; + font-family: var(--font-title); + font-size: 14px; + margin-bottom: 2px; +} +.map-popup-cc { + font-size: 10px; + color: var(--text-secondary); + font-weight: 400; + text-transform: uppercase; +} +.map-popup-count { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 6px; +} +.map-popup-articles { + display: flex; + flex-direction: column; + gap: 4px; +} +.map-popup-article { + display: block; + font-size: 12px; + color: var(--text-primary); + text-decoration: none; + padding: 3px 0; + border-top: 1px solid var(--border); + line-height: 1.4; +} +a.map-popup-article:hover { + color: var(--accent); +} +.map-popup-source { + color: var(--text-secondary); + font-size: 11px; +} +.map-popup-more { + font-size: 11px; + color: var(--text-secondary); + font-style: italic; + padding-top: 4px; + border-top: 1px solid var(--border); +} + +/* MarkerCluster in Gold-Akzent */ +.map-cluster { + background: rgba(200, 168, 81, 0.25); + border-radius: 50%; +} +.map-cluster div { + width: 30px; + height: 30px; + margin: 5px; + background: var(--accent); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.map-cluster span { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: #0B1121; +} +.map-cluster-medium div { + width: 36px; + height: 36px; + margin: 2px; +} +.map-cluster-medium span { + font-size: 13px; +} +.map-cluster-large div { + width: 44px; + height: 44px; + margin: -2px; +} +.map-cluster-large span { + font-size: 14px; +} + +/* Leaflet Controls: Dark-Theme */ +.leaflet-control-zoom a { + background-color: var(--bg-card) !important; + color: var(--text-primary) !important; + border-color: var(--border) !important; +} +.leaflet-control-zoom a:hover { + background-color: var(--bg-hover) !important; +} +.leaflet-control-attribution { + background: rgba(11, 17, 33, 0.7) !important; + color: var(--text-secondary) !important; + font-size: 10px !important; +} +.leaflet-control-attribution a { + color: var(--text-secondary) !important; +} + +/* Light-Theme Karten-Overrides */ +[data-theme="light"] .leaflet-control-zoom a { + background-color: #fff !important; + color: #333 !important; + border-color: #ccc !important; +} +[data-theme="light"] .leaflet-control-attribution { + background: rgba(255, 255, 255, 0.7) !important; + color: #666 !important; +} +[data-theme="light"] .map-cluster span { + color: #fff; +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index f609f0a..6e17913 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -13,7 +13,10 @@ - + + + + @@ -179,6 +182,7 @@ + @@ -255,6 +259,20 @@ + +
+
+
+
+
Geografische Verteilung
+ +
+
+
Keine Orte erkannt
+
+
+
+
@@ -539,10 +557,12 @@
- - - - - + + + + + + + diff --git a/src/static/js/api.js b/src/static/js/api.js index 2aafcd1..e20b5b8 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -102,6 +102,10 @@ const API = { return this._request('GET', `/incidents/${incidentId}/snapshots`); }, + getLocations(incidentId) { + return this._request('GET', `/incidents/${incidentId}/locations`); + }, + refreshIncident(id) { return this._request('POST', `/incidents/${id}/refresh`); }, diff --git a/src/static/js/app.js b/src/static/js/app.js index 3e84b77..8f582f6 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -19,6 +19,7 @@ const ThemeManager = { document.documentElement.setAttribute('data-theme', next); localStorage.setItem(this._key, next); this._updateIcon(next); + UI.updateMapTheme(); }, _updateIcon(theme) { const btn = document.getElementById('theme-toggle'); @@ -589,20 +590,21 @@ const App = { async loadIncidentDetail(id) { try { - const [incident, articles, factchecks, snapshots] = await Promise.all([ + const [incident, articles, factchecks, snapshots, locations] = await Promise.all([ API.getIncident(id), API.getArticles(id), API.getFactChecks(id), API.getSnapshots(id), + API.getLocations(id).catch(() => []), ]); - this.renderIncidentDetail(incident, articles, factchecks, snapshots); + this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations); } catch (err) { UI.showToast('Fehler beim Laden: ' + err.message, 'error'); } }, - renderIncidentDetail(incident, articles, factchecks, snapshots) { + renderIncidentDetail(incident, articles, factchecks, snapshots, locations) { // Header Strip document.getElementById('incident-title').textContent = incident.title; document.getElementById('incident-description').textContent = incident.description || ''; @@ -726,6 +728,9 @@ const App = { }); this.rerenderTimeline(); this._resizeTimelineTile(); + + // Karte rendern + UI.renderMap(locations || []); }, _collectEntries(filterType, searchTerm, range) { diff --git a/src/static/js/components.js b/src/static/js/components.js index 61b75a9..d3c726a 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -596,6 +596,133 @@ const UI = { return url.length > 50 ? url.substring(0, 47) + '...' : url; } }, + /** + * Leaflet-Karte mit Locations rendern. + */ + _map: null, + _mapCluster: null, + + renderMap(locations) { + const container = document.getElementById('map-container'); + const emptyEl = document.getElementById('map-empty'); + const statsEl = document.getElementById('map-stats'); + if (!container) 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`; + + // Karte initialisieren oder updaten + if (!this._map) { + this._map = L.map(container, { + zoomControl: true, + attributionControl: true, + }).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(); + } + + // Marker hinzufuegen + const bounds = []; + locations.forEach(loc => { + const marker = L.marker([loc.lat, loc.lon]); + + // Popup-Inhalt + let popupHtml = `
`; + popupHtml += `
${this.escape(loc.location_name)}`; + if (loc.country_code) popupHtml += ` ${this.escape(loc.country_code)}`; + popupHtml += `
`; + 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' }); + 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 }); + } + } + + // Resize-Fix fuer gridstack + setTimeout(() => { if (this._map) this._map.invalidateSize(); }, 200); + }, + + _applyMapTiles() { + if (!this._map) return; + // Alte Tile-Layer entfernen + this._map.eachLayer(layer => { + if (layer instanceof L.TileLayer) this._map.removeLayer(layer); + }); + + const theme = document.documentElement.getAttribute('data-theme') || 'dark'; + const tileUrl = theme === 'dark' + ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' + : 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + const attribution = theme === 'dark' + ? '© OSM © CARTO' + : '© OpenStreetMap'; + + L.tileLayer(tileUrl, { attribution, maxZoom: 18 }).addTo(this._map); + }, + + updateMapTheme() { + this._applyMapTiles(); + }, + + invalidateMap() { + if (this._map) this._map.invalidateSize(); + }, + /** * HTML escapen. */ diff --git a/src/static/js/layout.js b/src/static/js/layout.js index 09ce34c..afda120 100644 --- a/src/static/js/layout.js +++ b/src/static/js/layout.js @@ -14,6 +14,7 @@ const LayoutManager = { { id: 'faktencheck', x: 6, y: 0, w: 6, h: 4, minW: 4, minH: 4 }, { id: 'quellen', x: 0, y: 4, w: 12, h: 2, minW: 6, minH: 2 }, { id: 'timeline', x: 0, y: 5, w: 12, h: 4, minW: 6, minH: 4 }, + { id: 'karte', x: 0, y: 9, w: 12, h: 4, minW: 6, minH: 3 }, ], TILE_MAP: { @@ -21,6 +22,7 @@ const LayoutManager = { faktencheck: '.incident-analysis-factcheck', quellen: '.source-overview-card', timeline: '.timeline-card', + karte: '.map-card', }, init() { @@ -44,7 +46,11 @@ const LayoutManager = { this._applyLayout(saved); } - this._grid.on('change', () => this._debouncedSave()); + this._grid.on('change', () => { + this._debouncedSave(); + // Leaflet-Map bei Resize invalidieren + if (typeof UI !== 'undefined') UI.invalidateMap(); + }); const toolbar = document.getElementById('layout-toolbar'); if (toolbar) toolbar.style.display = 'flex';