// --- the only network call in the whole dashboard ------------------------
// A generator that holds its own last-good value, so a hiccup on the server
// shows slightly stale numbers rather than an error on the projector.
//
// Every viewer runs this independently against a static file. There is no
// per-viewer work on the server: Caddy hands the same small JSON to everyone,
// and the single R process producing it does not care whether 3 people or 300
// are watching.
//
// The poll interval comes from the data, because it is a property of the
// survey: 3 seconds for a live event, 10 minutes for one open for months.
data = {
let last = null;
let wait = 3000;
while (true) {
try {
const r = await fetch(`${slug}/summary.json`, { cache: "no-store" });
if (r.ok) {
last = await r.json();
if (last.interval) wait = Math.max(1000, last.interval * 1000);
}
} catch (e) {
// keep showing `last`
}
if (last) yield last;
await new Promise((res) => setTimeout(res, wait));
}
}// Blue ordinal ramp, taken unchanged from the validated reference palette.
// Ordinal rather than diverging because these are ordered response options,
// and a red/green polarity ramp would need its own validation run before it
// went in front of a client. Light mode starts at ramp step 250 and dark mode
// stops at step 600 -- the documented floors that keep the step nearest the
// surface above 2:1 contrast.
pal = dark
? {
surface: "#1a1a19", ink: "#ffffff", ink2: "#c3c2b7", muted: "#898781",
grid: "#2c2c2a", axis: "#383835", accent: "#3987e5",
ramp: ["#184f95", "#256abf", "#3987e5", "#86b6ef", "#cde2fb"]
}
: {
surface: "#fcfcfb", ink: "#0b0b0b", ink2: "#52514e", muted: "#898781",
grid: "#e1e0d9", axis: "#c3c2b7", accent: "#2a78d6",
ramp: ["#86b6ef", "#5598e7", "#2a78d6", "#1c5cab", "#0d366b"]
}// Shared plot styling, so every chart on the page agrees.
//
// NAMED `chartBase`, NOT `chrome`. It was called `chrome` and that silently
// broke everything: `window.chrome` exists as an empty object in Chrome and
// Edge, so `...chrome` spread nothing and the styling below was never applied
// -- no error, charts just quietly used Plot's defaults. In Safari, where no
// such global exists, every chart died with "chrome is not defined".
//
// Avoid cell names that collide with browser globals: chrome, name, status,
// location, history, screen, top, self, parent, origin, length, closed.
//
// Written as a block with an explicit `return`, NOT as `chartBase = ({...})`.
// Quarto silently fails to define a cell assigned a parenthesised object
// literal -- no parse error at render, no runtime error in the cell itself,
// the name just never exists. Consumers then fail with "not defined" and send
// you hunting in the wrong place.
chartBase = {
return { style: { background: "transparent", color: pal.ink2, fontSize: "13px" } };
}// Same trick as plotH: measure the card, size the content to fit. Depends on
// `vh` so it recomputes when the window or projector resolution changes.
//
// Measure the USABLE height, not the outer one: clientHeight includes the
// card's padding, and assuming otherwise is what made every earlier attempt
// overflow. Then subtract the label and sub-label, which are a constant ~34px
// whatever the figure does, and divide by the line-height to get back to a
// font size.
// Yields twice: once immediately, once after the dashboard's flex layout has
// settled. The first measurement runs before the rows have their final
// heights, which pinned the figure to its 18px floor.
kpiFont = {
vh;
const measure = () => {
const card = [...document.querySelectorAll(".card")].find((c) =>
(c.querySelector(".card-header")?.textContent || "").trim() === "Responses"
);
const body = card?.querySelector(".card-body");
if (!body) return 32;
const cs = getComputedStyle(body);
const avail = body.clientHeight
- parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
return Math.max(18, Math.min(56, Math.round((avail - 34) / 1.15)));
};
yield measure();
await new Promise((r) => setTimeout(r, 400));
yield measure();
}// Plot needs a height in pixels; the dashboard sizes its cards as a
// percentage of the viewport. So measure the card we are about to draw into.
// Referencing `vh` makes this recompute on resize -- without it, a chart laid
// out on a laptop gets clipped on a 1080p projector.
plotH = (titleFragment, reserve = 0) => {
vh;
kpiFont; // re-run once layout has settled, same reason as kpiFont
const card = [...document.querySelectorAll(".card")].find((c) =>
(c.querySelector(".card-header")?.textContent || "").includes(titleFragment)
);
const body = card?.querySelector(".card-body");
if (!body) return 200;
// clientHeight INCLUDES the card's padding. Subtracting `reserve` from it
// without removing the padding first is why the ratings chart kept
// overflowing by the same 11px no matter how much reserve was added.
const cs = getComputedStyle(body);
const avail = body.clientHeight
- parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
return Math.max(110, avail - 8 - reserve);
}// Two different questions depending on whether the survey has a roster.
// With one: what share of invitees answered. Without one: is it still moving.
data.counts.response_rate == null
? kpi(data.counts.last_hour, "In the last hour", "no invite list")
: kpi(Math.round(data.counts.response_rate * 100) + "%",
"Of those invited",
`${data.counts.complete} of ${data.counts.expected}`)Plot.plot({
...chartBase,
marginLeft: 132,
marginRight: 44,
marginBottom: 34,
// reserve room for the color legend Plot renders above the frame
height: plotH("Ratings", 62),
x: { label: "Responses", grid: true, nice: true },
y: { label: null, domain: data.aspects.map((a) => a.aspect) },
color: {
domain: satLabels,
range: pal.ramp,
legend: true,
label: null,
tickFormat: shorten
},
marks: [
Plot.barX(aspectRows, {
y: "aspect", x: "n", fill: "label", order: satLabels,
// 2px surface gap between stacked segments -- the documented spacer,
// rather than drawing borders around the marks.
insetLeft: 1, insetRight: 1, insetTop: 3, insetBottom: 3,
tip: true
}),
// Selective direct label: the mean, once per row. Not one per segment.
Plot.text(data.aspects, {
y: "aspect", x: (a) => a.n,
text: (a) => (a.mean == null ? "" : a.mean.toFixed(1)),
textAnchor: "start", dx: 8, fontWeight: 600, fill: pal.ink
}),
Plot.ruleX([0], { stroke: pal.axis })
]
})Plot.plot({
...chartBase,
marginLeft: 128,
marginRight: 46,
marginBottom: 34,
height: plotH("Before vs after"),
x: { domain: [1, 5], grid: true, label: "Mean rating (1–5)" },
y: { label: null, domain: data.growth.map((g) => g.item) },
marks: [
Plot.link(data.growth, {
y: "item", x1: "before", x2: "after",
stroke: pal.muted, strokeWidth: 2
}),
Plot.dot(data.growth, {
y: "item", x: "before", r: 6,
fill: pal.muted, stroke: pal.surface, strokeWidth: 2,
// The full item stem lives in the tooltip; the axis carries the short
// label so it stays readable from the back of the room.
tip: true, channels: { item_full: "item_full", n: "n" }
}),
Plot.dot(data.growth, {
y: "item", x: "after", r: 6,
fill: pal.accent, stroke: pal.surface, strokeWidth: 2,
tip: true, channels: { item_full: "item_full", n: "n" }
}),
Plot.text(data.growth, {
y: "item", x: "after",
text: (g) => (g.delta == null ? "" : (g.delta > 0 ? "+" : "") + g.delta.toFixed(1)),
textAnchor: "start", dx: 12, fontWeight: 600, fill: pal.ink
})
]
})Plot.plot({
...chartBase,
marginLeft: 44,
marginBottom: 34,
height: plotH("Responses over time"),
x: { label: null, grid: false },
y: { label: "Responses", grid: true, nice: true, ticks: 4 },
marks: [
// One series, one color. The area carries the shape; the line keeps it
// readable when counts are small.
Plot.areaY(timelineRows, {
x: "t", y: "n", fill: pal.accent, fillOpacity: 0.16, curve: "monotone-x"
}),
Plot.lineY(timelineRows, {
x: "t", y: "n", stroke: pal.accent, strokeWidth: 2, curve: "monotone-x"
}),
Plot.dot(timelineRows, {
x: "t", y: "n", r: 3.5,
fill: pal.accent, stroke: pal.surface, strokeWidth: 2, tip: true
}),
Plot.ruleY([0], { stroke: pal.axis })
]
})Plot.plot({
...chartBase,
marginLeft: 108,
marginBottom: 30,
height: plotH("Most-used words"),
x: { label: "Mentions", grid: true, nice: true, ticks: 4 },
y: { label: null },
marks: [
// One series, so one color -- no ramp keyed to bar length.
Plot.barX(data.words.slice(0, 10), {
y: "word", x: "n", fill: pal.accent,
sort: { y: "x", reverse: true },
insetTop: 2.5, insetBottom: 2.5, rx: 3,
tip: true
}),
Plot.ruleX([0], { stroke: pal.axis })
]
}){
const gen = new Date(data.generated_at);
const age = Math.round((Date.now() - gen) / 1000);
const stale = age > data.interval * 4 + 30;
return htl.html`<div class="stamp">
<span style="display:inline-flex;gap:1rem;margin-right:1rem;color:${pal.ink2}">
<span><span class="dot" style="background:${pal.muted}"></span>Before</span>
<span><span class="dot" style="background:${pal.accent}"></span>After</span>
</span>
${data.event.blurb ?? ""} · updated ${gen.toLocaleTimeString()}
<span class=${stale ? "stale" : ""}>${
stale ? ` — data is ${age}s old, check the summariser` : ""
}</span>
</div>`;
}