diff --git a/app.py b/app.py index cd2f81b..3552eae 100644 --- a/app.py +++ b/app.py @@ -73,31 +73,6 @@ MOSMIX_PARAMS = [ "hourly/large/uv_index", ] -# ── Wetter-Icons (OpenWeatherMap CDN) ──────────────────────────────────────── -_OWM = "https://openweathermap.org/img/wn/" -# Day icons – keyed by the emoji that weather_icon() / pick_daily_icon() returns -ICON_URLS_DAY = { - "☀️": _OWM + "01d@2x.png", # klar - "⛅": _OWM + "02d@2x.png", # leicht bewölkt - "☁️": _OWM + "04d@2x.png", # bedeckt - "🌧️": _OWM + "10d@2x.png", # Regen - "🌦️": _OWM + "09d@2x.png", # Regenschauer - "❄️": _OWM + "13d@2x.png", # Schnee -} -# Overrides for night hours (20–5 Uhr): only clear/partly change visually -ICON_URLS_NIGHT = { - "☀️": _OWM + "01n@2x.png", # klare Nacht (Mond) - "⛅": _OWM + "02n@2x.png", # leicht bewölkt nachts -} - -def _icon_url(emoji, hour=12): - """Return OWM icon URL. Uses night variants for clear/partly-cloudy between 20–5 Uhr.""" - e = str(emoji) - is_night = hour >= 20 or hour < 6 - if is_night and e in ICON_URLS_NIGHT: - return ICON_URLS_NIGHT[e] - return ICON_URLS_DAY.get(e, _OWM + "03d@2x.png") - def _get_berlin(): try: import zoneinfo @@ -350,60 +325,76 @@ 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) + def pick_daily_icon(hours): + """Choose the most representative icon key for a whole day.""" if not hours: - return "☀️" - if any((h.get("temp_c") is not None and h["temp_c"] <= 0 and ((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 50)) for h in hours): - return "❄️" - if any(((h.get("precip_mm") or 0) >= 0.6 or (h.get("rain_prob") or 0) >= 70) for h in hours): - return "🌧️" + return "sonne" + # Snow / sleet + if any( + h.get("temp_c") is not None and h["temp_c"] <= 2 + and ((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 40) + for h in hours + ): + return "schnee" clouds = [h.get("cloud_pct") for h in hours if h.get("cloud_pct") is not None] avg_cloud = sum(clouds) / len(clouds) if clouds else 0 - if avg_cloud > 80: - return "☁️" - if avg_cloud > 40: - return "⛅" - return "☀️" + # Significant rain/showers + if any((h.get("precip_mm") or 0) >= 0.8 or (h.get("rain_prob") or 0) >= 65 for h in hours): + return "regen" if avg_cloud >= 75 else "wolkig(1)" + # Light showers + if any((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 35 for h in hours): + return "wolkig(1)" + # Cloud cover + if avg_cloud > 80: return "wolkig" + if avg_cloud > 50: return "wolke" + if avg_cloud > 20: return "wolkig(2)" + return "sonne" def feels_like(temp_c, wind_kmh, cloud_pct): """Apparent / perceived temperature. - Uses the JAG/TI wind-chill formula blended smoothly across the full - temperature range so there is no abrupt jump at the old 10 °C threshold. - Above 27 °C the Rothfusz heat index (assumed RH 60 %) is applied. - - The previous cloud-based sunshine bonus has been removed: cloud cover - fluctuates strongly hour to hour, which caused the felt temperature to - jump by several degrees without any real change in conditions. + * ≤10 °C + wind >4.8 km/h → Windchill (JAG/TI formula) + * ≥27 °C → Heat index (Rothfusz, RH 60 %) + * 10–27 °C → Small sun/wind corrections: + - Very clear sky (<20 % clouds): up to +2 °C + - Notable wind (>20 km/h): up to -2 °C + Only reported when result differs by ≥1 °C from actual temp. """ if temp_c is None: return None - - # ── Hot range: heat index (Rothfusz, RH 60 %) ──────────────────── + # ── Wind chill (cold range) ─────────────────────────────────────── + if temp_c <= 10 and wind_kmh is not None and wind_kmh > 4.8: + v = wind_kmh + wc = 13.12 + 0.6215*temp_c - 11.37*(v**0.16) + 0.3965*temp_c*(v**0.16) + return round(wc, 1) + # ── Heat index (hot range) ──────────────────────────────────────── if temp_c >= 27: rh = 60 - hi = (-8.78469475556 + 1.61139411 * temp_c + 2.33854883889 * rh - - 0.14611605 * temp_c * rh - 0.012308094 * temp_c ** 2 - - 0.016424828 * rh ** 2 + 0.002211732 * temp_c ** 2 * rh - + 0.00072546 * temp_c * rh ** 2 - - 0.000003582 * temp_c ** 2 * rh ** 2) + hi = (-8.78469475556 + 1.61139411*temp_c + 2.33854883889*rh + - 0.14611605*temp_c*rh - 0.012308094*temp_c**2 + - 0.016424828*rh**2 + 0.002211732*temp_c**2*rh + + 0.00072546*temp_c*rh**2 - 0.000003582*temp_c**2*rh**2) return round(hi, 1) - - # ── Wind-chill (JAG/TI), blended into mild range ────────────────── - # Full wind-chill effect at ≤ 5 °C, linearly faded to zero at ≥ 20 °C. - # This avoids the hard jump that the old ≤ 10 °C threshold caused. - wind = wind_kmh or 0 + # ── Mild range: conservative corrections only ───────────────────── adjusted = float(temp_c) - if wind > 4.8: - wc = (13.12 - + 0.6215 * temp_c - - 11.37 * (wind ** 0.16) - + 0.3965 * temp_c * (wind ** 0.16)) - blend = _clamp((20.0 - temp_c) / 15.0, 0.0, 1.0) - adjusted = temp_c * (1.0 - blend) + wc * blend - + # Sunshine: very clear sky (<20 % clouds) adds max +2 °C + if cloud_pct is not None and cloud_pct < 20: + adjusted += (20 - cloud_pct) / 20 * 2.0 + # Wind: only meaningful wind (>20 km/h) cools, max -2 °C + if wind_kmh is not None and wind_kmh > 20: + adjusted -= _clamp((wind_kmh - 20) / 60 * 2.0, 0, 2.0) result = round(adjusted, 1) - return result if abs(result - temp_c) >= 0.5 else temp_c + return result if abs(result - temp_c) >= 1.0 else temp_c def get_sun_times(lat, lon, date=None): try: @@ -496,18 +487,24 @@ def wind_direction_name(degrees): return dirs[idx] def weather_icon(cloud_pct, precip_mm, rain_prob, temp_c): - if temp_c is not None and temp_c <= 0 and (precip_mm and precip_mm > 0 or rain_prob and rain_prob >= 40): - return "❄️" - if precip_mm and precip_mm > 0.2: - return "🌧️" - if rain_prob is not None and rain_prob >= 60: - return "🌦️" + """Return the icon key for static/icons/{key}.png.""" + # Snow / sleet + 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 "schnee" + # Rain with some sun visible (showers) + 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" + # Light rain probability: showers icon when partially cloudy if rain_prob is not None and rain_prob >= 30 and cloud_pct is not None and cloud_pct > 50: - return "🌦️" + return "wolkig(1)" + # Cloud cover only if cloud_pct is not None: - if cloud_pct > 80: return "☁️" - if cloud_pct > 35: return "⛅" - return "☀️" + if cloud_pct > 80: return "wolkig" + if cloud_pct > 50: return "wolke" + if cloud_pct > 20: return "wolkig(2)" + return "sonne" def get_mosmix_forecast(lat, lon, hours=72): cache_key = (round(lat,2), round(lon,2), hours) @@ -774,7 +771,6 @@ def wetter(): chart_precip=chart_precip, chart_rain_prob=chart_rain_prob, wind_dir_name=wind_direction_name, uv_risk_info=uv_risk_info, - icon_url=_icon_url, ) @app.route("/api/suggest") diff --git a/static/css/style.css b/static/css/style.css index 7cbf869..0ef6e36 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -299,12 +299,7 @@ main { flex: 1; } display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 0.6rem; } -.hero-icon-big { line-height: 1; margin-bottom: 0.4rem; } - -/* Weather icon images (OWM CDN) */ -.wx-icon { display: block; width: 40px; height: 40px; object-fit: contain; } -.wx-icon--hero { width: 80px; height: 80px; } -.wx-icon--sm { width: 32px; height: 32px; } +.hero-icon-big { font-size: 3rem; line-height: 1; margin-bottom: 0.4rem; } .hero-stats-mini { display: flex; flex-direction: column; gap: 0.2rem; } .hero-stats-mini span { font-size: 0.85rem; color: var(--muted2); } @@ -415,7 +410,14 @@ main { flex: 1; } .hcard-time { font-size: 0.85rem; font-weight: 600; color: var(--text); } .hcard-date { font-size: 0.68rem; color: var(--muted); margin-top: -0.15rem; } -.hcard-icon { line-height: 1; margin: 0.2rem 0; display: flex; justify-content: center; } +.hcard-icon { font-size: 1.4rem; line-height: 1; margin: 0.2rem 0; } +.hcard-icon img { display: block; } + +/* Weather icon images */ +.wx-icon { display: inline-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; } .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; } @@ -485,7 +487,7 @@ main { flex: 1; } .drow:hover { background: rgba(255,255,255,0.05); } .drow-left { display: flex; align-items: center; gap: 0.6rem; } -.drow-icon { font-size: 1.4rem; flex-shrink: 0; display: flex; align-items: center; } +.drow-icon { font-size: 1.4rem; flex-shrink: 0; } .drow-dow { font-size: 0.875rem; font-weight: 600; color: var(--text); } .drow-bar-wrap { position: relative; } diff --git a/static/icons/Bildnachweis.txt b/static/icons/Bildnachweis.txt new file mode 100644 index 0000000..0867166 --- /dev/null +++ b/static/icons/Bildnachweis.txt @@ -0,0 +1 @@ +Freepik, \ No newline at end of file diff --git a/static/icons/blitz.png b/static/icons/blitz.png new file mode 100644 index 0000000..6cbbf16 Binary files /dev/null and b/static/icons/blitz.png differ diff --git a/static/icons/mond.png b/static/icons/mond.png new file mode 100644 index 0000000..ce6e171 Binary files /dev/null and b/static/icons/mond.png differ diff --git a/static/icons/regen.png b/static/icons/regen.png new file mode 100644 index 0000000..a0a367f Binary files /dev/null and b/static/icons/regen.png differ diff --git a/static/icons/schnee.png b/static/icons/schnee.png new file mode 100644 index 0000000..bef6d7a Binary files /dev/null and b/static/icons/schnee.png differ diff --git a/static/icons/sonne.png b/static/icons/sonne.png new file mode 100644 index 0000000..36237c2 Binary files /dev/null and b/static/icons/sonne.png differ diff --git a/static/icons/wolke.png b/static/icons/wolke.png new file mode 100644 index 0000000..834f957 Binary files /dev/null and b/static/icons/wolke.png differ diff --git a/static/icons/wolkig(1).png b/static/icons/wolkig(1).png new file mode 100644 index 0000000..616cf7d Binary files /dev/null and b/static/icons/wolkig(1).png differ diff --git a/static/icons/wolkig(2).png b/static/icons/wolkig(2).png new file mode 100644 index 0000000..c632e2f Binary files /dev/null and b/static/icons/wolkig(2).png differ diff --git a/static/icons/wolkig.png b/static/icons/wolkig.png new file mode 100644 index 0000000..dab4f1e Binary files /dev/null and b/static/icons/wolkig.png differ diff --git a/templates/weather.html b/templates/weather.html index 397a457..13cdbea 100644 --- a/templates/weather.html +++ b/templates/weather.html @@ -8,10 +8,10 @@ {% block content %} {# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #} -{% if current.icon == "🌧️" %}{% set wclass = "w-rain" %} -{% elif current.icon == "❄️" %}{% set wclass = "w-snow" %} -{% elif current.icon == "☁️" %}{% set wclass = "w-cloudy" %} -{% elif current.icon == "⛅" %}{% set wclass = "w-partcloud" %} +{% if current.icon in ("regen", "wolkig(1)") %}{% set wclass = "w-rain" %} +{% elif current.icon == "schnee" %}{% set wclass = "w-snow" %} +{% elif current.icon in ("wolkig", "wolke") %}{% set wclass = "w-cloudy" %} +{% elif current.icon == "wolkig(2)" %}{% set wclass = "w-partcloud" %} {% else %}{% set wclass = "w-clear" %}{% endif %} @@ -28,7 +28,7 @@