Weather

Weather

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PJ Bay Weather</title>
<style>
  :root { --bg:#eaf3fb; --card:#ffffff; --text:#12263a; --muted:#5f7790; --line:#cfe0ef; --accent:#0a6ebd; }
  @media (prefers-color-scheme: dark) {
    :root { --bg:#0b1722; --card:#14263a; --text:#e8f1f9; --muted:#90a9c0; --line:#24405a; --accent:#5cb2f2; }
  }
  * { box-sizing:border-box; }
  body { margin:0; font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--text); padding:14px; max-width:760px; margin-inline:auto; }
  h1 { font-size:1.35rem; margin:4px 2px 12px; }
  .panel { background:var(--card); border:2px solid var(--accent); border-radius:18px; overflow:hidden; box-shadow:0 4px 18px rgba(10,110,189,.15); }
  .panel-head { background:linear-gradient(135deg,#0a6ebd,#16a5c9); padding:14px; }
  .panel-head select { width:100%; font-size:1.1rem; padding:12px; border-radius:12px; border:2px solid rgba(255,255,255,.7); background:#fff; color:#12263a; font-weight:600; }
  .panel-body { padding:14px; }
  .row { display:flex; gap:8px; margin:0 0 10px; }
  button { flex:1; padding:10px; border-radius:12px; border:1px solid var(--line); background:var(--card); color:var(--text); font-size:.95rem; cursor:pointer; }
  button.on { background:linear-gradient(135deg,#0a6ebd,#16a5c9); color:#fff; border-color:transparent; }
  .meta { color:var(--muted); font-size:.85rem; margin:0 2px 12px; }
  .hero { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
  .card { border-radius:14px; padding:14px; color:#fff; position:relative; }
  .card .k { font-size:.78rem; text-transform:uppercase; letter-spacing:.06em; opacity:.9; }
  .card .v { font-size:2.3rem; font-weight:700; line-height:1.1; margin-top:4px; }
  .card .u { font-size:1rem; font-weight:500; opacity:.9; }
  .card .s { font-size:.85rem; opacity:.95; margin-top:4px; }
  .compass-card { grid-column:1 / -1; background:linear-gradient(135deg,#5b3fd1,#8b5cf6); display:flex; align-items:center; gap:14px; }
  .compass-card svg { width:130px; height:130px; flex:none; }
  .compass-card .big { font-size:2.6rem; font-weight:700; line-height:1; }
  .compass-card .deg { font-size:1.2rem; margin-top:4px; }
  .compass-card .blow { font-size:.85rem; opacity:.9; margin-top:6px; }
  .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:10px; margin-top:10px; }
  .tile { border-radius:14px; padding:12px; color:#fff; }
  .tile .k { font-size:.75rem; text-transform:uppercase; letter-spacing:.05em; opacity:.9; }
  .tile .v { font-size:1.5rem; font-weight:700; margin-top:4px; }
  .tile .u { font-size:.85rem; font-weight:500; opacity:.9; }
  .c-temp { background:linear-gradient(135deg,#f97316,#ef4444); }
  .c-sea { background:linear-gradient(135deg,#0891b2,#0e7490); }
  .c-press { background:linear-gradient(135deg,#a855f7,#7c3aed); }
  .c-wave { background:linear-gradient(135deg,#14b8a6,#0d9488); }
  .c-tide { background:linear-gradient(135deg,#2563eb,#1d4ed8); }
  .c-hum { background:linear-gradient(135deg,#06b6d4,#3b82f6); }
  .c-other { background:linear-gradient(135deg,#64748b,#475569); }
  h2 { font-size:1rem; margin:18px 2px 8px; }
  .wrap { overflow-x:auto; background:var(--card); border:1px solid var(--line); border-radius:14px; }
  table { border-collapse:collapse; width:100%; font-size:.85rem; }
  th, td { padding:8px 10px; text-align:left; border-bottom:1px solid var(--line); white-space:nowrap; }
  th { background:linear-gradient(135deg,#0a6ebd,#16a5c9); color:#fff; font-weight:600; text-transform:capitalize; }
  tr:nth-child(even) td { background:rgba(10,110,189,.06); }
  .err { background:var(--card); border:2px solid #c0392b; color:#c0392b; padding:12px; border-radius:12px; margin-top:12px; }
</style>
</head>
<body>
<h1>🌊 PJ Bay Weather</h1>

<div class="panel">
  <div class="panel-head">
    <select id="station" aria-label="Station"></select>
  </div>
  <div class="panel-body">
    <div class="row">
      <button id="refresh">↻ Refresh</button>
      <button data-b="hourly" class="on">Hourly</button>
      <button data-b="daily">Daily</button>
    </div>
    <div class="meta" id="meta">Loading…</div>
    <div class="hero" id="hero"></div>
    <div class="grid" id="latest"></div>
  </div>
</div>

<h2 id="hist-title"></h2>
<div class="wrap"><table id="hist"></table></div>
<div id="error"></div>

<script>
const HUB = "https://silent-meadow-67e5.jontie999.workers.dev";
const DEFAULT_MATCH = "briggs";
let bucket = "hourly";

const sel = document.getElementById("station");
const errBox = document.getElementById("error");

const UNITS = { wind_ms:"m/s", gust_ms:"m/s", wind_kn:"kn", gust_kn:"kn", temp_c:"°C", air_temp_c:"°C", sea_temp_c:"°C",
  water_temp_c:"°C", pressure_hpa:"hPa", wave_height_m:"m", tide_m:"m", level_m:"m", humidity_pct:"%",
  wind_dir_deg:"°", wave_period_s:"s" };
const POINTS = ["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];
const BF = [1,4,7,11,17,22,28,34,41,48,56,64];

const pretty = k => k.replace(/_(ms|c|m|hpa|pct|deg|kn|s)$/,"").replace(/_/g," ");
const r1 = v => Math.round(v*10)/10;
const fmt = v => typeof v === "number" ? (Math.round(v*100)/100).toString() : String(v);
const fmtTime = ts => { try { return new Date(ts).toLocaleString([], {day:"numeric",month:"short",hour:"2-digit",minute:"2-digit"}); } catch (e) { return ts; } };
const compass = deg => POINTS[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16];

function toKn(key, v) {
  if (/kn|knot/i.test(key)) return v;
  if (/mph/i.test(key)) return v * 0.868976;
  if (/kmh|kph/i.test(key)) return v * 0.539957;
  return v * 1.94384; // assume m/s
}
function windColour(kn) {
  if (kn < 7)  return "linear-gradient(135deg,#16a34a,#22c55e)";
  if (kn < 17) return "linear-gradient(135deg,#0891b2,#0ea5e9)";
  if (kn < 22) return "linear-gradient(135deg,#f59e0b,#eab308)";
  if (kn < 28) return "linear-gradient(135deg,#f97316,#ea580c)";
  return "linear-gradient(135deg,#dc2626,#991b1b)";
}
function beaufort(kn) { let f = 0; while (f < BF.length && kn >= BF[f]) f++; return f; }
function catClass(k) {
  if (/sea|water/i.test(k) && /temp/i.test(k)) return "c-sea";
  if (/temp/i.test(k)) return "c-temp";
  if (/press/i.test(k)) return "c-press";
  if (/wave|swell/i.test(k)) return "c-wave";
  if (/tide|level|height/i.test(k)) return "c-tide";
  if (/hum|dew/i.test(k)) return "c-hum";
  return "c-other";
}
function unitOf(k) { if (/_ms$/.test(k)) return "kn"; return UNITS[k] || (/_c$/.test(k) ? "°C" : ""); }
function dispVal(k, v) { return (typeof v === "number" && /_ms$/.test(k)) ? v * 1.94384 : v; }
function findKey(d, re, excl) {
  return Object.keys(d).find(k => typeof d[k] === "number" && re.test(k) && !(excl && excl.test(k)));
}

async function get(path) {
  const r = await fetch(HUB + path);
  if (!r.ok) throw new Error(r.status + " " + r.statusText);
  return r.json();
}
function showError(e) {
  errBox.innerHTML = '<div class="err">Could not load data: ' + e.message + '.</div>';
}

async function loadStations() {
  const d = await get("/v1/stations");
  const list = Array.isArray(d) ? d : (d.stations || d.data || []);
  sel.innerHTML = "";
  list.forEach(s => {
    const o = document.createElement("option");
    o.value = s.slug;
    o.textContent = s.name || s.title || s.slug;
    sel.appendChild(o);
  });
  const saved = localStorage.getItem("station");
  let pick = [].slice.call(sel.options).find(o => o.value === saved);
  if (!pick) pick = [].slice.call(sel.options).find(o =>
    (o.value + " " + o.textContent).toLowerCase().indexOf(DEFAULT_MATCH) > -1);
  if (pick) sel.value = pick.value;
}

function compassSvg(toDeg) {
  return '<svg viewBox="0 0 120 120" aria-label="Wind direction">' +
    '<circle cx="60" cy="60" r="56" fill="rgba(255,255,255,.15)" stroke="#fff" stroke-width="2"/>' +
    '<text x="60" y="17" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">N</text>' +
    '<text x="105" y="64" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">E</text>' +
    '<text x="60" y="113" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">S</text>' +
    '<text x="15" y="64" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">W</text>' +
    '<g transform="rotate(' + toDeg + ' 60 60)">' +
    '<polygon points="60,24 72,60 62,56 62,96 58,96 58,56 48,60" fill="#fff"/>' +
    '</g></svg>';
}

function renderLatest(d) {
  const r = d.reading || d;
  const data = r.data || {};
  const hero = document.getElementById("hero");
  const grid = document.getElementById("latest");
  hero.innerHTML = ""; grid.innerHTML = "";
  const used = {};

  const kWind = findKey(data, /wind/i, /dir|gust|bearing|deg/i);
  const kGust = findKey(data, /gust/i, /dir/i);
  const kDir = findKey(data, /wind.*(dir|deg|bearing)|^(wdir|dir|direction|bearing)/i) ||
               findKey(data, /dir|bearing/i, /wave|swell|gust/i);

  if (kWind) {
    used[kWind] = 1;
    const kn = toKn(kWind, data[kWind]);
    hero.insertAdjacentHTML("beforeend",
      '<div class="card" style="background:' + windColour(kn) + '"><div class="k">Wind speed</div>' +
      '<div class="v">' + r1(kn) + ' <span class="u">kn</span></div>' +
      '<div class="s">Force ' + beaufort(kn) + '</div></div>');
  }
  if (kGust) {
    used[kGust] = 1;
    const kn = toKn(kGust, data[kGust]);
    hero.insertAdjacentHTML("beforeend",
      '<div class="card" style="background:' + windColour(kn) + '"><div class="k">Gust</div>' +
      '<div class="v">' + r1(kn) + ' <span class="u">kn</span></div>' +
      '<div class="s">Force ' + beaufort(kn) + '</div></div>');
  }
  if (kDir) {
    used[kDir] = 1;
    const deg = data[kDir], toDeg = (deg + 180) % 360;
    hero.insertAdjacentHTML("beforeend",
      '<div class="card compass-card">' + compassSvg(toDeg) +
      '<div><div class="k">Wind from</div><div class="big">' + compass(deg) + '</div>' +
      '<div class="deg">' + Math.round(deg) + '° bearing</div>' +
      '<div class="blow">Blowing towards ' + compass(toDeg) + ' (arrow)</div></div></div>');
  }

  Object.keys(data).forEach(k => {
    const v = data[k];
    if (used[k] || v === null || typeof v === "object") return;
    const u = unitOf(k);
    grid.insertAdjacentHTML("beforeend",
      '<div class="tile ' + catClass(k) + '"><div class="k">' + pretty(k) + '</div><div class="v">' + fmt(dispVal(k, v)) +
      (u ? ' <span class="u">' + u + '</span>' : "") + '</div></div>');
  });

  document.getElementById("meta").textContent =
    (sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].textContent : "") +
    " · updated " + (r.ts_utc ? fmtTime(r.ts_utc) : "—");
}

function renderHistory(d) {
  const rows = d.readings || [];
  const t = document.getElementById("hist");
  document.getElementById("hist-title").textContent = rows.length ? (bucket === "hourly" ? "Last 24 hours" : "Last 7 days") : "";
  if (!rows.length) { t.innerHTML = ""; return; }
  const first = rows[0].data || {};
  const keys = Object.keys(first).filter(k => typeof first[k] !== "object");
  const kDir = findKey(first, /wind.*(dir|deg|bearing)|^(wdir|dir|direction|bearing)/i) ||
               findKey(first, /dir|bearing/i, /wave|swell|gust/i);
  t.innerHTML = "<tr><th>Time</th>" + keys.map(k => "<th>" + pretty(k) + (/_ms$/.test(k) ? " (kn)" : "") + "</th>").join("") + "</tr>" +
    rows.map(r => "<tr><td>" + fmtTime(r.ts_utc) + "</td>" +
      keys.map(k => {
        const v = r.data[k];
        if (v == null) return "<td>—</td>";
        if (k === kDir && typeof v === "number") return "<td>" + compass(v) + " " + Math.round(v) + "°</td>";
        return "<td>" + (/_ms$/.test(k) ? r1(dispVal(k, v)) : fmt(v)) + "</td>";
      }).join("") + "</tr>").join("");
}

async function loadData() {
  errBox.innerHTML = "";
  const slug = sel.value;
  if (!slug) return;
  localStorage.setItem("station", slug);
  document.getElementById("meta").textContent = "Loading…";
  try {
    const limit = bucket === "hourly" ? 24 : 7;
    const res = await Promise.all([
      get("/v1/stations/" + slug + "/latest"),
      get("/v1/stations/" + slug + "/readings?limit=" + limit + "&bucket=" + bucket)
    ]);
    renderLatest(res[0]);
    renderHistory(res[1]);
  } catch (e) { showError(e); }
}

sel.addEventListener("change", loadData);
document.getElementById("refresh").addEventListener("click", loadData);
document.querySelectorAll("button[data-b]").forEach(b => b.addEventListener("click", () => {
  bucket = b.dataset.b;
  document.querySelectorAll("button[data-b]").forEach(x => x.classList.toggle("on", x === b));
  loadData();
}));

(async () => {
  try { await loadStations(); await loadData(); } catch (e) { showError(e); document.getElementById("meta").textContent = ""; }
})();
</script>
</body>
</html>