modified: app.py
modified: static/css/style.css modified: static/icons/Bildnachweis.txt deleted: static/icons/mond.png new file: static/icons/nacht(1).png new file: static/icons/nacht(2).png new file: static/icons/nacht(3).png new file: static/icons/nacht.png new file: static/icons/nebel.png new file: static/icons/nebel_wolkig.png new file: static/icons/wolkig_nebel_sonne.png modified: templates/weather.html
80
app.py
@@ -33,6 +33,7 @@ except ImportError:
|
|||||||
_forecast_cache = TTLCache(maxsize=64, ttl=2700)
|
_forecast_cache = TTLCache(maxsize=64, ttl=2700)
|
||||||
_warn_cache = TTLCache(maxsize=32, ttl=900)
|
_warn_cache = TTLCache(maxsize=32, ttl=900)
|
||||||
_suggest_cache = TTLCache(maxsize=256, ttl=900)
|
_suggest_cache = TTLCache(maxsize=256, ttl=900)
|
||||||
|
_sun_cache: dict = {} # (lat_r, lon_r, date) -> (naive_sunrise_ts, naive_sunset_ts)
|
||||||
|
|
||||||
# API protection: simple in-memory rate limiting by client IP.
|
# API protection: simple in-memory rate limiting by client IP.
|
||||||
_suggest_rate_lock = Lock()
|
_suggest_rate_lock = Lock()
|
||||||
@@ -325,15 +326,50 @@ def _parse_warning_datetime(value):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ── Icon key → static/icons/{key}.png mapping ────────────────────────────────
|
# ── Icon key → static/icons/{key}.png ──────────────────────────────────────
|
||||||
# sonne = clear sky
|
# sonne clear day
|
||||||
# wolkig(2) = sun behind one cloud (partly cloudy)
|
# wolkig(2) sun + one cloud (partly cloudy)
|
||||||
# wolke = single cloud (mostly cloudy)
|
# wolke single cloud (mostly cloudy)
|
||||||
# wolkig = two clouds (overcast)
|
# wolkig two clouds (overcast)
|
||||||
# wolkig(1) = sun + cloud + rain drops (showers)
|
# wolkig(1) sun+cloud+rain (showers)
|
||||||
# regen = heavy cloud + rain (rain)
|
# regen heavy rain
|
||||||
# schnee = cloud + snowflakes
|
# schnee snow
|
||||||
# blitz = two clouds + lightning (thunderstorm – not yet used, needs data)
|
# blitz thunderstorm (needs additional MOSMIX parameter)
|
||||||
|
# wolkig_nebel_sonne fog / mist
|
||||||
|
# nacht clear night (moon)
|
||||||
|
# nacht(1) moon behind cloud
|
||||||
|
# nacht(2) moon+cloud+rain
|
||||||
|
# nacht(3) moon+cloud+snow
|
||||||
|
|
||||||
|
def weather_icon(cloud_pct, precip_mm, rain_prob, temp_c, is_night=False):
|
||||||
|
"""Return the icon key for static/icons/{key}.png."""
|
||||||
|
# Snow / sleet (day and night)
|
||||||
|
if temp_c is not None and temp_c <= 2 and (
|
||||||
|
(precip_mm and precip_mm > 0.1) or (rain_prob and rain_prob >= 40)
|
||||||
|
):
|
||||||
|
return "nacht(3)" if is_night else "schnee"
|
||||||
|
|
||||||
|
# ── Night icons ───────────────────────────────────────────────────
|
||||||
|
if is_night:
|
||||||
|
if (precip_mm and precip_mm > 0.1) or (rain_prob is not None and rain_prob >= 50):
|
||||||
|
return "nacht(2)"
|
||||||
|
if rain_prob is not None and rain_prob >= 30 and cloud_pct is not None and cloud_pct > 50:
|
||||||
|
return "nacht(2)"
|
||||||
|
if cloud_pct is not None:
|
||||||
|
if cloud_pct > 80: return "wolkig" # too cloudy to see moon
|
||||||
|
if cloud_pct > 30: return "nacht(1)"
|
||||||
|
return "nacht"
|
||||||
|
|
||||||
|
# ── Day icons ─────────────────────────────────────────────────────
|
||||||
|
if (precip_mm and precip_mm > 0.1) or (rain_prob is not None and rain_prob >= 50):
|
||||||
|
return "wolkig(1)" if (cloud_pct is None or cloud_pct < 75) else "regen"
|
||||||
|
if rain_prob is not None and rain_prob >= 30 and cloud_pct is not None and cloud_pct > 50:
|
||||||
|
return "wolkig(1)"
|
||||||
|
if cloud_pct is not None:
|
||||||
|
if cloud_pct > 80: return "wolkig"
|
||||||
|
if cloud_pct > 50: return "wolke"
|
||||||
|
if cloud_pct > 20: return "wolkig(2)"
|
||||||
|
return "sonne"
|
||||||
|
|
||||||
def pick_daily_icon(hours):
|
def pick_daily_icon(hours):
|
||||||
"""Choose the most representative icon key for a whole day."""
|
"""Choose the most representative icon key for a whole day."""
|
||||||
@@ -406,6 +442,24 @@ def get_sun_times(lat, lon, date=None):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None, None, None, None
|
return None, None, None, None
|
||||||
|
|
||||||
|
def _sunrise_sunset(lat, lon, d):
|
||||||
|
"""Return (naive_sunrise, naive_sunset) in Berlin local time for date d.
|
||||||
|
Results are cached in _sun_cache to avoid recomputing for every forecast hour."""
|
||||||
|
key = (round(lat, 1), round(lon, 1), d)
|
||||||
|
if key in _sun_cache:
|
||||||
|
return _sun_cache[key]
|
||||||
|
try:
|
||||||
|
berlin = _get_berlin()
|
||||||
|
loc = LocationInfo(latitude=lat, longitude=lon, timezone="Europe/Berlin")
|
||||||
|
s = astral_sun(loc.observer, date=d, tzinfo=berlin)
|
||||||
|
sr = pd.Timestamp(s["sunrise"]).tz_convert(berlin).tz_localize(None)
|
||||||
|
ss = pd.Timestamp(s["sunset"]).tz_convert(berlin).tz_localize(None)
|
||||||
|
result = (sr, ss)
|
||||||
|
except Exception:
|
||||||
|
result = (None, None)
|
||||||
|
_sun_cache[key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
def get_dwd_warnings(lat, lon, state_hint=None, location_names=None):
|
def get_dwd_warnings(lat, lon, state_hint=None, location_names=None):
|
||||||
"""Fetch DWD warnings and filter by regionName matching the user's municipality/county.
|
"""Fetch DWD warnings and filter by regionName matching the user's municipality/county.
|
||||||
|
|
||||||
@@ -558,6 +612,12 @@ def get_mosmix_forecast(lat, lon, hours=72):
|
|||||||
wind_dir = float(wd) if not _isnan(wd) else None
|
wind_dir = float(wd) if not _isnan(wd) else None
|
||||||
uv_raw = p.get("uv_index")
|
uv_raw = p.get("uv_index")
|
||||||
dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None)
|
dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None)
|
||||||
|
# Determine day/night for icon selection
|
||||||
|
_sr, _ss = _sunrise_sunset(lat, lon, dt_local.date())
|
||||||
|
is_night = bool(
|
||||||
|
_sr is not None and _ss is not None
|
||||||
|
and (pd.Timestamp(dt_local) < _sr or pd.Timestamp(dt_local) > _ss)
|
||||||
|
)
|
||||||
if not _isnan(uv_raw):
|
if not _isnan(uv_raw):
|
||||||
uv = round(float(uv_raw), 1)
|
uv = round(float(uv_raw), 1)
|
||||||
else:
|
else:
|
||||||
@@ -584,7 +644,7 @@ def get_mosmix_forecast(lat, lon, hours=72):
|
|||||||
"confidence": confidence_score,
|
"confidence": confidence_score,
|
||||||
"confidence_label": confidence_label,
|
"confidence_label": confidence_label,
|
||||||
"activity_score": a_score,
|
"activity_score": a_score,
|
||||||
"icon": weather_icon(clouds, precip, rain_prob, temp_c),
|
"icon": weather_icon(clouds, precip, rain_prob, temp_c, is_night=is_night),
|
||||||
})
|
})
|
||||||
result_data = (forecast, station_info)
|
result_data = (forecast, station_info)
|
||||||
_forecast_cache[cache_key] = result_data
|
_forecast_cache[cache_key] = result_data
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ main { flex: 1; }
|
|||||||
.w-cloudy { background: linear-gradient(135deg, #0e1218 0%, #1c262e 60%, #0a0c10 100%); }
|
.w-cloudy { background: linear-gradient(135deg, #0e1218 0%, #1c262e 60%, #0a0c10 100%); }
|
||||||
.w-rain { background: linear-gradient(135deg, #050d18 0%, #0d2035 60%, #080c12 100%); }
|
.w-rain { background: linear-gradient(135deg, #050d18 0%, #0d2035 60%, #080c12 100%); }
|
||||||
.w-snow { background: linear-gradient(135deg, #0d1520 0%, #162035 60%, #0a0d12 100%); }
|
.w-snow { background: linear-gradient(135deg, #0d1520 0%, #162035 60%, #0a0d12 100%); }
|
||||||
|
.w-night { background: linear-gradient(135deg, #060810 0%, #0d1028 60%, #050608 100%); }
|
||||||
|
|
||||||
.hero::before {
|
.hero::before {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -414,10 +415,37 @@ main { flex: 1; }
|
|||||||
.hcard-icon img { display: block; }
|
.hcard-icon img { display: block; }
|
||||||
|
|
||||||
/* Weather icon images */
|
/* Weather icon images */
|
||||||
.wx-icon { display: inline-block; flex-shrink: 0; }
|
.wx-icon { display: block; flex-shrink: 0; }
|
||||||
.wx-icon--hero { width: 56px; height: 56px; }
|
.wx-icon--hero { width: 56px; height: 56px; }
|
||||||
.wx-icon--card { width: 36px; height: 36px; }
|
.wx-icon--card { width: 36px; height: 36px; }
|
||||||
.wx-icon--row { width: 28px; height: 28px; }
|
.wx-icon--row { width: 28px; height: 28px; }
|
||||||
|
|
||||||
|
/* Coloured accent box behind each weather icon */
|
||||||
|
.wx-icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 7px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* default fallback */
|
||||||
|
background: linear-gradient(135deg, #1e2a38 0%, #2a3a4e 100%);
|
||||||
|
}
|
||||||
|
/* Day icons */
|
||||||
|
.wx-icon-wrap[data-icon="sonne"] { background: linear-gradient(135deg, #e07010 0%, #f5c020 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="wolkig(2)"] { background: linear-gradient(135deg, #3a82b0 0%, #d08030 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="wolke"] { background: linear-gradient(135deg, #3e5a74 0%, #5a7896 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="wolkig"] { background: linear-gradient(135deg, #303e4c 0%, #485a6c 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="wolkig(1)"] { background: linear-gradient(135deg, #1a6090 0%, #4090c0 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="regen"] { background: linear-gradient(135deg, #0e3050 0%, #1a5080 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="schnee"] { background: linear-gradient(135deg, #3070a8 0%, #70b8e0 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="blitz"] { background: linear-gradient(135deg, #1e1040 0%, #5030a0 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="wolkig_nebel_sonne"] { background: linear-gradient(135deg, #706858 0%, #a09880 100%); }
|
||||||
|
/* Night icons */
|
||||||
|
.wx-icon-wrap[data-icon="nacht"] { background: linear-gradient(135deg, #080c20 0%, #141c40 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="nacht(1)"] { background: linear-gradient(135deg, #0c1228 0%, #1e2848 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="nacht(2)"] { background: linear-gradient(135deg, #081020 0%, #102040 100%); }
|
||||||
|
.wx-icon-wrap[data-icon="nacht(3)"] { background: linear-gradient(135deg, #0c1830 0%, #1e3858 100%); }
|
||||||
.hcard-temp { font-size: 1rem; font-weight: 700; }
|
.hcard-temp { font-size: 1rem; font-weight: 700; }
|
||||||
.hcard-precip { font-size: 0.7rem; color: var(--blue); font-weight: 500; }
|
.hcard-precip { font-size: 0.7rem; color: var(--blue); font-weight: 500; }
|
||||||
.hcard-precip--none { color: var(--muted); font-weight: 400; }
|
.hcard-precip--none { color: var(--muted); font-weight: 400; }
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
Freepik,
|
Freepik, iconixar
|
||||||
|
Before Width: | Height: | Size: 23 KiB |
BIN
static/icons/nacht(1).png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
static/icons/nacht(2).png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
static/icons/nacht(3).png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
static/icons/nacht.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
static/icons/nebel.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/icons/nebel_wolkig.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
static/icons/wolkig_nebel_sonne.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -8,10 +8,11 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #}
|
{# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #}
|
||||||
{% if current.icon in ("regen", "wolkig(1)") %}{% set wclass = "w-rain" %}
|
{% if current.icon in ("regen", "wolkig(1)", "nacht(2)") %}{% set wclass = "w-rain" %}
|
||||||
{% elif current.icon == "schnee" %}{% set wclass = "w-snow" %}
|
{% elif current.icon in ("schnee", "nacht(3)") %}{% set wclass = "w-snow" %}
|
||||||
{% elif current.icon in ("wolkig", "wolke") %}{% set wclass = "w-cloudy" %}
|
{% elif current.icon in ("wolkig", "wolke") %}{% set wclass = "w-cloudy" %}
|
||||||
{% elif current.icon == "wolkig(2)" %}{% set wclass = "w-partcloud" %}
|
{% elif current.icon in ("wolkig(2)", "nacht(1)") %}{% set wclass = "w-partcloud" %}
|
||||||
|
{% elif current.icon == "nacht" %}{% set wclass = "w-night" %}
|
||||||
{% else %}{% set wclass = "w-clear" %}{% endif %}
|
{% else %}{% set wclass = "w-clear" %}{% endif %}
|
||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
@@ -28,7 +29,9 @@
|
|||||||
<div class="hero-temp">{{ current.temp_c if current.temp_c is not none else "–" }}°</div>
|
<div class="hero-temp">{{ current.temp_c if current.temp_c is not none else "–" }}°</div>
|
||||||
<div class="hero-desc">
|
<div class="hero-desc">
|
||||||
<div class="hero-icon-big">
|
<div class="hero-icon-big">
|
||||||
<img class="wx-icon wx-icon--hero" src="{{ url_for('static', filename='icons/' ~ current.icon ~ '.png') }}" alt="{{ current.icon }}">
|
<div class="wx-icon-wrap" data-icon="{{ current.icon }}">
|
||||||
|
<img class="wx-icon wx-icon--hero" src="{{ url_for('static', filename='icons/' ~ current.icon ~ '.png') }}" alt="{{ current.icon }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stats-mini">
|
<div class="hero-stats-mini">
|
||||||
{% if current.wind_kmh is not none %}
|
{% if current.wind_kmh is not none %}
|
||||||
@@ -171,7 +174,9 @@
|
|||||||
{% else %}{{ h.datetime.strftime('%d.%m.') }}{% endif %}
|
{% else %}{{ h.datetime.strftime('%d.%m.') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="hcard-icon">
|
<div class="hcard-icon">
|
||||||
<img class="wx-icon wx-icon--card" src="{{ url_for('static', filename='icons/' ~ h.icon ~ '.png') }}" alt="{{ h.icon }}">
|
<div class="wx-icon-wrap" data-icon="{{ h.icon }}">
|
||||||
|
<img class="wx-icon wx-icon--card" src="{{ url_for('static', filename='icons/' ~ h.icon ~ '.png') }}" alt="{{ h.icon }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hcard-temp">
|
<div class="hcard-temp">
|
||||||
{% if h.temp_c is not none %}{{ h.temp_c }}°{% else %}–{% endif %}
|
{% if h.temp_c is not none %}{{ h.temp_c }}°{% else %}–{% endif %}
|
||||||
@@ -231,7 +236,9 @@
|
|||||||
<div class="drow">
|
<div class="drow">
|
||||||
<div class="drow-left">
|
<div class="drow-left">
|
||||||
<span class="drow-icon">
|
<span class="drow-icon">
|
||||||
<img class="wx-icon wx-icon--row" src="{{ url_for('static', filename='icons/' ~ d.icon ~ '.png') }}" alt="{{ d.icon }}">
|
<div class="wx-icon-wrap" data-icon="{{ d.icon }}">
|
||||||
|
<img class="wx-icon wx-icon--row" src="{{ url_for('static', filename='icons/' ~ d.icon ~ '.png') }}" alt="{{ d.icon }}">
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<div class="drow-date-wrap">
|
<div class="drow-date-wrap">
|
||||||
<span class="drow-dow">
|
<span class="drow-dow">
|
||||||
|
|||||||