diff --git a/app.py b/app.py index 3e1bb81..787e819 100644 --- a/app.py +++ b/app.py @@ -33,6 +33,7 @@ except ImportError: _forecast_cache = TTLCache(maxsize=64, ttl=2700) _warn_cache = TTLCache(maxsize=32, 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. _suggest_rate_lock = Lock() @@ -325,15 +326,50 @@ def _parse_warning_datetime(value): except Exception: return None -# ── Icon key → static/icons/{key}.png mapping ──────────────────────────────── -# sonne = clear sky -# wolkig(2) = sun behind one cloud (partly cloudy) -# wolke = single cloud (mostly cloudy) -# wolkig = two clouds (overcast) -# wolkig(1) = sun + cloud + rain drops (showers) -# regen = heavy cloud + rain (rain) -# schnee = cloud + snowflakes -# blitz = two clouds + lightning (thunderstorm – not yet used, needs data) +# ── Icon key → static/icons/{key}.png ────────────────────────────────────── +# sonne clear day +# wolkig(2) sun + one cloud (partly cloudy) +# wolke single cloud (mostly cloudy) +# wolkig two clouds (overcast) +# wolkig(1) sun+cloud+rain (showers) +# regen heavy rain +# schnee snow +# 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): """Choose the most representative icon key for a whole day.""" @@ -406,6 +442,24 @@ def get_sun_times(lat, lon, date=None): except Exception: 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): """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 uv_raw = p.get("uv_index") 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): uv = round(float(uv_raw), 1) else: @@ -584,7 +644,7 @@ def get_mosmix_forecast(lat, lon, hours=72): "confidence": confidence_score, "confidence_label": confidence_label, "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) _forecast_cache[cache_key] = result_data diff --git a/static/css/style.css b/static/css/style.css index 0ef6e36..0825577 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -261,6 +261,7 @@ main { flex: 1; } .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-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 { content: ""; @@ -414,10 +415,37 @@ main { flex: 1; } .hcard-icon img { display: block; } /* 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--card { width: 36px; height: 36px; } .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-precip { font-size: 0.7rem; color: var(--blue); font-weight: 500; } .hcard-precip--none { color: var(--muted); font-weight: 400; } diff --git a/static/icons/Bildnachweis.txt b/static/icons/Bildnachweis.txt index 0867166..063be87 100644 --- a/static/icons/Bildnachweis.txt +++ b/static/icons/Bildnachweis.txt @@ -1 +1 @@ -Freepik, \ No newline at end of file +Freepik, iconixar \ No newline at end of file diff --git a/static/icons/mond.png b/static/icons/mond.png deleted file mode 100644 index ce6e171..0000000 Binary files a/static/icons/mond.png and /dev/null differ diff --git a/static/icons/nacht(1).png b/static/icons/nacht(1).png new file mode 100644 index 0000000..de31218 Binary files /dev/null and b/static/icons/nacht(1).png differ diff --git a/static/icons/nacht(2).png b/static/icons/nacht(2).png new file mode 100644 index 0000000..8580b94 Binary files /dev/null and b/static/icons/nacht(2).png differ diff --git a/static/icons/nacht(3).png b/static/icons/nacht(3).png new file mode 100644 index 0000000..69ecf0c Binary files /dev/null and b/static/icons/nacht(3).png differ diff --git a/static/icons/nacht.png b/static/icons/nacht.png new file mode 100644 index 0000000..d429b08 Binary files /dev/null and b/static/icons/nacht.png differ diff --git a/static/icons/nebel.png b/static/icons/nebel.png new file mode 100644 index 0000000..b779dce Binary files /dev/null and b/static/icons/nebel.png differ diff --git a/static/icons/nebel_wolkig.png b/static/icons/nebel_wolkig.png new file mode 100644 index 0000000..386cb9d Binary files /dev/null and b/static/icons/nebel_wolkig.png differ diff --git a/static/icons/wolkig_nebel_sonne.png b/static/icons/wolkig_nebel_sonne.png new file mode 100644 index 0000000..68d59a4 Binary files /dev/null and b/static/icons/wolkig_nebel_sonne.png differ diff --git a/templates/weather.html b/templates/weather.html index 13cdbea..73dd13c 100644 --- a/templates/weather.html +++ b/templates/weather.html @@ -8,10 +8,11 @@ {% block content %} {# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #} -{% if current.icon in ("regen", "wolkig(1)") %}{% set wclass = "w-rain" %} -{% elif current.icon == "schnee" %}{% set wclass = "w-snow" %} +{% if current.icon in ("regen", "wolkig(1)", "nacht(2)") %}{% set wclass = "w-rain" %} +{% elif current.icon in ("schnee", "nacht(3)") %}{% set wclass = "w-snow" %} {% 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 %} @@ -28,7 +29,9 @@