repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

16.4 KB · 409 lines · JavaScript Raw History
  1// Widgets are the structured half of an answer. The model still writes the
  2// prose underneath, and these draw the numbers it would otherwise have to
  3// recite, which it is bad at and which read better as a chart anyway.
  4//
  5// A widget is handed only its subject. The readings come from /api/widget/...
  6// here, so changing a chart's range costs no turn and reopening an old
  7// conversation draws today's price rather than replaying the one that was on
  8// screen when it was asked.
  9(function () {
 10  "use strict";
 11
 12  const esc = (s) =>
 13    String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
 14      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
 15
 16  // A widget endpoint that fails answers with plain text, not json, and
 17  // r.json() on that throws a SyntaxError which then shows up as the widget's
 18  // own message. This reads the body once and turns anything unparseable into
 19  // something worth reading.
 20  async function readJSON(r) {
 21    const text = await r.text();
 22    let d = null;
 23    try { d = JSON.parse(text); } catch {}
 24    if (!r.ok || !d || d.error) {
 25      throw new Error((d && d.error) || (r.ok ? "no data" : "no data (" + r.status + ")"));
 26    }
 27    return d;
 28  }
 29
 30  const RANGES = [
 31    { key: "1d", label: "1D" },
 32    { key: "1w", label: "1W" },
 33    { key: "1m", label: "1M" },
 34    { key: "1y", label: "1Y" },
 35  ];
 36
 37  // The chart is drawn in a fixed box and stretched to whatever width the
 38  // message column gives it, which is what dash does. A stretched box turns a
 39  // circle into an ellipse, so the cursor is a vertical rule and the dot on the
 40  // line is an HTML element positioned over the top.
 41  const VW = 1000, VH = 220, PAD = 3;
 42
 43  const num = (v) => Math.round(v * 100) / 100;
 44
 45  function money(v, cur) {
 46    if (v == null || !isFinite(v)) return "";
 47    const digits = Math.abs(v) >= 1000 ? 2 : Math.abs(v) < 1 ? 4 : 2;
 48    const s = v.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
 49    return cur === "USD" || !cur ? "$" + s : s + " " + cur;
 50  }
 51
 52  const signedPct = (v) => (v > 0 ? "+" : "") + v.toFixed(2) + "%";
 53  const dirOf = (v) => (v > 0.0001 ? "up" : v < -0.0001 ? "down" : "flat");
 54
 55  function el(tag, cls, html) {
 56    const n = document.createElement(tag);
 57    if (cls) n.className = cls;
 58    if (html != null) n.innerHTML = html;
 59    return n;
 60  }
 61
 62  // ---------------------------------------------------------------- ticker
 63
 64  function tickerWidget(spec) {
 65    const root = el("section", "wdg wdg-ticker");
 66    root.innerHTML = `
 67      <header class="wdg-head">
 68        <div class="wdg-id">
 69          <b class="wdg-sym">${esc(spec.symbol || "")}</b>
 70          <span class="wdg-name"></span>
 71        </div>
 72        <div class="wdg-quote">
 73          <span class="wdg-price"></span>
 74          <span class="wdg-move"><span class="wdg-chg"></span><span class="wdg-pct"></span></span>
 75        </div>
 76      </header>
 77      <nav class="wdg-ranges">${RANGES.map(
 78        (r) => `<button type="button" data-range="${r.key}">${r.label}</button>`
 79      ).join("")}</nav>
 80      <div class="wdg-plot">
 81        <svg viewBox="0 0 ${VW} ${VH}" preserveAspectRatio="none" aria-hidden="true" focusable="false">
 82          <path class="wdg-area"></path>
 83          <line class="wdg-base" x1="0" x2="${VW}"></line>
 84          <path class="wdg-line"></path>
 85          <line class="wdg-cursor" y1="0" y2="${VH}" hidden></line>
 86          <rect class="wdg-band" y="0" height="${VH}" hidden></rect>
 87          <line class="wdg-anchor" y1="0" y2="${VH}" hidden></line>
 88        </svg>
 89        <i class="wdg-dot" hidden></i>
 90        <i class="wdg-dot wdg-dot-a" hidden></i>
 91        <div class="wdg-read" hidden></div>
 92        <p class="wdg-msg"></p>
 93      </div>
 94      <footer class="wdg-foot"><span class="wdg-span"></span><span class="wdg-hint"></span></footer>`;
 95
 96    const svg = root.querySelector("svg");
 97    const plot = root.querySelector(".wdg-plot");
 98    const area = root.querySelector(".wdg-area");
 99    const line = root.querySelector(".wdg-line");
100    const base = root.querySelector(".wdg-base");
101    const cursor = root.querySelector(".wdg-cursor");
102    const anchor = root.querySelector(".wdg-anchor");
103    const band = root.querySelector(".wdg-band");
104    const dot = root.querySelector(".wdg-dot");
105    const dotA = root.querySelector(".wdg-dot-a");
106    const read = root.querySelector(".wdg-read");
107    const msg = root.querySelector(".wdg-msg");
108
109    let series = null, xs = [], ys = [], dragFrom = -1, dragging = false;
110    let range = "1d";
111    const cache = new Map();
112
113    function setRange(key) {
114      range = key;
115      root.querySelectorAll("[data-range]").forEach((b) =>
116        b.classList.toggle("on", b.dataset.range === key));
117      load();
118    }
119
120    async function load() {
121      if (cache.has(range)) return paint(cache.get(range));
122      msg.textContent = "";
123      root.classList.add("loading");
124      try {
125        const r = await fetch(
126          `/api/widget/ticker?symbol=${encodeURIComponent(spec.symbol)}&range=${range}`);
127        const d = await readJSON(r);
128        cache.set(range, d);
129        paint(d);
130      } catch (e) {
131        series = null;
132        clearCursor();
133        line.removeAttribute("d");
134        area.removeAttribute("d");
135        base.setAttribute("hidden", "");
136        msg.textContent = e.message || String(e);
137      } finally {
138        root.classList.remove("loading");
139      }
140    }
141
142    function paint(d) {
143      series = d;
144      msg.textContent = "";
145      root.querySelector(".wdg-name").textContent = d.name || "";
146      root.querySelector(".wdg-price").textContent = money(d.price, d.currency);
147      root.querySelector(".wdg-chg").textContent =
148        (d.change > 0 ? "+" : "") + num(d.change).toLocaleString("en-US");
149      root.querySelector(".wdg-pct").textContent = signedPct(d.percent || 0);
150      root.dataset.dir = dirOf(d.percent || 0);
151
152      const pts = d.points || [];
153      const cs = pts.map((p) => p.c);
154      let lo = Math.min(...cs), hi = Math.max(...cs);
155      // The baseline has to be inside the box or the dotted rule that gives the
156      // shape its meaning is drawn off the top of a chart that only went up.
157      const hasBase = d.intraday && d.previous > 0;
158      if (hasBase) { lo = Math.min(lo, d.previous); hi = Math.max(hi, d.previous); }
159      if (hi - lo < 1e-9) hi = lo + 1;
160
161      const sy = (v) => PAD + (1 - (v - lo) / (hi - lo)) * (VH - 2 * PAD);
162      const step = pts.length > 1 ? VW / (pts.length - 1) : 0;
163      xs = pts.map((_, i) => (pts.length > 1 ? i * step : VW / 2));
164      ys = cs.map(sy);
165
166      const path = xs.map((x, i) => `${i ? "L" : "M"}${num(x)},${num(ys[i])}`).join(" ");
167      line.setAttribute("d", path);
168      area.setAttribute("d", `${path} L${num(xs[xs.length - 1])},${VH} L${num(xs[0])},${VH} Z`);
169      if (hasBase) {
170        base.setAttribute("y1", num(sy(d.previous)));
171        base.setAttribute("y2", num(sy(d.previous)));
172        base.removeAttribute("hidden");
173      } else {
174        base.setAttribute("hidden", "");
175      }
176
177      root.querySelector(".wdg-span").textContent = spanLabel(d);
178      root.querySelector(".wdg-hint").textContent = pts.length > 1 ? "drag to compare" : "";
179      clearCursor();
180    }
181
182    function spanLabel(d) {
183      const pts = d.points || [];
184      if (!pts.length) return "";
185      const f = new Date(pts[0].t * 1000), l = new Date(pts[pts.length - 1].t * 1000);
186      if (d.range === "1d") {
187        const day = f.toLocaleDateString("en-US", { month: "short", day: "numeric" });
188        const t = (x) => x.toLocaleString("en-US", { hour: "numeric", minute: "2-digit" });
189        return `${day}, ${t(f)} to ${t(l)}`;
190      }
191      // A year of daily bars starts and ends in the same week of two different
192      // years, so without one "Sep 5 to Sep 4" reads as a day.
193      const opts = { month: "short", day: "numeric" };
194      if (f.getFullYear() !== l.getFullYear()) opts.year = "numeric";
195      const stamp = (x) => x.toLocaleDateString("en-US", opts);
196      return `${stamp(f)} to ${stamp(l)}`;
197    }
198
199    // The index under the pointer. The box is stretched to the column width, so
200    // the fraction across the element is the only honest way back to a point.
201    function indexAt(clientX) {
202      const r = svg.getBoundingClientRect();
203      if (!r.width || !xs.length) return -1;
204      const frac = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
205      return Math.min(xs.length - 1, Math.round(frac * (xs.length - 1)));
206    }
207
208    function place(node, i) {
209      const r = svg.getBoundingClientRect(), p = plot.getBoundingClientRect();
210      node.style.left = (r.left - p.left + (xs[i] / VW) * r.width) + "px";
211      node.style.top = (r.top - p.top + (ys[i] / VH) * r.height) + "px";
212      node.removeAttribute("hidden");
213    }
214
215    function stampAt(i) {
216      const t = new Date(series.points[i].t * 1000);
217      return series.range === "1d"
218        ? t.toLocaleString("en-US", { hour: "numeric", minute: "2-digit" })
219        : t.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" });
220    }
221
222    function show(i) {
223      if (!series || i < 0 || i >= xs.length) return;
224      cursor.setAttribute("x1", num(xs[i]));
225      cursor.setAttribute("x2", num(xs[i]));
226      cursor.removeAttribute("hidden");
227      place(dot, i);
228
229      const here = series.points[i].c;
230      // Dragging measures between the two points held, which is the question a
231      // drag is asking. Otherwise it measures from whatever this range's
232      // baseline is, which is the previous close intraday and the first bar on
233      // every longer span.
234      let from, label;
235      if (dragFrom >= 0 && dragFrom !== i) {
236        from = series.points[dragFrom].c;
237        label = `${stampAt(Math.min(dragFrom, i))} to ${stampAt(Math.max(dragFrom, i))}`;
238        const a = Math.min(xs[dragFrom], xs[i]), b = Math.max(xs[dragFrom], xs[i]);
239        band.setAttribute("x", num(a));
240        band.setAttribute("width", num(b - a));
241        band.removeAttribute("hidden");
242        anchor.setAttribute("x1", num(xs[dragFrom]));
243        anchor.setAttribute("x2", num(xs[dragFrom]));
244        anchor.removeAttribute("hidden");
245        place(dotA, dragFrom);
246      } else {
247        from = series.intraday && series.previous > 0 ? series.previous : series.points[0].c;
248        label = stampAt(i);
249      }
250      const pct = from > 0 ? ((here - from) / from) * 100 : 0;
251      read.innerHTML =
252        `<b>${esc(money(here, series.currency))}</b>` +
253        `<span class="wdg-read-pct" data-dir="${dirOf(pct)}">${esc(signedPct(pct))}</span>` +
254        `<span class="wdg-read-at">${esc(label)}</span>`;
255      read.removeAttribute("hidden");
256
257      // Keep the readout inside the plot rather than letting it run off the
258      // edge on the first or last bar.
259      const r = svg.getBoundingClientRect(), p = plot.getBoundingClientRect();
260      const x = r.left - p.left + (xs[i] / VW) * r.width;
261      read.style.left = Math.min(Math.max(x, 4), p.width - 4) + "px";
262      read.dataset.side = x > p.width / 2 ? "left" : "right";
263    }
264
265    function clearCursor() {
266      dragFrom = -1; dragging = false;
267      for (const n of [cursor, band, anchor, dot, dotA, read]) n.setAttribute("hidden", "");
268    }
269
270    plot.addEventListener("pointerdown", (e) => {
271      if (!series) return;
272      const i = indexAt(e.clientX);
273      if (i < 0) return;
274      dragging = true;
275      dragFrom = i;
276      plot.setPointerCapture(e.pointerId);
277      show(i);
278    });
279    plot.addEventListener("pointermove", (e) => {
280      if (!series) return;
281      const i = indexAt(e.clientX);
282      if (i < 0) return;
283      // A hover with no button down is a reading, so it must not keep an old
284      // drag anchor alive and go on reporting a span nobody is holding.
285      if (!dragging) dragFrom = -1;
286      show(i);
287    });
288    const end = (e) => {
289      dragging = false;
290      if (plot.hasPointerCapture && e.pointerId != null && plot.hasPointerCapture(e.pointerId)) {
291        plot.releasePointerCapture(e.pointerId);
292      }
293    };
294    plot.addEventListener("pointerup", end);
295    plot.addEventListener("pointercancel", end);
296    plot.addEventListener("pointerleave", () => { if (!dragging) clearCursor(); });
297
298    root.querySelector(".wdg-ranges").addEventListener("click", (e) => {
299      const b = e.target.closest("[data-range]");
300      if (b) setRange(b.dataset.range);
301    });
302
303    setRange(spec.range || "1d");
304    return root;
305  }
306
307  // ---------------------------------------------------------------- weather
308
309  function tile(label, value, note, dir) {
310    return `<div class="wdg-tile"${dir ? ` data-dir="${dir}"` : ""}>
311      <span class="wdg-tile-k">${esc(label)}</span>
312      <b class="wdg-tile-v">${esc(value)}</b>
313      <span class="wdg-tile-n">${esc(note || "")}</span>
314    </div>`;
315  }
316
317  function weatherWidget(spec) {
318    const root = el("section", "wdg wdg-weather");
319    root.innerHTML = `<p class="wdg-msg">loading</p>`;
320
321    const q = new URLSearchParams({
322      lat: spec.lat, lon: spec.lon, place: spec.place || "",
323      zip: spec.zip || "", country: spec.country || "", days: "7",
324    });
325
326    fetch("/api/widget/weather?" + q)
327      .then(readJSON)
328      .then((d) => paint(root, d))
329      .catch((e) => { root.innerHTML = `<p class="wdg-msg">${esc(e.message || String(e))}</p>`; });
330
331    function paint(root, d) {
332      const days = d.days || [];
333      // The tiles are the readings that are not a temperature, and each one is
334      // dropped rather than shown empty when its source had nothing. Pollen is
335      // the one that is often missing, since it is a US only source.
336      const tiles = [
337        tile("rain", (days[0] ? Math.round(days[0].precip_pct) : 0) + "%", "today"),
338        tile("uv", days[0] ? days[0].uv.toFixed(1) : "0", uvBand(days[0] ? days[0].uv : 0)),
339      ];
340      if (d.has_air) tiles.push(tile("aqi", Math.round(d.aqi), d.aqi_band, aqiDir(d.aqi)));
341      if (d.has_pollen) tiles.push(tile("pollen", d.pollen.toFixed(1), d.pollen_band, pollenDir(d.pollen)));
342      if (d.humidity) tiles.push(tile("humidity", Math.round(d.humidity) + "%", ""));
343      if (d.wind_mph) tiles.push(tile("wind", Math.round(d.wind_mph), "mph"));
344
345      root.innerHTML = `
346        <header class="wdg-head">
347          <div class="wdg-id">
348            <b class="wdg-sym">${esc(d.place || spec.place || "")}</b>
349            <span class="wdg-name">${esc(d.summary || "")}</span>
350          </div>
351          <div class="wdg-quote">
352            <span class="wdg-price">${Math.round(d.now_f)}&deg;</span>
353            <span class="wdg-move"><span class="wdg-chg">feels ${Math.round(d.feels_f)}&deg;</span></span>
354          </div>
355        </header>
356        <div class="wdg-tiles">${tiles.join("")}</div>
357        <ol class="wdg-days">${days.map(dayCell).join("")}</ol>`;
358    }
359
360    function dayCell(x, i) {
361      // The bar is the day's spread placed on the week's, so a cool day is
362      // short and low and a hot one is long and high. Reading seven pairs of
363      // numbers is what this replaces.
364      return `<li class="wdg-day" title="${esc(x.summary || "")}">
365        <span class="wdg-day-k">${i === 0 ? "Today" : esc(x.weekday)}</span>
366        <span class="wdg-day-rain" data-wet="${x.precip_pct >= 30 ? "yes" : "no"}">${Math.round(x.precip_pct)}%</span>
367        <span class="wdg-day-t"><b>${Math.round(x.high_f)}&deg;</b><i>${Math.round(x.low_f)}&deg;</i></span>
368        <span class="wdg-day-feels">feels ${Math.round(x.feels_high_f)}&deg;</span>
369      </li>`;
370    }
371
372    return root;
373  }
374
375  const uvBand = (v) => (v < 3 ? "low" : v < 6 ? "moderate" : v < 8 ? "high" : v < 11 ? "very high" : "extreme");
376  const aqiDir = (v) => (v <= 50 ? "up" : v <= 100 ? "flat" : "down");
377  const pollenDir = (v) => (v < 4.8 ? "up" : v < 7.2 ? "flat" : "down");
378
379  // ---------------------------------------------------------------- registry
380
381  // Adding a kind is one entry here and one branch on the server. Nothing else
382  // in the page knows what a widget is.
383  const KINDS = { ticker: tickerWidget, weather: weatherWidget };
384
385  window.Widgets = {
386    // render fills a message's widget box. It is called both while a turn is
387    // streaming and when a stored conversation is reopened, and it is the same
388    // code either way because a widget only ever carries its subject.
389    render(box, list) {
390      if (!box) return;
391      if (!list || !list.length) { box.hidden = true; return; }
392      box.hidden = false;
393      box.replaceChildren(...list.map(one).filter(Boolean));
394    },
395    add(box, spec) {
396      if (!box) return;
397      const node = one(spec);
398      if (!node) return;
399      box.hidden = false;
400      box.appendChild(node);
401    },
402  };
403
404  function one(spec) {
405    const make = KINDS[spec && spec.kind];
406    return make ? make(spec) : null;
407  }
408})();