modified: app.py
modified: requirements.txt modified: static/css/style.css modified: templates/base.html modified: templates/weather.html
This commit is contained in:
56
app.py
56
app.py
@@ -84,6 +84,27 @@ def _get_berlin():
|
|||||||
import pytz
|
import pytz
|
||||||
return pytz.timezone("Europe/Berlin")
|
return pytz.timezone("Europe/Berlin")
|
||||||
|
|
||||||
|
_TF = None # TimezoneFinder singleton
|
||||||
|
|
||||||
|
def _get_location_tz(lat, lon):
|
||||||
|
"""Return (tzinfo, tz_name) for the given coordinates using timezonefinder."""
|
||||||
|
global _TF
|
||||||
|
try:
|
||||||
|
if _TF is None:
|
||||||
|
from timezonefinder import TimezoneFinder
|
||||||
|
_TF = TimezoneFinder()
|
||||||
|
tz_name = _TF.timezone_at(lat=lat, lng=lon)
|
||||||
|
if tz_name:
|
||||||
|
try:
|
||||||
|
import zoneinfo
|
||||||
|
return zoneinfo.ZoneInfo(tz_name), tz_name
|
||||||
|
except ImportError:
|
||||||
|
import pytz
|
||||||
|
return pytz.timezone(tz_name), tz_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _get_berlin(), "Europe/Berlin"
|
||||||
|
|
||||||
def _normalize_text(value):
|
def _normalize_text(value):
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
@@ -510,26 +531,27 @@ def feels_like(temp_c, wind_kmh, cloud_pct):
|
|||||||
|
|
||||||
def get_sun_times(lat, lon, date=None):
|
def get_sun_times(lat, lon, date=None):
|
||||||
try:
|
try:
|
||||||
loc = LocationInfo(latitude=lat, longitude=lon, timezone="Europe/Berlin")
|
loc_tz, tz_name = _get_location_tz(lat, lon)
|
||||||
|
loc = LocationInfo(latitude=lat, longitude=lon, timezone=tz_name)
|
||||||
d = date or _dt.date.today()
|
d = date or _dt.date.today()
|
||||||
s = astral_sun(loc.observer, date=d, tzinfo=_get_berlin())
|
s = astral_sun(loc.observer, date=d, tzinfo=loc_tz)
|
||||||
return (s["sunrise"].strftime("%H:%M"), s["sunset"].strftime("%H:%M"),
|
return (s["sunrise"].strftime("%H:%M"), s["sunset"].strftime("%H:%M"),
|
||||||
s["dawn"].strftime("%H:%M"), s["dusk"].strftime("%H:%M"))
|
s["dawn"].strftime("%H:%M"), s["dusk"].strftime("%H:%M"))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None, None, None, None
|
return None, None, None, None
|
||||||
|
|
||||||
def _sunrise_sunset(lat, lon, d):
|
def _sunrise_sunset(lat, lon, d):
|
||||||
"""Return (naive_sunrise, naive_sunset) in Berlin local time for date d.
|
"""Return (naive_sunrise, naive_sunset) in location local time for date d.
|
||||||
Results are cached in _sun_cache to avoid recomputing for every forecast hour."""
|
Results are cached in _sun_cache to avoid recomputing for every forecast hour."""
|
||||||
key = (round(lat, 1), round(lon, 1), d)
|
key = (round(lat, 1), round(lon, 1), d)
|
||||||
if key in _sun_cache:
|
if key in _sun_cache:
|
||||||
return _sun_cache[key]
|
return _sun_cache[key]
|
||||||
try:
|
try:
|
||||||
berlin = _get_berlin()
|
loc_tz, tz_name = _get_location_tz(lat, lon)
|
||||||
loc = LocationInfo(latitude=lat, longitude=lon, timezone="Europe/Berlin")
|
loc = LocationInfo(latitude=lat, longitude=lon, timezone=tz_name)
|
||||||
s = astral_sun(loc.observer, date=d, tzinfo=berlin)
|
s = astral_sun(loc.observer, date=d, tzinfo=loc_tz)
|
||||||
sr = pd.Timestamp(s["sunrise"]).tz_convert(berlin).tz_localize(None)
|
sr = pd.Timestamp(s["sunrise"]).tz_convert(loc_tz).tz_localize(None)
|
||||||
ss = pd.Timestamp(s["sunset"]).tz_convert(berlin).tz_localize(None)
|
ss = pd.Timestamp(s["sunset"]).tz_convert(loc_tz).tz_localize(None)
|
||||||
result = (sr, ss)
|
result = (sr, ss)
|
||||||
except Exception:
|
except Exception:
|
||||||
result = (None, None)
|
result = (None, None)
|
||||||
@@ -616,12 +638,13 @@ def wind_direction_name(degrees):
|
|||||||
idx = round(float(degrees)/22.5) % 16
|
idx = round(float(degrees)/22.5) % 16
|
||||||
return dirs[idx]
|
return dirs[idx]
|
||||||
|
|
||||||
def get_mosmix_forecast(lat, lon, hours=72):
|
def get_mosmix_forecast(lat, lon, hours=72, loc_tz=None):
|
||||||
cache_key = (round(lat,2), round(lon,2), hours)
|
cache_key = (round(lat,2), round(lon,2), hours)
|
||||||
if cache_key in _forecast_cache:
|
if cache_key in _forecast_cache:
|
||||||
return _forecast_cache[cache_key]
|
return _forecast_cache[cache_key]
|
||||||
try:
|
try:
|
||||||
berlin = _get_berlin()
|
if loc_tz is None:
|
||||||
|
loc_tz, _ = _get_location_tz(lat, lon)
|
||||||
req = DwdMosmixRequest(parameters=MOSMIX_PARAMS)
|
req = DwdMosmixRequest(parameters=MOSMIX_PARAMS)
|
||||||
nearest = req.filter_by_rank(latlon=(lat, lon), rank=1)
|
nearest = req.filter_by_rank(latlon=(lat, lon), rank=1)
|
||||||
result = nearest.values.all()
|
result = nearest.values.all()
|
||||||
@@ -671,7 +694,7 @@ def get_mosmix_forecast(lat, lon, hours=72):
|
|||||||
ww_raw = p.get("weather_significant")
|
ww_raw = p.get("weather_significant")
|
||||||
weather_code = int(float(ww_raw)) if not _isnan(ww_raw) else None
|
weather_code = int(float(ww_raw)) if not _isnan(ww_raw) 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(loc_tz).tz_localize(None)
|
||||||
# Determine day/night for icon selection
|
# Determine day/night for icon selection
|
||||||
_sr, _ss = _sunrise_sunset(lat, lon, dt_local.date())
|
_sr, _ss = _sunrise_sunset(lat, lon, dt_local.date())
|
||||||
is_night = bool(
|
is_night = bool(
|
||||||
@@ -821,7 +844,8 @@ def wetter():
|
|||||||
lat, lon, display_name, state_hint, location_names = geocode_location(ort)
|
lat, lon, display_name, state_hint, location_names = geocode_location(ort)
|
||||||
if lat is None:
|
if lat is None:
|
||||||
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
|
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
|
||||||
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=240)
|
loc_tz, location_tz = _get_location_tz(lat, lon)
|
||||||
|
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=240, loc_tz=loc_tz)
|
||||||
if not forecast:
|
if not forecast:
|
||||||
return render_template("index.html", error="Keine Wetterdaten verfügbar. Bitte später erneut versuchen.")
|
return render_template("index.html", error="Keine Wetterdaten verfügbar. Bitte später erneut versuchen.")
|
||||||
station_name = mosmix_station.get("name", ort)
|
station_name = mosmix_station.get("name", ort)
|
||||||
@@ -829,15 +853,14 @@ def wetter():
|
|||||||
station_lat = float(mosmix_station.get("latitude", lat))
|
station_lat = float(mosmix_station.get("latitude", lat))
|
||||||
station_lon = float(mosmix_station.get("longitude", lon))
|
station_lon = float(mosmix_station.get("longitude", lon))
|
||||||
station_dist = round(haversine(lat, lon, station_lat, station_lon), 1)
|
station_dist = round(haversine(lat, lon, station_lat, station_lon), 1)
|
||||||
berlin = _get_berlin()
|
now_local_dt = _dt.datetime.now(loc_tz)
|
||||||
now_local_dt = _dt.datetime.now(berlin)
|
|
||||||
now_local = now_local_dt.strftime("%H:%M")
|
now_local = now_local_dt.strftime("%H:%M")
|
||||||
now_berlin_naive = now_local_dt.replace(minute=0, second=0, microsecond=0, tzinfo=None)
|
now_loc_naive = now_local_dt.replace(minute=0, second=0, microsecond=0, tzinfo=None)
|
||||||
current_idx = 0
|
current_idx = 0
|
||||||
for i, h in enumerate(forecast):
|
for i, h in enumerate(forecast):
|
||||||
dt = h["datetime"]
|
dt = h["datetime"]
|
||||||
dt_naive = dt.replace(tzinfo=None) if hasattr(dt,"tzinfo") and dt.tzinfo is not None else dt
|
dt_naive = dt.replace(tzinfo=None) if hasattr(dt,"tzinfo") and dt.tzinfo is not None else dt
|
||||||
if dt_naive >= now_berlin_naive:
|
if dt_naive >= now_loc_naive:
|
||||||
current_idx = i
|
current_idx = i
|
||||||
break
|
break
|
||||||
current = forecast[current_idx]
|
current = forecast[current_idx]
|
||||||
@@ -889,6 +912,7 @@ def wetter():
|
|||||||
ort=ort, display_name=display_name, lat=lat, lon=lon,
|
ort=ort, display_name=display_name, lat=lat, lon=lon,
|
||||||
station_name=station_name, station_id=station_id, station_dist=station_dist,
|
station_name=station_name, station_id=station_id, station_dist=station_dist,
|
||||||
current=current, now_local=now_local,
|
current=current, now_local=now_local,
|
||||||
|
location_tz=location_tz,
|
||||||
pressure_delta=pressure_delta, pressure_trend=pressure_trend,
|
pressure_delta=pressure_delta, pressure_trend=pressure_trend,
|
||||||
temp_delta_6h=temp_delta_6h, temp_trend_6h=temp_trend_6h,
|
temp_delta_6h=temp_delta_6h, temp_trend_6h=temp_trend_6h,
|
||||||
best_window=best_window,
|
best_window=best_window,
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ requests>=2.31.0
|
|||||||
gunicorn>=21.2.0
|
gunicorn>=21.2.0
|
||||||
cachetools>=5.3.0
|
cachetools>=5.3.0
|
||||||
astral>=3.2
|
astral>=3.2
|
||||||
|
timezonefinder>=6.2.0
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ main { flex: 1; }
|
|||||||
.nav-logo span { color: var(--orange); }
|
.nav-logo span { color: var(--orange); }
|
||||||
|
|
||||||
.nav-search { flex: 1; max-width: 420px; margin-left: auto; }
|
.nav-search { flex: 1; max-width: 420px; margin-left: auto; }
|
||||||
|
.nav-clock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85rem; font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.nav-search-wrap { position: relative; display: flex; align-items: center; }
|
.nav-search-wrap { position: relative; display: flex; align-items: center; }
|
||||||
.nav-search-icon {
|
.nav-search-icon {
|
||||||
position: absolute; left: 10px; width: 16px; height: 16px;
|
position: absolute; left: 10px; width: 16px; height: 16px;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<ul class="ac-list" id="nav-ac"></ul>
|
<ul class="ac-list" id="nav-ac"></ul>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<span class="nav-clock" id="nav-clock"></span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
@@ -65,6 +66,30 @@ function setupAC(input, list) {
|
|||||||
}
|
}
|
||||||
setupAC(document.getElementById("nav-ort"), document.getElementById("nav-ac"));
|
setupAC(document.getElementById("nav-ort"), document.getElementById("nav-ac"));
|
||||||
window.setupAC = setupAC;
|
window.setupAC = setupAC;
|
||||||
|
|
||||||
|
// Live clock — shows location's local time when a timezone meta tag is present
|
||||||
|
(function () {
|
||||||
|
const tzMeta = document.querySelector('meta[name="location-tz"]');
|
||||||
|
const tz = tzMeta ? tzMeta.getAttribute("content") : null;
|
||||||
|
const navClock = document.getElementById("nav-clock");
|
||||||
|
const heroTime = document.getElementById("hero-time");
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = new Date();
|
||||||
|
const opts = { hour: "2-digit", minute: "2-digit", hour12: false };
|
||||||
|
let timeStr;
|
||||||
|
try {
|
||||||
|
timeStr = new Intl.DateTimeFormat("de-DE", tz ? { ...opts, timeZone: tz } : opts).format(now);
|
||||||
|
} catch (e) {
|
||||||
|
timeStr = new Intl.DateTimeFormat("de-DE", opts).format(now);
|
||||||
|
}
|
||||||
|
if (navClock) navClock.textContent = timeStr + " Uhr";
|
||||||
|
if (heroTime) heroTime.textContent = timeStr + " Uhr";
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 10000);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block title %}{{ display_name.split(',')[0] }} – Skywatcher{% endblock %}
|
{% block title %}{{ display_name.split(',')[0] }} – Skywatcher{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
<meta name="location-tz" content="{{ location_tz }}">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
{{ display_name.split(',')[0] }}
|
{{ display_name.split(',')[0] }}
|
||||||
<span class="hero-meta-sub">{{ display_name.split(',')[1:3]|join(',') if ',' in display_name else '' }}</span>
|
<span class="hero-meta-sub">{{ display_name.split(',')[1:3]|join(',') if ',' in display_name else '' }}</span>
|
||||||
<span class="hero-meta-time">{{ now_local }} Uhr</span>
|
<span class="hero-meta-time" id="hero-time">{{ now_local }} Uhr</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-main">
|
<div class="hero-main">
|
||||||
|
|||||||
Reference in New Issue
Block a user