Johannes commited on
Commit
0d4a0ba
·
0 Parent(s):

Initial deployment (no data - downloaded from HF Dataset at startup)

Browse files
.gitattributes ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.shp filter=lfs diff=lfs merge=lfs -text
37
+ *.dbf filter=lfs diff=lfs merge=lfs -text
38
+ *.shx filter=lfs diff=lfs merge=lfs -text
39
+ *.cpg filter=lfs diff=lfs merge=lfs -text
40
+ *.prj filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .DS_Store
5
+ data/
.streamlit/config.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ primaryColor = "#00ff88"
3
+ backgroundColor = "#0a0f0d"
4
+ secondaryBackgroundColor = "#111a14"
5
+ textColor = "#e8f5ec"
6
+ font = "monospace"
7
+
8
+ [server]
9
+ maxUploadSize = 50
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13.5-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ curl \
8
+ git \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY requirements.txt ./
12
+ COPY src/ ./src/
13
+
14
+ RUN pip3 install -r requirements.txt
15
+
16
+ EXPOSE 8501
17
+
18
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
+
20
+ ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ArtenTracker
3
+ emoji: 🦫
4
+ colorFrom: green
5
+ colorTo: green
6
+ sdk: streamlit
7
+ sdk_version: 1.57.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ Arten Tracker — Verbreitung invasiver und ausgewählter heimischer Arten in Europa.
13
+ Datenquelle: [GBIF](https://www.gbif.org)
app.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from pathlib import Path
3
+ import pandas as pd
4
+ import streamlit as st
5
+ from streamlit_folium import st_folium
6
+
7
+ # ── Daten-Download von HF Dataset (nur beim ersten Start auf dem Server) ───────
8
+ _DATA_DIR = Path(__file__).parent / "data"
9
+ _HF_DATASET = "Sueamit/arten-tracker-data"
10
+
11
+ def _ensure_data():
12
+ cache_dir = _DATA_DIR / "species_cache"
13
+ parquet_files = list(cache_dir.glob("*.parquet")) if cache_dir.exists() else []
14
+ boundaries_ok = (_DATA_DIR / "boundaries" / "vg250" / "VG250_LAN.shp").exists()
15
+ if len(parquet_files) >= 4 and boundaries_ok:
16
+ return
17
+ st.info(
18
+ "Erster Start: Daten werden vom Hugging Face Dataset heruntergeladen (~1,6 GB). "
19
+ "Dies dauert einmalig 5–15 Minuten."
20
+ )
21
+ with st.spinner("Download läuft …"):
22
+ from huggingface_hub import snapshot_download
23
+ snapshot_download(
24
+ repo_id=_HF_DATASET,
25
+ repo_type="dataset",
26
+ local_dir=str(_DATA_DIR),
27
+ )
28
+ st.rerun()
29
+
30
+ _ensure_data()
31
+ # ── Ende Daten-Download ────────────────────────────────────────────────────────
32
+
33
+ from src.utils import (
34
+ SCOPE_OPTIONS, BUNDESLAENDER_LIST, EUROPEAN_COUNTRIES,
35
+ CURRENT_YEAR, HIST_YEAR_TO, YEAR_FROM,
36
+ )
37
+ from src.species_config import SPECIES, SPECIES_DISPLAY_LABELS
38
+ from src.data_cache import (
39
+ cache_exists, cache_info, load_historical, save_historical,
40
+ historical_year_range,
41
+ )
42
+ from src.gbif_client import fetch_all_occurrences, fetch_current_year, get_occurrence_count
43
+ from src.csv_importer import import_gbif_csv
44
+ from src.boundary_loader import (
45
+ get_boundary_gdf, filter_gemeinden_by_bundesland, filter_admin1_by_country,
46
+ check_boundary_availability,
47
+ )
48
+ from src.spatial_analysis import build_geodataframe, spatial_join
49
+ from src.temporal_analysis import filter_current_year as filter_cy
50
+ from src.map_builder import build_current_map
51
+ from src.chart_builder import build_trend_chart, prepare_per_unit_data, build_unit_row_chart, compute_trend_from_df
52
+ from src.utils import UNIT_NAME_COL
53
+ from src.styles_v1_naturetech import inject_css, hide_sidebar
54
+
55
+ st.set_page_config(
56
+ page_title="Arten Tracker",
57
+ page_icon="🦫",
58
+ layout="wide",
59
+ initial_sidebar_state="auto",
60
+ )
61
+ inject_css()
62
+
63
+
64
+ # ── Home page ──────────────────────────────────────────────────────────────────
65
+
66
+ def render_home():
67
+ hide_sidebar()
68
+
69
+ # Hero
70
+ st.markdown("""
71
+ <div class="at-hero">
72
+ <h1>🦫 Arten Tracker</h1>
73
+ <p>Interaktive Verbreitungskarten für Tiere und Pflanzen in Europa — basierend auf GBIF-Daten</p>
74
+ </div>
75
+ """, unsafe_allow_html=True)
76
+
77
+ # Feature tiles
78
+ col1, col2, col3 = st.columns(3)
79
+ with col1:
80
+ st.markdown("""<div class="at-feature-box">
81
+ <div class="at-feature-icon">🗺️</div>
82
+ <strong>Interaktive Karten</strong>
83
+ <p>Fundpunkte und Choropleth-Karten auf Gemeinde-, Bundes&shy;land- und Länderebene</p>
84
+ </div>""", unsafe_allow_html=True)
85
+ with col2:
86
+ st.markdown("""<div class="at-feature-box">
87
+ <div class="at-feature-icon">📈</div>
88
+ <strong>Zeitverlauf</strong>
89
+ <p>Jahrestrends von 2006 bis heute — live ergänzt durch aktuelle GBIF-Meldungen</p>
90
+ </div>""", unsafe_allow_html=True)
91
+ with col3:
92
+ st.markdown("""<div class="at-feature-box">
93
+ <div class="at-feature-icon">🔬</div>
94
+ <strong>Mehrere Arten</strong>
95
+ <p>Asiatische Hornisse, Europäischer Biber, Wildkatze, Kanadagans und mehr</p>
96
+ </div>""", unsafe_allow_html=True)
97
+
98
+ # Start button
99
+ st.markdown("<br>", unsafe_allow_html=True)
100
+ col_btn = st.columns([1, 2, 1])[1]
101
+ with col_btn:
102
+ st.markdown("""
103
+ <style>
104
+ div[data-testid="stButton"].start-btn > button {
105
+ width: 100%; font-size: 1.1rem !important; padding: 0.75rem 2rem !important;
106
+ border-radius: 12px !important; font-weight: 700 !important;
107
+ }
108
+ </style>""", unsafe_allow_html=True)
109
+ def _go_to_tracker():
110
+ st.session_state["nav_radio"] = "🔍 Arten Tracker"
111
+ st.button("🔍 Arten Tracker starten →", type="primary", use_container_width=True,
112
+ on_click=_go_to_tracker)
113
+
114
+ st.markdown("<br>", unsafe_allow_html=True)
115
+
116
+ # Data source card
117
+ st.markdown("""<div class="at-card">
118
+ <h3>📡 Datenherkunft</h3>
119
+ <p>Die Funddaten stammen aus dem <strong>Global Biodiversity Information Facility (GBIF)</strong>,
120
+ dem weltweit größten offenen Datennetzwerk für Biodiversitätsinformationen.
121
+ GBIF aggregiert Meldungen von Museen, Naturschutzbehörden, Forschungsinstituten
122
+ und Bürgerwissenschaftsprojekten. 🔗 <a href="https://www.gbif.org" target="_blank">gbif.org</a></p>
123
+ <p><strong>⚠️ Hinweis zur Datenqualität:</strong> Die GBIF-Daten sind unvollständig und können Fehler
124
+ enthalten. Fundpunkte spiegeln nicht die tatsächliche Verbreitung einer Art wider, sondern zeigen,
125
+ wo Beobachtungen gemeldet und eingepflegt wurden. Regionale Unterschiede in der Meldeintensität,
126
+ fehlerhafte Koordinaten und taxonomische Unstimmigkeiten sind bekannte Einschränkungen.
127
+ Die Daten eignen sich für eine erste Orientierung, jedoch nicht für wissenschaftliche Analysen
128
+ ohne weitere Qualitätsprüfung.</p>
129
+ </div>""", unsafe_allow_html=True)
130
+
131
+ # About card
132
+ st.markdown("""<div class="at-card">
133
+ <h3>💡 Über dieses Projekt</h3>
134
+ <p>Arten Tracker wurde von <strong>Johannes Timaeus</strong> entwickelt — mit Unterstützung von
135
+ <a href="https://claude.ai/code" target="_blank">Claude Code</a> (Anthropic) als Beispiel
136
+ für <em>Vibe Coding</em>: die experimentelle, KI-gestützte Softwareentwicklung, bei der Ideen
137
+ direkt in funktionsfähigen Code übersetzt werden.</p>
138
+ <p>Es handelt sich um <strong>experimentelle Software</strong>. Funktionsumfang und Datenqualität
139
+ werden kontinuierlich weiterentwickelt.</p>
140
+ </div>""", unsafe_allow_html=True)
141
+
142
+ # Feedback card
143
+ st.markdown("""<div class="at-card">
144
+ <h3>✉️ Feedback</h3>
145
+ <p>Fehler, Verbesserungsvorschläge oder Artenwünsche sind herzlich willkommen:<br>
146
+ 📧 <a href="mailto:johannes.timaeus@bio-kultur.org">johannes.timaeus@bio-kultur.org</a></p>
147
+ </div>""", unsafe_allow_html=True)
148
+
149
+
150
+ # ── Sidebar ────────────────────────────────────────────────────────────────────
151
+
152
+ def render_sidebar():
153
+ st.sidebar.title("🦫 Arten Tracker")
154
+ st.sidebar.caption("Ausgewählte Arten · GBIF-Daten")
155
+ st.sidebar.divider()
156
+
157
+ _NAV_OPTIONS = ["🏠 Startseite", "🔍 Arten Tracker"]
158
+ st.session_state.setdefault("nav_radio", "🏠 Startseite")
159
+
160
+ st.sidebar.subheader("Navigation")
161
+ page = st.sidebar.radio(
162
+ "Seite",
163
+ options=_NAV_OPTIONS,
164
+ key="nav_radio",
165
+ label_visibility="collapsed",
166
+ )
167
+ st.sidebar.divider()
168
+
169
+ if page == "🏠 Startseite":
170
+ return None # signal to main() to render home page
171
+
172
+ st.sidebar.subheader("Art auswählen")
173
+ species_options = [None] + list(SPECIES.keys())
174
+ selected_label = st.sidebar.selectbox(
175
+ "Art",
176
+ options=species_options,
177
+ format_func=lambda k: "— Art auswählen —" if k is None else SPECIES_DISPLAY_LABELS[k],
178
+ key="selected_species",
179
+ )
180
+
181
+ if selected_label is None:
182
+ st.sidebar.info("Bitte oben eine Art auswählen, um die Analyse zu starten.")
183
+ return "no_species"
184
+
185
+ species_cfg = SPECIES[selected_label]
186
+ taxon_key = species_cfg["taxon_key"]
187
+ species_name = selected_label
188
+
189
+ st.sidebar.divider()
190
+
191
+ st.sidebar.subheader("Geografische Skala")
192
+ dl_option = st.sidebar.radio(
193
+ "Download & Auswertungsebene",
194
+ options=[1, 2, 3],
195
+ index=1,
196
+ format_func=lambda x: {
197
+ 1: "Weltweit + Vergleich Länder",
198
+ 2: "Länder + Vergleich Bundesländer",
199
+ 3: "Bundesland + Vergleich Kommunen",
200
+ }[x],
201
+ )
202
+
203
+ country_filter = None
204
+ bl_filter = "Alle"
205
+
206
+ if dl_option == 2:
207
+ eu_names = sorted(EUROPEAN_COUNTRIES.keys())
208
+ default_idx = eu_names.index("Deutschland")
209
+ country_name = st.sidebar.selectbox("Land auswählen", options=eu_names, index=default_idx)
210
+ country_filter = EUROPEAN_COUNTRIES[country_name]
211
+ elif dl_option == 3:
212
+ country_filter = "DE"
213
+ bl_filter = st.sidebar.selectbox("Bundesland", options=BUNDESLAENDER_LIST)
214
+
215
+ st.sidebar.divider()
216
+ st.sidebar.subheader("Datenqualität")
217
+ max_uncertainty = st.sidebar.slider(
218
+ "Max. Koordinatengenauigkeit (m)",
219
+ min_value=100, max_value=50000, value=50000, step=100,
220
+ )
221
+
222
+ st.sidebar.divider()
223
+ st.sidebar.caption("Daten: [GBIF](https://www.gbif.org) | Grenzen: BKG VG250, Natural Earth")
224
+
225
+ return (
226
+ taxon_key, species_name, species_cfg,
227
+ dl_option, country_filter, bl_filter,
228
+ max_uncertainty,
229
+ )
230
+
231
+
232
+ # ── Helpers ───────────────────────────────────────────────────────────────────
233
+
234
+ def _get_scope(dl_option: int, country_filter: str | None) -> str:
235
+ if dl_option == 1:
236
+ return "countries"
237
+ elif dl_option == 2:
238
+ return "bundeslaender" if country_filter == "DE" else "admin1"
239
+ return "gemeinden"
240
+
241
+
242
+ def _fmt_mb(mb: float) -> str:
243
+ return f"{mb:.1f} MB" if mb >= 1 else f"{mb * 1024:.0f} KB"
244
+
245
+
246
+ def _show_cache_panel(taxon_key: int, country_filter: str | None, species_name: str):
247
+ """Render the cache status box. Returns True if data is ready, False otherwise."""
248
+ hist_from, hist_to = historical_year_range()
249
+ country_label = country_filter if country_filter else "weltweit"
250
+ info = cache_info(taxon_key, country_filter)
251
+
252
+ if info:
253
+ fallback_note = " *(weltweiter Cache, gefiltert)*" if info.get("worldwide_fallback") else ""
254
+ st.success(
255
+ f"**Historische Daten gespeichert** ({hist_from}–{hist_to}, {country_label}){fallback_note} \n"
256
+ f"{info['rows']:,} Datensätze · {_fmt_mb(info['size_mb'])} · "
257
+ f"Stand: {info['modified'].strftime('%d.%m.%Y %H:%M')}"
258
+ )
259
+ return True
260
+
261
+ # No cache — two options: API download or local CSV import
262
+ st.warning(
263
+ f"Keine gespeicherten historischen Daten für **{species_name}** ({country_label}). "
264
+ "Einmalig herunterladen oder lokale GBIF-Datei importieren.",
265
+ icon="📥",
266
+ )
267
+
268
+ tab_api, tab_import = st.tabs(["⬇️ Via GBIF-API herunterladen", "📂 Lokale GBIF-Datei importieren"])
269
+
270
+ with tab_api:
271
+ with st.spinner("Prüfe verfügbare Datensätze bei GBIF…"):
272
+ total = get_occurrence_count(taxon_key, country_filter, hist_from, hist_to)
273
+
274
+ if total <= 0:
275
+ st.info(
276
+ "GBIF meldet keine Fundpunkte für diesen Bereich im historischen Zeitraum."
277
+ if total == 0 else "GBIF-Abfrage fehlgeschlagen."
278
+ )
279
+ else:
280
+ pages = math.ceil(min(total, 99_700) / 300)
281
+ est_min = max(1, round(pages * 1.2 / 60))
282
+ st.info(
283
+ f"**{total:,}** Fundpunkte verfügbar ({country_label}, {hist_from}–{hist_to}) \n"
284
+ f"Geschätzte Ladezeit: ~{est_min} Minute(n). Download läuft einmalig.",
285
+ icon="ℹ️",
286
+ )
287
+ if st.button("⬇️ Historische Daten laden & speichern", type="primary",
288
+ key=f"dl_{taxon_key}_{country_filter}"):
289
+ df_hist = fetch_all_occurrences(
290
+ taxon_key, country=country_filter,
291
+ year_from=hist_from, year_to=hist_to,
292
+ progress_label=f"Lade {species_name} ({hist_from}–{hist_to})…",
293
+ )
294
+ if df_hist.empty:
295
+ st.error("Download lieferte keine Daten. Bitte erneut versuchen.")
296
+ else:
297
+ save_historical(taxon_key, country_filter, df_hist)
298
+ st.success(f"{len(df_hist):,} Datensätze gespeichert.")
299
+ st.rerun()
300
+
301
+ with tab_import:
302
+ st.markdown(
303
+ "Pfad zur heruntergeladenen GBIF-Datei (CSV oder TSV, tab-getrennt). \n"
304
+ "Die Datei wird als **weltweiter Cache** gespeichert und für alle Länderfilter genutzt."
305
+ )
306
+
307
+ local_file = species_cfg.get("local_file", "")
308
+ if local_file:
309
+ st.info(f"Lokale Datei konfiguriert: `{local_file}`", icon="📁")
310
+ if st.button("⚡ Schnell-Import (vorkonfigurierter Pfad)", type="primary",
311
+ key=f"quickimport_{taxon_key}"):
312
+ try:
313
+ with st.spinner(f"Lese {local_file} …"):
314
+ df_imported = import_gbif_csv(local_file, exclude_current_year=True)
315
+ if df_imported.empty:
316
+ st.error("Keine gültigen Koordinaten in der Datei gefunden.")
317
+ else:
318
+ save_historical(taxon_key, None, df_imported)
319
+ st.success(
320
+ f"{len(df_imported):,} Datensätze importiert und als weltweiter Cache gespeichert."
321
+ )
322
+ st.rerun()
323
+ except FileNotFoundError as e:
324
+ st.error(str(e))
325
+ except Exception as e:
326
+ st.error(f"Fehler beim Import: {e}")
327
+ st.divider()
328
+
329
+ csv_path = st.text_input(
330
+ "Oder anderen Dateipfad eingeben",
331
+ placeholder="/home/…/0010143-250426092105405.csv",
332
+ key=f"csv_path_{taxon_key}",
333
+ )
334
+ if st.button("📂 Importieren & speichern", type="secondary",
335
+ key=f"import_{taxon_key}"):
336
+ if not csv_path:
337
+ st.error("Bitte einen Dateipfad eingeben.")
338
+ else:
339
+ try:
340
+ with st.spinner(f"Lese {csv_path} …"):
341
+ df_imported = import_gbif_csv(csv_path, exclude_current_year=True)
342
+ if df_imported.empty:
343
+ st.error("Keine gültigen Koordinaten in der Datei gefunden.")
344
+ else:
345
+ save_historical(taxon_key, None, df_imported)
346
+ st.success(
347
+ f"{len(df_imported):,} Datensätze importiert und als weltweiter Cache gespeichert."
348
+ )
349
+ st.rerun()
350
+ except FileNotFoundError as e:
351
+ st.error(str(e))
352
+ except Exception as e:
353
+ st.error(f"Fehler beim Import: {e}")
354
+
355
+ return False
356
+
357
+
358
+ # ── Main ───────────────────────────────────────────────────────────────────────
359
+
360
+ def main():
361
+ result = render_sidebar()
362
+ if result is None:
363
+ render_home()
364
+ return
365
+ if result == "no_species":
366
+ st.markdown("""
367
+ <div class="at-hero" style="text-align:center;padding:3rem 2rem">
368
+ <h1 style="font-size:1.8rem">Art auswählen</h1>
369
+ <p style="font-size:1.05rem;margin-top:0.75rem">
370
+ Wähle links in der Seitenleiste eine Art aus, um die Analyse und Karten zu laden.
371
+ </p>
372
+ </div>""", unsafe_allow_html=True)
373
+ return
374
+
375
+ (
376
+ taxon_key, species_name, species_cfg,
377
+ dl_option, country_filter, bl_filter,
378
+ max_uncertainty,
379
+ ) = result
380
+
381
+ icon = species_cfg["icon"]
382
+ common = species_cfg["common_name_de"]
383
+ st.markdown(
384
+ f"<h1 style='margin-bottom:0.1rem'>{icon} {common}</h1>"
385
+ f"<p style='color:#7ab89a;font-size:0.95rem;margin-top:0.2rem'>"
386
+ f"<em>{species_name}</em> &nbsp;·&nbsp; {species_cfg['description']}</p>",
387
+ unsafe_allow_html=True,
388
+ )
389
+ st.divider()
390
+
391
+ scope = _get_scope(dl_option, country_filter)
392
+
393
+ # ── Cache panel ──────────────────────────────────────────────────────────
394
+ hist_ready = _show_cache_panel(taxon_key, country_filter, species_name)
395
+ if not hist_ready:
396
+ return
397
+
398
+ # ── Load data (full range, unfiltered by year) ────────────────────────────
399
+ df_hist = load_historical(taxon_key, country_filter)
400
+ df_current = fetch_current_year(taxon_key, country_filter)
401
+
402
+ n_hist = len(df_hist)
403
+ n_current = len(df_current)
404
+ st.caption(
405
+ f"Historische Daten: **{n_hist:,}** Fundpunkte ({YEAR_FROM}–{HIST_YEAR_TO}) · "
406
+ f"Aktuelles Jahr ({CURRENT_YEAR}): **{n_current:,}** Funde (live von GBIF)"
407
+ )
408
+
409
+ df_full = pd.concat([df_hist, df_current], ignore_index=True) if not df_current.empty else df_hist.copy()
410
+
411
+ # Coordinate uncertainty filter (applied to all data)
412
+ if "coordinateUncertaintyInMeters" in df_full.columns:
413
+ before = len(df_full)
414
+ df_full = df_full[
415
+ df_full["coordinateUncertaintyInMeters"].isna() |
416
+ (df_full["coordinateUncertaintyInMeters"] <= max_uncertainty)
417
+ ].copy()
418
+ removed = before - len(df_full)
419
+ if removed > 0:
420
+ st.caption(f"{removed:,} Funde wegen Koordinatengenauigkeit > {max_uncertainty} m ausgeblendet")
421
+
422
+ if df_full.empty:
423
+ st.warning("Keine Fundpunkte nach Filterung vorhanden.")
424
+ return
425
+
426
+ # ── Boundary data ────────────────────────────────────────────────────────
427
+ boundary_gdf = get_boundary_gdf(scope)
428
+ outline_gdf = None
429
+
430
+ if dl_option == 2 and country_filter and country_filter != "DE" and boundary_gdf is not None:
431
+ boundary_gdf = filter_admin1_by_country(boundary_gdf, country_filter)
432
+ elif dl_option == 3:
433
+ if boundary_gdf is not None and bl_filter != "Alle":
434
+ boundary_gdf = filter_gemeinden_by_bundesland(boundary_gdf, bl_filter)
435
+ bl_gdf = get_boundary_gdf("bundeslaender")
436
+ if bl_gdf is not None:
437
+ outline_gdf = bl_gdf[bl_gdf["GEN"] == bl_filter].copy() if bl_filter != "Alle" else bl_gdf
438
+
439
+ # ── Spatial join on full dataset ──────────────────────────────────────────
440
+ occurrences_gdf = build_geodataframe(df_full)
441
+ has_boundary = boundary_gdf is not None and not boundary_gdf.empty
442
+
443
+ if has_boundary:
444
+ with st.spinner("Räumliche Zuordnung läuft…"):
445
+ joined_gdf = spatial_join(occurrences_gdf, boundary_gdf, scope)
446
+ else:
447
+ joined_gdf = occurrences_gdf
448
+
449
+ joined_filtered = joined_gdf
450
+
451
+ unit_col = UNIT_NAME_COL.get(scope, "GEN")
452
+
453
+ # All years actually present in data (for dropdowns and trend charts)
454
+ all_data_years = sorted(
455
+ joined_gdf["year"].dropna().astype(int).unique()
456
+ ) if "year" in joined_gdf.columns else []
457
+
458
+ # ── Tabs ─────────────────────────────────────────────────────────────────
459
+ tab1, tab2, tab3 = st.tabs([
460
+ f"📍 Aktuell ({CURRENT_YEAR})",
461
+ "🗂️ Historische Karte",
462
+ "📈 Zeitverlauf",
463
+ ])
464
+
465
+ with tab1:
466
+ current_gdf = filter_cy(joined_filtered)
467
+ st.subheader(f"Fundpunkte {CURRENT_YEAR} — {len(current_gdf):,} Funde (live)")
468
+ if len(current_gdf) == 0:
469
+ st.info(
470
+ f"Keine Fundpunkte für {CURRENT_YEAR} in diesem Bereich. "
471
+ "GBIF-Daten haben oft einen Verzug von Wochen bis Monaten.",
472
+ icon="��️",
473
+ )
474
+ current_map = build_current_map(current_gdf, scope, boundary_gdf, outline_gdf)
475
+ st_folium(current_map, width="100%", height=600, returned_objects=[], key="map_current")
476
+
477
+ with tab2:
478
+ if boundary_gdf is None:
479
+ st.warning("Keine Grenzdaten verfügbar — Karte benötigt politische Grenzen.")
480
+ elif not all_data_years:
481
+ st.info("Keine Jahresdaten vorhanden.")
482
+ else:
483
+ year_options = list(reversed(all_data_years))
484
+ _ctx = f"{species_name}|{dl_option}|{country_filter}|{bl_filter}"
485
+ if st.session_state.get("_hist_ctx") != _ctx:
486
+ st.session_state["_hist_ctx"] = _ctx
487
+ st.session_state["hist_year_select"] = year_options[0]
488
+ selected_year = st.selectbox("Jahr wählen", options=year_options,
489
+ key="hist_year_select")
490
+ year_gdf = joined_gdf[joined_gdf["year"] == selected_year].copy()
491
+ st.subheader(f"Fundkarte {selected_year} — {len(year_gdf):,} Funde")
492
+ if len(year_gdf) == 0:
493
+ st.info(f"Keine Funde für {selected_year} in diesem Bereich.", icon="ℹ️")
494
+ hist_map = build_current_map(year_gdf, scope, boundary_gdf, outline_gdf)
495
+ st_folium(hist_map, width="100%", height=600, returned_objects=[],
496
+ key="map_hist")
497
+
498
+ with tab3:
499
+ # Overall trend — full data range
500
+ trend_df = compute_trend_from_df(joined_gdf if has_boundary else df_full)
501
+
502
+ if trend_df.empty:
503
+ st.info("Keine Trenddaten vorhanden.")
504
+ else:
505
+ y_from = int(trend_df["year"].min())
506
+ y_to = int(trend_df["year"].max())
507
+ total_all = int(trend_df["count"].sum())
508
+ current_n = int(trend_df[trend_df["year"] == CURRENT_YEAR]["count"].sum())
509
+ peak_year = int(trend_df.loc[trend_df["count"].idxmax(), "year"])
510
+
511
+ col1, col2, col3 = st.columns(3)
512
+ col1.metric(f"Gesamt {y_from}–{y_to}", f"{total_all:,}")
513
+ col2.metric(f"Funde {CURRENT_YEAR} (live)", f"{current_n:,}")
514
+ col3.metric("Jahr mit meisten Funden", str(peak_year))
515
+
516
+ # Scope-dependent labels
517
+ scope_labels = {
518
+ "countries": ("Gesamt (weltweit)", "Land", "Länder"),
519
+ "bundeslaender": (f"Gesamt ({country_filter or 'Land'})", "Bundesland", "Bundesländer"),
520
+ "admin1": (f"Gesamt ({country_filter or 'Land'})", "Region", "Regionen"),
521
+ "gemeinden": (f"Gesamt ({bl_filter})", "Gemeinde", "Gemeinden"),
522
+ "kreise": (f"Gesamt ({bl_filter})", "Kreis", "Kreise"),
523
+ }
524
+ top_label, unit_label, units_label = scope_labels.get(
525
+ scope, ("Gesamt", "Einheit", "Einheiten")
526
+ )
527
+
528
+ # ── Overall chart ──────────────────────────────────────────────
529
+ st.subheader(f"Funde pro Jahr — {top_label}")
530
+ overall_chart = build_trend_chart(trend_df, species_name, y_from, y_to)
531
+ st.altair_chart(overall_chart, width="stretch")
532
+
533
+ # ── Per-unit charts (one full-width row per unit) ──────────────
534
+ if has_boundary and unit_col in joined_gdf.columns:
535
+ n_units = joined_gdf[unit_col].nunique()
536
+ max_show = 16 if scope == "bundeslaender" else min(n_units, 20)
537
+
538
+ st.subheader(
539
+ f"Funde pro Jahr je {unit_label}"
540
+ + (f" (Top {max_show} von {n_units})" if n_units > max_show else "")
541
+ )
542
+
543
+ top_units, unit_data = prepare_per_unit_data(
544
+ joined_gdf, unit_col=unit_col, max_units=max_show
545
+ )
546
+
547
+ if not top_units:
548
+ st.info("Keine Einheitendaten für Detaildarstellung verfügbar.")
549
+ else:
550
+ global_y_max = int(unit_data["count"].max())
551
+ for unit_name in top_units:
552
+ st.markdown(f"**{unit_name}**")
553
+ df_unit = unit_data[unit_data[unit_col] == unit_name]
554
+ chart = build_unit_row_chart(df_unit, y_max=global_y_max)
555
+ st.altair_chart(chart, width="stretch")
556
+
557
+
558
+ if __name__ == "__main__":
559
+ main()
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.57.0
2
+ huggingface_hub>=0.23.0
3
+ streamlit-folium>=0.27.0
4
+ pygbif>=0.6.6
5
+ geopandas>=1.0.0
6
+ shapely>=2.1.0
7
+ pyproj>=3.7.0
8
+ fiona>=1.9.0
9
+ pandas>=2.2.0
10
+ numpy>=1.26.0
11
+ folium>=0.20.0
12
+ altair>=6.0.0
13
+ requests>=2.31.0
14
+ pyarrow>=16.0.0
src/__init__.py ADDED
File without changes
src/boundary_loader.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from functools import lru_cache
3
+ import geopandas as gpd
4
+
5
+ from src.utils import VG250_DIR, NE_DIR, BKG_FILES, NE_FILES, AGS_TO_BUNDESLAND
6
+
7
+
8
+ def _find_shapefile(directory: Path, filename: str) -> Path | None:
9
+ direct = directory / filename
10
+ if direct.exists():
11
+ return direct
12
+ for sub in sorted(directory.iterdir()):
13
+ if sub.is_dir():
14
+ candidate = sub / filename
15
+ if candidate.exists():
16
+ return candidate
17
+ return None
18
+
19
+
20
+ def _load_and_reproject(path: Path, keep_cols: list[str]) -> gpd.GeoDataFrame:
21
+ gdf = gpd.read_file(path)
22
+ available = [c for c in keep_cols if c in gdf.columns] + ["geometry"]
23
+ gdf = gdf[available].copy()
24
+ if gdf.crs and gdf.crs.to_epsg() != 4326:
25
+ gdf = gdf.to_crs("EPSG:4326")
26
+ elif gdf.crs is None:
27
+ # No CRS info — assume WGS84 (unlikely for BKG but safe fallback)
28
+ gdf = gdf.set_crs("EPSG:4326")
29
+ return gdf
30
+
31
+
32
+ @lru_cache(maxsize=None)
33
+ def load_bundeslaender() -> gpd.GeoDataFrame | None:
34
+ path = _find_shapefile(VG250_DIR, BKG_FILES["bundeslaender"])
35
+ if path is None:
36
+ return None
37
+ return _load_and_reproject(path, ["GEN", "AGS", "EWZ"])
38
+
39
+
40
+ @lru_cache(maxsize=None)
41
+ def load_kreise() -> gpd.GeoDataFrame | None:
42
+ path = _find_shapefile(VG250_DIR, BKG_FILES["kreise"])
43
+ if path is None:
44
+ return None
45
+ return _load_and_reproject(path, ["GEN", "AGS", "BEZ", "EWZ"])
46
+
47
+
48
+ @lru_cache(maxsize=None)
49
+ def load_gemeinden() -> gpd.GeoDataFrame | None:
50
+ path = _find_shapefile(VG250_DIR, BKG_FILES["gemeinden"])
51
+ if path is None:
52
+ return None
53
+ gdf = _load_and_reproject(path, ["GEN", "AGS", "BEZ", "EWZ"])
54
+ if "AGS" in gdf.columns:
55
+ gdf["bundesland"] = gdf["AGS"].str[:2].map(AGS_TO_BUNDESLAND)
56
+ return gdf
57
+
58
+
59
+ @lru_cache(maxsize=None)
60
+ def load_world_countries() -> gpd.GeoDataFrame | None:
61
+ path = _find_shapefile(NE_DIR, NE_FILES["countries"])
62
+ if path is None:
63
+ return None
64
+ return _load_and_reproject(path, ["NAME", "ISO_A2", "CONTINENT", "POP_EST"])
65
+
66
+
67
+ @lru_cache(maxsize=None)
68
+ def load_admin1() -> gpd.GeoDataFrame | None:
69
+ path = _find_shapefile(NE_DIR, NE_FILES["admin1"])
70
+ if path is None:
71
+ return None
72
+ return _load_and_reproject(path, ["name", "admin", "iso_a2", "type_en"])
73
+
74
+
75
+ def get_boundary_gdf(scope: str) -> gpd.GeoDataFrame | None:
76
+ loaders = {
77
+ "bundeslaender": load_bundeslaender,
78
+ "kreise": load_kreise,
79
+ "gemeinden": load_gemeinden,
80
+ "countries": load_world_countries,
81
+ "admin1": load_admin1,
82
+ }
83
+ loader = loaders.get(scope)
84
+ if loader is None:
85
+ return None
86
+ try:
87
+ return loader()
88
+ except Exception as e:
89
+ print(f"[boundary] FEHLER beim Laden von scope={scope}: {e}")
90
+ return None
91
+
92
+
93
+ def filter_gemeinden_by_bundesland(gdf: gpd.GeoDataFrame, bundesland: str) -> gpd.GeoDataFrame:
94
+ if "bundesland" not in gdf.columns or bundesland == "Alle":
95
+ return gdf
96
+ return gdf[gdf["bundesland"] == bundesland].copy()
97
+
98
+
99
+ def filter_admin1_by_country(gdf: gpd.GeoDataFrame, iso2: str) -> gpd.GeoDataFrame:
100
+ if "iso_a2" not in gdf.columns or not iso2:
101
+ return gdf
102
+ return gdf[gdf["iso_a2"].str.upper() == iso2.upper()].copy()
103
+
104
+
105
+ def check_boundary_availability() -> dict[str, bool]:
106
+ result = {}
107
+ for scope, filename in BKG_FILES.items():
108
+ result[scope] = _find_shapefile(VG250_DIR, filename) is not None
109
+ for scope, filename in NE_FILES.items():
110
+ result[scope] = _find_shapefile(NE_DIR, filename) is not None
111
+ return result
src/chart_builder.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import altair as alt
3
+
4
+ from src.utils import PERIODS, CURRENT_YEAR, UNIT_NAME_COL
5
+
6
+
7
+ def build_trend_chart(trend_df: pd.DataFrame, species_name: str,
8
+ year_from: int = None, year_to: int = None) -> alt.Chart:
9
+ base = alt.Chart(trend_df).encode(
10
+ x=alt.X("year:O", title="Jahr", axis=alt.Axis(labelAngle=-45)),
11
+ y=alt.Y("count:Q", title="Anzahl Funde", scale=alt.Scale(zero=True)),
12
+ tooltip=[
13
+ alt.Tooltip("year:O", title="Jahr"),
14
+ alt.Tooltip("count:Q", title="Funde"),
15
+ ],
16
+ )
17
+
18
+ line = base.mark_line(color="#00cc6a", strokeWidth=2)
19
+ points = base.mark_circle(color="#00cc6a", size=40)
20
+
21
+ current_data = trend_df[trend_df["year"] == CURRENT_YEAR]
22
+ current_point = alt.Chart(current_data).mark_circle(
23
+ color="#ff6b35", size=80
24
+ ).encode(
25
+ x=alt.X("year:O"),
26
+ y=alt.Y("count:Q"),
27
+ tooltip=[alt.Tooltip("year:O", title="Jahr"), alt.Tooltip("count:Q", title="Funde")],
28
+ )
29
+
30
+ y_label = f"{year_from or PERIODS[0][0]}–{year_to or CURRENT_YEAR}"
31
+ chart = (line + points + current_point).properties(
32
+ title=alt.TitleParams(
33
+ text=f"Fundtrend: {species_name}",
34
+ subtitle=f"Jährliche GBIF-Funde {y_label} | Rot = aktuelles Jahr",
35
+ fontSize=14,
36
+ subtitleFontSize=11,
37
+ ),
38
+ height=300,
39
+ ).interactive()
40
+
41
+ return chart
42
+
43
+
44
+ def prepare_per_unit_data(
45
+ df: pd.DataFrame,
46
+ unit_col: str,
47
+ max_units: int = 20,
48
+ ) -> tuple[list[str], pd.DataFrame]:
49
+ """Return (ordered list of top unit names, aggregated year/count DataFrame).
50
+
51
+ The returned DataFrame has columns [unit_col, 'year', 'count'].
52
+ Units are ordered by total descending.
53
+ """
54
+ if unit_col not in df.columns or "year" not in df.columns:
55
+ return [], pd.DataFrame()
56
+
57
+ data = (
58
+ df.dropna(subset=["year", unit_col])
59
+ .assign(year=lambda d: d["year"].astype(int))
60
+ .groupby([unit_col, "year"])
61
+ .size()
62
+ .reset_index(name="count")
63
+ )
64
+
65
+ if data.empty:
66
+ return [], pd.DataFrame()
67
+
68
+ top_units = (
69
+ data.groupby(unit_col)["count"].sum()
70
+ .nlargest(max_units).index.tolist()
71
+ )
72
+ data = data[data[unit_col].isin(top_units)].copy()
73
+ return top_units, data
74
+
75
+
76
+ def build_unit_row_chart(unit_data: pd.DataFrame,
77
+ y_max: int | None = None) -> alt.Chart:
78
+ """Full-width line chart for a single geographic unit, same style as overall trend.
79
+
80
+ y_max: shared Y-axis maximum across all unit charts for comparability.
81
+ """
82
+ y_scale = alt.Scale(zero=True, domain=[0, y_max]) if y_max is not None else alt.Scale(zero=True)
83
+
84
+ base = alt.Chart(unit_data).encode(
85
+ x=alt.X("year:O", title="Jahr", axis=alt.Axis(labelAngle=-45)),
86
+ y=alt.Y("count:Q", title="Funde", scale=y_scale),
87
+ tooltip=[
88
+ alt.Tooltip("year:O", title="Jahr"),
89
+ alt.Tooltip("count:Q", title="Funde"),
90
+ ],
91
+ )
92
+
93
+ line = base.mark_line(color="#00cc6a", strokeWidth=2)
94
+ points = base.mark_circle(color="#00cc6a", size=40)
95
+
96
+ current_data = unit_data[unit_data["year"] == CURRENT_YEAR]
97
+ current_point = alt.Chart(current_data).mark_circle(
98
+ color="#ff6b35", size=80
99
+ ).encode(
100
+ x=alt.X("year:O"),
101
+ y=alt.Y("count:Q", scale=y_scale),
102
+ tooltip=[alt.Tooltip("year:O", title="Jahr"), alt.Tooltip("count:Q", title="Funde")],
103
+ )
104
+
105
+ return (line + points + current_point).properties(
106
+ height=140,
107
+ ).interactive()
108
+
109
+
110
+ def compute_trend_from_df(df: pd.DataFrame) -> pd.DataFrame:
111
+ """Count occurrences per year from any DataFrame with a 'year' column."""
112
+ if df.empty or "year" not in df.columns:
113
+ return pd.DataFrame(columns=["year", "count"])
114
+ counts = (
115
+ df.dropna(subset=["year"])
116
+ .assign(year=lambda d: d["year"].astype(int))
117
+ .groupby("year")
118
+ .size()
119
+ .reset_index(name="count")
120
+ .sort_values("year")
121
+ )
122
+ return counts
src/csv_importer.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Import GBIF occurrence CSV/TSV downloads (tab-separated) into our parquet cache format.
2
+
3
+ GBIF standard download columns differ slightly from our API-derived fields:
4
+ - 'datasetKey' instead of 'datasetName'
5
+ - 'countryCode' (ISO 2-letter) instead of 'country' (full name)
6
+ - No 'country' full-name column
7
+ """
8
+
9
+ import pandas as pd
10
+ from pathlib import Path
11
+
12
+ from src.utils import CURRENT_YEAR
13
+
14
+ GBIF_CSV_RENAME = {
15
+ "datasetKey": "datasetName",
16
+ }
17
+
18
+ TARGET_FIELDS = [
19
+ "gbifID", "occurrenceID", "decimalLatitude", "decimalLongitude",
20
+ "year", "month", "day", "eventDate",
21
+ "datasetName", "institutionCode", "recordedBy",
22
+ "scientificName", "species", "country", "countryCode",
23
+ "stateProvince", "locality", "coordinateUncertaintyInMeters",
24
+ ]
25
+
26
+ # Columns we need from the raw file (including aliases)
27
+ _READ_COLS = set(TARGET_FIELDS) | {"datasetKey"}
28
+ _CHUNK_SIZE = 200_000
29
+
30
+
31
+ def _process_chunk(chunk: pd.DataFrame, exclude_current_year: bool) -> pd.DataFrame:
32
+ # Rename datasetKey -> datasetName only when datasetName isn't also present
33
+ if "datasetKey" in chunk.columns and "datasetName" in chunk.columns:
34
+ chunk = chunk.drop(columns=["datasetKey"])
35
+ elif "datasetKey" in chunk.columns:
36
+ chunk = chunk.rename(columns=GBIF_CSV_RENAME)
37
+
38
+ # Numeric conversions
39
+ chunk["decimalLatitude"] = pd.to_numeric(chunk.get("decimalLatitude"), errors="coerce")
40
+ chunk["decimalLongitude"] = pd.to_numeric(chunk.get("decimalLongitude"), errors="coerce")
41
+ chunk["year"] = pd.to_numeric(chunk.get("year"), errors="coerce")
42
+ chunk["month"] = pd.to_numeric(chunk.get("month"), errors="coerce")
43
+ chunk["day"] = pd.to_numeric(chunk.get("day"), errors="coerce")
44
+ if "coordinateUncertaintyInMeters" in chunk.columns:
45
+ chunk["coordinateUncertaintyInMeters"] = pd.to_numeric(
46
+ chunk["coordinateUncertaintyInMeters"], errors="coerce"
47
+ )
48
+
49
+ chunk = chunk.dropna(subset=["decimalLatitude", "decimalLongitude"])
50
+
51
+ if exclude_current_year and "year" in chunk.columns:
52
+ chunk = chunk[chunk["year"] < CURRENT_YEAR]
53
+
54
+ for col in TARGET_FIELDS:
55
+ if col not in chunk.columns:
56
+ chunk[col] = None
57
+
58
+ return chunk[TARGET_FIELDS].copy()
59
+
60
+
61
+ def import_gbif_csv(filepath: str | Path, exclude_current_year: bool = True) -> pd.DataFrame:
62
+ """Read a GBIF CSV/TSV download and return a cleaned DataFrame.
63
+
64
+ Reads only the columns we need (huge memory saving for large files),
65
+ processes in chunks to handle files of any size, skips malformed lines.
66
+ """
67
+ filepath = Path(filepath)
68
+ if not filepath.exists():
69
+ raise FileNotFoundError(f"Datei nicht gefunden: {filepath}")
70
+
71
+ # Detect separator and available columns
72
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
73
+ header_line = f.readline()
74
+ sep = "\t" if "\t" in header_line else ","
75
+ available_cols = [c.strip() for c in header_line.split(sep)]
76
+ usecols = [c for c in available_cols if c in _READ_COLS]
77
+
78
+ chunks = pd.read_csv(
79
+ filepath,
80
+ sep=sep,
81
+ encoding="utf-8",
82
+ encoding_errors="replace",
83
+ low_memory=False,
84
+ dtype=str,
85
+ on_bad_lines="skip",
86
+ usecols=usecols,
87
+ chunksize=_CHUNK_SIZE,
88
+ )
89
+
90
+ parts = [_process_chunk(chunk, exclude_current_year) for chunk in chunks]
91
+ if not parts:
92
+ return pd.DataFrame(columns=TARGET_FIELDS)
93
+ return pd.concat(parts, ignore_index=True)
src/data_cache.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Disk-based Parquet cache for historical GBIF occurrences (YEAR_FROM … HIST_YEAR_TO).
2
+
3
+ Cache files live in data/species_cache/ and are named:
4
+ {taxon_key}_{scope}.parquet
5
+ where scope is a country code (e.g. "DE") or "worldwide".
6
+
7
+ Fallback rule: if no country-specific cache exists but a "worldwide" cache does,
8
+ load_best() returns the worldwide data filtered to the requested country in-memory.
9
+ """
10
+
11
+ import pandas as pd
12
+ from pathlib import Path
13
+ from datetime import datetime
14
+
15
+ from src.utils import CACHE_DIR, YEAR_FROM, HIST_YEAR_TO
16
+
17
+
18
+ def _scope_str(country: str | None) -> str:
19
+ return country if country else "worldwide"
20
+
21
+
22
+ def cache_path(taxon_key: int, country: str | None) -> Path:
23
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
24
+ return CACHE_DIR / f"{taxon_key}_{_scope_str(country)}.parquet"
25
+
26
+
27
+ def cache_exists(taxon_key: int, country: str | None) -> bool:
28
+ if cache_path(taxon_key, country).exists():
29
+ return True
30
+ # worldwide cache can serve any country filter
31
+ if country is not None:
32
+ return cache_path(taxon_key, None).exists()
33
+ return False
34
+
35
+
36
+ def cache_info(taxon_key: int, country: str | None) -> dict | None:
37
+ """Return size (MB), row count, modification date, and whether worldwide fallback is used."""
38
+ p = cache_path(taxon_key, country)
39
+ using_fallback = False
40
+ if not p.exists() and country is not None:
41
+ p = cache_path(taxon_key, None)
42
+ using_fallback = True
43
+ if not p.exists():
44
+ return None
45
+ size_mb = p.stat().st_size / 1_048_576
46
+ mtime = datetime.fromtimestamp(p.stat().st_mtime)
47
+ try:
48
+ df_meta = pd.read_parquet(p, columns=["year"])
49
+ rows = len(df_meta)
50
+ except Exception:
51
+ rows = -1
52
+ return {
53
+ "size_mb": size_mb,
54
+ "rows": rows,
55
+ "modified": mtime,
56
+ "path": p,
57
+ "worldwide_fallback": using_fallback,
58
+ }
59
+
60
+
61
+ def load_historical(taxon_key: int, country: str | None) -> pd.DataFrame:
62
+ """Load cached data. If no country-specific cache, falls back to worldwide + in-memory filter."""
63
+ p = cache_path(taxon_key, country)
64
+ if p.exists():
65
+ return pd.read_parquet(p)
66
+
67
+ # Worldwide fallback
68
+ if country is not None:
69
+ p_world = cache_path(taxon_key, None)
70
+ if p_world.exists():
71
+ df = pd.read_parquet(p_world)
72
+ if "countryCode" in df.columns:
73
+ return df[df["countryCode"] == country].copy()
74
+ return df
75
+
76
+ return pd.DataFrame()
77
+
78
+
79
+ def save_historical(taxon_key: int, country: str | None, df: pd.DataFrame) -> Path:
80
+ p = cache_path(taxon_key, country)
81
+ df.to_parquet(p, index=False)
82
+ return p
83
+
84
+
85
+ def delete_cache(taxon_key: int, country: str | None) -> bool:
86
+ p = cache_path(taxon_key, country)
87
+ if p.exists():
88
+ p.unlink()
89
+ return True
90
+ return False
91
+
92
+
93
+ def historical_year_range() -> tuple[int, int]:
94
+ return YEAR_FROM, HIST_YEAR_TO
src/gbif_client.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import pandas as pd
3
+ import streamlit as st
4
+ from pygbif import occurrences
5
+
6
+ from src.utils import GBIF_PAGE_SIZE, GBIF_MAX_OFFSET, CURRENT_YEAR
7
+
8
+ OCCURRENCE_FIELDS = [
9
+ "gbifID", "occurrenceID", "decimalLatitude", "decimalLongitude",
10
+ "year", "month", "day", "eventDate",
11
+ "datasetName", "institutionCode", "recordedBy",
12
+ "scientificName", "species", "country", "countryCode",
13
+ "stateProvince", "locality", "coordinateUncertaintyInMeters",
14
+ ]
15
+
16
+
17
+ def get_occurrence_count(taxon_key: int, country: str | None = None,
18
+ year_from: int | None = None, year_to: int | None = None) -> int:
19
+ params = dict(
20
+ taxonKey=taxon_key,
21
+ hasCoordinate=True,
22
+ hasGeospatialIssue=False,
23
+ limit=0,
24
+ )
25
+ if country:
26
+ params["country"] = country
27
+ if year_from is not None and year_to is not None:
28
+ params["year"] = f"{year_from},{year_to}"
29
+ try:
30
+ result = occurrences.search(**params)
31
+ return result.get("count", 0)
32
+ except Exception:
33
+ return -1
34
+
35
+
36
+ def _fmt_remaining(elapsed_s: float, pct: float) -> str:
37
+ if pct <= 0.01:
38
+ return ""
39
+ remaining = elapsed_s / pct * (1.0 - pct)
40
+ if remaining < 60:
41
+ return f" · noch ca. {int(remaining) + 1} Sek."
42
+ mins, secs = divmod(int(remaining), 60)
43
+ return f" · noch ca. {mins} Min. {secs} Sek."
44
+
45
+
46
+ def _clean_dataframe(records: list[dict]) -> pd.DataFrame:
47
+ df = pd.DataFrame(records)
48
+ if df.empty:
49
+ return df
50
+ keep = [c for c in OCCURRENCE_FIELDS if c in df.columns]
51
+ df = df[keep].copy()
52
+ df["decimalLatitude"] = pd.to_numeric(df.get("decimalLatitude"), errors="coerce")
53
+ df["decimalLongitude"] = pd.to_numeric(df.get("decimalLongitude"), errors="coerce")
54
+ df = df.dropna(subset=["decimalLatitude", "decimalLongitude"])
55
+ df["year"] = pd.to_numeric(df.get("year", pd.Series(dtype=int)), errors="coerce")
56
+ return df
57
+
58
+
59
+ def fetch_all_occurrences(taxon_key: int, country: str | None = None,
60
+ year_from: int | None = None, year_to: int | None = None,
61
+ progress_label: str = "Lade Daten von GBIF…") -> pd.DataFrame:
62
+ """Download all available records (no max_records cap) and show a progress bar.
63
+
64
+ Intended for the one-time historical cache download, not for repeated app calls.
65
+ """
66
+ params = dict(
67
+ taxonKey=taxon_key,
68
+ hasCoordinate=True,
69
+ hasGeospatialIssue=False,
70
+ limit=GBIF_PAGE_SIZE,
71
+ )
72
+ if country:
73
+ params["country"] = country
74
+ if year_from is not None and year_to is not None:
75
+ params["year"] = f"{year_from},{year_to}"
76
+
77
+ # Pre-check total count
78
+ count_params = {k: v for k, v in params.items() if k != "limit"}
79
+ count_params["limit"] = 0
80
+ try:
81
+ total = occurrences.search(**count_params).get("count", 0)
82
+ except Exception:
83
+ total = 0
84
+
85
+ limit = min(total, GBIF_MAX_OFFSET) if total > 0 else GBIF_MAX_OFFSET
86
+ progress_bar = st.progress(0.0, text=progress_label)
87
+ all_records: list[dict] = []
88
+ offset = 0
89
+ t0 = time.time()
90
+
91
+ try:
92
+ while offset < limit:
93
+ params["offset"] = offset
94
+ result = occurrences.search(**params)
95
+ batch = result.get("results", [])
96
+ if not batch:
97
+ break
98
+ all_records.extend(batch)
99
+ offset += len(batch)
100
+ pct = min(len(all_records) / max(limit, 1), 1.0)
101
+ eta = _fmt_remaining(time.time() - t0, pct)
102
+ progress_bar.progress(
103
+ pct,
104
+ text=f"{len(all_records):,} / {total:,} Datensätze geladen{eta}"
105
+ )
106
+ if result.get("endOfRecords", True):
107
+ break
108
+ time.sleep(0.05)
109
+ finally:
110
+ progress_bar.empty()
111
+
112
+ return _clean_dataframe(all_records)
113
+
114
+
115
+ MAX_CURRENT_YEAR_RECORDS = 10_000
116
+
117
+
118
+ def fetch_current_year(taxon_key: int, country: str | None = None) -> pd.DataFrame:
119
+ """Always fetch live from GBIF for the current year. Cached in session_state.
120
+
121
+ Capped at MAX_CURRENT_YEAR_RECORDS to keep response times reasonable.
122
+ """
123
+ cache_key = f"current_{taxon_key}_{country}"
124
+ if cache_key in st.session_state:
125
+ return st.session_state[cache_key]
126
+
127
+ params = dict(
128
+ taxonKey=taxon_key,
129
+ year=f"{CURRENT_YEAR},{CURRENT_YEAR}",
130
+ hasCoordinate=True,
131
+ hasGeospatialIssue=False,
132
+ limit=GBIF_PAGE_SIZE,
133
+ )
134
+ if country:
135
+ params["country"] = country
136
+
137
+ # Pre-check total so we can show a meaningful progress bar
138
+ count_params = {**params, "limit": 0}
139
+ try:
140
+ total = occurrences.search(**count_params).get("count", 0)
141
+ except Exception:
142
+ total = 0
143
+
144
+ fetch_limit = min(total, MAX_CURRENT_YEAR_RECORDS)
145
+ capped = total > MAX_CURRENT_YEAR_RECORDS
146
+
147
+ country_label = country if country else "weltweit"
148
+ progress_bar = st.progress(
149
+ 0.0,
150
+ text=f"Lade aktuelle Daten {CURRENT_YEAR} ({country_label}): {total:,} verfügbar…",
151
+ )
152
+
153
+ all_records: list[dict] = []
154
+ offset = 0
155
+ t0 = time.time()
156
+ try:
157
+ while len(all_records) < fetch_limit:
158
+ params["offset"] = offset
159
+ result = occurrences.search(**params)
160
+ batch = result.get("results", [])
161
+ if not batch:
162
+ break
163
+ all_records.extend(batch)
164
+ offset += len(batch)
165
+ pct = min(len(all_records) / max(fetch_limit, 1), 1.0)
166
+ eta = _fmt_remaining(time.time() - t0, pct)
167
+ progress_bar.progress(
168
+ pct,
169
+ text=f"Aktuelle Daten {CURRENT_YEAR}: {len(all_records):,} / {fetch_limit:,}{eta}",
170
+ )
171
+ if result.get("endOfRecords", True):
172
+ break
173
+ time.sleep(0.05)
174
+ except Exception:
175
+ pass
176
+ finally:
177
+ progress_bar.empty()
178
+
179
+ df = _clean_dataframe(all_records)
180
+ if capped:
181
+ st.caption(
182
+ f"Aktuelle Daten {CURRENT_YEAR} auf {MAX_CURRENT_YEAR_RECORDS:,} begrenzt "
183
+ f"({total:,} insgesamt verfügbar, {country_label})."
184
+ )
185
+ st.session_state[cache_key] = df
186
+ return df
src/map_builder.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import geopandas as gpd
2
+ import folium
3
+ import shapely
4
+
5
+ from src.utils import MAP_CENTER, MAP_ZOOM, UNIT_NAME_COL
6
+
7
+
8
+ TILE = "CartoDB positron"
9
+
10
+
11
+ def _clean_geometries(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
12
+ """Drop null/empty geometries and repair invalid ones before any spatial operation."""
13
+ gdf = gdf[~gdf["geometry"].isna()].copy()
14
+ gdf = gdf[~gdf["geometry"].is_empty].copy()
15
+ gdf["geometry"] = shapely.make_valid(gdf["geometry"])
16
+ # Drop any geometry that still has non-finite bounds
17
+ def _has_finite_bounds(geom):
18
+ try:
19
+ b = geom.bounds
20
+ return all(v == v and abs(v) != float("inf") for v in b) # NaN check + inf check
21
+ except Exception:
22
+ return False
23
+ gdf = gdf[gdf["geometry"].apply(_has_finite_bounds)].copy()
24
+ return gdf
25
+
26
+
27
+ def _format_popup(row: dict) -> str:
28
+ fields = [
29
+ ("Art", row.get("scientificName") or row.get("species")),
30
+ ("Datum", row.get("eventDate") or f"{row.get('year','')}-{str(row.get('month','')).zfill(2)}-{str(row.get('day','')).zfill(2)}"),
31
+ ("Datenquelle", row.get("datasetName")),
32
+ ("Institution", row.get("institutionCode")),
33
+ ("Erfasser", row.get("recordedBy")),
34
+ ("Ort", row.get("locality")),
35
+ ("Bundesland/Region", row.get("stateProvince")),
36
+ ("Land", row.get("country")),
37
+ ("GBIF-ID", row.get("gbifID")),
38
+ ("Koordinatengenauigkeit", row.get("coordinateUncertaintyInMeters")),
39
+ ]
40
+ rows_html = "".join(
41
+ f"<tr><td style='font-weight:bold;padding-right:8px'>{k}</td><td>{v}</td></tr>"
42
+ for k, v in fields
43
+ if v and str(v).strip() and str(v) not in ("nan", "None", "--")
44
+ )
45
+ return f"<table style='font-size:12px;min-width:220px'>{rows_html}</table>"
46
+
47
+
48
+ def build_current_map(occurrences_gdf: gpd.GeoDataFrame, scope: str,
49
+ boundary_gdf: gpd.GeoDataFrame | None = None,
50
+ outline_gdf: gpd.GeoDataFrame | None = None) -> folium.Map:
51
+ center = MAP_CENTER.get(scope, (51.2, 10.4))
52
+ zoom = MAP_ZOOM.get(scope, 6)
53
+ m = folium.Map(location=center, zoom_start=zoom, tiles=TILE)
54
+
55
+ if outline_gdf is not None and not outline_gdf.empty:
56
+ simplified_outline = _clean_geometries(outline_gdf.copy())
57
+ simplified_outline["geometry"] = simplified_outline["geometry"].simplify(tolerance=0.005, preserve_topology=True)
58
+ folium.GeoJson(
59
+ simplified_outline.__geo_interface__,
60
+ style_function=lambda _: {"color": "#1a1a1a", "weight": 2.5, "fillOpacity": 0},
61
+ name="Übergeordnete Grenze",
62
+ ).add_to(m)
63
+
64
+ if boundary_gdf is not None and not boundary_gdf.empty:
65
+ simplified = _clean_geometries(boundary_gdf.copy())
66
+ simplified["geometry"] = simplified["geometry"].simplify(tolerance=0.005, preserve_topology=True)
67
+ folium.GeoJson(
68
+ simplified.__geo_interface__,
69
+ style_function=lambda _: {"color": "#444", "weight": 0.8, "fillOpacity": 0},
70
+ name="Grenzen",
71
+ ).add_to(m)
72
+
73
+ points_layer = folium.FeatureGroup(name="Fundpunkte")
74
+ for _, row in occurrences_gdf.iterrows():
75
+ lat = row.get("decimalLatitude")
76
+ lon = row.get("decimalLongitude")
77
+ if lat is None or lon is None:
78
+ continue
79
+ popup_html = _format_popup(row.to_dict())
80
+ folium.CircleMarker(
81
+ location=[lat, lon],
82
+ radius=5,
83
+ color="#c0392b",
84
+ fill=True,
85
+ fill_color="#e74c3c",
86
+ fill_opacity=0.7,
87
+ popup=folium.Popup(popup_html, max_width=320),
88
+ tooltip=row.get("scientificName", ""),
89
+ ).add_to(points_layer)
90
+ points_layer.add_to(m)
91
+
92
+ folium.LayerControl().add_to(m)
93
+ return m
94
+
95
+
96
+
97
+ def build_choropleth_map(occurrences_gdf: gpd.GeoDataFrame, boundary_gdf: gpd.GeoDataFrame,
98
+ scope: str, period_label: str) -> folium.Map:
99
+ center = MAP_CENTER.get(scope, (51.2, 10.4))
100
+ zoom = MAP_ZOOM.get(scope, 6)
101
+ m = folium.Map(location=center, zoom_start=zoom, tiles=TILE)
102
+
103
+ unit_col = UNIT_NAME_COL.get(scope, "GEN")
104
+ if unit_col not in boundary_gdf.columns:
105
+ return m
106
+
107
+ # Count occurrences per unit
108
+ counts = occurrences_gdf.groupby(unit_col).size().reset_index(name="count") \
109
+ if unit_col in occurrences_gdf.columns else None
110
+
111
+ # Simplify for performance (clean invalid geometries first)
112
+ boundary_plot = _clean_geometries(boundary_gdf.copy())
113
+ boundary_plot["geometry"] = boundary_plot["geometry"].simplify(tolerance=0.005, preserve_topology=True)
114
+
115
+ if counts is not None and not counts.empty:
116
+ boundary_plot = boundary_plot.merge(counts, on=unit_col, how="left")
117
+ boundary_plot["count"] = boundary_plot["count"].fillna(0).astype(int)
118
+
119
+ folium.Choropleth(
120
+ geo_data=boundary_plot.__geo_interface__,
121
+ data=boundary_plot[[unit_col, "count"]],
122
+ columns=[unit_col, "count"],
123
+ key_on=f"feature.properties.{unit_col}",
124
+ fill_color="YlOrRd",
125
+ fill_opacity=0.75,
126
+ line_opacity=0.4,
127
+ legend_name=f"Funde {period_label}",
128
+ nan_fill_color="#eeeeee",
129
+ name=f"Funde {period_label}",
130
+ ).add_to(m)
131
+
132
+ # Tooltip overlay
133
+ tooltip_gdf = boundary_plot[[unit_col, "count", "geometry"]].copy()
134
+ folium.GeoJson(
135
+ tooltip_gdf.__geo_interface__,
136
+ style_function=lambda _: {"color": "#555", "weight": 0.5, "fillOpacity": 0},
137
+ tooltip=folium.GeoJsonTooltip(
138
+ fields=[unit_col, "count"],
139
+ aliases=["Einheit:", "Funde:"],
140
+ localize=True,
141
+ ),
142
+ name="Info",
143
+ ).add_to(m)
144
+ else:
145
+ folium.GeoJson(
146
+ boundary_plot.__geo_interface__,
147
+ style_function=lambda _: {"color": "#888", "weight": 0.8, "fillColor": "#eeeeee", "fillOpacity": 0.4},
148
+ ).add_to(m)
149
+
150
+ folium.LayerControl().add_to(m)
151
+ return m
src/spatial_analysis.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import geopandas as gpd
3
+ import shapely
4
+ from shapely.geometry import Point
5
+
6
+ from src.utils import PERIODS, CURRENT_YEAR, YEAR_FROM, UNIT_NAME_COL
7
+
8
+
9
+ def build_geodataframe(df: pd.DataFrame) -> gpd.GeoDataFrame:
10
+ geometry = [Point(lon, lat) for lon, lat in zip(df["decimalLongitude"], df["decimalLatitude"])]
11
+ return gpd.GeoDataFrame(df.copy(), geometry=geometry, crs="EPSG:4326")
12
+
13
+
14
+ def _prepare_boundary(boundary_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
15
+ """Reproject to WGS84 and drop invalid/degenerate geometries."""
16
+ # Ensure CRS is set and in WGS84
17
+ if boundary_gdf.crs is None:
18
+ gdf = boundary_gdf.set_crs("EPSG:4326", allow_override=True)
19
+ else:
20
+ gdf = boundary_gdf.to_crs("EPSG:4326")
21
+
22
+ # Drop null / empty geometries
23
+ gdf = gdf[~gdf["geometry"].isna()].copy()
24
+ gdf = gdf[~gdf["geometry"].is_empty].copy()
25
+
26
+ # Repair invalid geometries (fixes self-intersections etc.)
27
+ gdf["geometry"] = shapely.make_valid(gdf["geometry"])
28
+
29
+ # Drop geometries with non-finite coordinate bounds (NaN / Inf)
30
+ def _finite_bounds(geom):
31
+ try:
32
+ return all(v == v and abs(v) != float("inf") for v in geom.bounds)
33
+ except Exception:
34
+ return False
35
+
36
+ gdf = gdf[gdf["geometry"].apply(_finite_bounds)].copy()
37
+ return gdf
38
+
39
+
40
+ def spatial_join(occurrences_gdf: gpd.GeoDataFrame, boundary_gdf: gpd.GeoDataFrame,
41
+ scope: str) -> gpd.GeoDataFrame:
42
+ occ = occurrences_gdf.to_crs("EPSG:4326") if occurrences_gdf.crs else occurrences_gdf.set_crs("EPSG:4326")
43
+ bnd = _prepare_boundary(boundary_gdf)
44
+
45
+ if bnd.empty:
46
+ return occurrences_gdf.iloc[0:0]
47
+
48
+ unit_col = UNIT_NAME_COL.get(scope, "GEN")
49
+ join_cols = [c for c in [unit_col, "AGS", "bundesland", "ISO_A2", "admin", "iso_a2"]
50
+ if c in bnd.columns] + ["geometry"]
51
+ bnd_slim = bnd[join_cols].copy()
52
+
53
+ joined = gpd.sjoin(occ, bnd_slim, how="inner", predicate="intersects")
54
+ return joined.drop(columns=["index_right"], errors="ignore")
55
+
56
+
57
+ def count_by_unit(joined_gdf: gpd.GeoDataFrame, unit_col: str) -> pd.Series:
58
+ if unit_col not in joined_gdf.columns:
59
+ return pd.Series(dtype=int)
60
+ return joined_gdf.groupby(unit_col).size().rename("count")
61
+
62
+
63
+ def build_count_table(joined_gdf: gpd.GeoDataFrame, boundary_gdf: gpd.GeoDataFrame,
64
+ scope: str, year_from: int = YEAR_FROM,
65
+ year_to: int = CURRENT_YEAR) -> pd.DataFrame:
66
+ unit_col = UNIT_NAME_COL.get(scope, "GEN")
67
+ if unit_col not in boundary_gdf.columns:
68
+ return pd.DataFrame()
69
+
70
+ all_units = boundary_gdf[unit_col].dropna().unique()
71
+ table = pd.DataFrame(index=all_units)
72
+ table.index.name = unit_col
73
+
74
+ for year in range(year_from, year_to + 1):
75
+ year_data = joined_gdf[joined_gdf["year"] == year]
76
+ counts = count_by_unit(year_data, unit_col)
77
+ table[str(year)] = counts.reindex(table.index).fillna(0).astype(int)
78
+
79
+ table["Gesamt"] = table.sum(axis=1)
80
+ table = table[table["Gesamt"] > 0].sort_values("Gesamt", ascending=False)
81
+ return table.reset_index()
src/species_config.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SPECIES = {
2
+ "Vespa velutina": {
3
+ "taxon_key": 1311477,
4
+ "common_name_de": "Asiatische Hornisse",
5
+ "icon": "🐝",
6
+ "description": "Invasive Neobiota, ursprünglich aus Südostasien; breitet sich seit ~2004 in Europa aus.",
7
+ },
8
+ "Castor fiber": {
9
+ "taxon_key": 2439952,
10
+ "common_name_de": "Europäischer Biber",
11
+ "icon": "🦫",
12
+ "description": "Heimische Art; nach starker Dezimierung heute wieder weit verbreitet in Europa.",
13
+ "local_file": "/home/johannes/Nextcloud2/vespa_velutina_python/claude_vespa_dyna/Castor fiber bis 2025/occurrence.txt",
14
+ },
15
+ "Branta canadensis": {
16
+ "taxon_key": 5232437,
17
+ "common_name_de": "Kanadagans",
18
+ "icon": "🦢",
19
+ "description": "Eingebürgerter Neozoon aus Nordamerika; zahlreiche stabile Populationen in Europa.",
20
+ "local_file": "/home/johannes/Nextcloud2/vespa_velutina_python/claude_vespa_dyna/Branta canadensis bis 2025/Branta_canadensis_bis_2025.csv",
21
+ },
22
+ "Felis silvestris": {
23
+ "taxon_key": 7964291,
24
+ "common_name_de": "Wildkatze",
25
+ "icon": "🐱",
26
+ "description": "Heimische Art; stark gefährdet durch Lebensraumverlust und Hybridisierung.",
27
+ "local_file": "/home/johannes/Nextcloud2/vespa_velutina_python/claude_vespa_dyna/Wildkatze bis 2025/occurrence.txt",
28
+ },
29
+ }
30
+
31
+ SPECIES_DISPLAY_LABELS = {
32
+ key: f"{cfg['icon']} {key} – {cfg['common_name_de']}"
33
+ for key, cfg in SPECIES.items()
34
+ }
src/streamlit_app.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import altair as alt
2
+ import numpy as np
3
+ import pandas as pd
4
+ import streamlit as st
5
+
6
+ """
7
+ # Welcome to Streamlit!
8
+
9
+ Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
+ If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
+ forums](https://discuss.streamlit.io).
12
+
13
+ In the meantime, below is an example of what you can do with just a few lines of code:
14
+ """
15
+
16
+ num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
+ num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
+
19
+ indices = np.linspace(0, 1, num_points)
20
+ theta = 2 * np.pi * num_turns * indices
21
+ radius = indices
22
+
23
+ x = radius * np.cos(theta)
24
+ y = radius * np.sin(theta)
25
+
26
+ df = pd.DataFrame({
27
+ "x": x,
28
+ "y": y,
29
+ "idx": indices,
30
+ "rand": np.random.randn(num_points),
31
+ })
32
+
33
+ st.altair_chart(alt.Chart(df, height=700, width=700)
34
+ .mark_point(filled=True)
35
+ .encode(
36
+ x=alt.X("x", axis=None),
37
+ y=alt.Y("y", axis=None),
38
+ color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
+ size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
+ ))
src/styles.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ _CSS = """
4
+ <style>
5
+ /* ── Fonts ─────────────────────────────────────────────────────────────── */
6
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
7
+
8
+ html, body, [class*="css"], [class*="st-"] {
9
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
10
+ }
11
+
12
+ /* ── Main container ─────────────────────────────────────────────────────── */
13
+ .main .block-container {
14
+ padding: 2rem 2.5rem 3rem !important;
15
+ max-width: 1200px !important;
16
+ }
17
+
18
+ /* ── Typography ─────────────────────────────────────────────────────────── */
19
+ h1 { font-weight: 700 !important; letter-spacing: -0.4px !important; line-height: 1.2 !important; }
20
+ h2 { font-weight: 600 !important; color: #2d3748 !important; }
21
+ h3 { font-weight: 600 !important; }
22
+
23
+ [data-testid="stCaptionContainer"] p {
24
+ color: #64748b !important;
25
+ font-size: 0.85rem !important;
26
+ }
27
+
28
+ /* ── Sidebar ────────────────────────────────────────────────────────────── */
29
+ [data-testid="stSidebar"] {
30
+ border-right: 1px solid #e2e8f0 !important;
31
+ }
32
+ [data-testid="stSidebar"] > div:first-child {
33
+ padding-top: 1.5rem;
34
+ }
35
+ [data-testid="stSidebarUserContent"] h1 {
36
+ font-size: 1.3rem !important;
37
+ }
38
+
39
+ /* ── Metric cards ───────────────────────────────────────────────────────── */
40
+ [data-testid="stMetric"] {
41
+ background: #ffffff;
42
+ border: 1px solid #e2e8f0;
43
+ border-radius: 12px;
44
+ padding: 1.1rem 1.3rem !important;
45
+ box-shadow: 0 1px 4px rgba(0,0,0,0.06);
46
+ }
47
+ [data-testid="stMetricValue"] {
48
+ font-size: 1.9rem !important;
49
+ font-weight: 700 !important;
50
+ color: #1e6b3b !important;
51
+ }
52
+ [data-testid="stMetricLabel"] {
53
+ font-size: 0.78rem !important;
54
+ font-weight: 500 !important;
55
+ color: #64748b !important;
56
+ text-transform: uppercase !important;
57
+ letter-spacing: 0.4px !important;
58
+ }
59
+
60
+ /* ── Tabs ───────────────────────────────────────────────────────────────── */
61
+ [data-testid="stTabs"] [role="tablist"] {
62
+ gap: 2px;
63
+ border-bottom: 2px solid #e2e8f0 !important;
64
+ padding-bottom: 0 !important;
65
+ }
66
+ [data-testid="stTabs"] button[role="tab"] {
67
+ border-radius: 8px 8px 0 0 !important;
68
+ font-weight: 500 !important;
69
+ padding: 0.55rem 1.1rem !important;
70
+ color: #64748b !important;
71
+ border: 1px solid transparent !important;
72
+ border-bottom: none !important;
73
+ transition: color 0.15s, background 0.15s;
74
+ }
75
+ [data-testid="stTabs"] button[role="tab"]:hover {
76
+ color: #1e6b3b !important;
77
+ background: #f4f7f5 !important;
78
+ }
79
+ [data-testid="stTabs"] button[role="tab"][aria-selected="true"] {
80
+ color: #1e6b3b !important;
81
+ font-weight: 600 !important;
82
+ background: #ffffff !important;
83
+ border-color: #e2e8f0 !important;
84
+ border-bottom-color: #ffffff !important;
85
+ margin-bottom: -2px;
86
+ }
87
+
88
+ /* ── Alerts (info / success / warning / error) ──────────────────────────── */
89
+ [data-testid="stAlert"] {
90
+ border-radius: 10px !important;
91
+ border-left-width: 4px !important;
92
+ padding: 0.75rem 1rem !important;
93
+ }
94
+
95
+ /* ── Buttons ────────────────────────────────────────────────────────────── */
96
+ [data-testid="stButton"] > button {
97
+ border-radius: 8px !important;
98
+ font-weight: 500 !important;
99
+ transition: filter 0.15s, transform 0.1s !important;
100
+ }
101
+ [data-testid="stButton"] > button:hover {
102
+ filter: brightness(0.92) !important;
103
+ transform: translateY(-1px) !important;
104
+ }
105
+ [data-testid="stButton"] > button[kind="primary"] {
106
+ padding: 0.5rem 1.4rem !important;
107
+ font-weight: 600 !important;
108
+ }
109
+
110
+ /* ── Select / Input ─────────────────────────────────────────────────────── */
111
+ [data-testid="stSelectbox"] > div > div,
112
+ [data-testid="stTextInput"] > div > div > input {
113
+ border-radius: 8px !important;
114
+ }
115
+
116
+ /* ── Divider ────────────────────────────────────────────────────────────── */
117
+ hr {
118
+ border-color: #e2e8f0 !important;
119
+ margin: 0.8rem 0 !important;
120
+ }
121
+
122
+ /* ── Home page cards ────────────────────────────────────────────────────── */
123
+ .at-card {
124
+ background: #ffffff;
125
+ border: 1px solid #e2e8f0;
126
+ border-radius: 14px;
127
+ padding: 1.5rem 1.75rem;
128
+ margin-bottom: 1.25rem;
129
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
130
+ }
131
+ .at-card h3 {
132
+ margin-top: 0 !important;
133
+ color: #1e6b3b !important;
134
+ }
135
+ .at-hero {
136
+ background: linear-gradient(135deg, #1e6b3b 0%, #2d9c59 100%);
137
+ border-radius: 16px;
138
+ padding: 2.5rem 2rem;
139
+ margin-bottom: 2rem;
140
+ color: #ffffff !important;
141
+ }
142
+ .at-hero h1, .at-hero p {
143
+ color: #ffffff !important;
144
+ margin: 0 !important;
145
+ }
146
+ .at-hero h1 { font-size: 2rem !important; margin-bottom: 0.5rem !important; }
147
+ .at-hero p { font-size: 1.05rem !important; opacity: 0.92; }
148
+ .at-feature-icon { font-size: 1.8rem; margin-bottom: 0.4rem; }
149
+ .at-feature-box {
150
+ background: #f4f7f5;
151
+ border-radius: 12px;
152
+ padding: 1.2rem 1rem;
153
+ text-align: center;
154
+ height: 100%;
155
+ }
156
+ .at-feature-box p { color: #4a5568 !important; font-size: 0.9rem !important; margin: 0 !important; }
157
+ .at-feature-box strong { color: #1a2332 !important; font-size: 0.95rem; }
158
+
159
+ /* ── Spinner ────────────────────────────────────────────────────────────── */
160
+ [data-testid="stSpinner"] > div { border-top-color: #1e6b3b !important; }
161
+
162
+ /* ── Mobile responsive ──────────────────────────────────────────────────── */
163
+ @media (max-width: 768px) {
164
+ .main .block-container {
165
+ padding: 1rem 0.9rem 2rem !important;
166
+ }
167
+ h1 { font-size: 1.55rem !important; }
168
+ h2 { font-size: 1.15rem !important; }
169
+
170
+ /* Stack the 3-column metric row */
171
+ [data-testid="stHorizontalBlock"] {
172
+ flex-wrap: wrap !important;
173
+ gap: 0.6rem !important;
174
+ }
175
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] {
176
+ min-width: calc(50% - 0.3rem) !important;
177
+ flex: 1 1 calc(50% - 0.3rem) !important;
178
+ }
179
+
180
+ /* Smaller tab labels */
181
+ [data-testid="stTabs"] button[role="tab"] {
182
+ padding: 0.4rem 0.65rem !important;
183
+ font-size: 0.82rem !important;
184
+ }
185
+
186
+ /* Map iframe: shorter on phone */
187
+ .stFoliumChart iframe, iframe[title*="folium"] {
188
+ height: 340px !important;
189
+ }
190
+
191
+ .at-hero { padding: 1.5rem 1.25rem; }
192
+ .at-hero h1 { font-size: 1.5rem !important; }
193
+ .at-card { padding: 1.1rem 1.2rem; }
194
+ }
195
+
196
+ @media (max-width: 480px) {
197
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] {
198
+ min-width: 100% !important;
199
+ }
200
+ }
201
+ </style>
202
+ """
203
+
204
+
205
+ _HIDE_SIDEBAR_CSS = """
206
+ <style>
207
+ [data-testid="stSidebar"] { display: none !important; }
208
+ [data-testid="stSidebarCollapsedControl"]{ display: none !important; }
209
+ .main .block-container { max-width: 900px !important; margin: 0 auto !important; }
210
+ </style>
211
+ """
212
+
213
+
214
+ def inject_css() -> None:
215
+ st.markdown(_CSS, unsafe_allow_html=True)
216
+
217
+
218
+ def hide_sidebar() -> None:
219
+ st.markdown(_HIDE_SIDEBAR_CSS, unsafe_allow_html=True)
src/styles_v1_naturetech.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Variant 1 — NatureTech
3
+ Biolumineszenz trifft digitalen Wald. Dunkles Substrat, organische Datengitter,
4
+ neongrüne Signale. Wie ein Mikroskop-Slide im Terminal.
5
+
6
+ config.toml für beste Ergebnisse:
7
+ [theme]
8
+ primaryColor = "#00ff88"
9
+ backgroundColor = "#0a0f0d"
10
+ secondaryBackgroundColor = "#111a14"
11
+ textColor = "#e8f5ec"
12
+ font = "monospace"
13
+ """
14
+ import streamlit as st
15
+
16
+ _CSS = """
17
+ <style>
18
+ @import url('https://fonts.googleapis.com/css2?family=Oxanium:wght@300;400;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
19
+
20
+ /* ── Keyframes ──────────────────────────────────────────────────── */
21
+ @keyframes neon-pulse {
22
+ 0%,100% { box-shadow: 0 0 4px #00ff8855, 0 0 14px #00ff8818; }
23
+ 50% { box-shadow: 0 0 10px #00ff88aa, 0 0 30px #00ff8835, 0 0 55px #00ff8810; }
24
+ }
25
+ @keyframes data-in {
26
+ from { opacity: 0; transform: translateY(5px); }
27
+ to { opacity: 1; transform: translateY(0); }
28
+ }
29
+ @keyframes cursor-blink {
30
+ 0%,100% { opacity: 1; } 50% { opacity: 0; }
31
+ }
32
+
33
+ /* ── Base ───────────────────────────────────────────────────────── */
34
+ .stApp {
35
+ background-color: #0a0f0d !important;
36
+ background-image:
37
+ linear-gradient(rgba(0,255,136,0.018) 1px, transparent 1px),
38
+ linear-gradient(90deg, rgba(0,255,136,0.018) 1px, transparent 1px);
39
+ background-size: 44px 44px !important;
40
+ }
41
+ html, body { background-color: #0a0f0d !important; }
42
+ section.main, [data-testid="stAppViewContainer"] {
43
+ background-color: #0a0f0d !important;
44
+ }
45
+
46
+ /* ── Main container ─────────────────────────────────────────────── */
47
+ .main .block-container {
48
+ padding: 2rem 2.5rem 3rem !important;
49
+ max-width: 1200px !important;
50
+ }
51
+
52
+ /* ── Typography ─────────────────────────────────────────────────── */
53
+ html, body, [class*="css"], [class*="st-"] {
54
+ font-family: 'JetBrains Mono', monospace !important;
55
+ color: #e8f5ec !important;
56
+ }
57
+ h1, h2, h3 {
58
+ font-family: 'Oxanium', sans-serif !important;
59
+ text-transform: uppercase !important;
60
+ letter-spacing: 2.5px !important;
61
+ color: #e8f5ec !important;
62
+ }
63
+ h1 {
64
+ font-weight: 800 !important;
65
+ font-size: 2rem !important;
66
+ text-shadow: 0 0 24px #00ff8840 !important;
67
+ line-height: 1.2 !important;
68
+ }
69
+ h2 {
70
+ font-weight: 700 !important;
71
+ color: #00cc6a !important;
72
+ font-size: 1.15rem !important;
73
+ }
74
+ h3 {
75
+ font-weight: 600 !important;
76
+ color: #00ff8899 !important;
77
+ font-size: 0.95rem !important;
78
+ }
79
+ /* Nur spezifische Streamlit-Textcontainer überschreiben, nicht generisch div/span */
80
+ .stMarkdown p, .stMarkdown li, .stMarkdown a { color: #e8f5ec !important; }
81
+ .stText p { color: #e8f5ec !important; }
82
+ [data-testid="stCaptionContainer"] p {
83
+ color: #5a9a72 !important;
84
+ font-size: 0.77rem !important;
85
+ letter-spacing: 0.5px !important;
86
+ }
87
+
88
+ /* ── Sidebar ────────────────────────────────────────────────────── */
89
+ [data-testid="stSidebar"] {
90
+ background-color: #060d08 !important;
91
+ border-right: 1px solid #00ff8828 !important;
92
+ background-image:
93
+ radial-gradient(circle, #00ff8820 1.5px, transparent 1.5px),
94
+ radial-gradient(circle at 12px 12px, #00ff8810 1px, transparent 1px) !important;
95
+ background-size: 24px 24px, 24px 24px !important;
96
+ }
97
+ [data-testid="stSidebar"] > div:first-child { padding-top: 1.5rem; }
98
+ [data-testid="stSidebarUserContent"] h1 {
99
+ font-size: 1.15rem !important;
100
+ color: #00ff88 !important;
101
+ text-shadow: 0 0 14px #00ff8870 !important;
102
+ }
103
+ [data-testid="stSidebarUserContent"] label {
104
+ font-size: 0.72rem !important;
105
+ color: #2e6645 !important;
106
+ text-transform: uppercase !important;
107
+ letter-spacing: 1.5px !important;
108
+ }
109
+
110
+ /* ── Selectbox / Input ──────────────────────────────────────────── */
111
+ [data-testid="stSelectbox"] > div > div,
112
+ [data-testid="stTextInput"] > div > div > input {
113
+ background-color: #0d1a10 !important;
114
+ border: 1px solid #00ff8835 !important;
115
+ border-radius: 3px !important;
116
+ color: #e8f5ec !important;
117
+ font-size: 0.82rem !important;
118
+ }
119
+ [data-testid="stSelectbox"] > div > div:focus-within {
120
+ border-color: #00ff8890 !important;
121
+ box-shadow: 0 0 0 2px #00ff8818 !important;
122
+ }
123
+
124
+ /* ── Metric cards ───────────────────────────────────────────────── */
125
+ [data-testid="stMetric"] {
126
+ background: rgba(0,255,136,0.035) !important;
127
+ border: 1px solid #00ff8830 !important;
128
+ border-radius: 5px !important;
129
+ padding: 1.1rem 1.3rem !important;
130
+ backdrop-filter: blur(6px) !important;
131
+ animation: neon-pulse 3.5s ease-in-out infinite !important;
132
+ transition: border-color 0.2s, background 0.2s !important;
133
+ }
134
+ [data-testid="stMetric"]:hover {
135
+ border-color: #00ff8875 !important;
136
+ background: rgba(0,255,136,0.06) !important;
137
+ }
138
+ [data-testid="stMetricValue"] {
139
+ font-family: 'JetBrains Mono', monospace !important;
140
+ font-size: 2rem !important;
141
+ font-weight: 700 !important;
142
+ color: #00ff88 !important;
143
+ text-shadow: 0 0 14px #00ff8855 !important;
144
+ animation: data-in 0.45s ease both !important;
145
+ }
146
+ [data-testid="stMetricLabel"] {
147
+ font-size: 0.68rem !important;
148
+ font-weight: 400 !important;
149
+ color: #2e6645 !important;
150
+ text-transform: uppercase !important;
151
+ letter-spacing: 2px !important;
152
+ }
153
+
154
+ /* ── Tabs ───────────────────────────────────────────────────────── */
155
+ [data-testid="stTabs"] [role="tablist"] {
156
+ gap: 0 !important;
157
+ border-bottom: 1px solid #00ff8820 !important;
158
+ padding-bottom: 0 !important;
159
+ }
160
+ [data-testid="stTabs"] button[role="tab"] {
161
+ border-radius: 0 !important;
162
+ font-family: 'Oxanium', sans-serif !important;
163
+ font-weight: 600 !important;
164
+ font-size: 0.78rem !important;
165
+ text-transform: uppercase !important;
166
+ letter-spacing: 2px !important;
167
+ padding: 0.6rem 1.2rem !important;
168
+ color: #2e6645 !important;
169
+ border: none !important;
170
+ border-bottom: 2px solid transparent !important;
171
+ background: transparent !important;
172
+ transition: color 0.15s, border-color 0.15s !important;
173
+ }
174
+ [data-testid="stTabs"] button[role="tab"]:hover {
175
+ color: #7fffbe !important;
176
+ background: rgba(0,255,136,0.04) !important;
177
+ }
178
+ [data-testid="stTabs"] button[role="tab"][aria-selected="true"] {
179
+ color: #00ff88 !important;
180
+ border-bottom-color: #00ff88 !important;
181
+ text-shadow: 0 0 10px #00ff8840 !important;
182
+ background: rgba(0,255,136,0.05) !important;
183
+ margin-bottom: -2px !important;
184
+ }
185
+
186
+ /* ── Alerts ─────────────────────────────────────────────────────── */
187
+ [data-testid="stAlert"] {
188
+ background: rgba(0,255,136,0.04) !important;
189
+ border: 1px solid #00ff8825 !important;
190
+ border-left: 3px solid #00ff88 !important;
191
+ border-radius: 3px !important;
192
+ font-size: 0.83rem !important;
193
+ }
194
+
195
+ /* ── Buttons ────────────────────────────────────────────────────── */
196
+ [data-testid="stButton"] > button {
197
+ background: transparent !important;
198
+ border: 1px solid #00ff8850 !important;
199
+ border-radius: 2px !important;
200
+ color: #00ff88 !important;
201
+ font-family: 'Oxanium', sans-serif !important;
202
+ font-weight: 700 !important;
203
+ font-size: 0.82rem !important;
204
+ text-transform: uppercase !important;
205
+ letter-spacing: 1.5px !important;
206
+ transition: all 0.18s !important;
207
+ padding: 0.5rem 1.4rem !important;
208
+ }
209
+ [data-testid="stButton"] > button:hover {
210
+ background: rgba(0,255,136,0.1) !important;
211
+ border-color: #00ff88 !important;
212
+ box-shadow: 0 0 14px #00ff8835 !important;
213
+ transform: translateY(-1px) !important;
214
+ }
215
+ [data-testid="stButton"] > button[kind="primary"] {
216
+ background: rgba(0,255,136,0.12) !important;
217
+ border-color: #00ff88 !important;
218
+ box-shadow: 0 0 10px #00ff8825 !important;
219
+ }
220
+
221
+ /* ── Sidebar collapse button ────────────────────────────────────── */
222
+ [data-testid="stSidebarCollapseButton"] span,
223
+ [data-testid="stSidebarCollapseButton"] p,
224
+ [data-testid="stSidebarCollapseButton"] [data-testid="stIconMaterial"],
225
+ [data-testid="stSidebarCollapsedControl"] span,
226
+ [data-testid="stSidebarCollapsedControl"] [data-testid="stIconMaterial"] {
227
+ font-size: 0 !important;
228
+ color: transparent !important;
229
+ }
230
+ [data-testid="stSidebarCollapseButton"] button::after {
231
+ content: "Sidebar Einklappen" !important;
232
+ font-size: 0.7rem !important;
233
+ font-family: 'Oxanium', sans-serif !important;
234
+ color: #00ff8870 !important;
235
+ letter-spacing: 1.5px !important;
236
+ text-transform: uppercase !important;
237
+ pointer-events: none !important;
238
+ }
239
+
240
+ /* ── Divider ────────────────────────────────────────────────────── */
241
+ hr {
242
+ border: none !important;
243
+ height: 1px !important;
244
+ background: linear-gradient(to right, transparent, #00ff8830, transparent) !important;
245
+ margin: 0.9rem 0 !important;
246
+ }
247
+
248
+ /* ── Spinner ────────────────────────────────────────────────────── */
249
+ [data-testid="stSpinner"] > div {
250
+ border-top-color: #00ff88 !important;
251
+ filter: drop-shadow(0 0 5px #00ff88) !important;
252
+ }
253
+
254
+ /* ── Hero ───────────────────────────────────────────────────────── */
255
+ .at-hero {
256
+ background: linear-gradient(135deg, #001408 0%, #002a12 50%, #001408 100%) !important;
257
+ border: 1px solid #00ff8828 !important;
258
+ border-top: 2px solid #00ff8870 !important;
259
+ border-radius: 5px !important;
260
+ padding: 2.5rem 2rem !important;
261
+ margin-bottom: 2rem !important;
262
+ position: relative !important;
263
+ overflow: hidden !important;
264
+ }
265
+ .at-hero::before {
266
+ content: '' !important;
267
+ position: absolute !important; inset: 0 !important;
268
+ background: repeating-linear-gradient(
269
+ 0deg, transparent, transparent 3px,
270
+ rgba(0,255,136,0.012) 3px, rgba(0,255,136,0.012) 4px
271
+ ) !important;
272
+ pointer-events: none !important;
273
+ }
274
+ .at-hero::after {
275
+ content: '' !important;
276
+ position: absolute !important;
277
+ width: 280px; height: 280px !important;
278
+ top: -90px; right: -40px !important;
279
+ background: radial-gradient(circle, #00ff8818 0%, transparent 65%) !important;
280
+ pointer-events: none !important;
281
+ }
282
+ .at-hero h1, .at-hero p { margin: 0 !important; color: #ffffff !important; }
283
+ .at-hero h1 {
284
+ font-family: 'Oxanium', sans-serif !important;
285
+ color: #00ff88 !important;
286
+ text-shadow: 0 0 30px #00ff8855 !important;
287
+ font-size: 2.2rem !important;
288
+ margin-bottom: 0.5rem !important;
289
+ }
290
+ .at-hero p {
291
+ color: #4a9966 !important;
292
+ font-size: 0.95rem !important;
293
+ opacity: 0.9 !important;
294
+ }
295
+
296
+ /* ── Feature boxes ──────────────────────────────────────────────── */
297
+ .at-feature-icon { font-size: 1.8rem; margin-bottom: 0.4rem; }
298
+ .at-feature-box {
299
+ background: rgba(0,255,136,0.025) !important;
300
+ border: 1px solid #00ff8820 !important;
301
+ border-top: 2px solid #00ff8855 !important;
302
+ border-radius: 4px !important;
303
+ padding: 1.2rem 1rem !important;
304
+ text-align: center !important;
305
+ height: 100% !important;
306
+ transition: background 0.18s, border-color 0.18s !important;
307
+ }
308
+ .at-feature-box:hover {
309
+ background: rgba(0,255,136,0.05) !important;
310
+ border-top-color: #00ff88 !important;
311
+ }
312
+ .at-feature-box strong {
313
+ font-family: 'Oxanium', sans-serif !important;
314
+ color: #c8f5dc !important;
315
+ font-size: 0.88rem !important;
316
+ text-transform: uppercase !important;
317
+ letter-spacing: 0.8px !important;
318
+ }
319
+ .at-feature-box p {
320
+ color: #2e6645 !important;
321
+ font-size: 0.8rem !important;
322
+ margin: 0 !important;
323
+ }
324
+
325
+ /* ── Cards ──────────────────────────────────────────────────────── */
326
+ .at-card {
327
+ background: rgba(6,13,8,0.9) !important;
328
+ border: 1px solid #00ff8825 !important;
329
+ border-radius: 5px !important;
330
+ padding: 1.5rem 1.75rem !important;
331
+ margin-bottom: 1.25rem !important;
332
+ backdrop-filter: blur(4px) !important;
333
+ transition: border-color 0.2s !important;
334
+ }
335
+ .at-card:hover { border-color: #00ff8848 !important; }
336
+ .at-card h3 {
337
+ margin-top: 0 !important;
338
+ color: #00ff88 !important;
339
+ text-shadow: 0 0 8px #00ff8830 !important;
340
+ }
341
+
342
+ /* ── Mobile ─────────────────────────────────────────────────────── */
343
+ @media (max-width: 768px) {
344
+ .main .block-container { padding: 1rem 0.9rem 2rem !important; }
345
+ h1 { font-size: 1.5rem !important; }
346
+ [data-testid="stHorizontalBlock"] { flex-wrap: wrap !important; gap: 0.6rem !important; }
347
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] {
348
+ min-width: calc(50% - 0.3rem) !important;
349
+ flex: 1 1 calc(50% - 0.3rem) !important;
350
+ }
351
+ [data-testid="stTabs"] button[role="tab"] { padding: 0.4rem 0.65rem !important; font-size: 0.72rem !important; }
352
+ .at-hero { padding: 1.5rem 1.25rem !important; }
353
+ .at-hero h1 { font-size: 1.5rem !important; }
354
+ .at-card { padding: 1.1rem 1.2rem !important; }
355
+ }
356
+ @media (max-width: 480px) {
357
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] { min-width: 100% !important; }
358
+ }
359
+ </style>
360
+ """
361
+
362
+ _HIDE_SIDEBAR_CSS = """
363
+ <style>
364
+ [data-testid="stSidebar"] { display: none !important; }
365
+ [data-testid="stSidebarCollapsedControl"]{ display: none !important; }
366
+ .main .block-container { max-width: 900px !important; margin: 0 auto !important; }
367
+ </style>
368
+ """
369
+
370
+
371
+ def inject_css() -> None:
372
+ st.markdown(_CSS, unsafe_allow_html=True)
373
+
374
+
375
+ def hide_sidebar() -> None:
376
+ st.markdown(_HIDE_SIDEBAR_CSS, unsafe_allow_html=True)
src/styles_v2_fieldguide.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Variant 2 — Field Guide
3
+ Historisches Naturforschertagebuch. Pergament, Tinte, botanische Ästhetik.
4
+ Wie Alexander von Humboldt auf einem Tablet.
5
+
6
+ config.toml:
7
+ [theme]
8
+ primaryColor = "#2d5016"
9
+ backgroundColor = "#faf5e4"
10
+ secondaryBackgroundColor = "#f0e8d0"
11
+ textColor = "#2a1f0e"
12
+ font = "serif"
13
+ """
14
+ import streamlit as st
15
+
16
+ _CSS = """
17
+ <style>
18
+ @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,400;1,600&family=IM+Fell+English:ital@0;1&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap');
19
+
20
+ /* ── Base ───────────────────────────────────────────────────────── */
21
+ .stApp {
22
+ background-color: #faf5e4 !important;
23
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E") !important;
24
+ }
25
+ html, body { background-color: #faf5e4 !important; }
26
+
27
+ /* ── Main container ─────────────────────────────────────────────── */
28
+ .main .block-container {
29
+ padding: 2rem 2.5rem 3rem !important;
30
+ max-width: 1200px !important;
31
+ }
32
+
33
+ /* ── Typography ─────────────────────────────────────────────────── */
34
+ html, body, [class*="css"], [class*="st-"] {
35
+ font-family: 'IM Fell English', Georgia, serif !important;
36
+ color: #2a1f0e !important;
37
+ }
38
+ h1, h2, h3 {
39
+ font-family: 'Cormorant Garamond', Georgia, serif !important;
40
+ color: #1a1008 !important;
41
+ }
42
+ h1 {
43
+ font-weight: 700 !important;
44
+ font-style: italic !important;
45
+ font-size: 2.2rem !important;
46
+ letter-spacing: 0.5px !important;
47
+ line-height: 1.2 !important;
48
+ }
49
+ h2 {
50
+ font-weight: 600 !important;
51
+ font-size: 1.4rem !important;
52
+ color: #2d5016 !important;
53
+ letter-spacing: 0.3px !important;
54
+ text-transform: uppercase !important;
55
+ letter-spacing: 2px !important;
56
+ font-style: normal !important;
57
+ }
58
+ h3 {
59
+ font-weight: 600 !important;
60
+ font-size: 1.1rem !important;
61
+ font-style: italic !important;
62
+ }
63
+ [data-testid="stCaptionContainer"] p {
64
+ font-family: 'Space Mono', monospace !important;
65
+ color: #8b6e4e !important;
66
+ font-size: 0.75rem !important;
67
+ font-style: italic !important;
68
+ }
69
+
70
+ /* ── Sidebar ────────────────────────────────────────────────────── */
71
+ [data-testid="stSidebar"] {
72
+ background-color: #f5ecda !important;
73
+ border-right: 2px solid #c4a882 !important;
74
+ border-right-style: solid !important;
75
+ }
76
+ [data-testid="stSidebar"]::after {
77
+ content: '' !important;
78
+ position: absolute !important;
79
+ top: 0; right: -8px; bottom: 0 !important;
80
+ width: 6px !important;
81
+ background: linear-gradient(to right, rgba(196,168,130,0.2), transparent) !important;
82
+ pointer-events: none !important;
83
+ }
84
+ [data-testid="stSidebar"] > div:first-child { padding-top: 1.5rem; }
85
+ [data-testid="stSidebarUserContent"] h1 {
86
+ font-family: 'Cormorant Garamond', serif !important;
87
+ font-size: 1.3rem !important;
88
+ font-style: italic !important;
89
+ color: #2d5016 !important;
90
+ }
91
+ [data-testid="stSidebarUserContent"] label {
92
+ font-family: 'Space Mono', monospace !important;
93
+ font-size: 0.7rem !important;
94
+ color: #7a5c3a !important;
95
+ text-transform: uppercase !important;
96
+ letter-spacing: 1px !important;
97
+ }
98
+
99
+ /* ── Selectbox / Input ──────────────────────────────────────────── */
100
+ [data-testid="stSelectbox"] > div > div,
101
+ [data-testid="stTextInput"] > div > div > input {
102
+ background-color: #fdf8ee !important;
103
+ border: 1px dashed #c4a882 !important;
104
+ border-radius: 3px !important;
105
+ color: #2a1f0e !important;
106
+ font-family: 'Space Mono', monospace !important;
107
+ font-size: 0.8rem !important;
108
+ }
109
+ [data-testid="stSelectbox"] > div > div:focus-within {
110
+ border-color: #2d5016 !important;
111
+ border-style: solid !important;
112
+ box-shadow: 0 0 0 2px rgba(45,80,22,0.1) !important;
113
+ }
114
+
115
+ /* ── Metric cards ───────────────────────────────────────────────── */
116
+ [data-testid="stMetric"] {
117
+ background: #fdf8ee !important;
118
+ border: 1px dashed #c4a882 !important;
119
+ border-radius: 4px !important;
120
+ padding: 1.1rem 1.3rem !important;
121
+ box-shadow: 2px 3px 10px rgba(139,90,43,0.12) !important;
122
+ position: relative !important;
123
+ }
124
+ [data-testid="stMetricValue"] {
125
+ font-family: 'Space Mono', monospace !important;
126
+ font-size: 1.85rem !important;
127
+ font-weight: 700 !important;
128
+ color: #2d5016 !important;
129
+ }
130
+ [data-testid="stMetricLabel"] {
131
+ font-family: 'Cormorant Garamond', serif !important;
132
+ font-size: 0.9rem !important;
133
+ font-style: italic !important;
134
+ font-weight: 400 !important;
135
+ color: #8b6e4e !important;
136
+ text-transform: none !important;
137
+ letter-spacing: 0.2px !important;
138
+ }
139
+
140
+ /* ── Tabs ───────────────────────────────────────────────────────── */
141
+ [data-testid="stTabs"] [role="tablist"] {
142
+ gap: 4px !important;
143
+ border-bottom: 1px solid #c4a882 !important;
144
+ border-bottom-style: dashed !important;
145
+ padding-bottom: 0 !important;
146
+ }
147
+ [data-testid="stTabs"] button[role="tab"] {
148
+ border-radius: 4px 4px 0 0 !important;
149
+ font-family: 'Cormorant Garamond', serif !important;
150
+ font-weight: 600 !important;
151
+ font-size: 1rem !important;
152
+ font-style: italic !important;
153
+ padding: 0.5rem 1.1rem !important;
154
+ color: #8b6e4e !important;
155
+ border: 1px dashed transparent !important;
156
+ border-bottom: none !important;
157
+ background: transparent !important;
158
+ transition: color 0.15s, background 0.15s !important;
159
+ }
160
+ [data-testid="stTabs"] button[role="tab"]:hover {
161
+ color: #2d5016 !important;
162
+ background: rgba(45,80,22,0.05) !important;
163
+ }
164
+ [data-testid="stTabs"] button[role="tab"][aria-selected="true"] {
165
+ color: #2d5016 !important;
166
+ font-weight: 700 !important;
167
+ background: #fdf8ee !important;
168
+ border-color: #c4a882 !important;
169
+ border-bottom-color: #fdf8ee !important;
170
+ margin-bottom: -2px !important;
171
+ }
172
+
173
+ /* ── Alerts ─────────────────────────────────────────────────────── */
174
+ [data-testid="stAlert"] {
175
+ background: #fdf8ee !important;
176
+ border: 1px dashed #c4a882 !important;
177
+ border-left: 3px solid #8b3a3a !important;
178
+ border-left-style: solid !important;
179
+ border-radius: 4px !important;
180
+ font-family: 'IM Fell English', serif !important;
181
+ }
182
+
183
+ /* ── Buttons ────────────────────────────────────────────────────── */
184
+ [data-testid="stButton"] > button {
185
+ background: #f0e8d0 !important;
186
+ border: 1px solid #c4a882 !important;
187
+ border-radius: 3px !important;
188
+ color: #2d5016 !important;
189
+ font-family: 'Cormorant Garamond', serif !important;
190
+ font-weight: 600 !important;
191
+ font-style: italic !important;
192
+ font-size: 1rem !important;
193
+ transition: all 0.15s !important;
194
+ padding: 0.45rem 1.4rem !important;
195
+ letter-spacing: 0.3px !important;
196
+ }
197
+ [data-testid="stButton"] > button:hover {
198
+ background: #e8dcc0 !important;
199
+ border-color: #8b6e4e !important;
200
+ transform: translateY(-1px) !important;
201
+ box-shadow: 2px 3px 8px rgba(139,90,43,0.18) !important;
202
+ }
203
+ [data-testid="stButton"] > button[kind="primary"] {
204
+ background: #2d5016 !important;
205
+ color: #faf5e4 !important;
206
+ border-color: #1a3009 !important;
207
+ }
208
+
209
+ /* ── Divider ────────────────────────────────────────────────────── */
210
+ hr {
211
+ border: none !important;
212
+ height: 1px !important;
213
+ background: linear-gradient(to right, transparent, #c4a882 30%, #c4a882 70%, transparent) !important;
214
+ margin: 1rem 0 !important;
215
+ }
216
+
217
+ /* ── Spinner ────────────────────────────────────────────────────── */
218
+ [data-testid="stSpinner"] > div { border-top-color: #2d5016 !important; }
219
+
220
+ /* ── Hero ───────────────────────────────────────────────────────── */
221
+ .at-hero {
222
+ background: linear-gradient(135deg, #2d5016 0%, #3d7020 60%, #2d5016 100%) !important;
223
+ border: 2px solid #1a3009 !important;
224
+ border-radius: 5px !important;
225
+ padding: 2.5rem 2rem !important;
226
+ margin-bottom: 2rem !important;
227
+ position: relative !important;
228
+ overflow: hidden !important;
229
+ box-shadow: 4px 6px 20px rgba(45,80,22,0.25) !important;
230
+ }
231
+ .at-hero::before {
232
+ content: '' !important;
233
+ position: absolute !important; inset: 0 !important;
234
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E") !important;
235
+ pointer-events: none !important;
236
+ }
237
+ .at-hero h1, .at-hero p { margin: 0 !important; color: #faf5e4 !important; }
238
+ .at-hero h1 {
239
+ font-family: 'Cormorant Garamond', serif !important;
240
+ color: #faf5e4 !important;
241
+ font-style: italic !important;
242
+ text-shadow: none !important;
243
+ font-size: 2.2rem !important;
244
+ font-weight: 700 !important;
245
+ letter-spacing: 1px !important;
246
+ margin-bottom: 0.5rem !important;
247
+ }
248
+ .at-hero p {
249
+ color: #b8d4a8 !important;
250
+ font-family: 'IM Fell English', serif !important;
251
+ font-style: italic !important;
252
+ font-size: 1.05rem !important;
253
+ opacity: 0.92 !important;
254
+ }
255
+
256
+ /* ── Feature boxes ──────────────────────────────────────────────── */
257
+ .at-feature-icon { font-size: 1.8rem; margin-bottom: 0.4rem; }
258
+ .at-feature-box {
259
+ background: #fdf8ee !important;
260
+ border: 1px dashed #c4a882 !important;
261
+ border-radius: 4px !important;
262
+ padding: 1.2rem 1rem !important;
263
+ text-align: center !important;
264
+ height: 100% !important;
265
+ box-shadow: 1px 2px 6px rgba(139,90,43,0.1) !important;
266
+ }
267
+ .at-feature-box strong {
268
+ font-family: 'Cormorant Garamond', serif !important;
269
+ color: #1a1008 !important;
270
+ font-size: 1.05rem !important;
271
+ font-weight: 700 !important;
272
+ font-style: italic !important;
273
+ }
274
+ .at-feature-box p {
275
+ color: #7a5c3a !important;
276
+ font-family: 'IM Fell English', serif !important;
277
+ font-style: italic !important;
278
+ font-size: 0.88rem !important;
279
+ margin: 0 !important;
280
+ }
281
+
282
+ /* ── Cards ──────────────────────────────────────────────────────── */
283
+ .at-card {
284
+ background: #fdf8ee !important;
285
+ border: 1px dashed #c4a882 !important;
286
+ border-radius: 5px !important;
287
+ padding: 1.5rem 1.75rem !important;
288
+ margin-bottom: 1.25rem !important;
289
+ box-shadow: 2px 3px 10px rgba(139,90,43,0.1) !important;
290
+ }
291
+ .at-card h3 {
292
+ margin-top: 0 !important;
293
+ color: #2d5016 !important;
294
+ font-style: italic !important;
295
+ }
296
+
297
+ /* ── Mobile ─────────────────────────────────────────────────────── */
298
+ @media (max-width: 768px) {
299
+ .main .block-container { padding: 1rem 0.9rem 2rem !important; }
300
+ h1 { font-size: 1.7rem !important; }
301
+ [data-testid="stHorizontalBlock"] { flex-wrap: wrap !important; gap: 0.6rem !important; }
302
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] {
303
+ min-width: calc(50% - 0.3rem) !important;
304
+ flex: 1 1 calc(50% - 0.3rem) !important;
305
+ }
306
+ [data-testid="stTabs"] button[role="tab"] { padding: 0.4rem 0.65rem !important; font-size: 0.88rem !important; }
307
+ .at-hero { padding: 1.5rem 1.25rem !important; }
308
+ .at-hero h1 { font-size: 1.6rem !important; }
309
+ .at-card { padding: 1.1rem 1.2rem !important; }
310
+ }
311
+ @media (max-width: 480px) {
312
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] { min-width: 100% !important; }
313
+ }
314
+ </style>
315
+ """
316
+
317
+ _HIDE_SIDEBAR_CSS = """
318
+ <style>
319
+ [data-testid="stSidebar"] { display: none !important; }
320
+ [data-testid="stSidebarCollapsedControl"]{ display: none !important; }
321
+ .main .block-container { max-width: 900px !important; margin: 0 auto !important; }
322
+ </style>
323
+ """
324
+
325
+
326
+ def inject_css() -> None:
327
+ st.markdown(_CSS, unsafe_allow_html=True)
328
+
329
+
330
+ def hide_sidebar() -> None:
331
+ st.markdown(_HIDE_SIDEBAR_CSS, unsafe_allow_html=True)
src/styles_v3_scientific.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Variant 3 — Scientific Observatory
3
+ Präzisions-Datenzentrum. Klar, kantig, kompromisslos.
4
+ Wie eine Seite aus Nature — aber interaktiv.
5
+
6
+ config.toml:
7
+ [theme]
8
+ primaryColor = "#0056b3"
9
+ backgroundColor = "#f8fafc"
10
+ secondaryBackgroundColor = "#ffffff"
11
+ textColor = "#212529"
12
+ font = "sans serif"
13
+ """
14
+ import streamlit as st
15
+
16
+ _CSS = """
17
+ <style>
18
+ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
19
+
20
+ /* ── Base ───────────────────────────────────────────────────────── */
21
+ .stApp { background-color: #f8fafc !important; }
22
+ html, body { background-color: #f8fafc !important; }
23
+
24
+ /* ── Main container ─────────────────────────────────────────────── */
25
+ .main .block-container {
26
+ padding: 2rem 2.5rem 3rem !important;
27
+ max-width: 1200px !important;
28
+ }
29
+
30
+ /* ── Typography ─────────────────────────────────────────────────── */
31
+ html, body, [class*="css"], [class*="st-"] {
32
+ font-family: 'IBM Plex Sans', sans-serif !important;
33
+ color: #212529 !important;
34
+ }
35
+ h1 {
36
+ font-weight: 700 !important;
37
+ font-size: 1.85rem !important;
38
+ letter-spacing: -0.2px !important;
39
+ line-height: 1.2 !important;
40
+ color: #0d1117 !important;
41
+ text-transform: uppercase !important;
42
+ letter-spacing: 3px !important;
43
+ }
44
+ h2 {
45
+ font-weight: 600 !important;
46
+ font-size: 1.05rem !important;
47
+ color: #0056b3 !important;
48
+ text-transform: uppercase !important;
49
+ letter-spacing: 2.5px !important;
50
+ border-bottom: 2px solid #0056b3 !important;
51
+ padding-bottom: 0.35rem !important;
52
+ }
53
+ h3 {
54
+ font-weight: 600 !important;
55
+ font-size: 0.9rem !important;
56
+ text-transform: uppercase !important;
57
+ letter-spacing: 1.5px !important;
58
+ color: #495057 !important;
59
+ }
60
+ [data-testid="stCaptionContainer"] p {
61
+ font-family: 'IBM Plex Mono', monospace !important;
62
+ color: #6c757d !important;
63
+ font-size: 0.75rem !important;
64
+ }
65
+
66
+ /* ── Sidebar ────────────────────────────────────────────────────── */
67
+ [data-testid="stSidebar"] {
68
+ background-color: #ffffff !important;
69
+ border-right: 2px solid #0056b3 !important;
70
+ }
71
+ [data-testid="stSidebar"] > div:first-child { padding-top: 1.5rem; }
72
+ [data-testid="stSidebarUserContent"] h1 {
73
+ font-size: 0.95rem !important;
74
+ font-weight: 700 !important;
75
+ text-transform: uppercase !important;
76
+ letter-spacing: 2.5px !important;
77
+ color: #0056b3 !important;
78
+ border-bottom: 1px solid #dee2e6 !important;
79
+ padding-bottom: 0.5rem !important;
80
+ }
81
+ [data-testid="stSidebarUserContent"] label {
82
+ font-family: 'IBM Plex Mono', monospace !important;
83
+ font-size: 0.7rem !important;
84
+ color: #6c757d !important;
85
+ text-transform: uppercase !important;
86
+ letter-spacing: 1.5px !important;
87
+ font-weight: 500 !important;
88
+ }
89
+
90
+ /* ── Selectbox / Input ──────────────────────────────────────────── */
91
+ [data-testid="stSelectbox"] > div > div,
92
+ [data-testid="stTextInput"] > div > div > input {
93
+ background-color: #ffffff !important;
94
+ border: 1px solid #ced4da !important;
95
+ border-radius: 0 !important;
96
+ color: #212529 !important;
97
+ font-family: 'IBM Plex Mono', monospace !important;
98
+ font-size: 0.82rem !important;
99
+ }
100
+ [data-testid="stSelectbox"] > div > div:focus-within {
101
+ border-color: #0056b3 !important;
102
+ box-shadow: 0 0 0 3px rgba(0,86,179,0.12) !important;
103
+ }
104
+
105
+ /* ── Metric cards ───────────────────────────────────────────────── */
106
+ [data-testid="stMetric"] {
107
+ background: #ffffff !important;
108
+ border: 1px solid #dee2e6 !important;
109
+ border-top: 4px solid #0056b3 !important;
110
+ border-radius: 0 !important;
111
+ padding: 1.1rem 1.3rem !important;
112
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06) !important;
113
+ }
114
+ [data-testid="stMetricValue"] {
115
+ font-family: 'IBM Plex Mono', monospace !important;
116
+ font-size: 1.9rem !important;
117
+ font-weight: 600 !important;
118
+ color: #0056b3 !important;
119
+ }
120
+ [data-testid="stMetricLabel"] {
121
+ font-family: 'IBM Plex Mono', monospace !important;
122
+ font-size: 0.65rem !important;
123
+ font-weight: 500 !important;
124
+ color: #6c757d !important;
125
+ text-transform: uppercase !important;
126
+ letter-spacing: 2px !important;
127
+ }
128
+
129
+ /* ── Tabs ───────────────────────────────────────────────────────── */
130
+ [data-testid="stTabs"] [role="tablist"] {
131
+ gap: 0 !important;
132
+ border-bottom: 2px solid #dee2e6 !important;
133
+ padding-bottom: 0 !important;
134
+ }
135
+ [data-testid="stTabs"] button[role="tab"] {
136
+ border-radius: 0 !important;
137
+ font-family: 'IBM Plex Sans', sans-serif !important;
138
+ font-weight: 600 !important;
139
+ font-size: 0.75rem !important;
140
+ text-transform: uppercase !important;
141
+ letter-spacing: 2px !important;
142
+ padding: 0.6rem 1.2rem !important;
143
+ color: #6c757d !important;
144
+ border: none !important;
145
+ border-bottom: 3px solid transparent !important;
146
+ background: transparent !important;
147
+ transition: color 0.12s, border-color 0.12s !important;
148
+ }
149
+ [data-testid="stTabs"] button[role="tab"]:hover {
150
+ color: #0056b3 !important;
151
+ background: rgba(0,86,179,0.04) !important;
152
+ }
153
+ [data-testid="stTabs"] button[role="tab"][aria-selected="true"] {
154
+ color: #0056b3 !important;
155
+ border-bottom-color: #0056b3 !important;
156
+ background: transparent !important;
157
+ margin-bottom: -2px !important;
158
+ }
159
+
160
+ /* ── Alerts ─────────────────────────────────────────────────────── */
161
+ [data-testid="stAlert"] {
162
+ background: #ffffff !important;
163
+ border: 1px solid #dee2e6 !important;
164
+ border-left: 4px solid #0056b3 !important;
165
+ border-radius: 0 !important;
166
+ font-size: 0.85rem !important;
167
+ }
168
+
169
+ /* ── Buttons ────────────────────────────────────────────────────── */
170
+ [data-testid="stButton"] > button {
171
+ background: #ffffff !important;
172
+ border: 1px solid #0056b3 !important;
173
+ border-radius: 0 !important;
174
+ color: #0056b3 !important;
175
+ font-family: 'IBM Plex Sans', sans-serif !important;
176
+ font-weight: 600 !important;
177
+ font-size: 0.8rem !important;
178
+ text-transform: uppercase !important;
179
+ letter-spacing: 1.5px !important;
180
+ transition: background 0.12s, color 0.12s !important;
181
+ padding: 0.5rem 1.4rem !important;
182
+ }
183
+ [data-testid="stButton"] > button:hover {
184
+ background: #0056b3 !important;
185
+ color: #ffffff !important;
186
+ }
187
+ [data-testid="stButton"] > button[kind="primary"] {
188
+ background: #0056b3 !important;
189
+ color: #ffffff !important;
190
+ border-color: #0046a0 !important;
191
+ padding: 0.5rem 1.6rem !important;
192
+ font-weight: 700 !important;
193
+ }
194
+ [data-testid="stButton"] > button[kind="primary"]:hover {
195
+ background: #0046a0 !important;
196
+ }
197
+
198
+ /* ── Divider ────────────────────────────────────────────────────── */
199
+ hr {
200
+ border: none !important;
201
+ border-top: 1px solid #dee2e6 !important;
202
+ margin: 0.8rem 0 !important;
203
+ }
204
+
205
+ /* ── Spinner ────────────────────────────────────────────────────── */
206
+ [data-testid="stSpinner"] > div { border-top-color: #0056b3 !important; }
207
+
208
+ /* ── Hero ───────────────────────────────────────────────────────── */
209
+ .at-hero {
210
+ background: #0056b3 !important;
211
+ border-radius: 0 !important;
212
+ padding: 2.5rem 2rem !important;
213
+ margin-bottom: 2rem !important;
214
+ border-left: 6px solid #002b6b !important;
215
+ position: relative !important;
216
+ }
217
+ .at-hero::after {
218
+ content: '' !important;
219
+ position: absolute !important;
220
+ top: 0; right: 0; bottom: 0 !important;
221
+ width: 4px !important;
222
+ background: #e63946 !important;
223
+ }
224
+ .at-hero h1, .at-hero p { margin: 0 !important; color: #ffffff !important; }
225
+ .at-hero h1 {
226
+ color: #ffffff !important;
227
+ font-size: 1.9rem !important;
228
+ font-weight: 700 !important;
229
+ letter-spacing: 4px !important;
230
+ text-transform: uppercase !important;
231
+ margin-bottom: 0.6rem !important;
232
+ }
233
+ .at-hero p {
234
+ font-family: 'IBM Plex Mono', monospace !important;
235
+ color: rgba(255,255,255,0.75) !important;
236
+ font-size: 0.85rem !important;
237
+ }
238
+
239
+ /* ── Feature boxes ──────────────────────────────────────────────── */
240
+ .at-feature-icon { font-size: 1.4rem; margin-bottom: 0.4rem; }
241
+ .at-feature-box {
242
+ background: #ffffff !important;
243
+ border: 1px solid #dee2e6 !important;
244
+ border-top: 3px solid #0056b3 !important;
245
+ border-radius: 0 !important;
246
+ padding: 1.2rem 1rem !important;
247
+ text-align: center !important;
248
+ height: 100% !important;
249
+ }
250
+ .at-feature-box strong {
251
+ font-family: 'IBM Plex Sans', sans-serif !important;
252
+ color: #0d1117 !important;
253
+ font-size: 0.82rem !important;
254
+ font-weight: 700 !important;
255
+ text-transform: uppercase !important;
256
+ letter-spacing: 1px !important;
257
+ }
258
+ .at-feature-box p {
259
+ font-family: 'IBM Plex Mono', monospace !important;
260
+ color: #6c757d !important;
261
+ font-size: 0.78rem !important;
262
+ margin: 0 !important;
263
+ }
264
+
265
+ /* ── Cards ──────────────────���───────────────────────────────────── */
266
+ .at-card {
267
+ background: #ffffff !important;
268
+ border: 1px solid #dee2e6 !important;
269
+ border-radius: 0 !important;
270
+ padding: 1.5rem 1.75rem !important;
271
+ margin-bottom: 1.25rem !important;
272
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05) !important;
273
+ }
274
+ .at-card h3 {
275
+ margin-top: 0 !important;
276
+ color: #0056b3 !important;
277
+ text-transform: uppercase !important;
278
+ letter-spacing: 1.5px !important;
279
+ font-style: normal !important;
280
+ }
281
+
282
+ /* ── Mobile ─────────────────────────────────────────────────────── */
283
+ @media (max-width: 768px) {
284
+ .main .block-container { padding: 1rem 0.9rem 2rem !important; }
285
+ h1 { font-size: 1.35rem !important; }
286
+ [data-testid="stHorizontalBlock"] { flex-wrap: wrap !important; gap: 0.6rem !important; }
287
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] {
288
+ min-width: calc(50% - 0.3rem) !important;
289
+ flex: 1 1 calc(50% - 0.3rem) !important;
290
+ }
291
+ [data-testid="stTabs"] button[role="tab"] { padding: 0.4rem 0.65rem !important; font-size: 0.7rem !important; }
292
+ .at-hero { padding: 1.5rem 1.25rem !important; }
293
+ .at-hero h1 { font-size: 1.3rem !important; }
294
+ .at-card { padding: 1.1rem 1.2rem !important; }
295
+ }
296
+ @media (max-width: 480px) {
297
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] { min-width: 100% !important; }
298
+ }
299
+ </style>
300
+ """
301
+
302
+ _HIDE_SIDEBAR_CSS = """
303
+ <style>
304
+ [data-testid="stSidebar"] { display: none !important; }
305
+ [data-testid="stSidebarCollapsedControl"]{ display: none !important; }
306
+ .main .block-container { max-width: 900px !important; margin: 0 auto !important; }
307
+ </style>
308
+ """
309
+
310
+
311
+ def inject_css() -> None:
312
+ st.markdown(_CSS, unsafe_allow_html=True)
313
+
314
+
315
+ def hide_sidebar() -> None:
316
+ st.markdown(_HIDE_SIDEBAR_CSS, unsafe_allow_html=True)
src/styles_v4_forestguardian.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Variant 4 — Deep Forest Guardian
3
+ Nächtliche Umweltüberwachungszentrale. Reiches Dunkelgrün,
4
+ warmes Amber, wie ein Leitstand tief im Wald.
5
+
6
+ config.toml:
7
+ [theme]
8
+ primaryColor = "#4caf50"
9
+ backgroundColor = "#0d1f0f"
10
+ secondaryBackgroundColor = "#142916"
11
+ textColor = "#c8e6c9"
12
+ font = "sans serif"
13
+ """
14
+ import streamlit as st
15
+
16
+ _CSS = """
17
+ <style>
18
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=Fira+Code:wght@400;500;600&display=swap');
19
+
20
+ /* ── Keyframes ──────────────────────────────────────────────────── */
21
+ @keyframes status-pulse {
22
+ 0%,100% { opacity: 1; box-shadow: 0 0 0 0 rgba(76,175,80,0.5); }
23
+ 50% { opacity: 0.85; box-shadow: 0 0 0 5px rgba(76,175,80,0); }
24
+ }
25
+ @keyframes amber-pulse {
26
+ 0%,100% { opacity: 1; box-shadow: 0 0 0 0 rgba(255,167,38,0.45); }
27
+ 50% { opacity: 0.8; box-shadow: 0 0 0 5px rgba(255,167,38,0); }
28
+ }
29
+ @keyframes slide-up {
30
+ from { opacity: 0; transform: translateY(8px); }
31
+ to { opacity: 1; transform: translateY(0); }
32
+ }
33
+
34
+ /* ── Base ───────────────────────────────────────────────────────── */
35
+ .stApp {
36
+ background-color: #0d1f0f !important;
37
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E") !important;
38
+ }
39
+ html, body { background-color: #0d1f0f !important; }
40
+ section.main, [data-testid="stAppViewContainer"] {
41
+ background-color: #0d1f0f !important;
42
+ }
43
+
44
+ /* ── Main container ─────────────────────────────────────────────── */
45
+ .main .block-container {
46
+ padding: 2rem 2.5rem 3rem !important;
47
+ max-width: 1200px !important;
48
+ }
49
+
50
+ /* ── Typography ─────────────────────────────────────────────────── */
51
+ html, body, [class*="css"], [class*="st-"] {
52
+ font-family: 'Nunito', sans-serif !important;
53
+ color: #c8e6c9 !important;
54
+ }
55
+ h1, h2, h3 {
56
+ font-family: 'Nunito', sans-serif !important;
57
+ color: #e8f5e9 !important;
58
+ }
59
+ h1 {
60
+ font-weight: 800 !important;
61
+ font-size: 2rem !important;
62
+ letter-spacing: -0.3px !important;
63
+ line-height: 1.2 !important;
64
+ }
65
+ h2 {
66
+ font-weight: 700 !important;
67
+ font-size: 1.15rem !important;
68
+ color: #81c784 !important;
69
+ }
70
+ h3 {
71
+ font-weight: 600 !important;
72
+ font-size: 1rem !important;
73
+ color: #a5d6a7 !important;
74
+ }
75
+ p, span, div { color: #c8e6c9 !important; }
76
+ [data-testid="stCaptionContainer"] p {
77
+ font-family: 'Fira Code', monospace !important;
78
+ color: #558b5a !important;
79
+ font-size: 0.76rem !important;
80
+ }
81
+
82
+ /* ── Sidebar ────────────────────────────────────────────────────── */
83
+ [data-testid="stSidebar"] {
84
+ background-color: #0a1a0b !important;
85
+ border-right: 1px solid #1e3a1f !important;
86
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E") !important;
87
+ }
88
+ [data-testid="stSidebar"] > div:first-child { padding-top: 1.5rem; }
89
+ [data-testid="stSidebarUserContent"] h1 {
90
+ font-size: 1.2rem !important;
91
+ font-weight: 800 !important;
92
+ color: #4caf50 !important;
93
+ }
94
+ [data-testid="stSidebarUserContent"] label {
95
+ font-family: 'Fira Code', monospace !important;
96
+ font-size: 0.72rem !important;
97
+ color: #558b5a !important;
98
+ text-transform: uppercase !important;
99
+ letter-spacing: 1px !important;
100
+ }
101
+
102
+ /* ── Selectbox / Input ──────────────────────────────────────────── */
103
+ [data-testid="stSelectbox"] > div > div,
104
+ [data-testid="stTextInput"] > div > div > input {
105
+ background-color: #142916 !important;
106
+ border: 1px solid #1e3a1f !important;
107
+ border-radius: 8px !important;
108
+ color: #c8e6c9 !important;
109
+ font-family: 'Fira Code', monospace !important;
110
+ font-size: 0.82rem !important;
111
+ }
112
+ [data-testid="stSelectbox"] > div > div:focus-within {
113
+ border-color: #4caf50 !important;
114
+ box-shadow: 0 0 0 2px rgba(76,175,80,0.2) !important;
115
+ }
116
+
117
+ /* ── Metric cards ────���──────────────────────────────────────────── */
118
+ [data-testid="stMetric"] {
119
+ background: #142916 !important;
120
+ border: 1px solid #1e3a1f !important;
121
+ border-left: 3px solid #4caf50 !important;
122
+ border-radius: 10px !important;
123
+ padding: 1.1rem 1.3rem !important;
124
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3) !important;
125
+ animation: slide-up 0.4s ease both !important;
126
+ transition: border-color 0.2s !important;
127
+ }
128
+ [data-testid="stMetric"]:hover {
129
+ border-left-color: #66bb6a !important;
130
+ background: #183318 !important;
131
+ }
132
+ [data-testid="stMetricValue"] {
133
+ font-family: 'Fira Code', monospace !important;
134
+ font-size: 1.9rem !important;
135
+ font-weight: 600 !important;
136
+ color: #4caf50 !important;
137
+ }
138
+ [data-testid="stMetricLabel"] {
139
+ font-family: 'Nunito', sans-serif !important;
140
+ font-size: 0.72rem !important;
141
+ font-weight: 700 !important;
142
+ color: #558b5a !important;
143
+ text-transform: uppercase !important;
144
+ letter-spacing: 1.5px !important;
145
+ }
146
+
147
+ /* ── Tabs ───────────────────────────────────────────────────────── */
148
+ [data-testid="stTabs"] [role="tablist"] {
149
+ gap: 4px !important;
150
+ border-bottom: 1px solid #1e3a1f !important;
151
+ padding-bottom: 0 !important;
152
+ }
153
+ [data-testid="stTabs"] button[role="tab"] {
154
+ border-radius: 8px 8px 0 0 !important;
155
+ font-family: 'Nunito', sans-serif !important;
156
+ font-weight: 700 !important;
157
+ font-size: 0.85rem !important;
158
+ padding: 0.55rem 1.1rem !important;
159
+ color: #558b5a !important;
160
+ border: 1px solid transparent !important;
161
+ border-bottom: none !important;
162
+ background: transparent !important;
163
+ transition: color 0.15s, background 0.15s !important;
164
+ }
165
+ [data-testid="stTabs"] button[role="tab"]:hover {
166
+ color: #81c784 !important;
167
+ background: rgba(76,175,80,0.08) !important;
168
+ }
169
+ [data-testid="stTabs"] button[role="tab"][aria-selected="true"] {
170
+ color: #ffa726 !important;
171
+ font-weight: 800 !important;
172
+ background: #142916 !important;
173
+ border-color: #1e3a1f !important;
174
+ border-bottom-color: #142916 !important;
175
+ margin-bottom: -1px !important;
176
+ }
177
+
178
+ /* ── Alerts ─────────────────────────────────────────────────────── */
179
+ [data-testid="stAlert"] {
180
+ background: #142916 !important;
181
+ border: 1px solid #1e3a1f !important;
182
+ border-left: 3px solid #ffa726 !important;
183
+ border-radius: 8px !important;
184
+ font-size: 0.85rem !important;
185
+ }
186
+
187
+ /* ── Buttons ────────────────────────────────────────────────────── */
188
+ [data-testid="stButton"] > button {
189
+ background: #142916 !important;
190
+ border: 1px solid #2d5a30 !important;
191
+ border-radius: 8px !important;
192
+ color: #4caf50 !important;
193
+ font-family: 'Nunito', sans-serif !important;
194
+ font-weight: 700 !important;
195
+ font-size: 0.9rem !important;
196
+ transition: all 0.18s !important;
197
+ padding: 0.5rem 1.4rem !important;
198
+ }
199
+ [data-testid="stButton"] > button:hover {
200
+ background: #1e3a1f !important;
201
+ border-color: #4caf50 !important;
202
+ color: #a5d6a7 !important;
203
+ transform: translateY(-1px) !important;
204
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important;
205
+ }
206
+ [data-testid="stButton"] > button[kind="primary"] {
207
+ background: #2d5a30 !important;
208
+ border-color: #4caf50 !important;
209
+ color: #e8f5e9 !important;
210
+ padding: 0.5rem 1.6rem !important;
211
+ }
212
+ [data-testid="stButton"] > button[kind="primary"]:hover {
213
+ background: #3d7a40 !important;
214
+ }
215
+
216
+ /* ── Divider ────────────────────────────────────────────────────── */
217
+ hr {
218
+ border: none !important;
219
+ border-top: 1px solid #1e3a1f !important;
220
+ margin: 0.8rem 0 !important;
221
+ }
222
+
223
+ /* ── Spinner ────────────────────────────────────────────────────── */
224
+ [data-testid="stSpinner"] > div {
225
+ border-top-color: #4caf50 !important;
226
+ }
227
+
228
+ /* ── Hero ───────────────────────────────────────────────────────── */
229
+ .at-hero {
230
+ background: radial-gradient(ellipse at 60% 40%, #1a4020 0%, #0d1f0f 65%) !important;
231
+ border: 1px solid #1e3a1f !important;
232
+ border-radius: 14px !important;
233
+ padding: 2.8rem 2rem !important;
234
+ margin-bottom: 2rem !important;
235
+ position: relative !important;
236
+ overflow: hidden !important;
237
+ box-shadow: 0 4px 24px rgba(0,0,0,0.4) !important;
238
+ }
239
+ .at-hero::before {
240
+ content: '' !important;
241
+ position: absolute !important;
242
+ width: 200px; height: 200px !important;
243
+ top: -50px; left: -50px !important;
244
+ background: radial-gradient(circle, rgba(76,175,80,0.15) 0%, transparent 65%) !important;
245
+ pointer-events: none !important;
246
+ }
247
+ .at-hero::after {
248
+ content: '' !important;
249
+ position: absolute !important;
250
+ width: 12px; height: 12px !important;
251
+ top: 1.2rem; right: 1.5rem !important;
252
+ border-radius: 50% !important;
253
+ background: #4caf50 !important;
254
+ animation: status-pulse 2.5s ease-in-out infinite !important;
255
+ }
256
+ .at-hero h1, .at-hero p { margin: 0 !important; color: #e8f5e9 !important; }
257
+ .at-hero h1 {
258
+ color: #e8f5e9 !important;
259
+ font-weight: 800 !important;
260
+ font-size: 2.2rem !important;
261
+ margin-bottom: 0.5rem !important;
262
+ }
263
+ .at-hero p {
264
+ color: #81c784 !important;
265
+ font-size: 1rem !important;
266
+ opacity: 0.88 !important;
267
+ }
268
+
269
+ /* ── Feature boxes ──────────────────────────────────────────────── */
270
+ .at-feature-icon { font-size: 1.8rem; margin-bottom: 0.4rem; }
271
+ .at-feature-box {
272
+ background: #142916 !important;
273
+ border: 1px solid #1e3a1f !important;
274
+ border-radius: 12px !important;
275
+ padding: 1.2rem 1rem !important;
276
+ text-align: center !important;
277
+ height: 100% !important;
278
+ transition: background 0.18s, border-color 0.18s !important;
279
+ }
280
+ .at-feature-box:hover {
281
+ background: #183318 !important;
282
+ border-color: #2d5a30 !important;
283
+ }
284
+ .at-feature-box strong {
285
+ font-family: 'Nunito', sans-serif !important;
286
+ color: #a5d6a7 !important;
287
+ font-size: 0.95rem !important;
288
+ font-weight: 700 !important;
289
+ }
290
+ .at-feature-box p {
291
+ color: #558b5a !important;
292
+ font-size: 0.85rem !important;
293
+ margin: 0 !important;
294
+ }
295
+
296
+ /* ── Cards ──────────────────────────────────────────────────────── */
297
+ .at-card {
298
+ background: #142916 !important;
299
+ border: 1px solid #1e3a1f !important;
300
+ border-left: 3px solid #4caf50 !important;
301
+ border-radius: 12px !important;
302
+ padding: 1.5rem 1.75rem !important;
303
+ margin-bottom: 1.25rem !important;
304
+ box-shadow: 0 2px 10px rgba(0,0,0,0.25) !important;
305
+ }
306
+ .at-card h3 {
307
+ margin-top: 0 !important;
308
+ color: #81c784 !important;
309
+ }
310
+
311
+ /* ── Pulsing status dot — reusuable helper ──────────────────────── */
312
+ .at-status-dot {
313
+ display: inline-block !important;
314
+ width: 8px; height: 8px !important;
315
+ border-radius: 50% !important;
316
+ background: #4caf50 !important;
317
+ margin-right: 0.4rem !important;
318
+ vertical-align: middle !important;
319
+ animation: status-pulse 2.5s ease-in-out infinite !important;
320
+ }
321
+ .at-status-dot.amber {
322
+ background: #ffa726 !important;
323
+ animation: amber-pulse 2.5s ease-in-out infinite !important;
324
+ }
325
+
326
+ /* ── Mobile ─────────────────────────────────────────────────────── */
327
+ @media (max-width: 768px) {
328
+ .main .block-container { padding: 1rem 0.9rem 2rem !important; }
329
+ h1 { font-size: 1.6rem !important; }
330
+ [data-testid="stHorizontalBlock"] { flex-wrap: wrap !important; gap: 0.6rem !important; }
331
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] {
332
+ min-width: calc(50% - 0.3rem) !important;
333
+ flex: 1 1 calc(50% - 0.3rem) !important;
334
+ }
335
+ [data-testid="stTabs"] button[role="tab"] { padding: 0.4rem 0.65rem !important; font-size: 0.78rem !important; }
336
+ .at-hero { padding: 1.5rem 1.25rem !important; }
337
+ .at-hero h1 { font-size: 1.6rem !important; }
338
+ .at-card { padding: 1.1rem 1.2rem !important; }
339
+ }
340
+ @media (max-width: 480px) {
341
+ [data-testid="stHorizontalBlock"] > [data-testid="stColumn"] { min-width: 100% !important; }
342
+ }
343
+ </style>
344
+ """
345
+
346
+ _HIDE_SIDEBAR_CSS = """
347
+ <style>
348
+ [data-testid="stSidebar"] { display: none !important; }
349
+ [data-testid="stSidebarCollapsedControl"]{ display: none !important; }
350
+ .main .block-container { max-width: 900px !important; margin: 0 auto !important; }
351
+ </style>
352
+ """
353
+
354
+
355
+ def inject_css() -> None:
356
+ st.markdown(_CSS, unsafe_allow_html=True)
357
+
358
+
359
+ def hide_sidebar() -> None:
360
+ st.markdown(_HIDE_SIDEBAR_CSS, unsafe_allow_html=True)
src/temporal_analysis.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import geopandas as gpd
3
+
4
+ from src.utils import PERIODS, CURRENT_YEAR, YEAR_FROM
5
+
6
+
7
+ def filter_by_period(gdf: gpd.GeoDataFrame, start: int, end: int) -> gpd.GeoDataFrame:
8
+ return gdf[gdf["year"].between(start, end)].copy()
9
+
10
+
11
+ def filter_current_year(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
12
+ return gdf[gdf["year"] == CURRENT_YEAR].copy()
13
+
14
+
15
+ def compute_annual_trend(df: pd.DataFrame, year_from: int = YEAR_FROM,
16
+ year_to: int = CURRENT_YEAR) -> pd.DataFrame:
17
+ if df.empty or "year" not in df.columns:
18
+ years = range(year_from, year_to + 1)
19
+ return pd.DataFrame({"year": list(years), "count": [0] * len(list(years))})
20
+
21
+ counts = df.dropna(subset=["year"]).groupby("year").size().reset_index(name="count")
22
+ all_years = pd.DataFrame({"year": range(year_from, year_to + 1)})
23
+ trend = all_years.merge(counts, on="year", how="left").fillna(0)
24
+ trend["count"] = trend["count"].astype(int)
25
+ trend["year"] = trend["year"].astype(int)
26
+ return trend
27
+
28
+
29
+ def period_label_for_year(year: int) -> str:
30
+ for start, end, label in PERIODS:
31
+ if start <= year <= end:
32
+ return label
33
+ if year == CURRENT_YEAR:
34
+ return str(CURRENT_YEAR)
35
+ return str(year)
src/utils.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ BASE_DIR = Path(__file__).parent.parent
4
+ BOUNDARIES_DIR = BASE_DIR / "data" / "boundaries"
5
+ VG250_DIR = BOUNDARIES_DIR / "vg250"
6
+ NE_DIR = BOUNDARIES_DIR / "natural_earth"
7
+ CACHE_DIR = BASE_DIR / "data" / "species_cache"
8
+
9
+ PERIODS = [
10
+ (2006, 2009, "2006–2009"),
11
+ (2010, 2013, "2010–2013"),
12
+ (2014, 2017, "2014–2017"),
13
+ (2018, 2021, "2018–2021"),
14
+ (2022, 2025, "2022–2025"),
15
+ ]
16
+ CURRENT_YEAR = 2026
17
+ YEAR_FROM = 2006
18
+ HIST_YEAR_TO = CURRENT_YEAR - 1 # last year included in on-disk cache
19
+
20
+ SCOPE_OPTIONS = {
21
+ "Gemeinden (DE)": "gemeinden",
22
+ "Kreise (DE)": "kreise",
23
+ "Bundesländer (DE)": "bundeslaender",
24
+ "Admin-1 (Welt)": "admin1",
25
+ "Länder (Welt)": "countries",
26
+ }
27
+
28
+ UNIT_NAME_COL = {
29
+ "gemeinden": "GEN",
30
+ "kreise": "GEN",
31
+ "bundeslaender": "GEN",
32
+ "admin1": "name",
33
+ "countries": "NAME",
34
+ }
35
+
36
+ AGS_TO_BUNDESLAND = {
37
+ "01": "Schleswig-Holstein",
38
+ "02": "Hamburg",
39
+ "03": "Niedersachsen",
40
+ "04": "Bremen",
41
+ "05": "Nordrhein-Westfalen",
42
+ "06": "Hessen",
43
+ "07": "Rheinland-Pfalz",
44
+ "08": "Baden-Württemberg",
45
+ "09": "Bayern",
46
+ "10": "Saarland",
47
+ "11": "Berlin",
48
+ "12": "Brandenburg",
49
+ "13": "Mecklenburg-Vorpommern",
50
+ "14": "Sachsen",
51
+ "15": "Sachsen-Anhalt",
52
+ "16": "Thüringen",
53
+ }
54
+
55
+ BUNDESLAENDER_LIST = sorted(AGS_TO_BUNDESLAND.values())
56
+
57
+ EUROPEAN_COUNTRIES = {
58
+ "Albanien": "AL",
59
+ "Andorra": "AD",
60
+ "Belgien": "BE",
61
+ "Bosnien und Herzegowina": "BA",
62
+ "Bulgarien": "BG",
63
+ "Dänemark": "DK",
64
+ "Deutschland": "DE",
65
+ "Estland": "EE",
66
+ "Finnland": "FI",
67
+ "Frankreich": "FR",
68
+ "Griechenland": "GR",
69
+ "Irland": "IE",
70
+ "Island": "IS",
71
+ "Italien": "IT",
72
+ "Kosovo": "XK",
73
+ "Kroatien": "HR",
74
+ "Lettland": "LV",
75
+ "Liechtenstein": "LI",
76
+ "Litauen": "LT",
77
+ "Luxemburg": "LU",
78
+ "Malta": "MT",
79
+ "Moldau": "MD",
80
+ "Monaco": "MC",
81
+ "Montenegro": "ME",
82
+ "Niederlande": "NL",
83
+ "Nordmazedonien": "MK",
84
+ "Norwegen": "NO",
85
+ "Österreich": "AT",
86
+ "Polen": "PL",
87
+ "Portugal": "PT",
88
+ "Rumänien": "RO",
89
+ "San Marino": "SM",
90
+ "Schweden": "SE",
91
+ "Schweiz": "CH",
92
+ "Serbien": "RS",
93
+ "Slowakei": "SK",
94
+ "Slowenien": "SI",
95
+ "Spanien": "ES",
96
+ "Tschechien": "CZ",
97
+ "Türkei": "TR",
98
+ "Ukraine": "UA",
99
+ "Ungarn": "HU",
100
+ "Vereinigtes Königreich": "GB",
101
+ "Weißrussland": "BY",
102
+ "Zypern": "CY",
103
+ }
104
+
105
+ BKG_FILES = {
106
+ "gemeinden": "VG250_GEM.shp",
107
+ "kreise": "VG250_KRS.shp",
108
+ "bundeslaender": "VG250_LAN.shp",
109
+ }
110
+
111
+ NE_FILES = {
112
+ "countries": "ne_10m_admin_0_countries.shp",
113
+ "admin1": "ne_10m_admin_1_states_provinces.shp",
114
+ }
115
+
116
+ MAP_CENTER = {
117
+ "gemeinden": (51.2, 10.4),
118
+ "kreise": (51.2, 10.4),
119
+ "bundeslaender": (51.2, 10.4),
120
+ "admin1": (20.0, 0.0),
121
+ "countries": (20.0, 0.0),
122
+ }
123
+
124
+ MAP_ZOOM = {
125
+ "gemeinden": 6,
126
+ "kreise": 6,
127
+ "bundeslaender": 6,
128
+ "admin1": 2,
129
+ "countries": 2,
130
+ }
131
+
132
+ GBIF_PAGE_SIZE = 300
133
+ GBIF_MAX_OFFSET = 99_700