Download
282 lines 9.1 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
// Weather widget: condition icon + temperature from wttr.in (fetched by
// the backend; divebar://weather events). Location auto-detects by IP
// unless set in settings. Details (conditions, feels-like, area) in the
// tooltip; click opens a 3-day forecast pop-down.

import { createPopup } from "../lib/popup.js";

// wttr.in uses WWO condition codes; group them into icon categories.
const CODE_ICONS = [
  { icon: "sun", codes: [113] },
  { icon: "partly", codes: [116] },
  { icon: "cloud", codes: [119, 122] },
  { icon: "fog", codes: [143, 149, 248, 260] },
  {
    icon: "rain",
    codes: [
      176, 263, 266, 281, 284, 293, 296, 299, 302, 305, 308, 311, 314, 353,
      356, 359,
    ],
  },
  { icon: "thunder", codes: [200, 386, 389, 392, 395] },
  {
    icon: "snow",
    codes: [
      179, 182, 185, 227, 230, 317, 320, 323, 326, 329, 332, 335, 338, 350,
      362, 365, 368, 371, 374, 377,
    ],
  },
];

const SUN_CORE = `
  <circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/>
  <path d="M12 3.5 V5.5 M12 18.5 V20.5 M3.5 12 H5.5 M18.5 12 H20.5
           M6 6 L7.4 7.4 M16.6 16.6 L18 18 M18 6 L16.6 7.4 M7.4 16.6 L6 18"/>`;

const CLOUD = `
  <path d="M7 17.5 a4 4 0 0 1 -0.6-7.96 A5 5 0 0 1 16 8.3 a3.8 3.8 0 0 1 1.4 7.2 z"
        transform="translate(0,-1)"/>`;

const ICONS = {
  sun: SUN_CORE,
  partly: `
    <g transform="translate(-3,-3) scale(0.75)">${SUN_CORE}</g>
    <g transform="translate(2,3) scale(0.85)">${CLOUD.replace('stroke="none"', "")}</g>`,
  cloud: CLOUD,
  fog: `
    <g transform="scale(0.8) translate(3,-2)">${CLOUD}</g>
    <path d="M5 16.5 H19 M7 19.5 H17"/>`,
  rain: `
    <g transform="scale(0.85) translate(2,-1)">${CLOUD}</g>
    <path d="M8.5 17 L7.5 20 M12 17 L11 20 M15.5 17 L14.5 20"/>`,
  thunder: `
    <g transform="scale(0.85) translate(2,-1)">${CLOUD}</g>
    <path d="M12.5 15.5 L10 19 h2.4 l-1.4 3" stroke-width="1.4"/>`,
  snow: `
    <g transform="scale(0.85) translate(2,-1)">${CLOUD}</g>
    <path d="M8.5 18.2 v0.01 M12 19.8 v0.01 M15.5 18.2 v0.01" stroke-width="2.4"/>`,
};

function iconFor(code) {
  for (const group of CODE_ICONS) {
    if (group.codes.includes(code)) return group.icon;
  }
  return "cloud";
}

function iconSvg(code) {
  return `
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
         stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
      ${ICONS[iconFor(code)]}
    </svg>`;
}

const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];

// wttr.in strings (area, descriptions) get interpolated into popup markup.
const esc = (s) =>
  String(s).replace(
    /[&<>"]/g,
    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
  );

function dayLabel(iso) {
  const [y, m, d] = iso.split("-").map(Number);
  const date = new Date(y, m - 1, d);
  return date.toDateString() === new Date().toDateString()
    ? "Today"
    : DAYS[date.getDay()];
}

function hourLabel(hour) {
  if (hour === 0) return "12am";
  if (hour < 12) return `${hour}am`;
  if (hour === 12) return "12pm";
  return `${hour - 12}pm`;
}

// The next 24 hours of 3-hour forecast slots, spilling into tomorrow.
function upcomingSlots(forecast) {
  const slots = [];
  for (const day of forecast) {
    const [y, m, d] = day.date.split("-").map(Number);
    for (const h of day.hourly ?? []) {
      slots.push({ ...h, at: new Date(y, m - 1, d, h.hour) });
    }
  }
  // Keep a slot until it's half over (each covers 3 hours).
  const cutoff = Date.now() - 90 * 60 * 1000;
  return slots.filter((s) => s.at.getTime() >= cutoff).slice(0, 8);
}

export default {
  name: "weather",
  css: `
    .widget-weather { display: none; }
    .widget-weather.present { display: flex; }
    /* The composed cloud icons fill their box more fully than the other
       widget icons — draw them slightly smaller so heights match. */
    .widget-weather .icon svg {
      width: calc(var(--icon-size) * 0.85);
      height: calc(var(--icon-size) * 0.85);
    }
    .widget-weather .value { width: 4ch; text-align: center; } /* "104°" */

    /* Forecast pop-down */
    .wxcast {
      padding: 12px 16px 14px;
      font-size: calc(var(--bar-font) * 0.8);
      color: var(--bar-fg);
      user-select: none;
    }
    .wxcast-head { font-weight: 600; margin-bottom: 2px; }
    .wxcast-now { color: var(--bar-fg-dim); margin-bottom: 10px; }
    .wxcast-hours {
      display: flex;
      gap: 2px;
      margin-bottom: 12px;
    }
    .wxcast-hours .slot {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 3px;
      min-width: 3.2em;
    }
    .wxcast-hours .t { color: var(--bar-fg-dim); font-size: 0.85em; }
    .wxcast-hours svg { width: 1.3em; height: 1.3em; display: block; }
    .wxcast-hours .rain {
      color: var(--accent);
      font-size: 0.85em;
      min-height: 1.2em;
    }
    .wxcast-days {
      display: grid;
      grid-template-columns: auto auto 1fr auto;
      gap: 6px 12px;
      align-items: center;
    }
    .wxcast-days svg { width: 1.3em; height: 1.3em; display: block; }
    .wxcast-desc { color: var(--bar-fg-dim); padding-right: 8px; }
    .wxcast-desc .rain { color: var(--accent); }
    .wxcast-temps { text-align: right; font-variant-numeric: tabular-nums; }
    .wxcast-temps .lo { color: var(--bar-fg-dim); }
  `,
  settings: [
    {
      key: "units",
      label: "Units",
      type: "select",
      choices: ["fahrenheit", "celsius"],
      default: "fahrenheit",
    },
    {
      key: "location",
      label: "Location",
      type: "location",
      placeholder: "city, zip, or lat,lon — empty = auto",
      default: "",
    },
  ],
  create({ options, onEvent }) {
    const el = document.createElement("div");
    el.className = "chip clickable widget-weather";
    el.style.cursor = "pointer";
    el.innerHTML = `
      <span class="icon">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
             stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
        </svg>
      </span>
      <span class="value"></span>
    `;
    const svgEl = el.querySelector("svg");
    const valueEl = el.querySelector(".value");
    let currentIcon = null;
    let latest = null;

    const popup = createPopup({
      anchor: el,
      className: "wxcast",
      render(body) {
        const useF = options.units === "fahrenheit";
        const unit = useF ? "°F" : "°C";
        const temp = useF ? (latest.tempF ?? latest.tempC) : latest.tempC;
        const feels = useF ? (latest.feelsF ?? latest.feelsC) : latest.feelsC;
        const rows = latest.forecast
          .map((day) => {
            const hi = useF ? (day.maxF ?? day.maxC) : day.maxC;
            const lo = useF ? (day.minF ?? day.minC) : day.minC;
            const rain =
              day.rain > 0
                ? ` <span class="rain">${Math.round(day.rain)}%</span>`
                : "";
            return `
              <span>${dayLabel(day.date)}</span>
              ${iconSvg(day.code)}
              <span class="wxcast-desc">${esc(day.desc)}${rain}</span>
              <span class="wxcast-temps">${Math.round(hi)}°
                <span class="lo">${Math.round(lo)}°</span></span>`;
          })
          .join("");
        const hours = upcomingSlots(latest.forecast)
          .map((slot) => {
            const t = useF ? (slot.tempF ?? slot.tempC) : slot.tempC;
            const rain =
              slot.rain > 0 ? `${Math.round(slot.rain)}%` : "";
            return `
              <span class="slot">
                <span class="t">${hourLabel(slot.at.getHours())}</span>
                ${iconSvg(slot.code)}
                <span>${Math.round(t)}°</span>
                <span class="rain">${rain}</span>
              </span>`;
          })
          .join("");
        body.innerHTML = `
          <div class="wxcast-head">${esc(latest.area || "Weather")}</div>
          <div class="wxcast-now">${esc(latest.desc)}, ${Math.round(temp)}${unit}${
            feels != null ? ` (feels like ${Math.round(feels)}${unit})` : ""
          }</div>
          ${hours ? `<div class="wxcast-hours">${hours}</div>` : ""}
          <div class="wxcast-days">${rows}</div>`;
      },
    });
    el.addEventListener("click", () => {
      if (latest?.forecast?.length) popup.toggle();
    });

    const unsubscribe = onEvent("divebar://weather", (weather) => {
      latest = weather;
      const present = weather && weather.tempC != null;
      el.classList.toggle("present", Boolean(present));
      if (!present) return;

      const useF = options.units === "fahrenheit";
      const temp = useF ? (weather.tempF ?? weather.tempC) : weather.tempC;
      const feels = useF ? (weather.feelsF ?? weather.feelsC) : weather.feelsC;
      valueEl.textContent = `${Math.round(temp)}°`;

      const icon = iconFor(weather.code);
      if (icon !== currentIcon) {
        currentIcon = icon;
        svgEl.innerHTML = ICONS[icon];
      }

      const unit = useF ? "°F" : "°C";
      el.title =
        `${weather.desc}, ${Math.round(temp)}${unit}` +
        (feels != null ? ` (feels like ${Math.round(feels)}${unit})` : "") +
        (weather.area ? ` — ${weather.area}` : "");
    });

    return {
      el,
      destroy() {
        unsubscribe();
        popup.close();
      },
    };
  },
};