initial commit

This commit is contained in:
2026-06-25 23:17:45 +00:00
commit 780fae7df0
11 changed files with 3502 additions and 0 deletions
+834
View File
@@ -0,0 +1,834 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CGI endpoint for on-demand, cached consensus forecasts.
Usage:
GET /api/forecast.py?q=<place>&debug=1 # e.g., "Dallas, TX", "76084"
GET /api/forecast.py?lat=..&lon=.. # explicit coordinates
Geocoding order:
1) OpenMeteo Geocoding (names & postal codes, no key)
2) Nominatim fallback (no key) requires custom UA; ≤1 rps
Weather providers (no key):
- OpenMeteo (daily temp max/min, precip prob max, wind 10 m max; hourly humidity/precip/wind fallbacks)
- api.weather.gov (NWS; period PoP, wind, humidity → daily rollup; requires UserAgent)
- MET Norway Locationforecast (hourly humidity/wind/temp → daily rollup; requires UserAgent)
- 7Timer! civillight (daily temp max/min)
Cache:
24h perlocation under /var/www/WEATHER/public_html/data/locations/<slug>/latest.json
"""
import os
import sys
import json
import re
import time
from datetime import datetime as dt, timezone
from urllib.parse import parse_qs
from collections import defaultdict
from zoneinfo import ZoneInfo
import requests
# -------------------- Paths & Config --------------------
PUBLIC_ROOT = "/var/www/WEATHER/public_html"
DATA_ROOT = os.path.join(PUBLIC_ROOT, "data")
FETCHER_DIR = os.path.join(PUBLIC_ROOT, "fetcher") # config.json lives here
CONFIG_PATH = os.path.join(FETCHER_DIR, "config.json")
CACHE_TTL_SECONDS = 24 * 3600 # 24 hours
NOMINATIM_RATELIMIT_MARK = "/tmp/nominatim_last_call" # crude 1 rps limiter across CGI invocations
# Bump when payload shape changes
# v5 adds: payload["timezone"], payload["all_days"]; cached responses are re-sliced to "today"
SCHEMA_VERSION = 5
def load_config():
with open(CONFIG_PATH, "r") as f:
return json.load(f)
# -------------------- CGI helpers --------------------
def http_json(payload, status=200, cors="*"):
"""Emit CGI headers + JSON body (always)."""
reason = "OK" if status == 200 else "ERROR"
print(f"Status: {status} {reason}")
print("Content-Type: application/json; charset=utf-8")
if cors:
print(f"Access-Control-Allow-Origin: {cors}")
print()
sys.stdout.write(json.dumps(payload, indent=2))
def http_error(message, status=400, debug=None):
body = {"error": message}
if debug is not None:
body["debug"] = debug
http_json(body, status=status)
# -------------------- General helpers --------------------
def parse_query():
qs = os.environ.get("QUERY_STRING", "")
params = parse_qs(qs, keep_blank_values=False)
q = (params.get("q", [None])[0] or "").strip()
debug = (params.get("debug", ["0"])[0] == "1")
lat = params.get("lat", [None])[0]
lon = params.get("lon", [None])[0]
latf = lonf = None
if lat is not None and lon is not None:
try:
latf = float(lat)
lonf = float(lon)
except Exception:
latf = lonf = None
return q, latf, lonf, debug
def slugify(s: str) -> str:
safe = "".join(ch.lower() if ch.isalnum() else "-" for ch in s.strip())
while "--" in safe:
safe = safe.replace("--", "-")
return safe.strip("-")
def ensure_dirs(path):
os.makedirs(path, exist_ok=True)
def file_is_fresh(path, ttl_seconds):
if not os.path.exists(path):
return False
age = time.time() - os.path.getmtime(path)
return 0 <= age < ttl_seconds
def c_to_f(c):
return (float(c) * 9.0 / 5.0) + 32.0
def f_to_c(f):
return (float(f) - 32.0) * 5.0 / 9.0
def mph_to_kmh(m):
return float(m) * 1.609344
def mps_to_mph(s):
return float(s) * 2.23693629
def mps_to_kmh(s):
return float(s) * 3.6
def normalize_temp(value, target_units, source_units):
if value is None:
return None
if target_units == source_units:
return float(value)
if source_units == "metric" and target_units == "imperial":
return c_to_f(value)
if source_units == "imperial" and target_units == "metric":
return f_to_c(value)
return float(value)
def normalize_wind(value, target_units, source_origin):
"""Return wind speed in target_units: imperial=>mph, metric=>km/h."""
if value is None:
return None
v = float(value)
if target_units == "imperial":
if source_origin == "mph":
return v
if source_origin == "kmh":
return v / 1.609344
if source_origin == "mps":
return mps_to_mph(v)
else: # metric
if source_origin == "mph":
return mph_to_kmh(v)
if source_origin == "kmh":
return v
if source_origin == "mps":
return mps_to_kmh(v)
return v
def iso_to_local_date(iso_ts, tz_name):
"""ISO string (maybe Z) -> local YYYY-MM-DD in tz_name."""
if iso_ts.endswith("Z"):
iso_ts = iso_ts.replace("Z", "+00:00")
d = dt.fromisoformat(iso_ts)
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
return d.astimezone(ZoneInfo(tz_name)).strftime("%Y-%m-%d")
# -------------------- Geocoding: overrides + OpenMeteo + Nominatim --------------------
US_STATES = {
"AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California",
"CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "FL": "Florida", "GA": "Georgia",
"HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas",
"KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland", "MA": "Massachusetts",
"MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi", "MO": "Missouri", "MT": "Montana",
"NE": "Nebraska", "NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico",
"NY": "New York", "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma",
"OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina",
"SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", "VT": "Vermont",
"VA": "Virginia", "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming",
"DC": "District of Columbia"
}
_zip_re = re.compile(r"^\s*\d{5}(?:-\d{4})?\s*$")
def norm_key(s: str) -> str:
t = s.lower().strip()
t = re.sub(r"[^\w,\s\-]", " ", t)
t = re.sub(r"\s+", " ", t)
return t
def lookup_override(q: str, overrides: dict):
"""Return dict(name,lat,lon) if q matches an override key or any alias."""
if not overrides:
return None
k = norm_key(q)
if k in overrides:
o = overrides[k]
if "lat" in o and "lon" in o:
name = o.get("name") or q
return {"source": "override", "name": name, "lat": float(o["lat"]), "lon": float(o["lon"]), "admin1": None, "country": None}
for ok, ov in overrides.items():
aliases = ov.get("aliases") or []
for a in aliases:
if norm_key(a) == k and "lat" in ov and "lon" in ov:
name = ov.get("name") or q
return {"source": "override", "name": name, "lat": float(ov["lat"]), "lon": float(ov["lon"]), "admin1": None, "country": None}
return None
def build_om_attempts(query: str):
"""Return list of (variant_string, countryCode or None) to try with OpenMeteo."""
q = (query or "").strip()
attempts = []
if not q:
return attempts
is_zip = bool(_zip_re.match(q))
tokens = re.split(r"[, ]+", q)
last = tokens[-1].upper() if tokens else ""
state_full = US_STATES.get(last)
if is_zip:
attempts.append((q, "US"))
else:
attempts.append((q, None))
if state_full:
city = " ".join(tokens[:-1]).strip(", ")
if city:
attempts.append((f"{city}, {state_full}", "US"))
attempts.append((f"{city}, {last}, US", "US"))
stripped = re.sub(r"[^a-zA-Z0-9 ]+", " ", q)
stripped = re.sub(r"\s+", " ", stripped).strip()
if stripped and stripped != q:
attempts.append((stripped, None))
if any(k in q.lower() for k in [" texas", ", tx", " usa"]):
attempts.append((q + ", United States", "US"))
seen = set(); deduped = []
for name, cc in attempts:
key = (name.lower(), (cc or "").lower())
if key not in seen:
seen.add(key); deduped.append((name, cc))
return deduped
def geocode_open_meteo_first(query, user_agent, language="en", attempts_out=None):
base_url = "https://geocoding-api.open-meteo.com/v1/search"
headers = {"User-Agent": user_agent}
if attempts_out is None:
attempts_out = []
for name, cc in build_om_attempts(query):
attempts_out.append({"geocoder": "open-meteo", "name": name, "countryCode": cc})
params = {"name": name, "count": 1, "language": language, "format": "json"}
if cc:
params["countryCode"] = cc
try:
r = requests.get(base_url, params=params, headers=headers, timeout=15)
r.raise_for_status()
j = r.json()
results = j.get("results") or []
if results:
r0 = results[0]
return {
"source": "open-meteo",
"name": r0.get("name"),
"admin1": r0.get("admin1"),
"country": r0.get("country"),
"lat": float(r0["latitude"]),
"lon": float(r0["longitude"])
}
except Exception as e:
print(f"Open-Meteo geocode error for {name} cc={cc}: {e}", file=sys.stderr)
return None
def _nominatim_rate_limit_once_per_second():
try:
now = time.time()
if os.path.exists(NOMINATIM_RATELIMIT_MARK):
last = os.path.getmtime(NOMINATIM_RATELIMIT_MARK)
wait = 1.0 - (now - last)
if wait > 0:
time.sleep(wait)
with open(NOMINATIM_RATELIMIT_MARK, "w") as f:
f.write(str(now))
except Exception:
pass
def geocode_nominatim_fallback(query, user_agent, email=None, country_hint="us", language="en", attempts_out=None):
if attempts_out is None:
attempts_out = []
q = (query or "").strip()
if not q:
return None
base = "https://nominatim.openstreetmap.org/search"
headers = {"User-Agent": user_agent}
def call_nominatim(params):
_nominatim_rate_limit_once_per_second()
attempts_out.append({"geocoder": "nominatim", **{k: v for k, v in params.items() if k in ("q", "city", "state", "country", "countrycodes")}})
r = requests.get(base, params=params, headers=headers, timeout=15)
r.raise_for_status()
arr = r.json()
if isinstance(arr, list) and arr:
top = arr[0]
lat = float(top["lat"]); lon = float(top["lon"])
addr = top.get("address") or {}
name = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality") or top.get("display_name", q)
admin1 = addr.get("state"); country = addr.get("country")
display = ", ".join([x for x in [name, admin1, country] if x])
return {"source": "nominatim", "name": display or q, "admin1": admin1, "country": country, "lat": lat, "lon": lon}
return None
# A) Freeform with country hint
A = {"q": q, "format": "json", "addressdetails": 1, "limit": 1, "accept-language": language}
if country_hint:
A["countrycodes"] = country_hint.lower()
if email:
A["email"] = email
try:
res = call_nominatim(A)
if res:
return res
except Exception as e:
print(f"Nominatim free-form (hint) error: {e}", file=sys.stderr)
# B) Structured City/State[/Country]
m = re.match(r"^\s*([A-Za-z .'\-]+)[,\s]+\s*([A-Za-z]{2,})\s*$", q)
if m:
city_raw, state_token = m.group(1), m.group(2)
state_full = US_STATES.get(state_token.upper(), state_token)
B = {"city": city_raw.strip(), "state": state_full, "country": "US",
"format": "json", "addressdetails": 1, "limit": 1, "accept-language": language}
if email:
B["email"] = email
try:
res = call_nominatim(B)
if res:
return res
except Exception as e:
print(f"Nominatim structured error: {e}", file=sys.stderr)
# C) Freeform without hint
C = {"q": q, "format": "json", "addressdetails": 1, "limit": 1, "accept-language": language}
if email:
C["email"] = email
try:
res = call_nominatim(C)
if res:
return res
except Exception as e:
print(f"Nominatim free-form (no hint) error: {e}", file=sys.stderr)
return None
# -------------------- Providers (OpenMeteo / NWS / MET / 7Timer) --------------------
def fetch_open_meteo(conf, lat, lon, days, units, tz_name, user_agent):
"""Return per-day dicts: high, low, precip_chance, wind_max, humidity_avg (with hourly fallbacks)."""
url = conf["base_url"]
params = {
"latitude": lat, "longitude": lon,
"daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max",
"hourly": "relative_humidity_2m,precipitation_probability,wind_speed_10m",
"temperature_unit": "fahrenheit" if units == "imperial" else "celsius",
"windspeed_unit": "mph" if units == "imperial" else "kmh",
"timezone": tz_name or "auto"
}
r = requests.get(url, params=params, headers={"User-Agent": user_agent}, timeout=20)
r.raise_for_status()
data = r.json()
out = defaultdict(lambda: {"provider": "open-meteo"})
# ---- daily (direct) ----
daily = data.get("daily") or {}
times_d = daily.get("time") or []
tmax_d = daily.get("temperature_2m_max") or [None] * len(times_d)
tmin_d = daily.get("temperature_2m_min") or [None] * len(times_d)
pmax_d = daily.get("precipitation_probability_max") or [None] * len(times_d)
wmax_d = daily.get("wind_speed_10m_max") or [None] * len(times_d)
for i, dday in enumerate(times_d):
rec = out[dday]
if tmax_d[i] is not None:
rec["high"] = round(float(tmax_d[i]), 1)
if tmin_d[i] is not None:
rec["low"] = round(float(tmin_d[i]), 1)
if pmax_d[i] is not None:
rec["precip_chance"] = int(pmax_d[i])
if wmax_d[i] is not None:
rec["wind_max"] = round(float(wmax_d[i]), 1)
# ---- hourly fallbacks + humidity avg ----
hourly = data.get("hourly") or {}
times_h = hourly.get("time") or []
rh_h = hourly.get("relative_humidity_2m") or []
pop_h = hourly.get("precipitation_probability") or []
wind_h = hourly.get("wind_speed_10m") or []
for t, h in zip(times_h, rh_h):
if h is None:
continue
out[t[:10]].setdefault("_hum_list", []).append(float(h))
for t, p in zip(times_h, pop_h):
if p is None:
continue
dday = t[:10]
cur = out[dday].get("_pop_max")
out[dday]["_pop_max"] = max(cur, int(p)) if cur is not None else int(p)
for t, w in zip(times_h, wind_h):
if w is None:
continue
dday = t[:10]
cur = out[dday].get("_wind_max")
out[dday]["_wind_max"] = max(cur, float(w)) if cur is not None else float(w)
result = []
for dday in sorted(out.keys())[:days]:
rec = out[dday]
if rec.get("_hum_list"):
rec["humidity_avg"] = int(round(sum(rec["_hum_list"]) / len(rec["_hum_list"])))
del rec["_hum_list"]
if "precip_chance" not in rec and rec.get("_pop_max") is not None:
rec["precip_chance"] = int(rec["_pop_max"])
if "_pop_max" in rec:
del rec["_pop_max"]
if "wind_max" not in rec and rec.get("_wind_max") is not None:
rec["wind_max"] = round(float(rec["_wind_max"]), 1)
if "_wind_max" in rec:
del rec["_wind_max"]
rec["date"] = dday
rec["units"] = units
result.append(rec)
return result, data
def _parse_nws_wind_mph(s):
"""Parse 'Calm', '5 mph', '10 to 20 mph' -> number mph (max)."""
if not s or not isinstance(s, str):
return None
s_lower = s.lower()
if "calm" in s_lower:
return 0.0
nums = [float(x) for x in re.findall(r"(\d+(?:\.\d+)?)", s)]
return max(nums) if nums else None
def fetch_nws(conf, lat, lon, days, units, tz_name, user_agent):
base = conf["base_url"].rstrip("/")
headers = {"User-Agent": user_agent}
meta_url = f"{base}/points/{round(lat,4)},{round(lon,4)}"
r_meta = requests.get(meta_url, headers=headers, timeout=20)
r_meta.raise_for_status()
meta = r_meta.json()
forecast_url = (meta.get("properties") or {}).get("forecast")
if not forecast_url:
return [], {"points": meta}
r_fcst = requests.get(forecast_url, headers=headers, timeout=20)
r_fcst.raise_for_status()
fcst = r_fcst.json()
day_buckets = defaultdict(lambda: {"provider": "nws"})
for p in (fcst.get("properties") or {}).get("periods", []):
start = p.get("startTime")
if not start:
continue
day = iso_to_local_date(start, tz_name)
rec = day_buckets[day]
# Temperature (F) -> track both min and max
t = p.get("temperature")
if t is not None:
val = float(normalize_temp(t, units, "imperial"))
rec["high"] = max(rec.get("high", -1e9), val)
rec["low"] = min(rec.get("low", 1e9), val)
# Precip chance (%)
pop = ((p.get("probabilityOfPrecipitation") or {}).get("value"))
if pop is not None:
rec["precip_chance"] = max(int(pop), rec.get("precip_chance", -1))
# Wind ("10 mph" / "10 to 20 mph")
w = _parse_nws_wind_mph(p.get("windSpeed"))
if w is not None:
w_norm = normalize_wind(w, units, "mph")
rec["wind_max"] = max(rec.get("wind_max", -1e9), w_norm)
# Humidity (%)
rh = ((p.get("relativeHumidity") or {}).get("value"))
if rh is not None:
rec.setdefault("_hum_list", []).append(float(rh))
result = []
for day in sorted(day_buckets.keys())[:days]:
rec = day_buckets[day]
if rec.get("_hum_list"):
rec["humidity_avg"] = int(round(sum(rec["_hum_list"]) / len(rec["_hum_list"])))
del rec["_hum_list"]
if "wind_max" in rec:
rec["wind_max"] = round(rec["wind_max"], 1)
if "high" in rec:
rec["high"] = round(rec["high"], 1)
if "low" in rec and rec["low"] != 1e9:
rec["low"] = round(rec["low"], 1)
rec["date"] = day
rec["units"] = units
result.append(rec)
return result, {"points": meta, "forecast": fcst}
def fetch_metno(conf, lat, lon, days, units, tz_name, user_agent):
url = conf["base_url"]
headers = {"User-Agent": user_agent}
params = {"lat": round(lat, 4), "lon": round(lon, 4)}
r = requests.get(url, params=params, headers=headers, timeout=20)
r.raise_for_status()
data = r.json()
day_buckets = defaultdict(lambda: {"provider": "metno"})
for item in (data.get("properties") or {}).get("timeseries", []):
t = item.get("time")
if not t:
continue
day = iso_to_local_date(t, tz_name)
details = ((item.get("data") or {}).get("instant") or {}).get("details") or {}
rh = details.get("relative_humidity") # %
ws = details.get("wind_speed") # m/s
ta = details.get("air_temperature") # °C
if rh is not None:
day_buckets[day].setdefault("_hum_list", []).append(float(rh))
if ws is not None:
v = normalize_wind(ws, units, "mps")
day_buckets[day]["wind_max"] = max(day_buckets[day].get("wind_max", -1e9), v)
if ta is not None:
val = float(ta) if units == "metric" else float(c_to_f(ta))
day_buckets[day].setdefault("_t_list", []).append(val)
result = []
for day in sorted(day_buckets.keys())[:days]:
rec = day_buckets[day]
if rec.get("_hum_list"):
rec["humidity_avg"] = int(round(sum(rec["_hum_list"]) / len(rec["_hum_list"])))
del rec["_hum_list"]
if rec.get("_t_list"):
rec["low"] = round(min(rec["_t_list"]), 1)
del rec["_t_list"]
if "wind_max" in rec:
rec["wind_max"] = round(rec["wind_max"], 1)
rec["date"] = day
rec["units"] = units
result.append(rec)
return result, data
def fetch_seventimer(conf, lat, lon, days, units, tz_name, user_agent):
url = conf["base_url"]
params = {"lon": lon, "lat": lat, "product": "civillight", "output": "json", "unit": "metric"}
r = requests.get(url, params=params, headers={"User-Agent": user_agent}, timeout=20)
r.raise_for_status()
data = r.json()
result = []
for d in (data.get("dataseries") or [])[:days]:
date_raw = d.get("date")
t2m = d.get("temp2m") or {}
tmax_c = t2m.get("max")
tmin_c = t2m.get("min")
if date_raw is None:
continue
s = str(date_raw)
day = f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
rec = {"date": day, "units": units, "provider": "7timer"}
if tmax_c is not None:
rec["high"] = round(float(normalize_temp(tmax_c, target_units=units, source_units="metric")), 1)
if tmin_c is not None:
rec["low"] = round(float(normalize_temp(tmin_c, target_units=units, source_units="metric")), 1)
result.append(rec)
return result, data
# -------------------- Aggregation --------------------
def aggregate_days(provider_lists, units, max_days=None):
by_date = defaultdict(list)
for lst in provider_lists:
for rec in lst:
if rec:
by_date[rec["date"]].append(rec)
def _finite(xs):
return [x for x in xs if isinstance(x, (int, float)) and x == x and abs(x) != float("inf")]
# Unit-aware temperature guardrails
if units == "metric":
TEMP_MIN, TEMP_MAX = -73.3, 60.0 # ~ -100..140 F
else:
TEMP_MIN, TEMP_MAX = -100.0, 140.0
def _clamp_temp(xs):
xs2 = []
for x in _finite(xs):
if TEMP_MIN <= x <= TEMP_MAX:
xs2.append(x)
return xs2
def _clamp_pct(xs):
return [min(100, max(0, int(round(x)))) for x in _finite(xs)]
def _clamp_wind(xs):
# allow 0..200 mph (or ~0..320 km/h)
xs2 = []
for x in _finite(xs):
if 0 <= x <= 200:
xs2.append(x)
return xs2
aggregated = []
for day in sorted(by_date.keys()):
entries = by_date[day]
highs = [e.get("high") for e in entries]
lows = [e.get("low") for e in entries]
pops = [e.get("precip_chance") for e in entries]
winds = [e.get("wind_max") for e in entries]
hums = [e.get("humidity_avg") for e in entries]
# Sanitize / clamp before computing statistics
highs = _clamp_temp(highs)
lows = _clamp_temp(lows)
pops = _clamp_pct(pops)
winds = _clamp_wind(winds)
hums = _clamp_pct(hums)
def mean_or_none(xs): return round(sum(xs)/len(xs), 1) if xs else None
def int_mean_or_none(xs): return int(round(sum(xs)/len(xs))) if xs else None
def median_or_none(xs):
if not xs: return None
s = sorted(xs); n = len(s)
return round(s[n//2], 1) if n % 2 == 1 else round((s[n//2 - 1] + s[n//2]) / 2.0, 1)
day_obj = {
"date": day,
"providers": entries,
"consensus": {
"mean_high": mean_or_none(highs),
"median_high": median_or_none(highs),
"mean_low": mean_or_none(lows),
"median_low": median_or_none(lows),
"precip_chance_mean": int_mean_or_none(pops),
"wind_max_mean": mean_or_none(winds),
"humidity_avg_mean": int_mean_or_none(hums),
"provider_count": len(entries),
"spread_high": (round(max(highs) - min(highs), 1) if len(highs) >= 2 else None),
"spread_low": (round(max(lows) - min(lows), 1) if len(lows) >= 2 else None),
},
"units": units,
"wind_unit": "mph" if units == "imperial" else "km/h",
"humidity_unit": "%",
"precip_unit": "%"
}
aggregated.append(day_obj)
if max_days is not None:
aggregated = aggregated[:max_days]
return aggregated
# -------------------- Display label de-duplication --------------------
def _dedupe_label(name, admin1, country):
parts_in = [p for p in [name, admin1, country] if p]
out = []
seen = set()
combined = []
for p in parts_in:
if p:
combined.extend([s.strip() for s in str(p).split(",") if s and s.strip()])
for s in combined:
key = s.lower()
if key and key not in seen:
seen.add(key)
out.append(s)
return ", ".join(out)
# -------------------- Slicing helpers for "today" in forecast timezone --------------------
def _parse_ymd_safe(s: str):
"""Robust YYYY-M-D or YYYY-MM-DD parser returning a date() (naively in UTC)."""
s2 = (s or "").strip()
m = re.match(r"^\s*(\d{4})-(\d{1,2})-(\d{1,2})\s*$", s2)
if not m:
return None
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
return dt(y, mo, d, tzinfo=timezone.utc).date()
except Exception:
return None
def slice_days_for_timezone(all_days, tz_name, tz_fallback):
"""Given unfiltered all_days, compute today's 7-day window using tz_name."""
try:
today_local_date = dt.now(ZoneInfo(tz_name)).date()
except Exception:
today_local_date = dt.now(ZoneInfo(tz_fallback)).date()
normalized = []
for d in all_days or []:
d_date = _parse_ymd_safe(d.get("date"))
if d_date is None:
continue
nd = dict(d)
nd["__date_obj"] = d_date
normalized.append(nd)
normalized = [d for d in normalized if d["__date_obj"] >= today_local_date]
normalized.sort(key=lambda r: r["__date_obj"])
window = normalized[:7]
for d in window:
d.pop("__date_obj", None)
return window, today_local_date.isoformat()
# -------------------- Main handler --------------------
def run():
conf = load_config()
units = conf.get("units", "imperial") # 'imperial' or 'metric'
days = int(conf.get("days", 7)) # fetch horizon for providers
# Fallback only; actual tz will be detected from OpenMeteo's response (timezone=auto)
tz_fallback = conf["location"].get("timezone", "UTC")
user_agent = conf.get("user_agent") or "WeatherConsensus/1.0 (https://weather.thedarkelite.com, admin@thedarkelite.com)"
nominatim_email = conf.get("nominatim_email") # optional (polite)
overrides = conf.get("geocoding_overrides") or {}
q, lat_in, lon_in, debug_flag = parse_query()
debug_info = {"attempts": []}
# Resolve coordinates
if lat_in is not None and lon_in is not None:
loc = {"name": f"{lat_in:.4f},{lon_in:.4f}", "lat": lat_in, "lon": lon_in}
debug_info["geocode_source"] = "coords"
debug_info["query"] = None
elif q:
debug_info["query"] = q
g = lookup_override(q, overrides)
if g:
debug_info["attempts"].append({"geocoder": "override", "match": norm_key(q)})
else:
g = geocode_open_meteo_first(q, user_agent=user_agent, attempts_out=debug_info["attempts"])
if not g:
hint = "us" if (_zip_re.match(q) or " texas" in q.lower() or re.search(r",\s*[A-Za-z]{2}$", q)) else None
g = geocode_nominatim_fallback(q, user_agent=user_agent, email=nominatim_email, country_hint=hint, attempts_out=debug_info["attempts"])
if not g:
return http_error("Location not found", status=404, debug=debug_info if debug_flag else None)
display_name = _dedupe_label(g.get("name"), g.get("admin1"), g.get("country")) or q
loc = {"name": display_name, "lat": g["lat"], "lon": g["lon"]}
debug_info["geocode_source"] = g.get("source") or "open-meteo/nominatim/override"
else:
return http_error("Missing query. Provide q=city,state or lat & lon.", 400)
# Cache paths
slug = slugify(f"{loc['name']}_{loc['lat']:.4f}_{loc['lon']:.4f}")
loc_dir = os.path.join(DATA_ROOT, "locations", slug)
raw_dir = os.path.join(loc_dir, "raw")
ensure_dirs(raw_dir)
cache_path = os.path.join(loc_dir, "latest.json")
# Try cache; if schema matches, re-slice to "today" using stored timezone + all_days
if file_is_fresh(cache_path, CACHE_TTL_SECONDS):
try:
with open(cache_path, "r") as f:
cached = json.load(f)
if cached.get("schema_version") == SCHEMA_VERSION:
tz_eff = cached.get("timezone") or tz_fallback
all_days = cached.get("all_days") or cached.get("days") or []
sliced, today_iso = slice_days_for_timezone(all_days, tz_eff, tz_fallback)
cached = dict(cached)
cached["days"] = sliced
if debug_flag:
cached["debug"] = {**debug_info, "tz_effective": tz_eff, "today_local_date": today_iso, "from_cache": True}
return http_json(cached, status=200)
except Exception as e:
print(f"Cache read error (ignored): {e}", file=sys.stderr)
# Fetch from providers
provider_lists = []
date_tag = dt.now(timezone.utc).strftime("%Y-%m-%d")
# (A) OpenMeteo FIRST, with timezone=auto to detect the location's tz
tz_effective = tz_fallback
try:
if conf["providers"]["open_meteo"]["enabled"]:
om_conf = dict(conf["providers"]["open_meteo"])
lst, raw = fetch_open_meteo(om_conf, loc["lat"], loc["lon"], days, units, "auto", user_agent)
provider_lists.append(lst)
if isinstance(raw, dict):
tz_effective = raw.get("timezone") or tz_fallback
with open(os.path.join(raw_dir, f"open-meteo_{date_tag}.json"), "w") as f:
json.dump(raw, f, indent=2)
except Exception as e:
print(f"OpenMeteo error: {e}", file=sys.stderr)
# (B) Remaining providers pass tz_effective for correct local-day bucketing
try:
if conf["providers"]["nws"]["enabled"]:
lst, raw = fetch_nws(conf["providers"]["nws"], loc["lat"], loc["lon"], days, units, tz_effective, user_agent)
provider_lists.append(lst)
with open(os.path.join(raw_dir, f"nws_{date_tag}.json"), "w") as f:
json.dump(raw, f, indent=2)
except Exception as e:
print(f"NWS error: {e}", file=sys.stderr)
try:
if conf["providers"]["metno"]["enabled"]:
lst, raw = fetch_metno(conf["providers"]["metno"], loc["lat"], loc["lon"], days, units, tz_effective, user_agent)
provider_lists.append(lst)
with open(os.path.join(raw_dir, f"metno_{date_tag}.json"), "w") as f:
json.dump(raw, f, indent=2)
except Exception as e:
print(f"MET Norway error: {e}", file=sys.stderr)
try:
if conf["providers"]["seventimer"]["enabled"]:
lst, raw = fetch_seventimer(conf["providers"]["seventimer"], loc["lat"], loc["lon"], days, units, tz_effective, user_agent)
provider_lists.append(lst)
with open(os.path.join(raw_dir, f"7timer_{date_tag}.json"), "w") as f:
json.dump(raw, f, indent=2)
except Exception as e:
print(f"7Timer error: {e}", file=sys.stderr)
# Aggregate across providers (unfiltered)
aggregated_full = aggregate_days(provider_lists, units, max_days=None)
# Compute the visible window TODAY..TODAY+6 using the location timezone
days_view, today_iso = slice_days_for_timezone(aggregated_full, tz_effective, tz_fallback)
payload = {
"schema_version": SCHEMA_VERSION,
"location": loc["name"],
"lat": round(loc["lat"], 4),
"lon": round(loc["lon"], 4),
"timezone": tz_effective, # <-- NEW: forecast location timezone
"generated_at_utc": dt.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"units": units,
"all_days": aggregated_full, # <-- NEW: keep unfiltered days in cache
"days": days_view
}
if debug_flag:
payload["debug"] = {
**debug_info,
"tz_effective": tz_effective,
"today_local_date": today_iso,
"first_3_dates_full": sorted({(d.get("date") or "").strip() for d in aggregated_full})[:3]
}
# Save cache & return
try:
with open(cache_path, "w") as f:
json.dump(payload, f, indent=2)
except Exception as e:
print(f"Cache write error (ignored): {e}", file=sys.stderr)
return http_json(payload, status=200)
# -------------------- Entrypoint --------------------
if __name__ == "__main__":
try:
run()
except Exception as ex:
print(f"FATAL: {ex}", file=sys.stderr)
http_error(f"Server error: {ex}", 500)
+596
View File
@@ -0,0 +1,596 @@
/* Weather Consensus — app.v6.4.js */
(function () {
const API_BASE = "/api";
// DOM refs
const form = document.getElementById('searchForm');
const qInput = document.getElementById('q');
const daysEl = document.getElementById('days');
const locationTitle = document.getElementById('locationTitle');
const generatedAt = document.getElementById('generatedAt');
const yearEl = document.getElementById('year');
const themeSelect = document.getElementById('themeMode');
const rainLayer = document.querySelector('.rain');
const snowLayer = document.querySelector('.snow');
const radarPanel = document.getElementById('radarPanel');
if (yearEl) yearEl.textContent = new Date().getFullYear();
// Units + utils
const unitTemp = (u) => u === 'metric' ? '°C' : '°F';
const unitWind = (u) => u === 'metric' ? 'km/h' : 'mph';
const isNum = (v) => Number.isFinite(v);
const el = (tag, cls, html) => { const d=document.createElement(tag); if (cls) d.className=cls; if (html!==undefined) d.innerHTML=html; return d; };
// Dedupe location label
function dedupeLocationLabel(label){
if (!label || typeof label!=='string') return label;
const parts = label.split(',').map(s=>s.trim()).filter(Boolean);
const seen=new Set(), out=[];
for (const p of parts){ const k=p.toLowerCase(); if(!seen.has(k)){ seen.add(k); out.push(p); } }
return out.join(', ');
}
// Deviceagnostic date label (we always format the YMD as UTC so its stable)
function labelFromYMD(ymd){
if (typeof ymd!=='string') return String(ymd ?? '');
const m=ymd.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if(!m) return ymd;
const y=+m[1], mo=+m[2]-1, d=+m[3];
const dt=new Date(Date.UTC(y,mo,d));
return dt.toLocaleDateString(undefined,{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'});
}
// ---- Regionlocal “today” helpers ----
function ymdNowInTZ(tz) {
const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year:'numeric', month:'2-digit', day:'2-digit' });
const parts = Object.fromEntries(fmt.formatToParts(new Date()).map(p => [p.type, p.value]));
return `${parts.year}-${parts.month}-${parts.day}`;
}
function formatInTZ(dateISO, tz) {
try { return new Date(dateISO).toLocaleString(undefined, { timeZone: tz }); }
catch { return new Date(dateISO).toLocaleString(); }
}
// Visual sanity checks
function saneTemp(v,u){ if(!isNum(v)) return null; return (u==='metric') ? ((v>=-73.3&&v<=60.0)?v:null) : ((v>=-100&&v<=140)?v:null); }
function sanePct(v){ if(!isNum(v)) return null; const n=Math.round(v); return (n>=0&&n<=100)?n:null; }
function saneWind(v,u){ if(!isNum(v)) return null; const cap=(u==='metric')?320:200; return (v>=0&&v<=cap)?v:null; }
// Icons
function svgBase(pathD,opts={}){ const ns='http://www.w3.org/2000/svg'; const s=document.createElementNS(ns,'svg'); s.setAttribute('viewBox','0 0 24 24'); s.setAttribute('aria-hidden','true'); const p=document.createElementNS(ns,'path'); for(const [k,v] of Object.entries(opts)){ if(['fill','stroke','stroke-width','stroke-linecap','stroke-linejoin'].includes(k)) p.setAttribute(k,v);} p.setAttribute('d',pathD); s.appendChild(p); return s; }
const iconDroplet=()=>svgBase("M12 2C8.2 7.1 6.2 10.2 6.2 13.1a5.8 5.8 0 0011.6 0c0-2.9-2-6-5.8-11.1z",{fill:'currentColor'});
function iconWind(){ const ns='http://www.w3.org/2000/svg'; const s=document.createElementNS(ns,'svg'); s.setAttribute('viewBox','0 0 24 24'); s.setAttribute('aria-hidden','true'); const p1=document.createElementNS(ns,'path'); p1.setAttribute('fill','none'); p1.setAttribute('stroke','currentColor'); p1.setAttribute('stroke-width','2'); p1.setAttribute('stroke-linecap','round'); p1.setAttribute('stroke-linejoin','round'); p1.setAttribute('d',"M3 8h10a3 3 0 100-6"); const p2=document.createElementNS(ns,'path'); p2.setAttribute('fill','none'); p2.setAttribute('stroke','currentColor'); p2.setAttribute('stroke-width','2'); p2.setAttribute('stroke-linecap','round'); p2.setAttribute('stroke-linejoin','round'); p2.setAttribute('d',"M3 14h14a3 3 0 110 6"); s.append(p1,p2); return s; }
function iconHumidity(){ const ns='http://www.w3.org/2000/svg'; const s=document.createElementNS(ns,'svg'); s.setAttribute('viewBox','0 0 24 24'); s.setAttribute('aria-hidden','true'); const p1=document.createElementNS(ns,'path'); p1.setAttribute('fill','none'); p1.setAttribute('stroke','currentColor'); p1.setAttribute('stroke-width','2'); p1.setAttribute('stroke-linecap','round'); p1.setAttribute('stroke-linejoin','round'); p1.setAttribute('d',"M12 2C7.5 8 5 11 5 14a7 7 0 0014 0c0-3-2.5-6-7-12z"); const p2=document.createElementNS(ns,'path'); p2.setAttribute('fill','none'); p2.setAttribute('stroke','currentColor'); p2.setAttribute('stroke-width','2'); p2.setAttribute('stroke-linecap','round'); p2.setAttribute('d',"M8 16c1.5 1 3 1 4.5 0s3-1 4.5 0"); s.append(p1,p2); return s; }
// API
async function queryForecast(q){
const url=new URL(`${API_BASE}/forecast.py`, window.location.origin);
if(q) url.searchParams.set('q',q);
const res=await fetch(url.toString(),{cache:'no-store'});
const txt=await res.text().catch(()=> '');
if(!res.ok) throw new Error(`API error ${res.status}: ${txt.slice(0,200)}`);
return JSON.parse(txt);
}
// Theme helpers (unchanged)
async function getSunTimes(lat,lon,ymd){
try{
const url=new URL('https://api.open-meteo.com/v1/forecast');
url.searchParams.set('latitude',lat); url.searchParams.set('longitude',lon);
url.searchParams.set('daily','sunrise,sunset'); url.searchParams.set('timezone','auto');
url.searchParams.set('start_date',ymd); url.searchParams.set('end_date',ymd);
const r=await fetch(url.toString(),{cache:'no-store'}); const j=await r.json();
const tz=j.timezone||'UTC'; const daily=j.daily||{};
return { tz, sunrise:(daily.sunrise&&daily.sunrise[0])||null, sunset:(daily.sunset&&daily.sunset[0])||null };
}catch(e){ console.warn('sun times fetch failed',e); return { tz:'UTC', sunrise:null, sunset:null }; }
}
function nowPartsInTZ(tz){
const fmt=new Intl.DateTimeFormat('en-CA',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false});
const parts=fmt.formatToParts(new Date()); const map=Object.fromEntries(parts.map(p=>[p.type,p.value]));
return { ymd:`${map.year}-${map.month}-${map.day}`, hm:`${map.hour}:${map.minute}` };
}
function extractHM(iso){ if(!iso) return null; const m=/T(\d{2}):(\d{2})/.exec(iso); return m ? `${m[1]}:${m[2]}` : null; }
function applyTheme(mode,ctx){ const body=document.body; if(mode==='day') body.setAttribute('data-theme','day'); else if(mode==='night') body.setAttribute('data-theme','night'); else body.setAttribute('data-theme',(ctx&&ctx.isDay)?'day':'night'); }
async function refreshTheme(data){
const mode=(localStorage.getItem('themeMode')||'auto'); if(themeSelect) themeSelect.value=mode;
if(mode==='day'||mode==='night'){ applyTheme(mode); return; }
const today=(data.days&&data.days[0])?data.days[0].date:null; const {lat,lon}=data;
if(!today||!Number.isFinite(lat)||!Number.isFinite(lon)){ applyTheme('auto',{isDay:true}); return; }
const {tz,sunrise,sunset}=await getSunTimes(lat,lon,today); const now=nowPartsInTZ(tz);
const sr=extractHM(sunrise), ss=extractHM(sunset); let isDay=true;
if(now.ymd===today && sr && ss) isDay=(now.hm>=sr && now.hm<ss);
applyTheme('auto',{isDay});
}
// Rain overlay
function ensureRainDrops(){
if(!rainLayer) return; if(rainLayer.childElementCount>0) return;
const COUNT=36; const w=rainLayer.clientWidth||window.innerWidth||1200;
for(let i=0;i<COUNT;i++){ const s=document.createElement('span'); const left=Math.random()*w;
const delay=(Math.random()*1.2).toFixed(2); const dur=(0.9+Math.random()*0.8).toFixed(2);
s.style.left=`${left}px`; s.style.animationDelay=`${delay}s`; s.style.animationDuration=`${dur}s`; rainLayer.appendChild(s); }
}
function updateRainFlag(data){
const body=document.body; const today=(data.days&&data.days[0])?data.days[0]:null;
const pop=today&&today.consensus ? today.consensus.precip_chance_mean : null;
if(isNum(pop) && pop>=40){ body.classList.add('rainy'); ensureRainDrops(); } else { body.classList.remove('rainy'); }
}
// Snow overlay
const SNOW_MIN_POP=40, SNOW_MAX_FLAKES=180;
const SNOW_BASE_PER_400PX={far:18, mid:24, near:32};
let __snowLastWidthBucket=null;
function ensureSnowFlakes(){
if(!snowLayer) return;
const w=snowLayer.clientWidth||window.innerWidth||1200;
const widthBucket=Math.max(400, Math.round(w/200)*200);
if(snowLayer.childElementCount>0 && __snowLastWidthBucket===widthBucket) return;
__snowLastWidthBucket=widthBucket;
snowLayer.innerHTML='';
const scale=widthBucket/400;
let far=Math.round(SNOW_BASE_PER_400PX.far*scale), mid=Math.round(SNOW_BASE_PER_400PX.mid*scale), near=Math.round(SNOW_BASE_PER_400PX.near*scale);
const total=far+mid+near; if(total>SNOW_MAX_FLAKES){ const k=SNOW_MAX_FLAKES/total; far=Math.max(8,Math.floor(far*k)); mid=Math.max(8,Math.floor(mid*k)); near=Math.max(8,Math.floor(near*k)); }
const make=(count,opts)=>{ for(let i=0;i<count;i++){ const s=document.createElement('span');
s.style.left=`${Math.random()*w}px`;
const delay=(Math.random()*opts.delayRange+opts.delayBase).toFixed(2);
const dur=(opts.durBase+Math.random()*opts.durRange).toFixed(2);
const size=(opts.sizeMin+Math.random()*opts.sizeRange).toFixed(1);
const drift=((Math.random()*opts.driftRange)-(opts.driftRange/2)).toFixed(0);
const alpha=(opts.alphaMin+Math.random()*opts.alphaRange).toFixed(2);
s.style.animationDelay=`${delay}s`;
s.style.setProperty('--dur',`${dur}s`);
s.style.setProperty('--size',`${size}px`);
s.style.setProperty('--drift',`${drift}px`);
s.style.setProperty('--alpha',alpha);
snowLayer.appendChild(s); } };
make(far,{delayBase:0,delayRange:6,durBase:10,durRange:6,sizeMin:1.5,sizeRange:2.5,driftRange:40,alphaMin:0.35,alphaRange:0.25});
make(mid,{delayBase:0,delayRange:5,durBase:8, durRange:6,sizeMin:2.5,sizeRange:3.5,driftRange:80,alphaMin:0.45,alphaRange:0.35});
make(near,{delayBase:0,delayRange:4,durBase:6, durRange:5,sizeMin:4, sizeRange:5, driftRange:120,alphaMin:0.55,alphaRange:0.35});
}
function shouldSnow(c,u){
if(!c) return false; const pop=c.precip_chance_mean;
if(!isNum(pop)||pop<SNOW_MIN_POP) return false;
const hi=c.mean_high, lo=c.mean_low;
return (u==='imperial') ? ((isNum(lo)&&lo<=32) || (isNum(hi)&&hi<=36 && isNum(lo)&&lo<=34))
: ((isNum(lo)&&lo<=0) || (isNum(hi)&&hi<=2 && isNum(lo)&&lo<=1));
}
function updateSnowFlag(data){
const body=document.body; const today=(data.days&&data.days[0])?data.days[0]:null; const c=today&&today.consensus;
const show=shouldSnow(c, data.units);
if(show){ body.classList.add('snowy'); body.classList.remove('rainy'); ensureSnowFlakes(); } else { body.classList.remove('snowy'); }
}
let __snowResizeTid=null;
window.addEventListener('resize',()=>{ if(!document.body.classList.contains('snowy')) return; if(__snowResizeTid) clearTimeout(__snowResizeTid); __snowResizeTid=setTimeout(()=>ensureSnowFlakes(),200); });
// Rendering helpers
function metricRow(svg,label,value){ const row=el('div','metric'); const i=el('span','i'); i.appendChild(svg); row.append(i, el('span','name',label), el('span','value',value)); return row; }
// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
// CHANGED: header markup place date and Today badge in a flex row
function renderDay(d,u, todayYMD){
const uT=unitTemp(u), uW=unitWind(u), c=d.consensus||{};
const hi=isNum(c.mean_high)?`${c.mean_high.toFixed(1)}${uT}`:'—';
const lo=isNum(c.mean_low)?`${c.mean_low.toFixed(1)}${uT}`:'—';
const pc=isNum(c.precip_chance_mean)?`${Math.round(c.precip_chance_mean)}%`:'—';
const wm=isNum(c.wind_max_mean)?`${c.wind_max_mean.toFixed(1)} ${uW}`:'—';
const rh=isNum(c.humidity_avg_mean)?`${Math.round(c.humidity_avg_mean)}%`:'—';
const card=el('article','card');
// Build card head with a single-row date line + optional Today badge
const top=el('div','card-head');
const dateRow = el('div','date-row');
const dateText = el('span','date-text', labelFromYMD(d.date));
dateRow.appendChild(dateText);
if (d.date === todayYMD) {
dateRow.appendChild(el('span','today-badge','Today'));
}
top.appendChild(dateRow);
const hiLo=el('div','hi-lo', `<strong>High</strong> ${hi} <strong>Low</strong> ${lo}`);
top.appendChild(hiLo);
card.appendChild(top);
const metrics=el('div','metrics'); metrics.append(
metricRow(iconDroplet(),'Precip chance',pc),
metricRow(iconWind(),'Max wind',wm),
metricRow(iconHumidity(),'Humidity (avg)',rh)
); card.appendChild(metrics);
const providers=el('details','providers'); providers.appendChild(el('summary','', 'Provider breakdown'));
const pre=el('pre','prov-pre',(d.providers||[]).map(p=>{
const bits=[]; const h=isNum(p.high)?p.high:null; const l=isNum(p.low)?p.low:null; const pop=isNum(p.precip_chance)?Math.round(p.precip_chance):null; const w=isNum(p.wind_max)?p.wind_max:null; const hum=isNum(p.humidity_avg)?Math.round(p.humidity_avg):null;
if(h!==null) bits.push(`high=${h}${uT}`); if(l!==null) bits.push(`low=${l}${uT}`); if(pop!==null) bits.push(`precip=${pop}%`); if(w!==null) bits.push(`wind=${w} ${uW}`); if(hum!==null) bits.push(`rh=${hum}%`);
return bits.length ? `${p.provider}: ${bits.join(' · ')}` : `${p.provider}: —`;
}).join('\n')); providers.appendChild(pre); card.appendChild(providers);
return card;
}
// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
// Reversegeocode prettifier for "lat,lon"
function parseLatLonLabel(label){
if (typeof label !== 'string') return null;
const m = label.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/);
if (!m) return null;
const lat = parseFloat(m[1]), lon = parseFloat(m[2]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon };
}
function labelFromNominatim(j){
if (!j) return null;
const a = j.address || {};
const locality = a.city || a.town || a.village || a.hamlet || a.municipality || a.county;
const state = a.state || a.region;
const country = a.country || (a.country_code ? a.country_code.toUpperCase() : null);
const parts = [locality, state, (country && country !== 'US') ? country : null].filter(Boolean);
return parts.length ? parts.join(', ') : (j.display_name || null);
}
const revGeoSessionKey = (lat, lon) => `wc:revgeo:${lat.toFixed(4)},${lon.toFixed(4)}`;
async function reverseGeocodeNominatim(lat, lon){
try{
const key = revGeoSessionKey(lat, lon);
const cached = sessionStorage.getItem(key);
if (cached) return cached;
const url = new URL('https://nominatim.openstreetmap.org/reverse');
url.searchParams.set('lat', String(lat));
url.searchParams.set('lon', String(lon));
url.searchParams.set('format', 'json');
url.searchParams.set('addressdetails', '1');
url.searchParams.set('accept-language', 'en');
const r = await fetch(url.toString(), { cache: 'no-store' });
if (!r.ok) throw new Error(`Nominatim ${r.status}`);
const j = await r.json();
const label = labelFromNominatim(j);
if (label) sessionStorage.setItem(key, label);
return label;
}catch(e){
console.warn('reverseGeocode failed', e);
return null;
}
}
// Rendering root
function render(data){
const tz = data.timezone || 'UTC';
const todayYMD = ymdNowInTZ(tz);
locationTitle.textContent=`Forecast · ${dedupeLocationLabel(data.location)}`;
if (generatedAt) generatedAt.textContent=`Updated (local to forecast): ${formatInTZ(data.generated_at_utc, tz)}`;
daysEl.innerHTML='';
(data.days||[]).slice(0,7).forEach(d=>daysEl.appendChild(renderDay(d, data.units, todayYMD)));
refreshTheme(data); updateRainFlag(data); updateSnowFlag(data);
if (radarPanel && radarPanel.style.display === 'none') {
radarPanel.style.display = 'block';
initRadar();
}
if (Number.isFinite(data.lat) && Number.isFinite(data.lon) && window.__WC_centerRadar) {
window.__WC_centerRadar(data.lat, data.lon);
}
const coords = parseLatLonLabel(data.location);
if (coords){
reverseGeocodeNominatim(coords.lat, coords.lon).then(label=>{
if (label && locationTitle) {
locationTitle.textContent = `Forecast · ${dedupeLocationLabel(label)}`;
if (qInput && (qInput.value.trim() === '' || qInput.value === 'Your location')) qInput.value = label;
}
});
}
}
// Submit handler
async function handleSubmit(e){
e.preventDefault();
const q=(qInput.value||'').trim(); if(!q) return;
closeSuggest();
daysEl.innerHTML='\nLoading…\n';
try{
const data=await queryForecast(q);
render(data);
history.replaceState({}, "", `?q=${encodeURIComponent(q)}`);
saveLastLocation({ q, label: data.location });
}catch(err){
console.error(err);
daysEl.innerHTML=`\nUnable to load forecast. ${err.message || err}\n`;
}
}
if(form) form.addEventListener('submit', handleSubmit);
// Theme control
if(themeSelect){
const saved=(localStorage.getItem('themeMode')||'auto');
themeSelect.value=saved; applyTheme(saved);
themeSelect.addEventListener('change',()=>{ const mode=themeSelect.value||'auto'; localStorage.setItem('themeMode',mode); applyTheme(mode); });
}else applyTheme(localStorage.getItem('themeMode')||'auto');
// --- Autocomplete (unchanged from v6.3) ---
const suggBox=document.createElement('div');
suggBox.className='sugg-box';
suggBox.setAttribute('role','listbox');
suggBox.id='suggestions';
suggBox.style.position='fixed';
suggBox.style.display='none';
suggBox.style.zIndex='10000';
document.body.appendChild(suggBox);
let tId=null; let suppressSuggestOnce=false; let committing=false;
function positionSuggest(anchorEl){
if(!anchorEl || !suggBox || suggBox.style.display!=='block') return;
const rect=anchorEl.getBoundingClientRect();
const vw=window.innerWidth || document.documentElement.clientWidth || 360;
const vh=window.innerHeight|| document.documentElement.clientHeight|| 640;
const isNarrow=vw<=640;
const width=Math.min(520, Math.round(vw * (isNarrow ? 0.96 : 0.86)));
const left = isNarrow ? Math.round((vw - width)/2) : Math.round(rect.left + (rect.width/2) - (width/2));
const top = Math.round(rect.bottom) + 6;
const margin=6;
const clampedLeft=Math.max(margin, Math.min(left, vw - width - margin));
const maxH=Math.max(120, vh - top - margin - 10);
suggBox.style.transform='none';
suggBox.style.left = `${clampedLeft}px`;
suggBox.style.top = `${Math.max(margin, top)}px`;
suggBox.style.width= `${width}px`;
suggBox.style.maxWidth= `${Math.round(vw*0.98)}px`;
suggBox.style.maxHeight= `${maxH}px`;
}
function closeSuggest(){ suggBox.style.display='none'; suggBox.innerHTML=''; }
qInput?.addEventListener('input', ()=>{
const q=qInput.value.trim();
if(tId) clearTimeout(tId);
if(suppressSuggestOnce){ suppressSuggestOnce=false; return; }
if(q.length<2 || committing || document.activeElement!==qInput){ closeSuggest(); return; }
tId=setTimeout(()=>fetchSuggest(q), 200);
});
document.addEventListener('click',(e)=>{ if(!suggBox.contains(e.target) && e.target!==qInput) closeSuggest(); });
window.addEventListener('resize', ()=>{ if(suggBox.style.display==='block') positionSuggest(qInput); });
window.addEventListener('scroll', ()=>{ if(suggBox.style.display==='block') positionSuggest(qInput); }, {passive:true});
async function fetchSuggest(q){
try{
if(committing) return;
const url=new URL('https://geocoding-api.open-meteo.com/v1/search');
url.searchParams.set('name', q);
url.searchParams.set('count','8');
url.searchParams.set('language','en');
url.searchParams.set('format','json');
const r=await fetch(url.toString(),{cache:'no-store'});
const j=await r.json();
const list=(j.results||[]).map(r=>({ label:[r.name, r.admin1, r.country].filter(Boolean).join(', '),
query:[r.name, r.admin1, r.country].filter(Boolean).join(', ') }));
showSuggest(list);
positionSuggest(qInput);
}catch(e){ console.warn('suggest error',e); closeSuggest(); }
}
function showSuggest(items){
suggBox.innerHTML='';
if(!items.length){ closeSuggest(); return; }
items.forEach((it)=>{
const opt=document.createElement('div');
opt.className='sugg-item';
opt.textContent=it.label;
opt.setAttribute('role','option');
opt.setAttribute('tabindex','0');
const choose=(e)=>{
if(e) e.preventDefault();
committing=true;
suppressSuggestOnce=true;
qInput.value=it.query;
qInput.blur();
closeSuggest();
if(form && typeof form.requestSubmit==='function'){ form.requestSubmit(); }
else{ form.dispatchEvent(new Event('submit',{bubbles:true, cancelable:true})); }
setTimeout(()=>{ committing=false; }, 300);
};
opt.addEventListener('click', choose);
opt.addEventListener('keydown', (e)=>{ if(e.key==='Enter'||e.key===' ') choose(e); });
suggBox.appendChild(opt);
});
suggBox.style.display='block';
positionSuggest(qInput);
}
// Coord fetch helper
async function queryForecastByCoords(lat, lon) {
const url = new URL(`${API_BASE}/forecast.py`, window.location.origin);
url.searchParams.set('lat', String(lat));
url.searchParams.set('lon', String(lon));
const res = await fetch(url.toString(), { cache: 'no-store' });
const txt = await res.text().catch(() => '');
if (!res.ok) throw new Error(`API error ${res.status}: ${txt.slice(0,200)}`);
return JSON.parse(txt);
}
// Use current location
const useMyLocationBtn = document.getElementById('useMyLocation');
function showInlineMessage(msg) { if (!daysEl) return; daysEl.innerHTML = `\n${msg}\n`; }
async function handleUseMyLocation() {
if (!('geolocation' in navigator)) { showInlineMessage('Geolocation isnt available in this browser.'); return; }
const prevHTML = daysEl.innerHTML;
showInlineMessage('Getting your location…');
const options = { enableHighAccuracy: false, timeout: 10000, maximumAge: 60_000 };
let settled = false;
const done = () => { settled = true; };
try {
await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, options); })
.then(async (pos) => {
const { latitude, longitude } = pos.coords || {};
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { throw new Error('Got invalid coordinates.'); }
showInlineMessage('Loading forecast for your location…');
const data = await queryForecastByCoords(latitude, longitude);
render(data);
history.replaceState({}, '', `?lat=${latitude.toFixed(4)}&lon=${longitude.toFixed(4)}`);
const pretty = await reverseGeocodeNominatim(latitude, longitude);
if (pretty && qInput) qInput.value = pretty; else if (qInput) qInput.value = 'Your location';
saveLastLocation({ lat: latitude, lon: longitude, label: pretty || `${latitude.toFixed(4)},${longitude.toFixed(4)}` });
}).finally(done);
} catch (err) {
console.error(err);
if (!settled) showInlineMessage('Could not get your location. You can type a city or ZIP instead.');
else showInlineMessage('Could not get your location. You can type a city or ZIP instead.');
setTimeout(() => { if (daysEl && daysEl.innerHTML.includes('Could not get')) daysEl.innerHTML = prevHTML; }, 3000);
}
}
if (useMyLocationBtn) { useMyLocationBtn.addEventListener('click', handleUseMyLocation); }
// Persist/restore last location
const lastLocKey = 'wc:lastLocation';
function saveLastLocation(obj){
try{ const payload = { ...obj, t: Date.now() }; localStorage.setItem(lastLocKey, JSON.stringify(payload)); }catch{}
}
function loadLastLocation(){
try{
const raw = localStorage.getItem(lastLocKey);
if (!raw) return null;
const j = JSON.parse(raw);
if (j && (typeof j.q === 'string' || (Number.isFinite(j.lat) && Number.isFinite(j.lon)))) return j;
return null;
}catch{ return null; }
}
// Bootstrap
const params=new URLSearchParams(window.location.search);
const qParam=params.get('q');
const latParam=params.get('lat'), lonParam=params.get('lon');
if(qParam){ qInput.value=qParam; }
else if(latParam && lonParam){ /* user will submit or use current location */ }
else{
const last = loadLastLocation();
if (last){
(async ()=>{
try{
if (typeof last.q === 'string'){
qInput.value = last.label || last.q;
const data = await queryForecast(last.q);
render(data);
history.replaceState({}, '', `?q=${encodeURIComponent(last.q)}`);
}else if (Number.isFinite(last.lat) && Number.isFinite(last.lon)){
if (qInput) qInput.value = last.label || 'Your last location';
const data = await queryForecastByCoords(last.lat, last.lon);
render(data);
history.replaceState({}, '', `?lat=${last.lat.toFixed(4)}&lon=${last.lon.toFixed(4)}`);
}
}catch(e){
console.warn('restore last location failed', e);
daysEl.innerHTML='\nEnter a city/state or ZIP (e.g., "76084") and tap Get Forecast.\n';
}
})();
}else{
daysEl.innerHTML='\nEnter a city/state or ZIP (e.g., "76084") and tap Get Forecast.\n';
}
}
// ===============================
// Time-enabled Radar (unchanged)
// ===============================
const RADAR_IMG_URL =
'https://mapservices.weather.noaa.gov/eventdriven/rest/services/radar/radar_base_reflectivity_time/ImageServer';
const FRAME_RATE = 1;
const AUTO_PLAY_ON_LOAD = true;
const mapEl = document.getElementById('radarMap');
const playBtn = document.getElementById('radarPlayPause');
const slider = document.getElementById('radarSlider');
const tsLabel = document.getElementById('radarTs');
let dbg = document.getElementById('radarDbg');
if (!dbg) { dbg = document.createElement('div'); dbg.id = 'radarDbg'; dbg.style.cssText = 'margin-top:6px;font-size:12px;color:#9ba3af'; tsLabel?.parentNode?.insertAdjacentElement('afterend', dbg); }
const setDbg = (t) => { if (dbg) dbg.textContent = t; };
let radarMap, baseTiles, imageLayer;
let frames = [], idx = 0, playing = false, animTimer = null, radarInitialized = false;
let stepMs = 10 * 60 * 1000;
function fmtLocal(d){ return d.toLocaleString(); }
async function buildSnappedFrames() {
let startMs, endMs;
try {
const r = await fetch(`${RADAR_IMG_URL}?f=pjson`, { cache: 'no-store' });
const j = await r.json();
if (Array.isArray(j?.timeInfo?.timeExtent) && j.timeInfo.timeExtent.length === 2) {
startMs = j.timeInfo.timeExtent[0];
endMs = j.timeInfo.timeExtent[1];
} else {
endMs = Date.now(); startMs = endMs - 4 * 3600 * 1000;
}
stepMs = (typeof j?.timeInfo?.defaultTimeInterval === 'number' && j.timeInfo.defaultTimeInterval > 0)
? j.timeInfo.defaultTimeInterval : 10 * 60 * 1000;
} catch {
endMs = Date.now(); startMs = endMs - 4 * 3600 * 1000; stepMs = 10 * 60 * 1000;
}
const end = Math.floor(endMs / stepMs) * stepMs;
const out = [];
for (let t = end - 8 * stepMs; t <= end; t += stepMs) { if (t >= startMs) out.push(new Date(t)); }
if (out.length < 2) out.push(new Date(end - stepMs), new Date(end));
return out;
}
function ensureRadarMap() {
if (!mapEl || typeof L === 'undefined' || !L.esri) return;
if (radarMap) return;
radarMap = L.map('radarMap', { preferCanvas:true, zoomControl:true })
.setView([37.5, -96.5], 5);
baseTiles = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap · Radar © NOAA/NWS',
maxZoom: 12
}).addTo(radarMap);
imageLayer = L.esri.imageMapLayer({
url: RADAR_IMG_URL,
f: 'image',
format: 'png32',
opacity: 0.85,
useCors: true
}).addTo(radarMap);
imageLayer.on('load', () => {});
imageLayer.on('error', (e) => { console.warn('ImageServer exportImage error', e); });
}
function showFrame() {
if (!frames.length || !imageLayer) return;
const d = frames[idx];
imageLayer.setTimeRange(d, d);
tsLabel && (tsLabel.textContent = `Showing: ${fmtLocal(d)} (local)`);
slider && (slider.value = String(idx));
setDbg(`Mode: ImageServer (REST time) · frames=${frames.length} · step=${Math.round(stepMs/60000)}min · ${d.toISOString()}`);
}
function play() {
if (playing || frames.length < 2) return;
playing = true; if (playBtn) playBtn.textContent = '❚❚ Pause';
animTimer = setInterval(() => { idx = (idx + 1) % frames.length; showFrame(); }, 1000 / FRAME_RATE);
}
function pause() {
playing = false; if (playBtn) playBtn.textContent = '▶︎ Play';
if (animTimer) { clearInterval(animTimer); animTimer = null; }
}
function toggle(){ playing ? pause() : play(); }
async function initRadar(){
if (radarInitialized) return;
radarInitialized = true;
ensureRadarMap();
if (!radarMap || !imageLayer) return;
frames = await buildSnappedFrames();
idx = frames.length - 1;
if (slider){
slider.min = '0'; slider.max = String(frames.length - 1); slider.value = String(idx);
slider.disabled = false;
slider.addEventListener('input', () => { idx = +slider.value; showFrame(); });
}
if (playBtn){
playBtn.disabled = false;
playBtn.addEventListener('click', toggle);
}
showFrame();
if (AUTO_PLAY_ON_LOAD) play();
setInterval(async ()=>{
const fresh = await buildSnappedFrames();
if (fresh.length){
const wasAtEnd = (idx >= frames.length - 2);
frames = fresh;
if (slider) slider.max = String(frames.length - 1);
if (wasAtEnd) idx = frames.length - 1;
showFrame();
}
}, 300000);
}
window.__WC_centerRadar = function(lat, lon){
try{
ensureRadarMap();
if (!radarMap || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
const z = (lon > -170 && lon < -50 && lat > 15 && lat < 60) ? 7 : 5;
radarMap.setView([lat, lon], z, { animate: true });
}catch(e){}
};
})();
+12
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import os, time, shutil
DATA_ROOT = "/var/www/WEATHER/public_html/data"
TTL = 24 * 3600 # 24 hours
def is_old(path):
try:
age = time.time() - os.path.getmtime(path)
return age > TTL
except Exception:
return False
def main():
# Remove old files
for root, dirs, files in os.walk(DATA_ROOT, topdown=False):
for name in files:
p = os.path.join(root, name)
if is_old(p):
try:
os.remove(p)
except Exception as e:
print(f"Failed to remove {p}: {e}")
# Remove empty dirs
for d in dirs:
dp = os.path.join(root, d)
try:
if not os.listdir(dp):
os.rmdir(dp)
except Exception:
pass
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
{
"location": {
"name": "Seattle",
"lat": 47.6062,
"lon": -122.3321
},
"providers": {
"openweathermap": {
"enabled": true,
"api_key": "YOUR_OPENWEATHERMAP_KEY",
"base_url": "https://api.openweathermap.org/data/2.5/forecast"
},
"weatherapi": {
"enabled": true,
"api_key": "YOUR_WEATHERAPI_KEY",
"base_url": "https://api.weatherapi.com/v1/forecast.json"
}
},
"output_dir": "/var/www/WEATHER/public_html/data",
"days": 5,
"units": "imperial"
}
+36
View File
@@ -0,0 +1,36 @@
{
"location": {
"name": "Venus, TX",
"lat": 32.4330,
"lon": -97.1010,
"timezone": "America/Chicago"
},
"output_dir": "/var/www/WEATHER/public_html/data",
"days": 7,
"units": "imperial",
"user_agent": "WeatherConsensus/1.0 (https://weather.thedarkelite.com, info@thedarkelite.com)",
"nominatim_email": "info@thedarkelite.com",
"providers": {
"open_meteo": {
"enabled": true,
"base_url": "https://api.open-meteo.com/v1/forecast"
},
"nws": {
"enabled": true,
"base_url": "https://api.weather.gov"
},
"metno": {
"enabled": true,
"base_url": "https://api.met.no/weatherapi/locationforecast/2.0/compact"
},
"seventimer": {
"enabled": true,
"base_url": "http://www.7timer.info/bin/api.pl"
}
}
}
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""
Fetches weather from *no-key* providers, normalizes units, aggregates to a daily consensus,
and writes JSON outputs into /var/www/WEATHER/public_html/data/.
Providers:
- Open-Meteo (no key) -> daily temperature_2m_max (C/F) [https://open-meteo.com]
- api.weather.gov (no key; User-Agent required) -> periods [https://api.weather.gov]
- MET Norway Locationforecast (no key; User-Agent) -> hourly [https://api.met.no]
- 7Timer! civillight (no key) -> daily max/min [http://www.7timer.info]
Outputs:
data/raw/<provider>_<YYYY-MM-DD>_<Location>.json (raw)
data/aggregated_<YYYY-MM-DD>_<Location>.json (aggregated daily consensus)
data/latest.json (copy of aggregated)
"""
import os
import json
from datetime import datetime as dt
from datetime import timezone
from collections import defaultdict
from zoneinfo import ZoneInfo # stdlib (Python 3.9+)
import requests
# ---------------------- Utilities ----------------------
def here(*parts):
return os.path.join(os.path.dirname(os.path.abspath(__file__)), *parts)
def load_config():
conf_path = here("config.json")
if not os.path.exists(conf_path):
raise FileNotFoundError(
f"Missing config.json at {conf_path}. Copy config.example.json and fill in your values."
)
with open(conf_path, "r") as f:
return json.load(f)
def ensure_dirs(output_dir):
raw_dir = os.path.join(output_dir, "raw")
os.makedirs(raw_dir, exist_ok=True)
return raw_dir
def date_str_utc():
return dt.now(timezone.utc).strftime("%Y-%m-%d")
def c_to_f(c): return (float(c) * 9.0/5.0) + 32.0
def f_to_c(f): return (float(f) - 32.0) * 5.0/9.0
def normalize_temp(value, target_units, source_units):
"""Return temp in target_units ('imperial' or 'metric') given source_units."""
if value is None:
return None
if target_units == source_units:
return float(value)
if source_units == "metric" and target_units == "imperial":
return c_to_f(value)
if source_units == "imperial" and target_units == "metric":
return f_to_c(value)
# default passthrough
return float(value)
def iso_to_local_date(iso_ts, tz_name):
"""
Convert ISO-8601 string to local YYYY-MM-DD using zoneinfo tz.
Accepts Z or +00:00. Returns date string.
"""
if iso_ts.endswith("Z"):
iso_ts = iso_ts.replace("Z", "+00:00")
dt_utc = dt.fromisoformat(iso_ts)
if dt_utc.tzinfo is None:
dt_utc = dt_utc.replace(tzinfo=timezone.utc)
local = dt_utc.astimezone(ZoneInfo(tz_name))
return local.strftime("%Y-%m-%d")
# ---------------------- Providers ----------------------
def fetch_open_meteo(provider_conf, lat, lon, days, units, tz_name):
"""
Open-Meteo: request daily max temperature. Supports temperature_unit and timezone parameters.
No API key required.
"""
url = provider_conf["base_url"]
params = {
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max",
"timezone": tz_name or "auto",
"temperature_unit": "fahrenheit" if units == "imperial" else "celsius"
}
r = requests.get(url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
result = []
daily = (data.get("daily") or {})
times = daily.get("time") or []
highs = daily.get("temperature_2m_max") or []
for t, h in list(zip(times, highs))[:days]:
if h is None:
continue
result.append({
"date": t, # already aligned to requested timezone
"high": round(float(h), 1),
"units": units,
"provider": "open-meteo"
})
return result, data
def fetch_nws(provider_conf, lat, lon, days, units, tz_name, user_agent):
"""
NWS api.weather.gov:
1) /points/{lat},{lon} -> get 'forecast' URL
2) GET forecast -> 'properties.periods' (local time). Temperatures usually in Fahrenheit.
Requires a User-Agent header.
"""
base = provider_conf["base_url"].rstrip("/")
headers = {"User-Agent": user_agent}
# Metadata to find forecast URL
meta_url = f"{base}/points/{round(lat,4)},{round(lon,4)}"
r_meta = requests.get(meta_url, headers=headers, timeout=20)
r_meta.raise_for_status()
meta = r_meta.json()
forecast_url = (meta.get("properties") or {}).get("forecast")
if not forecast_url:
return [], {"points": meta}
r_fcst = requests.get(forecast_url, headers=headers, timeout=20)
r_fcst.raise_for_status()
fcst = r_fcst.json()
by_date = defaultdict(list)
for p in (fcst.get("properties") or {}).get("periods", []):
start = p.get("startTime")
temp = p.get("temperature")
unit = p.get("temperatureUnit") # e.g., 'F'
if start is None or temp is None:
continue
# Convert to configured timezone day
day_key = iso_to_local_date(start, tz_name)
# Normalize temperature to requested display units
src_units = "imperial" if str(unit).upper() == "F" else "metric"
temp_norm = normalize_temp(temp, target_units=units, source_units=src_units)
by_date[day_key].append(temp_norm)
result = []
for day in sorted(by_date.keys())[:days]:
# Take the max observed temp for that calendar day
vals = [v for v in by_date[day] if isinstance(v, (int, float))]
if not vals:
continue
result.append({
"date": day,
"high": round(max(vals), 1),
"units": units,
"provider": "nws"
})
# Return both combined raw (meta + forecast)
return result, {"points": meta, "forecast": fcst}
def fetch_metno(provider_conf, lat, lon, days, units, tz_name, user_agent):
"""
MET Norway Locationforecast/2.0 compact (GeoJSON-like).
'properties.timeseries' with hourly 'instant.details.air_temperature' (°C).
User-Agent is required by policy.
"""
url = provider_conf["base_url"]
headers = {"User-Agent": user_agent}
params = {"lat": round(lat, 4), "lon": round(lon, 4)}
r = requests.get(url, headers=headers, params=params, timeout=20)
r.raise_for_status()
data = r.json()
by_date = defaultdict(list)
for item in (data.get("properties") or {}).get("timeseries", []):
t = item.get("time")
details = ((item.get("data") or {}).get("instant") or {}).get("details") or {}
temp_c = details.get("air_temperature")
if t is None or temp_c is None:
continue
# Convert timestamp to local calendar day for bucketing
day_key = iso_to_local_date(t, tz_name)
# Normalize to requested units
val = normalize_temp(temp_c, target_units=units, source_units="metric")
by_date[day_key].append(val)
result = []
for day in sorted(by_date.keys())[:days]:
vals = [v for v in by_date[day] if isinstance(v, (int, float))]
if not vals:
continue
result.append({
"date": day,
"high": round(max(vals), 1),
"units": units,
"provider": "metno"
})
return result, data
def fetch_seventimer(provider_conf, lat, lon, days, units, tz_name):
"""
7Timer! civillight:
JSON shape: { product:'civillight', init:'YYYYMMDDHH', dataseries:[ { date:YYYYMMDD, temp2m:{max,min}, ... } ] }
Values are in metric by default; we request metric explicitly.
"""
url = provider_conf["base_url"]
params = {
"lon": lon,
"lat": lat,
"product": "civillight",
"output": "json",
"unit": "metric"
}
r = requests.get(url, params=params, timeout=20)
r.raise_for_status()
data = r.json()
result = []
for d in (data.get("dataseries") or [])[:days]:
date_raw = d.get("date") # e.g. 20250210
t2m = d.get("temp2m") or {}
tmax_c = t2m.get("max")
if date_raw is None or tmax_c is None:
continue
# 7Timer uses YYYYMMDD integer; convert to ISO date string
s = str(date_raw)
day = f"{s[0:4]}-{s[4:6]}-{s[6:8]}"
high = normalize_temp(tmax_c, target_units=units, source_units="metric")
result.append({
"date": day,
"high": round(float(high), 1),
"units": units,
"provider": "7timer"
})
return result, data
# ---------------------- Aggregation ----------------------
def aggregate_daily_highs(provider_forecasts, units, max_days=None):
"""
Combine daily highs across providers.
Output per date: providers[], consensus{mean, median, spread, provider_count}, units
"""
by_date = defaultdict(list)
for lst in provider_forecasts:
for rec in lst:
if rec and isinstance(rec.get("high"), (int, float)):
by_date[rec["date"]].append(rec)
aggregated = []
for day in sorted(by_date.keys()):
entries = by_date[day]
highs = [e["high"] for e in entries if isinstance(e["high"], (int, float))]
if not highs:
continue
highs_sorted = sorted(highs)
n = len(highs_sorted)
mean_val = sum(highs_sorted) / n
if n % 2 == 1:
median_val = highs_sorted[n // 2]
else:
median_val = (highs_sorted[n // 2 - 1] + highs_sorted[n // 2]) / 2.0
spread = highs_sorted[-1] - highs_sorted[0]
aggregated.append({
"date": day,
"providers": entries,
"consensus": {
"mean_high": round(mean_val, 1),
"median_high": round(median_val, 1),
"provider_count": n,
"spread": round(spread, 1)
},
"units": units
})
if max_days is not None:
aggregated = aggregated[:max_days]
return aggregated
# ---------------------- Main ----------------------
def main():
conf = load_config()
out_dir = conf["output_dir"]
os.makedirs(out_dir, exist_ok=True)
raw_dir = ensure_dirs(out_dir)
days = int(conf.get("days", 5))
units = conf.get("units", "imperial") # 'imperial' or 'metric'
lat = float(conf["location"]["lat"])
lon = float(conf["location"]["lon"])
loc_name = conf["location"]["name"]
tz_name = conf["location"].get("timezone") or "UTC"
user_agent = conf.get("user_agent", "WeatherConsensus/1.0 (contact: example@example.com)")
date_tag = date_str_utc()
provider_results = []
raw_bundle = {}
# Open-Meteo
try:
if conf["providers"]["open_meteo"]["enabled"]:
om_list, om_raw = fetch_open_meteo(conf["providers"]["open_meteo"], lat, lon, days, units, tz_name)
provider_results.append(om_list)
raw_bundle["open_meteo"] = om_raw
with open(os.path.join(raw_dir, f"open-meteo_{date_tag}_{loc_name}.json"), "w") as f:
json.dump(om_raw, f, indent=2)
except Exception as e:
print("Open-Meteo error:", repr(e))
# NWS (api.weather.gov)
try:
if conf["providers"]["nws"]["enabled"]:
nws_list, nws_raw = fetch_nws(conf["providers"]["nws"], lat, lon, days, units, tz_name, user_agent)
provider_results.append(nws_list)
raw_bundle["nws"] = nws_raw
with open(os.path.join(raw_dir, f"nws_{date_tag}_{loc_name}.json"), "w") as f:
json.dump(nws_raw, f, indent=2)
except Exception as e:
print("NWS error:", repr(e))
# MET Norway
try:
if conf["providers"]["metno"]["enabled"]:
met_list, met_raw = fetch_metno(conf["providers"]["metno"], lat, lon, days, units, tz_name, user_agent)
provider_results.append(met_list)
raw_bundle["metno"] = met_raw
with open(os.path.join(raw_dir, f"metno_{date_tag}_{loc_name}.json"), "w") as f:
json.dump(met_raw, f, indent=2)
except Exception as e:
print("MET Norway error:", repr(e))
# 7Timer!
try:
if conf["providers"]["seventimer"]["enabled"]:
st_list, st_raw = fetch_seventimer(conf["providers"]["seventimer"], lat, lon, days, units, tz_name)
provider_results.append(st_list)
raw_bundle["7timer"] = st_raw
with open(os.path.join(raw_dir, f"7timer_{date_tag}_{loc_name}.json"), "w") as f:
json.dump(st_raw, f, indent=2)
except Exception as e:
print("7Timer error:", repr(e))
# Aggregate across providers
aggregated = aggregate_daily_highs(provider_forecasts=provider_results, units=units, max_days=days)
# Write aggregated & latest
agg_path = os.path.join(out_dir, f"aggregated_{date_tag}_{loc_name}.json")
with open(agg_path, "w") as f:
json.dump({
"location": loc_name,
"generated_at_utc": dt.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"units": units,
"days": aggregated
}, f, indent=2)
# Update latest.json
latest_path = os.path.join(out_dir, "latest.json")
try:
with open(agg_path, "r") as src, open(latest_path, "w") as dst:
dst.write(src.read())
except Exception as e:
print("Failed to update latest.json:", repr(e))
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Weather Consensus</title>
<meta name="description"
content="Aggregated weather forecast: temperature, precipitation chance, wind, and humidity from multiple providers." />
<!-- Prevent favicon 404 noise -->
<link rel="icon" href="data:," />
<!-- Site styles -->
<link rel="stylesheet" href="./styles.css" />
<!-- Leaflet CSS (LOCAL, same folder as index.html) -->
<link rel="stylesheet" href="./leaflet.css" />
</head>
<body data-theme="night">
<!-- Multi-color top bar -->
<div class="topbar"></div>
<!-- HERO with subtle animated clouds / lightning / optional rain/snow -->
<header class="site-header">
<div class="hero">
<!-- Daytime sun / Nighttime moon (CSS-driven) -->
<div class="sun" aria-hidden="true"><span class="disc"></span></div>
<div class="moon" aria-hidden="true"><span class="disc"></span></div>
<!-- Cloud layers -->
<div class="clouds layer-back" aria-hidden="true">
<svg viewBox="0 0 800 200" preserveAspectRatio="none">
<defs>
<linearGradient id="cloudGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.25" />
<stop offset="100%" stop-color="#d7e3ff" stop-opacity="0.07" />
</linearGradient>
</defs>
<path fill="url(#cloudGrad)"
d="M0,120 C120,100 180,140 300,120 C420,100 480,145 600,125 C720,105 780,140 800,130 L800,200 L0,200 Z" />
</svg>
</div>
<div class="clouds layer-front" aria-hidden="true">
<svg viewBox="0 0 800 200" preserveAspectRatio="none">
<path fill="rgba(255,255,255,.15)"
d="M0,140 C160,120 220,160 360,140 C500,120 560,165 700,145 C760,137 790,150 800,148 L800,200 L0,200 Z" />
</svg>
</div>
<!-- Decorative effects -->
<div class="lightning" aria-hidden="true"></div>
<div class="rain" aria-hidden="true"></div>
<div class="snow" aria-hidden="true"></div>
<div class="container hero-inner">
<h1>Weather Consensus</h1>
<p class="tagline">Aggregated forecast from multiple providers</p>
<!-- Controls -->
<div class="controls">
<form id="searchForm" class="search" role="search" aria-label="Search for a location"
autocomplete="off">
<input id="q" type="text" inputmode="search"
placeholder='Enter city & state or ZIP (e.g., "Dallas, TX" or "76084")'
aria-autocomplete="list" aria-haspopup="listbox" aria-controls="suggestions"
aria-expanded="false" />
<button type="submit" aria-label="Get forecast for the location entered">Get Forecast</button>
<button id="useMyLocation" type="button" aria-label="Use current location">Use current
location</button>
</form>
<label class="theme-toggle" for="themeMode" title="Theme">
<span>Theme</span>
<select id="themeMode">
<option value="auto" selected>Auto</option>
<option value="day">Day</option>
<option value="night">Night</option>
</select>
</label>
</div>
</div>
</div>
</header>
<main class="container">
<!-- Summary -->
<section id="summary" class="panel" aria-live="polite">
<h2 id="locationTitle" style="margin-top:0">Enter a location to see the forecast</h2>
<p id="generatedAt"></p>
</section>
<!-- Forecast cards -->
<section id="days" class="grid" aria-label="Daily forecast cards"></section>
<!-- Radar (hidden until weather data loads) -->
<section id="radarPanel" class="panel" style="display:none;">
<h2 style="margin-top:0">Live Radar (last ~4 hours)</h2>
<div id="radarMap" style="height:420px; border-radius:12px; overflow:hidden;"></div>
<div id="radarControls" style="display:flex; align-items:center; gap:.6rem; margin-top:.6rem;">
<button id="radarPlayPause" type="button">▶︎ Play</button>
<input id="radarSlider" type="range" min="0" max="0" value="0" step="1" style="flex:1;" />
<span id="radarTs" class="micro" style="color:#98a2b3; min-width:220px; text-align:right;"></span>
</div>
<p class="micro" style="color:#98a2b3">
Radar © NOAA/NWS (MRMS) · timeenabled imagery (WMS) with ~4hour rolling window and ~5minute cadence.
</p>
</section>
<!-- Sources / Attribution -->
<section class="panel">
<details>
<summary>Data sources</summary>
<ul>
<li>Geocoding: OpenMeteo (primary), Nominatim / OpenStreetMap (fallback)</li>
<li>Weather providers: OpenMeteo, National Weather Service (api.weather.gov), MET Norway, 7Timer!
</li>
<li>Radar: NOAA/NWS MRMS via public OGC services (WMS/WMTS)</li>
</ul>
<p class="micro" style="color:#98a2b3">
We cache results for 24 hours to minimize network calls and improve performance.
</p>
</details>
</section>
</main>
<footer class="site-footer">
<div class="container">
<span>© <span id="year"></span> WEATHER</span>
</div>
</footer>
<!-- Leaflet JS (LOCAL, same folder as index.html) -->
<script src="./leaflet.js"></script>
<!-- Esri Leaflet (new, local) -->
<script src="./esri-leaflet.js"></script>
<!-- Your app; must come after Leaflet -->
<script src="./app.v6.4.js" defer></script>
</body>
</html>
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+6
View File
File diff suppressed because one or more lines are too long
+782
View File
@@ -0,0 +1,782 @@
/* === Weather Consensus Enhanced Theme with Day/Night + Rain + Snow === */
/* ---------- Design tokens (Night default) ---------- */
:root {
--bg: #0b1220;
--bg-2: #0d1526;
--panel: #10192b;
--panel-2: #0f1726;
--card: #121c31;
--card-2: #16223a;
--muted: #98a2b3;
--text: #e6eefc;
--brand: #5ea0ff;
--accent: #8ef2d0;
--accent-2: #b388ff;
--warning: #ffd166;
--error: #ff6b6b;
--border: #1e2a42;
--glass: rgba(255, 255, 255, 0.05);
--shadow-lg: 0 20px 45px rgba(0, 0, 0, .35);
--shadow-md: 0 10px 25px rgba(0, 0, 0, .28);
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 10px;
}
/* ---------- Day theme overrides (darker sky) ---------- */
body[data-theme="day"] {
--bg: #9ec7ff;
/* zenith */
--bg-2: #cfe4ff;
/* near horizon */
--panel: #ffffff;
--panel-2: #f6fbff;
--card: #ffffff;
--card-2: #f4f9ff;
--text: #0e1c33;
--muted: #3c4b63;
--border: #c0d6f7;
--brand: #2e75ff;
--accent: #00bba0;
--accent-2: #8a75ff;
}
/* ---------- Base ---------- */
* {
box-sizing: border-box
}
html,
body {
height: 100%
}
body {
margin: 0;
background:
radial-gradient(1200px 600px at 70% -10%, #1b2a4a33 0%, transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 60%, var(--bg) 100%);
color: var(--text);
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial;
letter-spacing: .2px;
}
/* subtle animated star dots (night only) */
body:not([data-theme="day"])::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
radial-gradient(1px 1px at 10% 20%, #fff2 40%, transparent 41%),
radial-gradient(1px 1px at 80% 30%, #fff3 40%, transparent 41%),
radial-gradient(1px 1px at 50% 70%, #fff2 40%, transparent 41%),
radial-gradient(1px 1px at 20% 80%, #fff1 40%, transparent 41%);
animation: twinkle 8s linear infinite;
}
@keyframes twinkle {
0%,
100% {
opacity: .4
}
50% {
opacity: .8
}
}
.container {
width: min(1100px, 92vw);
margin: 0 auto;
padding: 1rem;
position: relative;
z-index: 2
}
/* ---------- Top gradient bar ---------- */
.topbar {
height: 6px;
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 5;
background: linear-gradient(90deg,
#4ea2ff 0%,
#7b86ff 20%,
#b07cff 40%,
#ff7fd1 60%,
#77e1c6 80%,
#4ea2ff 100%);
box-shadow: 0 2px 10px rgba(94, 160, 255, .6);
}
/* ---------- Header / Hero with clouds ---------- */
.site-header {
padding-top: 24px
}
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid #12203b;
background: radial-gradient(800px 240px at 50% 0%, #1d2b48aa 0%, transparent 70%);
}
/* Day: softer atmospheric glow */
body[data-theme="day"] .hero {
background: radial-gradient(900px 260px at 70% -10%,
rgba(255, 255, 255, 0.65) 0%,
rgba(255, 255, 255, 0.15) 40%,
transparent 75%);
}
.hero-inner {
padding: 2.6rem 1rem 1.1rem
}
.controls {
display: flex;
gap: .9rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-top: .6rem;
}
.site-header h1 {
margin: .25rem 0 .35rem;
font-size: 2.1rem;
font-weight: 800;
letter-spacing: .3px;
text-shadow: 0 3px 16px rgba(0, 0, 0, .35);
}
body[data-theme="day"] .site-header h1 {
text-shadow: none
}
.tagline {
color: var(--muted);
margin: 0 .5rem .9rem;
text-align: center
}
/* ---------- Minimal modern sun (day only) ---------- */
.sun {
position: absolute;
top: 20px;
right: 40px;
width: 90px;
height: 90px;
display: none;
z-index: 0;
}
body[data-theme="day"] .sun {
display: block;
}
.sun .disc {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%,
#fff7c2 0%,
#ffe169 45%,
#ffc94d 75%,
#ffb84b 100%);
box-shadow:
0 0 45px 12px rgba(255, 215, 91, 0.45),
0 0 120px 40px rgba(255, 215, 91, 0.15);
}
.sun .ray {
display: none !important;
}
/* if old markup exists */
/* ---------- Minimal modern moon (night only) ---------- */
.moon {
position: absolute;
top: 20px;
/* matches sun placement for symmetry */
right: 40px;
width: 84px;
height: 84px;
display: none;
z-index: 0;
/* behind clouds/precip, same as sun */
}
body[data-theme="night"] .moon {
display: block;
}
/* show only at night */
body[data-theme="day"] .moon {
display: none;
}
/* hide in day */
.moon .disc {
position: absolute;
inset: 0;
border-radius: 50%;
/* Soft, cool moon gradient */
background: radial-gradient(circle at 40% 40%,
#f7f9ff 0%,
#e9efff 45%,
#d6defe 72%,
#c3ccff 100%);
/* Subtle bluish glow */
box-shadow:
0 0 40px 12px rgba(150, 170, 255, 0.28),
0 0 120px 40px rgba(140, 160, 255, 0.14);
}
/* (Optional) very subtle craters — keep modern & minimal */
.moon .disc {
background:
radial-gradient(10px 10px at 28% 36%, rgba(0, 0, 0, .12) 0 55%, transparent 56%),
radial-gradient(7px 7px at 62% 30%, rgba(0, 0, 0, .10) 0 55%, transparent 56%),
radial-gradient(6px 6px at 50% 64%, rgba(0, 0, 0, .10) 0 55%, transparent 56%),
radial-gradient(circle at 40% 40%, #f7f9ff 0%, #e9efff 45%, #d6defe 72%, #c3ccff 100%);
}
/* Clouds */
.clouds {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 120px;
pointer-events: none;
z-index: 1;
opacity: .9;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, .18));
}
.layer-back {
animation: driftBack 36s linear infinite;
opacity: .45
}
.layer-front {
animation: driftFront 28s linear infinite;
opacity: .65
}
.clouds svg {
width: 150%;
height: 100%
}
@keyframes driftBack {
0% {
transform: translateX(0)
}
100% {
transform: translateX(-20%)
}
}
@keyframes driftFront {
0% {
transform: translateX(0)
}
100% {
transform: translateX(-35%)
}
}
/* Lightning flash (kept subtle; barely visible by day) */
.lightning {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background: radial-gradient(400px 220px at 70% 0%, #fff0 0%, #fff0 55%, #fff3 55.5%, #fff0 56%);
mix-blend-mode: screen;
opacity: 0;
animation: flash 9s ease-in-out infinite;
}
@keyframes flash {
0%,
86%,
100% {
opacity: 0
}
89% {
opacity: .22
}
90% {
opacity: .55
}
92% {
opacity: .12
}
}
/* ---------- Rain overlay ---------- */
.rain {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
opacity: 0;
transition: opacity .3s ease
}
body.rainy .rain {
opacity: .65
}
.rain span {
position: absolute;
top: -10vh;
width: 2px;
height: 8vh;
background: linear-gradient(to bottom, #aee1ff 0%, #4f9bf2 80%, transparent 100%);
opacity: .8;
border-radius: 1px;
filter: blur(.3px);
animation: drop 1.2s linear infinite;
}
@keyframes drop {
0% {
transform: translateY(-12vh)
}
100% {
transform: translateY(110vh)
}
}
/* ---------- Snow overlay ---------- */
.snow {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
opacity: 0;
transition: opacity .3s ease
}
body.snowy .snow {
opacity: .9
}
.snow span {
position: absolute;
top: -10vh;
width: var(--size, 6px);
height: var(--size, 6px);
background: #ffffff;
border-radius: 50%;
opacity: var(--alpha, .9);
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .28));
animation: snowFall var(--dur, 8s) linear infinite;
animation-play-state: paused;
}
body.snowy .snow span {
animation-play-state: running;
}
@keyframes snowFall {
0% {
transform: translateY(-12vh) translateX(0)
}
100% {
transform: translateY(110vh) translateX(var(--drift, 40px))
}
}
/* ---------- Search ---------- */
.search {
display: flex;
gap: .6rem;
justify-content: center;
position: relative;
flex-wrap: wrap;
z-index: 4;
}
.search input {
width: min(520px, 86vw);
padding: .85rem 1rem;
border-radius: 12px;
border: 1px solid #22314e;
background: #0e1626;
color: var(--text);
outline: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), 0 8px 18px rgba(0, 0, 0, .35);
}
body[data-theme="day"] .search input {
background: #ffffff;
border-color: #cfe3ff;
color: #10233d;
box-shadow: 0 8px 18px rgba(0, 0, 0, .10)
}
.search input:focus {
border-color: #2f4b7f;
box-shadow: 0 0 0 4px #2f4b7f33
}
.search button {
padding: .85rem 1rem;
border-radius: 12px;
border: none;
background: linear-gradient(135deg, var(--brand), var(--accent-2));
color: #061229;
font-weight: 800;
letter-spacing: .3px;
cursor: pointer;
box-shadow: var(--shadow-md);
}
.search button:hover {
filter: brightness(1.06)
}
.theme-toggle {
display: flex;
align-items: center;
gap: .45rem;
color: var(--muted);
font-weight: 600
}
.theme-toggle select {
appearance: none;
padding: .62rem .7rem;
border-radius: 10px;
border: 1px solid #22314e;
background: #0e1626;
color: var(--text)
}
body[data-theme="day"] .theme-toggle select {
background: #fff;
border-color: #cfe3ff;
color: #10233d
}
/* ---------- Autocomplete (portal dropdown) ---------- */
.sugg-box {
position: fixed;
/* NOTE: JS controls left/transform now */
display: none;
z-index: 10000;
background: #0e1626;
border: 1px solid #22314e;
border-radius: 12px;
box-shadow: 0 18px 38px rgba(0, 0, 0, .35);
max-height: 55vh;
overflow: auto;
color: var(--text);
/* No default left/transform here to avoid conflict with JS */
}
.sugg-item {
padding: .7rem .95rem;
border-bottom: 1px solid #18253e;
cursor: pointer;
color: var(--text);
}
.sugg-item:last-child {
border-bottom: none
}
.sugg-item:hover,
.sugg-item:focus {
background: #15223a;
outline: none
}
/* Day theme for autocomplete: light background + dark text */
body[data-theme="day"] .sugg-box {
background: #ffffff;
border-color: #cfe3ff;
color: #0e1c33;
box-shadow: 0 18px 38px rgba(0, 0, 0, .14);
}
body[data-theme="day"] .sugg-item {
color: #0e1c33;
border-bottom: 1px solid #e6efff;
}
body[data-theme="day"] .sugg-item:hover,
body[data-theme="day"] .sugg-item:focus {
background: #f0f6ff;
}
/* ---------- Panels & Grid ---------- */
.panel {
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.1rem;
margin: 1rem 0;
color: var(--muted);
box-shadow: var(--shadow-md);
backdrop-filter: blur(4px);
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin: 1rem 0
}
@media(min-width:720px) {
.grid {
grid-template-columns: repeat(2, 1fr)
}
}
@media(min-width:1024px) {
.grid {
grid-template-columns: repeat(3, 1fr)
}
}
/* ---------- Forecast cards ---------- */
.card {
position: relative;
background: linear-gradient(180deg, var(--card) 0%, var(--card-2) 100%);
border: 1px solid #1f2c47;
border-radius: var(--radius-lg);
padding: 1rem;
box-shadow: var(--shadow-lg);
overflow: hidden;
transition: transform .2s ease, box-shadow .2s ease, border-color .2s ease;
}
.card::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(600px 300px at 120% -10%, rgba(94, 160, 255, 0.12), transparent 60%);
opacity: .8;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 22px 50px rgba(0, 0, 0, .42);
border-color: #28406c;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: .5rem;
gap: .6rem
}
.card .day {
font-weight: 700
}
.card .hi .label {
display: block;
color: var(--muted);
font-size: .8rem
}
.card .hi .val {
font-size: 1.4rem;
font-weight: 800
}
/* ---------- Metrics rows ---------- */
.metrics {
display: grid;
grid-template-columns: 1fr;
gap: .6rem;
margin-top: .6rem
}
.metric {
display: grid;
grid-template-columns: auto 1fr auto;
gap: .6rem;
align-items: center
}
.metric .i {
width: 24px;
height: 24px;
color: var(--accent);
display: grid;
place-items: center
}
.metric .i svg {
width: 22px;
height: 22px
}
.metric .name {
color: var(--muted)
}
.metric .value {
font-weight: 800
}
/* ---------- Provider breakdown ---------- */
.providers {
margin-top: .8rem
}
.providers summary {
cursor: pointer;
color: var(--brand);
font-weight: 700;
letter-spacing: .2px
}
.prov-pre {
white-space: pre-wrap;
overflow: auto;
background: #0e1626;
border: 1px dashed #27406b;
border-radius: 10px;
padding: .7rem;
margin: .6rem 0;
color: #bdd0ff;
}
body[data-theme="day"] .prov-pre {
background: #f6fbff;
color: #213555;
border-color: #cfe3ff
}
/* ---------- Utility / Micro ---------- */
.error {
background: #240d12;
border: 1px solid #4d1c2a;
color: #ffb5b5;
padding: .9rem;
border-radius: 12px
}
.site-footer {
padding: 1.2rem 0;
text-align: center;
color: var(--muted)
}
:focus-visible {
outline: 3px solid #6db0ff;
outline-offset: 2px
}
/* Reduced motion: keep precipitation (slower), disable heavy chrome */
@media (prefers-reduced-motion: reduce) {
.layer-back,
.layer-front,
.lightning,
body::before,
.sun {
animation: none !important
}
.rain span {
animation-duration: 2s !important;
}
.snow span {
animation-duration: calc(var(--dur, 8s) * 1.5) !important;
}
}
#useMyLocation{
padding:.85rem 1rem;
border-radius:12px;
border:1px solid var(--border);
background:linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
color:var(--text);
font-weight:700;
box-shadow: var(--shadow-md);
}
#useMyLocation:hover{ filter:brightness(1.03) }
body[data-theme="day"] #useMyLocation{
background:#ffffff;
border-color:#cfe3ff;
color:#10233d;
box-shadow:0 8px 18px rgba(0,0,0,.10);
}
#radarControls button {
padding:.55rem .9rem;
border-radius:10px;
border:1px solid var(--border);
background:linear-gradient(180deg,var(--panel) 0%, var(--panel-2) 100%);
color:var(--text); font-weight:700;
}
/* --- Card header layout tweaks --- */
.card .card-head {
display: grid;
grid-template-columns: 1fr auto; /* date row on left, hi/low on right */
gap: .25rem 1rem;
align-items: center;
}
.card .card-head .date-row {
display: inline-flex;
align-items: center;
gap: .5rem;
white-space: nowrap; /* keep "Wed, Jan 28" together */
min-width: 0; /* prevent overflow issues in narrow cards */
}
.card .card-head .date-text {
font-weight: 600;
}
/* Small pill for “Today” */
.today-badge {
display: inline-block;
font-size: .78rem;
line-height: 1;
padding: .18rem .45rem;
border-radius: 999px;
background: rgba(80,145,255,.16); /* subtle blue pill on dark */
color: #b9d4ff; /* lighter blue text */
border: 1px solid rgba(80,145,255,.3);
user-select: none;
}
/* Optional: keep the hi/low line from wrapping too early */
.card .card-head .hi-lo {
white-space: nowrap;
justify-self: end;
}