orchard
mirrorEvery 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
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 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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)}°</span>
353 <span class="wdg-move"><span class="wdg-chg">feels ${Math.round(d.feels_f)}°</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)}°</b><i>${Math.round(x.low_f)}°</i></span>
368 <span class="wdg-day-feels">feels ${Math.round(x.feels_high_f)}°</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})();