initial commit
This commit is contained in:
Executable
+834
@@ -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) Open‑Meteo Geocoding (names & postal codes, no key)
|
||||||
|
2) Nominatim fallback (no key) – requires custom UA; ≤1 rps
|
||||||
|
|
||||||
|
Weather providers (no key):
|
||||||
|
- Open‑Meteo (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 roll‑up; requires User‑Agent)
|
||||||
|
- MET Norway Locationforecast (hourly humidity/wind/temp → daily roll‑up; requires User‑Agent)
|
||||||
|
- 7Timer! civillight (daily temp max/min)
|
||||||
|
|
||||||
|
Cache:
|
||||||
|
24h per‑location 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 + Open‑Meteo + 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 Open‑Meteo."""
|
||||||
|
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) Free‑form 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) Free‑form 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 (Open‑Meteo / 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 Open‑Meteo'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) Open‑Meteo 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"Open‑Meteo 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
@@ -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(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device‑agnostic date label (we always format the YMD as UTC so it’s 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'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Region‑local “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;
|
||||||
|
}
|
||||||
|
// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
|
||||||
|
|
||||||
|
// Reverse‑geocode 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 isn’t 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){}
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because one or more lines are too long
Executable
+34
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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) · time‑enabled imagery (WMS) with ~4‑hour rolling window and ~5‑minute cadence.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sources / Attribution -->
|
||||||
|
<section class="panel">
|
||||||
|
<details>
|
||||||
|
<summary>Data sources</summary>
|
||||||
|
<ul>
|
||||||
|
<li>Geocoding: Open‑Meteo (primary), Nominatim / OpenStreetMap (fallback)</li>
|
||||||
|
<li>Weather providers: Open‑Meteo, 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
+782
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user