From 2a9882c0aa208b64073fc073783f2a8c8ffa11b9 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:01:53 +0200 Subject: [PATCH] new file: .dockerignore new file: Dockerfile new file: __pycache__/app.cpython-310.pyc new file: app.py new file: requirements.txt new file: static/css/style.css new file: templates/base.html new file: templates/index.html new file: templates/weather.html --- .dockerignore | 14 + Dockerfile | 33 +++ __pycache__/app.cpython-310.pyc | Bin 0 -> 7322 bytes app.py | 286 +++++++++++++++++++ requirements.txt | 7 + static/css/style.css | 478 ++++++++++++++++++++++++++++++++ templates/base.html | 63 +++++ templates/index.html | 48 ++++ templates/weather.html | 265 ++++++++++++++++++ 9 files changed, 1194 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 __pycache__/app.cpython-310.pyc create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/weather.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87de49a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.env +.env.* +.venv/ +venv/ +*.log +.git/ +.gitignore +.github/ +*.md +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b295af4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# DWD Wetter +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app.py +ENV FLASK_ENV=production + +# Non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# 4 workers, 120s timeout (DWD requests can be slow) +CMD ["gunicorn", \ + "--bind", "0.0.0.0:5000", \ + "--workers", "4", \ + "--timeout", "120", \ + "--keep-alive", "5", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "app:app"] + diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dbc08db35b9a786c2aacae0ffb00a53b9a8df1c4 GIT binary patch literal 7322 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 D{m@lD literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..e964e8d --- /dev/null +++ b/app.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +from flask import Flask, render_template, request, jsonify +from geopy.geocoders import Nominatim +from geopy.exc import GeocoderTimedOut +import math +import traceback +import pandas as pd + +from wetterdienst.provider.dwd.mosmix import DwdMosmixRequest + +app = Flask(__name__) + +# ── Parameter (wetterdienst ≥ 0.100, Format: resolution/dataset/parameter) ─── +MOSMIX_PARAMS = [ + "hourly/small/temperature_air_mean_2m", + "hourly/small/wind_speed", + "hourly/small/wind_direction", + "hourly/small/wind_gust_max_last_1h", + "hourly/small/cloud_cover_total", + "hourly/small/pressure_air_site_reduced", + "hourly/small/precipitation_height_significant_weather_last_1h", + "hourly/small/sunshine_duration", + "hourly/large/precipitation_height_last_1h", + "hourly/large/probability_precipitation_height_gt_0_1mm_last_1h", +] + +# ── Hilfsfunktionen ────────────────────────────────────────────────────────── + +def geocode_location(query: str): + geolocator = Nominatim(user_agent="dwd-wetter-app/1.0") + try: + loc = geolocator.geocode(query, language="de", timeout=10) + if loc: + return loc.latitude, loc.longitude, loc.address + except GeocoderTimedOut: + pass + return None, None, None + + +def haversine(lat1, lon1, lat2, lon2): + R = 6371 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2) + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def _isnan(v): + try: + return math.isnan(float(v)) + except (TypeError, ValueError): + return True + + +def _round_temp(k): + """Wetterdienst gives temperature already in degC.""" + if k is None or _isnan(k): + return None + return round(float(k), 1) + + +def get_mosmix_forecast(lat, lon, hours=72): + """Holt MOSMIX-Vorhersage für die nächsten Stunden.""" + try: + req = DwdMosmixRequest(parameters=MOSMIX_PARAMS) + nearest = req.filter_by_rank(latlon=(lat, lon), rank=1) + result = nearest.values.all() + df = result.df + + if df is None or (hasattr(df, "__len__") and len(df) == 0): + return [], {} + + # Polars → Pandas + if hasattr(df, "to_pandas"): + df = df.to_pandas() + + station_info = {} + sdf = nearest.df + if sdf is not None and len(sdf) > 0: + if hasattr(sdf, "to_pandas"): + sdf = sdf.to_pandas() + station_info = sdf.iloc[0].to_dict() + + df = df.sort_values("date").copy() + + # Auf die nächsten `hours` Stunden begrenzen + min_date = df["date"].min() + cutoff = min_date + pd.Timedelta(hours=hours) + df = df[df["date"] <= cutoff] + + forecast = [] + for date_val, group in df.groupby("date"): + params = {row["parameter"]: row["value"] for _, row in group.iterrows()} + + temp_c = _round_temp(params.get("temperature_air_mean_2m")) + ff = params.get("wind_speed") + wind_kmh = round(float(ff) * 3.6, 1) if not _isnan(ff) else None + fx1 = params.get("wind_gust_max_last_1h") + gust_kmh = round(float(fx1) * 3.6, 1) if not _isnan(fx1) else None + pppp = params.get("pressure_air_site_reduced") + pressure = round(float(pppp), 1) if not _isnan(pppp) else None + # Niederschlag: rr1c (significant) oder rr1 (gesamt) als Fallback + rr1c = params.get("precipitation_height_significant_weather_last_1h") + rr1 = params.get("precipitation_height_last_1h") + precip_raw = rr1c if not _isnan(rr1c) else (rr1 if not _isnan(rr1) else None) + precip = round(float(precip_raw), 1) if precip_raw is not None else 0.0 + + # Regenwahrscheinlichkeit + rprob_raw = params.get("probability_precipitation_height_gt_0_1mm_last_1h") + rain_prob = round(float(rprob_raw)) if not _isnan(rprob_raw) else None + + n = params.get("cloud_cover_total") + clouds = round(float(n)) if not _isnan(n) else None + sun = params.get("sunshine_duration") + sun_min = round(float(sun) / 60) if not _isnan(sun) else 0 + wind_dir_v = params.get("wind_direction") + wind_dir = float(wind_dir_v) if not _isnan(wind_dir_v) else None + + forecast.append({ + "datetime": date_val, + "temp_c": temp_c, + "wind_kmh": wind_kmh, + "gust_kmh": gust_kmh, + "pressure_hpa": pressure, + "precip_mm": precip, + "rain_prob": rain_prob, + "cloud_pct": clouds, + "sun_min": sun_min, + "wind_dir": wind_dir, + "icon": weather_icon(clouds, precip, rain_prob, temp_c), + }) + + return forecast, station_info + except Exception: + traceback.print_exc() + return [], {} + + +def wind_direction_name(degrees): + if degrees is None or _isnan(degrees): + return "–" + dirs = ["N", "NNO", "NO", "ONO", "O", "OSO", "SO", "SSO", + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] + idx = round(float(degrees) / 22.5) % 16 + return dirs[idx] + + +def weather_icon(cloud_pct, precip_mm, rain_prob, temp_c): + # Schnee + 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 "❄️" + # Regen (tatsächlicher Niederschlag) + if precip_mm and precip_mm > 0.2: + return "🌧️" + # Hohe Regenwahrscheinlichkeit + if rain_prob is not None and rain_prob >= 60: + return "🌦️" + if rain_prob is not None and rain_prob >= 30: + if cloud_pct is not None and cloud_pct > 50: + return "🌦️" + if cloud_pct is not None: + if cloud_pct > 80: + return "☁️" + if cloud_pct > 35: + return "⛅" + return "☀️" + + +# ── Routen ─────────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/wetter", methods=["GET"]) +def wetter(): + ort = request.args.get("ort", "").strip() + if not ort: + return render_template("index.html", error="Bitte einen Ort eingeben.") + + lat, lon, display_name = geocode_location(ort) + if lat is None: + return render_template("index.html", + error=f'Ort "{ort}" konnte nicht gefunden werden.') + + forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72) + + if not forecast: + return render_template( + "index.html", + error="Keine Wetterdaten von DWD verfügbar. Bitte später erneut versuchen.", + ) + + station_name = mosmix_station.get("name", ort) + station_id = mosmix_station.get("station_id", "–") + station_lat = float(mosmix_station.get("latitude", lat)) + station_lon = float(mosmix_station.get("longitude", lon)) + station_dist = round(haversine(lat, lon, station_lat, station_lon), 1) + + current = forecast[0] + + # Tageszusammenfassung + daily = {} + for h in forecast: + dt = h["datetime"] + day = dt.date() if hasattr(dt, "date") else str(dt)[:10] + if day not in daily: + daily[day] = {"temps": [], "precip": 0.0, "cloud": [], "wind": [], "icons": [], "rain_prob": []} + if h["temp_c"] is not None: + daily[day]["temps"].append(h["temp_c"]) + daily[day]["precip"] += h.get("precip_mm") or 0 + if h["cloud_pct"] is not None: + daily[day]["cloud"].append(h["cloud_pct"]) + if h["wind_kmh"] is not None: + daily[day]["wind"].append(h["wind_kmh"]) + if h.get("rain_prob") is not None: + daily[day]["rain_prob"].append(h["rain_prob"]) + daily[day]["icons"].append(h["icon"]) + + daily_summary = [] + for day, d in daily.items(): + daily_summary.append({ + "date": day, + "temp_min": min(d["temps"]) if d["temps"] else None, + "temp_max": max(d["temps"]) if d["temps"] else None, + "precip": round(d["precip"], 1), + "rain_prob": max(d["rain_prob"]) if d["rain_prob"] else None, + "cloud": round(sum(d["cloud"]) / len(d["cloud"])) if d["cloud"] else None, + "wind_max": max(d["wind"]) if d["wind"] else None, + "icon": max(set(d["icons"]), key=d["icons"].count), + }) + + # Chart-Daten (erste 48 h) + chart_labels, chart_temps, chart_precip = [], [], [] + for h in forecast[:48]: + dt = h["datetime"] + label = (dt.strftime("%d.%m %H:%M") if hasattr(dt, "strftime") + else str(dt)[5:16]) + chart_labels.append(label) + chart_temps.append(h["temp_c"]) + chart_precip.append(h.get("precip_mm") or 0) + + return render_template( + "weather.html", + ort=ort, + display_name=display_name, + lat=lat, + lon=lon, + station_name=station_name, + station_id=station_id, + station_dist=station_dist, + current=current, + forecast=forecast[:48], + daily=daily_summary, + chart_labels=chart_labels, + chart_temps=chart_temps, + chart_precip=chart_precip, + wind_dir_name=wind_direction_name, + ) + + +@app.route("/api/suggest") +def suggest(): + q = request.args.get("q", "").strip() + if len(q) < 2: + return jsonify([]) + geolocator = Nominatim(user_agent="dwd-wetter-app/1.0") + try: + results = geolocator.geocode( + q, exactly_one=False, limit=5, language="de", + addressdetails=True, timeout=5, + ) + return jsonify( + [{"name": r.address, "lat": r.latitude, "lon": r.longitude} + for r in results] + if results else [] + ) + except Exception: + return jsonify([]) + + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=5000) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b7dce63 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask>=3.0.0 +wetterdienst>=0.90.0 +geopy>=2.4.0 +pandas>=2.0.0 +numpy>=1.24.0 +requests>=2.31.0 +gunicorn>=21.2.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..7a4998b --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,478 @@ +/* ═══════════════════════════════════════════════════════════ + BASE +═══════════════════════════════════════════════════════════ */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #08090d; + --bg1: #0f1117; + --bg2: #161b25; + --glass: rgba(255,255,255,0.035); + --glass-b: rgba(255,255,255,0.07); + --text: #e8edf5; + --muted: #5a6a7a; + --muted2: #9aa8b8; + --orange: #ff8c32; + --blue: #50b4ff; + --green: #34d399; + --r: 14px; + --nav-h: 56px; +} + +html { scroll-behavior: smooth; } +body { + font-family: "Inter", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; + -webkit-font-smoothing: antialiased; +} +a { color: var(--blue); text-decoration: none; } +a:hover { text-decoration: underline; } +main { flex: 1; } + +/* ═══════════════════════════════════════════════════════════ + NAV +═══════════════════════════════════════════════════════════ */ +.nav { + position: sticky; top: 0; z-index: 100; + height: var(--nav-h); + display: flex; align-items: center; gap: 1rem; + padding: 0 1.5rem; + background: rgba(8,9,13,0.85); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.nav-logo { + font-weight: 900; font-size: 1.1rem; + color: var(--text); letter-spacing: -0.5px; + flex-shrink: 0; text-decoration: none; +} +.nav-logo span { color: var(--orange); } + +.nav-search { flex: 1; max-width: 420px; margin-left: auto; } +.nav-search-wrap { position: relative; display: flex; align-items: center; } +.nav-search-icon { + position: absolute; left: 10px; width: 16px; height: 16px; + color: var(--muted); pointer-events: none; +} +.nav-search input { + width: 100%; padding: 0.45rem 0.9rem 0.45rem 2.2rem; + background: var(--bg2); border: 1px solid rgba(255,255,255,0.07); + border-radius: 8px; color: var(--text); font: inherit; font-size: 0.9rem; + outline: none; transition: border-color 0.2s; +} +.nav-search input:focus { border-color: var(--orange); } + +/* ═══════════════════════════════════════════════════════════ + AUTOCOMPLETE +═══════════════════════════════════════════════════════════ */ +.ac-list { + position: absolute; top: calc(100% + 4px); left: 0; right: 0; + background: var(--bg2); border: 1px solid rgba(255,255,255,0.1); + border-radius: 10px; list-style: none; + box-shadow: 0 12px 40px rgba(0,0,0,0.6); z-index: 999; overflow: hidden; +} +.ac-list[hidden] { display: none; } +.ac-list li { + padding: 0.6rem 1rem; font-size: 0.875rem; cursor: pointer; + color: var(--muted2); border-bottom: 1px solid rgba(255,255,255,0.05); + transition: background 0.12s, color 0.12s; +} +.ac-list li:last-child { border-bottom: none; } +.ac-list li:hover { background: var(--glass); color: var(--text); } + +/* ═══════════════════════════════════════════════════════════ + FOOTER +═══════════════════════════════════════════════════════════ */ +.footer { + text-align: center; padding: 2rem 1rem; + font-size: 0.78rem; color: var(--muted); + border-top: 1px solid rgba(255,255,255,0.05); +} + +/* ═══════════════════════════════════════════════════════════ + HOME PAGE +═══════════════════════════════════════════════════════════ */ +.home-wrap { + min-height: calc(100vh - var(--nav-h)); + display: flex; align-items: center; justify-content: center; + position: relative; overflow: hidden; + padding: 4rem 1.5rem; +} + +/* Giant muted "WETTER" behind everything */ +.home-bg-text { + position: absolute; + font-size: clamp(7rem, 22vw, 20rem); + font-weight: 900; + color: rgba(255,255,255,0.018); + letter-spacing: -0.05em; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + white-space: nowrap; + user-select: none; + pointer-events: none; +} + +.home-center { position: relative; z-index: 1; max-width: 620px; width: 100%; } + +.home-eyebrow { + font-size: 0.72rem; font-weight: 600; + letter-spacing: 1.5px; text-transform: uppercase; + color: var(--orange); margin-bottom: 1rem; +} + +.home-title { + font-size: clamp(2.2rem, 6vw, 4rem); + font-weight: 900; line-height: 1.05; + letter-spacing: -0.03em; + margin-bottom: 2.5rem; +} + +.home-form { display: flex; flex-direction: column; gap: 0.8rem; } + +.home-input-wrap { position: relative; } +.home-search-icon { + position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); + width: 20px; height: 20px; color: var(--muted); pointer-events: none; +} +.home-input-wrap input { + width: 100%; + padding: 1rem 1.2rem 1rem 3rem; + font: inherit; font-size: 1.05rem; + background: var(--bg2); + border: 1px solid rgba(255,255,255,0.1); + border-radius: var(--r); + color: var(--text); outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.home-input-wrap input:focus { + border-color: var(--orange); + box-shadow: 0 0 0 3px rgba(255,140,50,0.15); +} + +.home-btn { + align-self: flex-start; + display: flex; align-items: center; gap: 0.5rem; + padding: 0.85rem 1.8rem; + background: var(--orange); color: #000; + font: inherit; font-weight: 700; font-size: 1rem; + border: none; border-radius: var(--r); cursor: pointer; + transition: background 0.15s, transform 0.1s; +} +.home-btn svg { width: 16px; height: 16px; } +.home-btn:hover { background: #ffaa5e; } +.home-btn:active { transform: scale(0.97); } + +.home-error { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: rgba(255,80,80,0.1); + border: 1px solid rgba(255,80,80,0.3); + border-radius: var(--r); font-size: 0.9rem; color: #ff6b6b; +} + +.home-chips { + display: flex; flex-wrap: wrap; gap: 0.5rem; + margin-top: 2rem; align-items: center; +} +.chip { + padding: 0.3rem 0.85rem; + background: var(--glass); border: 1px solid var(--glass-b); + border-radius: 99px; font-size: 0.82rem; color: var(--muted2); + transition: all 0.15s; white-space: nowrap; +} +.chip:hover { background: rgba(255,140,50,0.1); border-color: rgba(255,140,50,0.3); color: var(--orange); text-decoration: none; } + +/* Decorative weather art */ +.home-deco { + position: absolute; right: -4rem; top: 50%; + transform: translateY(-50%); + width: clamp(200px, 35vw, 480px); + height: clamp(200px, 35vw, 480px); + pointer-events: none; user-select: none; +} +.deco-sun { + position: absolute; + width: 55%; height: 55%; + top: 5%; right: 10%; + border-radius: 50%; + background: radial-gradient(circle, rgba(255,180,50,0.35) 0%, rgba(255,120,30,0.1) 55%, transparent 75%); + animation: pulse-sun 6s ease-in-out infinite; +} +@keyframes pulse-sun { + 0%,100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.08); opacity: 0.8; } +} +.deco-cloud { + position: absolute; + background: rgba(255,255,255,0.04); + border-radius: 99px; +} +.deco-cloud::before, .deco-cloud::after { + content: ""; position: absolute; + background: inherit; border-radius: 50%; +} +.c1 { width: 48%; height: 18%; bottom: 28%; left: 10%; animation: drift 14s ease-in-out infinite; } +.c1::before { width: 55%; height: 200%; top: -80%; left: 20%; } +.c1::after { width: 35%; height: 160%; top: -60%; left: 55%; } +.c2 { width: 36%; height: 14%; bottom: 18%; left: 35%; opacity: 0.6; animation: drift 18s 3s ease-in-out infinite reverse; } +.c2::before { width: 50%; height: 200%; top: -70%; left: 15%; } +.c2::after { width: 30%; height: 160%; top: -55%; left: 55%; } +@keyframes drift { + 0%,100% { transform: translateX(0); } + 50% { transform: translateX(10px); } +} +.deco-rain { + position: absolute; bottom: 10%; left: 15%; + width: 70%; height: 30%; + display: flex; gap: 0.4rem; align-items: flex-start; +} +.deco-rain span { + flex: 1; height: 60%; min-height: 20px; + background: linear-gradient(to bottom, rgba(80,180,255,0.3), transparent); + border-radius: 99px; + animation: rain 2.2s ease-in-out infinite; +} +.deco-rain span:nth-child(2n) { animation-delay: 0.4s; height: 40%; } +.deco-rain span:nth-child(3n) { animation-delay: 0.8s; height: 70%; } +.deco-rain span:nth-child(4n) { animation-delay: 1.2s; height: 50%; } +.deco-rain span:nth-child(5n) { animation-delay: 0.2s; height: 35%; } +@keyframes rain { + 0%,100% { opacity: 0.6; transform: scaleY(1); } + 50% { opacity: 0.2; transform: scaleY(0.6); } +} + +/* ═══════════════════════════════════════════════════════════ + WEATHER PAGE – HERO +═══════════════════════════════════════════════════════════ */ +.hero { + position: relative; overflow: hidden; + padding: 3rem 2rem 1.5rem; +} + +/* Weather-condition gradients */ +.w-clear { background: linear-gradient(135deg, #1a0f05 0%, #2d1a08 50%, #0f0d12 100%); } +.w-partcloud{ background: linear-gradient(135deg, #0d1520 0%, #1a2535 60%, #0a0d14 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-snow { background: linear-gradient(135deg, #0d1520 0%, #162035 60%, #0a0d12 100%); } + +.hero::before { + content: ""; + position: absolute; inset: 0; + background: radial-gradient(ellipse 70% 70% at 80% 20%, rgba(255,255,255,0.03) 0%, transparent 70%); + pointer-events: none; +} + +.hero-inner { max-width: 1000px; margin: 0 auto; } + +.hero-meta { + display: flex; align-items: center; gap: 0.5rem; + font-size: 0.85rem; color: var(--muted2); margin-bottom: 1.5rem; + font-weight: 500; +} +.hero-meta svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--orange); } +.hero-meta-sub { color: var(--muted); font-weight: 400; } + +.hero-main { + display: flex; align-items: flex-start; gap: 2rem; + margin-bottom: 2rem; flex-wrap: wrap; +} + +.hero-temp { + font-size: clamp(4.5rem, 12vw, 8rem); + font-weight: 900; line-height: 0.9; + letter-spacing: -0.04em; + color: var(--text); +} + +.hero-desc { + display: flex; flex-direction: column; + justify-content: flex-end; padding-bottom: 0.6rem; +} +.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); } + +.hero-metrics { + display: flex; gap: 0; flex-wrap: wrap; + border: 1px solid rgba(255,255,255,0.08); + border-radius: var(--r); overflow: hidden; +} +.hero-metric { + flex: 1; min-width: 100px; + padding: 0.9rem 1.2rem; + display: flex; flex-direction: column; gap: 0.2rem; + border-right: 1px solid rgba(255,255,255,0.07); + background: rgba(255,255,255,0.025); +} +.hero-metric:last-child { border-right: none; } +.hm-val { font-size: 1.05rem; font-weight: 600; } +.hm-label { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.8px; } + +.hero-station { + max-width: 1000px; margin: 1.5rem auto 0; + font-size: 0.75rem; color: var(--muted); + border-top: 1px solid rgba(255,255,255,0.06); + padding-top: 1rem; +} + +/* ═══════════════════════════════════════════════════════════ + SECTIONS +═══════════════════════════════════════════════════════════ */ +.section { + max-width: 1000px; margin: 0 auto; + padding: 2.5rem 2rem 0; +} + +.section-title { + font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 1.4px; + color: var(--muted); margin-bottom: 1.2rem; +} + +.data-note { + max-width: 1000px; margin: 2.5rem auto 0; + padding: 1.5rem 2rem; + font-size: 0.75rem; color: var(--muted); +} + +/* ═══════════════════════════════════════════════════════════ + HOURLY STRIP +═══════════════════════════════════════════════════════════ */ +.hourly-strip-wrap { + overflow-x: auto; + padding-bottom: 0.75rem; + /* hide scrollbar but keep functionality */ + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.1) transparent; +} +.hourly-strip-wrap::-webkit-scrollbar { height: 4px; } +.hourly-strip-wrap::-webkit-scrollbar-track { background: transparent; } +.hourly-strip-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; } + +.hourly-strip { + display: flex; gap: 0.5rem; + min-width: max-content; padding: 0.25rem 0; +} + +.hcard { + display: flex; flex-direction: column; + align-items: center; gap: 0.3rem; + padding: 0.8rem 0.9rem; + background: var(--glass); + border: 1px solid var(--glass-b); + border-radius: 12px; + min-width: 72px; + transition: background 0.12s; +} +.hcard:hover { background: rgba(255,255,255,0.065); } +.hcard--now { + background: rgba(255,140,50,0.12); + border-color: rgba(255,140,50,0.3); +} + +.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 { font-size: 1.4rem; line-height: 1; margin: 0.2rem 0; } +.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; } +.hcard-precip--prob { color: #7dd3fc; font-weight: 500; } +.hcard-wind { font-size: 0.72rem; color: var(--muted2); } +.hcard-wind small { font-size: 0.62rem; color: var(--muted); } + +/* ═══════════════════════════════════════════════════════════ + CHART +═══════════════════════════════════════════════════════════ */ +.chart-head { + display: flex; align-items: baseline; gap: 0.75rem; + margin-bottom: 1.2rem; +} +.chart-range { + font-size: 0.7rem; font-weight: 600; + background: rgba(255,140,50,0.12); + color: var(--orange); border-radius: 99px; + padding: 0.15rem 0.6rem; letter-spacing: 0.3px; +} +.chart-head .section-title { margin-bottom: 0; } + +.chart-box { + height: 300px; + background: var(--glass); + border: 1px solid var(--glass-b); + border-radius: var(--r); + padding: 1.25rem 1rem 0.75rem; +} + +/* ═══════════════════════════════════════════════════════════ + DAILY LIST +═══════════════════════════════════════════════════════════ */ +.daily-list { + display: flex; flex-direction: column; + border: 1px solid rgba(255,255,255,0.07); + border-radius: var(--r); overflow: hidden; +} + +.drow { + display: grid; + grid-template-columns: 80px 1fr 160px; + align-items: center; + padding: 0.9rem 1.2rem; + border-bottom: 1px solid rgba(255,255,255,0.05); + gap: 1rem; + background: var(--glass); + transition: background 0.12s; +} +.drow:last-child { border-bottom: none; } +.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; } +.drow-dow { font-size: 0.875rem; font-weight: 600; color: var(--text); } + +.drow-bar-wrap { position: relative; } +.drow-bar-track { + height: 6px; background: rgba(255,255,255,0.08); + border-radius: 99px; position: relative; overflow: hidden; +} +.drow-bar { + position: absolute; top: 0; height: 100%; + background: linear-gradient(90deg, var(--blue), var(--orange)); + border-radius: 99px; + min-width: 12px; +} + +.drow-right { + display: flex; align-items: center; + gap: 0.5rem; justify-content: flex-end; + font-size: 0.875rem; font-weight: 600; +} +.drow-min { color: var(--blue); } +.drow-max { color: var(--orange); } +.drow-precip { font-size: 0.72rem; color: var(--muted2); font-weight: 400; margin-left: 0.4rem; } + +/* ═══════════════════════════════════════════════════════════ + RESPONSIVE +═══════════════════════════════════════════════════════════ */ +@media (max-width: 640px) { + .hero { padding: 2rem 1.25rem 1.25rem; } + .section { padding: 2rem 1.25rem 0; } + .drow { grid-template-columns: 64px 1fr 120px; padding: 0.75rem 0.9rem; gap: 0.6rem; } + .hero-metrics { border-radius: 10px; } + .hero-metric { padding: 0.7rem 0.9rem; min-width: 80px; } + .hm-val { font-size: 0.95rem; } + .home-deco { display: none; } + .home-title { font-size: 2.2rem; } + .chart-box { height: 220px; } +} + +@media (max-width: 400px) { + .drow-right { flex-wrap: wrap; justify-content: flex-end; } + .drow-precip { display: none; } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f30e299 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,63 @@ + + + + + + {% block title %}Wetter{% endblock %} + + + + {% block head %}{% endblock %} + + + + +
{% block content %}{% endblock %}
+ + + + +{% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7d77bb7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}DWD Wetter{% endblock %} + +{% block content %} +
+ + +
+

Deutscher Wetterdienst · Open Data

+

Wo willst du
das Wetter wissen?

+ +
+
+ + + + +
    +
    + +
    + + {% if error %}

    {{ error }}

    {% endif %} + +
    + {% for city in ["Berlin","Hamburg","München","Köln","Frankfurt","Dresden","Düsseldorf","Stuttgart"] %} + {{ city }} + {% endfor %} +
    +
    + + +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/weather.html b/templates/weather.html new file mode 100644 index 0000000..e85f54c --- /dev/null +++ b/templates/weather.html @@ -0,0 +1,265 @@ +{% extends "base.html" %} +{% block title %}{{ display_name.split(',')[0] }} – DWD Wetter{% endblock %} + +{% block head %} + +{% endblock %} + +{% 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" %} +{% else %}{% set wclass = "w-clear" %}{% endif %} + + +
    +
    +
    + + {{ display_name.split(',')[0] }} + {{ display_name.split(',')[1:3]|join(',') if ',' in display_name else '' }} +
    + +
    +
    {{ current.temp_c if current.temp_c is not none else "–" }}°
    +
    +
    {{ current.icon or "☁️" }}
    +
    + {% if current.wind_kmh is not none %} + {{ current.wind_kmh }} km/h {{ wind_dir_name(current.wind_dir) }} + {% endif %} + {% if current.cloud_pct is not none %} + {{ current.cloud_pct }}% Bedeckung + {% endif %} + {% if current.pressure_hpa is not none %} + {{ current.pressure_hpa }} hPa + {% endif %} +
    +
    +
    + +
    + {% set items = [ + ("Gefühlt wie", (current.temp_c|string + " °C") if current.temp_c is not none else "–"), + ("Böen", (current.gust_kmh|string + " km/h") if current.gust_kmh is not none else "–"), + ("Niederschlag", (current.precip_mm|string + " mm") if current.precip_mm is not none else "0 mm"), + ("Sonne", (current.sun_min|string + " min/h") if current.sun_min is not none else "–"), + ] %} + {% for label, val in items %} +
    + {{ val }} + {{ label }} +
    + {% endfor %} +
    +
    + +
    + 📡 Station {{ station_name }} ({{ station_dist }} km) · ID {{ station_id }} +
    +
    + + +
    +

    Stundenweise

    +
    +
    + {% for h in forecast %} +
    +
    + {% if h.datetime is string %}{{ h.datetime[11:16] }} + {% else %}{{ h.datetime.strftime('%H:%M') }}{% endif %} +
    +
    + {% if h.datetime is string %}{{ h.datetime[8:10] }}.{{ h.datetime[5:7] }}. + {% else %}{{ h.datetime.strftime('%d.%m.') }}{% endif %} +
    +
    {{ h.icon }}
    +
    + {% if h.temp_c is not none %}{{ h.temp_c }}°{% else %}–{% endif %} +
    + {% if h.precip_mm and h.precip_mm > 0 %} +
    {{ h.precip_mm }} mm
    + {% elif h.rain_prob is not none and h.rain_prob > 0 %} +
    {{ h.rain_prob }}%
    + {% else %} +
    + {% endif %} + {% if h.wind_kmh is not none %} +
    {{ h.wind_kmh }}km/h
    + {% endif %} +
    + {% endfor %} +
    +
    +
    + + +
    +
    +

    Temperatur & Niederschlag

    + 48 Stunden +
    +
    + +
    +
    + + +
    +

    Tagesübersicht

    +
    + {# calc global min/max for bar scaling – do it in a loop to stay Jinja2-safe #} + {% set ns = namespace(g_min=99, g_max=-99) %} + {% for d in daily %} + {% if d.temp_min is not none and d.temp_min < ns.g_min %}{% set ns.g_min = d.temp_min %}{% endif %} + {% if d.temp_max is not none and d.temp_max > ns.g_max %}{% set ns.g_max = d.temp_max %}{% endif %} + {% endfor %} + {% set g_min = ns.g_min %} + {% set g_max = ns.g_max %} + {% set g_range = (g_max - g_min) if (g_max - g_min) > 0 else 1 %} + + {% for d in daily %} +
    +
    + {{ d.icon }} +
    + + {% if d.date is string %} + {% set y = d.date[:4]|int %}{% set mo = d.date[5:7]|int %}{% set dy = d.date[8:10]|int %} + {% set days = ["Mo","Di","Mi","Do","Fr","Sa","So"] %} + {{ dy }}.{{ d.date[5:7] }}. + {% else %} + {{ d.date.strftime('%a') }} + {% endif %} + +
    +
    + +
    + {% if d.temp_min is not none and d.temp_max is not none %} + {% set left_pct = ((d.temp_min - g_min) / g_range * 100)|round(1) %} + {% set width_pct = ((d.temp_max - d.temp_min) / g_range * 100)|round(1) %} +
    +
    +
    + {% endif %} +
    + +
    + {{ d.temp_min }}° + {{ d.temp_max }}° + {% if d.precip > 0 %} + 🌧 {{ d.precip }}mm + {% elif d.rain_prob is not none and d.rain_prob > 0 %} + 💧 {{ d.rain_prob }}% + {% endif %} +
    +
    + {% endfor %} +
    +
    + +

    + Alle Daten: Deutscher Wetterdienst – Open Data (MOSMIX) +

    +{% endblock %} + +{% block scripts %} + +{% endblock %}