modified: app.py
modified: static/css/style.css new file: static/icons/Bildnachweis.txt new file: static/icons/blitz.png new file: static/icons/mond.png new file: static/icons/regen.png new file: static/icons/schnee.png new file: static/icons/sonne.png new file: static/icons/wolke.png new file: static/icons/wolkig(1).png new file: static/icons/wolkig(2).png new file: static/icons/wolkig.png modified: templates/weather.html
144
app.py
@@ -73,31 +73,6 @@ MOSMIX_PARAMS = [
|
|||||||
"hourly/large/uv_index",
|
"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():
|
def _get_berlin():
|
||||||
try:
|
try:
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
@@ -350,60 +325,76 @@ def _parse_warning_datetime(value):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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):
|
def pick_daily_icon(hours):
|
||||||
|
"""Choose the most representative icon key for a whole day."""
|
||||||
if not hours:
|
if not hours:
|
||||||
return "☀️"
|
return "sonne"
|
||||||
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):
|
# Snow / sleet
|
||||||
return "❄️"
|
if any(
|
||||||
if any(((h.get("precip_mm") or 0) >= 0.6 or (h.get("rain_prob") or 0) >= 70) for h in hours):
|
h.get("temp_c") is not None and h["temp_c"] <= 2
|
||||||
return "🌧️"
|
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]
|
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
|
avg_cloud = sum(clouds) / len(clouds) if clouds else 0
|
||||||
if avg_cloud > 80:
|
# Significant rain/showers
|
||||||
return "☁️"
|
if any((h.get("precip_mm") or 0) >= 0.8 or (h.get("rain_prob") or 0) >= 65 for h in hours):
|
||||||
if avg_cloud > 40:
|
return "regen" if avg_cloud >= 75 else "wolkig(1)"
|
||||||
return "⛅"
|
# Light showers
|
||||||
return "☀️"
|
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):
|
def feels_like(temp_c, wind_kmh, cloud_pct):
|
||||||
"""Apparent / perceived temperature.
|
"""Apparent / perceived temperature.
|
||||||
|
|
||||||
Uses the JAG/TI wind-chill formula blended smoothly across the full
|
* ≤10 °C + wind >4.8 km/h → Windchill (JAG/TI formula)
|
||||||
temperature range so there is no abrupt jump at the old 10 °C threshold.
|
* ≥27 °C → Heat index (Rothfusz, RH 60 %)
|
||||||
Above 27 °C the Rothfusz heat index (assumed RH 60 %) is applied.
|
* 10–27 °C → Small sun/wind corrections:
|
||||||
|
- Very clear sky (<20 % clouds): up to +2 °C
|
||||||
The previous cloud-based sunshine bonus has been removed: cloud cover
|
- Notable wind (>20 km/h): up to -2 °C
|
||||||
fluctuates strongly hour to hour, which caused the felt temperature to
|
Only reported when result differs by ≥1 °C from actual temp.
|
||||||
jump by several degrees without any real change in conditions.
|
|
||||||
"""
|
"""
|
||||||
if temp_c is None:
|
if temp_c is None:
|
||||||
return None
|
return None
|
||||||
|
# ── Wind chill (cold range) ───────────────────────────────────────
|
||||||
# ── Hot range: heat index (Rothfusz, RH 60 %) ────────────────────
|
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:
|
if temp_c >= 27:
|
||||||
rh = 60
|
rh = 60
|
||||||
hi = (-8.78469475556 + 1.61139411 * temp_c + 2.33854883889 * rh
|
hi = (-8.78469475556 + 1.61139411*temp_c + 2.33854883889*rh
|
||||||
- 0.14611605 * temp_c * rh - 0.012308094 * temp_c ** 2
|
- 0.14611605*temp_c*rh - 0.012308094*temp_c**2
|
||||||
- 0.016424828 * rh ** 2 + 0.002211732 * temp_c ** 2 * rh
|
- 0.016424828*rh**2 + 0.002211732*temp_c**2*rh
|
||||||
+ 0.00072546 * temp_c * rh ** 2
|
+ 0.00072546*temp_c*rh**2 - 0.000003582*temp_c**2*rh**2)
|
||||||
- 0.000003582 * temp_c ** 2 * rh ** 2)
|
|
||||||
return round(hi, 1)
|
return round(hi, 1)
|
||||||
|
# ── Mild range: conservative corrections only ─────────────────────
|
||||||
# ── 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
|
|
||||||
adjusted = float(temp_c)
|
adjusted = float(temp_c)
|
||||||
if wind > 4.8:
|
# Sunshine: very clear sky (<20 % clouds) adds max +2 °C
|
||||||
wc = (13.12
|
if cloud_pct is not None and cloud_pct < 20:
|
||||||
+ 0.6215 * temp_c
|
adjusted += (20 - cloud_pct) / 20 * 2.0
|
||||||
- 11.37 * (wind ** 0.16)
|
# Wind: only meaningful wind (>20 km/h) cools, max -2 °C
|
||||||
+ 0.3965 * temp_c * (wind ** 0.16))
|
if wind_kmh is not None and wind_kmh > 20:
|
||||||
blend = _clamp((20.0 - temp_c) / 15.0, 0.0, 1.0)
|
adjusted -= _clamp((wind_kmh - 20) / 60 * 2.0, 0, 2.0)
|
||||||
adjusted = temp_c * (1.0 - blend) + wc * blend
|
|
||||||
|
|
||||||
result = round(adjusted, 1)
|
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):
|
def get_sun_times(lat, lon, date=None):
|
||||||
try:
|
try:
|
||||||
@@ -496,18 +487,24 @@ def wind_direction_name(degrees):
|
|||||||
return dirs[idx]
|
return dirs[idx]
|
||||||
|
|
||||||
def weather_icon(cloud_pct, precip_mm, rain_prob, temp_c):
|
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 the icon key for static/icons/{key}.png."""
|
||||||
return "❄️"
|
# Snow / sleet
|
||||||
if precip_mm and precip_mm > 0.2:
|
if temp_c is not None and temp_c <= 2 and (
|
||||||
return "🌧️"
|
(precip_mm and precip_mm > 0.1) or (rain_prob and rain_prob >= 40)
|
||||||
if rain_prob is not None and rain_prob >= 60:
|
):
|
||||||
return "🌦️"
|
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:
|
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 is not None:
|
||||||
if cloud_pct > 80: return "☁️"
|
if cloud_pct > 80: return "wolkig"
|
||||||
if cloud_pct > 35: return "⛅"
|
if cloud_pct > 50: return "wolke"
|
||||||
return "☀️"
|
if cloud_pct > 20: return "wolkig(2)"
|
||||||
|
return "sonne"
|
||||||
|
|
||||||
def get_mosmix_forecast(lat, lon, hours=72):
|
def get_mosmix_forecast(lat, lon, hours=72):
|
||||||
cache_key = (round(lat,2), round(lon,2), hours)
|
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,
|
chart_precip=chart_precip, chart_rain_prob=chart_rain_prob,
|
||||||
wind_dir_name=wind_direction_name,
|
wind_dir_name=wind_direction_name,
|
||||||
uv_risk_info=uv_risk_info,
|
uv_risk_info=uv_risk_info,
|
||||||
icon_url=_icon_url,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route("/api/suggest")
|
@app.route("/api/suggest")
|
||||||
|
|||||||
@@ -299,12 +299,7 @@ main { flex: 1; }
|
|||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
justify-content: flex-end; padding-bottom: 0.6rem;
|
justify-content: flex-end; padding-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
.hero-icon-big { line-height: 1; margin-bottom: 0.4rem; }
|
.hero-icon-big { font-size: 3rem; 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-stats-mini { display: flex; flex-direction: column; gap: 0.2rem; }
|
.hero-stats-mini { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
.hero-stats-mini span { font-size: 0.85rem; color: var(--muted2); }
|
.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-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-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-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; }
|
||||||
@@ -485,7 +487,7 @@ main { flex: 1; }
|
|||||||
.drow:hover { background: rgba(255,255,255,0.05); }
|
.drow:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
|
||||||
.drow-left { display: flex; align-items: center; gap: 0.6rem; }
|
.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-dow { font-size: 0.875rem; font-weight: 600; color: var(--text); }
|
||||||
|
|
||||||
.drow-bar-wrap { position: relative; }
|
.drow-bar-wrap { position: relative; }
|
||||||
|
|||||||
1
static/icons/Bildnachweis.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Freepik,
|
||||||
BIN
static/icons/blitz.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
static/icons/mond.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
static/icons/regen.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
static/icons/schnee.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/icons/sonne.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/icons/wolke.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
static/icons/wolkig(1).png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
static/icons/wolkig(2).png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
static/icons/wolkig.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
@@ -8,10 +8,10 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #}
|
{# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #}
|
||||||
{% if current.icon == "🌧️" %}{% set wclass = "w-rain" %}
|
{% if current.icon in ("regen", "wolkig(1)") %}{% set wclass = "w-rain" %}
|
||||||
{% elif current.icon == "❄️" %}{% set wclass = "w-snow" %}
|
{% elif current.icon == "schnee" %}{% set wclass = "w-snow" %}
|
||||||
{% elif current.icon == "☁️" %}{% set wclass = "w-cloudy" %}
|
{% elif current.icon in ("wolkig", "wolke") %}{% set wclass = "w-cloudy" %}
|
||||||
{% elif current.icon == "⛅" %}{% set wclass = "w-partcloud" %}
|
{% elif current.icon == "wolkig(2)" %}{% set wclass = "w-partcloud" %}
|
||||||
{% else %}{% set wclass = "w-clear" %}{% endif %}
|
{% else %}{% set wclass = "w-clear" %}{% endif %}
|
||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<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="{{ icon_url(current.icon, current.datetime.hour if current.datetime is not none and current.datetime is not string else 12) }}" alt="{{ current.icon }}">
|
<img class="wx-icon wx-icon--hero" src="{{ url_for('static', filename='icons/' ~ current.icon ~ '.png') }}" alt="{{ current.icon }}">
|
||||||
</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 +171,7 @@
|
|||||||
{% 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" src="{{ icon_url(h.icon, h.datetime.hour if h.datetime is not none and h.datetime is not string else 12) }}" alt="{{ h.icon }}">
|
<img class="wx-icon wx-icon--card" src="{{ url_for('static', filename='icons/' ~ h.icon ~ '.png') }}" alt="{{ h.icon }}">
|
||||||
</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 +231,7 @@
|
|||||||
<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--sm" src="{{ icon_url(d.icon, 12) }}" alt="{{ d.icon }}">
|
<img class="wx-icon wx-icon--row" src="{{ url_for('static', filename='icons/' ~ d.icon ~ '.png') }}" alt="{{ d.icon }}">
|
||||||
</span>
|
</span>
|
||||||
<div class="drow-date-wrap">
|
<div class="drow-date-wrap">
|
||||||
<span class="drow-dow">
|
<span class="drow-dow">
|
||||||
|
|||||||