Spaces:
Runtime error
Runtime error
Johannes commited on
Commit ·
0d4a0ba
0
Parent(s):
Initial deployment (no data - downloaded from HF Dataset at startup)
Browse files- .gitattributes +40 -0
- .gitignore +5 -0
- .streamlit/config.toml +9 -0
- Dockerfile +20 -0
- README.md +13 -0
- app.py +559 -0
- requirements.txt +14 -0
- src/__init__.py +0 -0
- src/boundary_loader.py +111 -0
- src/chart_builder.py +122 -0
- src/csv_importer.py +93 -0
- src/data_cache.py +94 -0
- src/gbif_client.py +186 -0
- src/map_builder.py +151 -0
- src/spatial_analysis.py +81 -0
- src/species_config.py +34 -0
- src/streamlit_app.py +40 -0
- src/styles.py +219 -0
- src/styles_v1_naturetech.py +376 -0
- src/styles_v2_fieldguide.py +331 -0
- src/styles_v3_scientific.py +316 -0
- src/styles_v4_forestguardian.py +360 -0
- src/temporal_analysis.py +35 -0
- src/utils.py +133 -0
.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­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> · {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
|