niceTop = (v) => {
if (!isFinite(v) || v <= 0) return 10;
const p = Math.pow(10, Math.floor(Math.log10(v)));
return Math.ceil(v / (p / 2)) * (p / 2);
}
d3g = (rows, keyfn) => {
const m = new Map();
for (const r of rows) { const k = keyfn(r); if (!m.has(k)) m.set(k, []); m.get(k).push(r); }
return m;
}
SERIES_COLS = ["#0E7FA8","#B02A50","#5F8A1E","#7C52A8","#B5651D","#2F6F62",
"#8A3B8F","#4A6FA5","#9A7B10","#496B2E"]
OFFICE_NAME_MAP = new Map(OFFNAMES.code.map((c, i) => [c, OFFNAMES.name[i]]))
officeName = (c) => OFFICE_NAME_MAP.get(c) ?? c
DYROWS = DYEAR.year.map((y, i) => ({
year: y, confirmed: DYEAR.confirmed[i], provisional: DYEAR.provisional[i],
forms: DYEAR.forms[i], offices: DYEAR.offices[i],
peak_day: DYEAR.peak_day[i], peak_n: DYEAR.peak_n[i]
}))
DFROWS = DFORM.form_name.map((f, i) => ({
form: f, keys: DFORM.keys[i], offices: DFORM.offices[i],
subtypes: DFORM.subtypes[i], first: DFORM.first[i], last: DFORM.last[i]
}))
OFFROWS = OFF.label.map((l, i) => ({
label: l, code: OFF.office_code[i], name: OFF.office_name[i],
fu: OFF.first_up[i], lu: OFF.last_up[i],
fl: OFF.first_lo[i], ll: OFF.last_lo[i],
du: (OFF.last_up[i] == null || OFF.first_up[i] == null) ? null
: OFF.last_up[i] - OFF.first_up[i],
dl: (OFF.last_lo[i] == null || OFF.first_lo[i] == null) ? null
: OFF.last_lo[i] - OFF.first_lo[i]
}))
ALLROWS = SERIES.form.map((f, i) => ({
form: f, subtype: SERIES.subtype[i], office: SERIES.office[i],
ym: SERIES.ym[i], lo: SERIES.lo[i], up: SERIES.up[i]
}))
DESC_MAP = new Map(DESC.form_name.map((f, i) => [f + "|" + DESC.form_subtype[i],
DESC.subtype_info_en[i]]))
shortDesc = (f, s) => {
const d = DESC_MAP.get(f + "|" + s) ?? "";
return d.length > 44 ? d.slice(0, 44) + "\u2026" : d;
}
BWROWS = BWM.ym.map((y, i) => ({
ym: y, events: BWM.events[i], keys: BWM.keys[i], days: BWM.days[i],
mean_d: BWM.mean_d[i], worst_d: BWM.worst_d[i]
}))
BWFROWS = BWF.form_name.map((f, i) => ({
form: f, events: BWF.events[i], offices: BWF.offices[i],
mean_d: BWF.mean_d[i], med_d: BWF.med_d[i], worst_d: BWF.worst_d[i]
}))
nf = (v) => v == null ? "not recorded" : v.toLocaleString("en-US")
plural = (v, w) => v == null ? "not recorded" : nf(v) + " " + w + (v === 1 ? "" : "s")
// The stylesheet decides when the page stops being a fixed grid and becomes one
// scrolling column, and publishes the answer as --compact. Reading it here means
// the charts and the layout can never disagree about which mode they are in.
isCompact = () =>
getComputedStyle(document.documentElement).getPropertyValue("--compact").trim() === "1"
// Below this width a chart is on a phone, or in a card as narrow as one, and
// trades its desktop margins and end-of-line labels for room to plot.
NARROW = 480
// A chart handed no height is in the scrolling layout and sizes itself from its
// width: tall enough to read, short enough to share a sideways phone with its note.
selfHeight = (w, ratio = 0.62, lo = 220, hi = 340) =>
Math.round(Math.max(lo, Math.min(hi, window.innerHeight * 0.72, w * ratio)))
// The left margin a narrow chart needs: its widest tick label and no more.
leftFor = (top, unit = "") => Math.max(30, (String(top) + unit).length * 6.5 + 12)
// Fewer gridlines when there is no height to keep five of them apart.
yFracs = (plot) => plot < 120 ? [0, .5, 1] : [0, .25, .5, .75, 1]
// Which of a row of axis labels to draw. All of them when they fit. Otherwise
// the shorter spellings, if any were offered, and then every nth of those, so
// the survivors stay evenly spaced instead of colliding.
axisLabels = function (full, pitch, short = full) {
const at = full.map((l, i) => l == null ? -1 : i).filter(i => i >= 0);
if (at.length < 2) return full;
const gap = Math.min(...at.slice(1).map((i, k) => i - at[k])) * pitch;
const need = set => Math.max(...at.map(i => String(set[i]).length)) * 6.5 + 8;
const set = need(full) <= gap ? full : short;
const step = Math.max(1, Math.ceil(need(set) / gap));
return full.map((_, i) => { const k = at.indexOf(i); return k < 0 || k % step ? null : set[i]; });
}
// Ties a drawn mark to the row it stands for, so one listener on the chart can
// ask what is under the pointer instead of every mark carrying its own.
mark = (el, d) => { el.__d = d; el.classList.add("mk"); return el; }
under = function (ev, svg) {
let el = document.elementFromPoint(ev.clientX, ev.clientY);
while (el && el !== svg && el.__d === undefined) el = el.parentNode;
if (el && el !== svg) return el.__d;
if (ev.pointerType !== "touch") return undefined;
// a fingertip is wider than a dot: settle on the nearest mark within reach
let best, reach = 30 * 30;
for (const m of svg.querySelectorAll(".mk")) {
const b = m.getBoundingClientRect();
const d = (b.left + b.width / 2 - ev.clientX) ** 2 + (b.top + b.height / 2 - ev.clientY) ** 2;
if (d < reach) { reach = d; best = m; }
}
return best?.__d;
}
// One wiring for a cursor and a finger. A cursor hovers and then leaves. A
// finger taps, or drags sideways, and then lifts, and the readout has to outlive
// the lift to be read. A finger that drags up or down is scrolling the page, not
// asking about the chart, so nothing is shown until the gesture proves otherwise.
track = function (el, show, hide) {
let from = null;
el.addEventListener("pointerdown", ev => {
if (ev.pointerType === "touch") from = { x: ev.clientX, live: false };
});
el.addEventListener("pointermove", ev => {
if (ev.pointerType !== "touch") return show(ev);
if (!from) return;
if (!from.live && Math.abs(ev.clientX - from.x) < 6) return;
from.live = true;
show(ev);
});
el.addEventListener("pointerup", ev => {
if (ev.pointerType === "touch" && from) { from = null; show(ev); }
});
el.addEventListener("pointercancel", () => { from = null; });
el.addEventListener("pointerleave", ev => { if (ev.pointerType !== "touch") hide(); });
(el.ownerSVGElement ?? el).addEventListener("tipclear", hide);
}
// A readout a tap left floating over a chart closes when the next tap lands
// somewhere else. One docked under its plot is part of the card and stays.
tipClear = {
const clear = ev => {
if (ev.pointerType !== "touch") return;
for (const wrap of document.querySelectorAll(".chart-wrap"))
if (!wrap.contains(ev.target) && !wrap.querySelector(".ctip.docked"))
wrap.querySelector("svg.ojs-chart")?.dispatchEvent(new CustomEvent("tipclear"));
};
document.addEventListener("pointerdown", clear, true);
invalidation.then(() => document.removeEventListener("pointerdown", clear, true));
}
// On a narrow screen the page links are one row that scrolls sideways, so the
// current page's link can start out of view. Bring it to the middle of the row.
navFollow = {
const follow = () => {
const a = document.querySelector(".navbar .navbar-nav .nav-link.active");
const row = a?.closest(".navbar-nav");
if (row && row.scrollWidth > row.clientWidth)
row.scrollTo({ left: a.offsetLeft - (row.clientWidth - a.offsetWidth) / 2 });
};
document.addEventListener("shown.bs.tab", follow);
window.addEventListener("resize", follow);
follow();
invalidation.then(() => {
document.removeEventListener("shown.bs.tab", follow);
window.removeEventListener("resize", follow);
});
}
// Keeps the readout inside its card: it flips to the cursor's left only when
// there is genuinely no room on the right, measured rather than guessed.
// Quarto's OJS `width` is the page width, not the cell's, so a chart sized from
// it gets scaled down by the browser and every label shrinks with it. This
// measures the card the chart actually lands in and draws at 1:1, redrawing on
// resize. The second pass exists because the note's height is only knowable
// once it has been laid out at this width.
fitted = function (draw, opts = {}) {
const { fallbackRatio = 0.55, minHeight = 92 } = opts;
// The host stretches to fill the card, so its own box IS the space available:
// no arithmetic over padding, sibling controls or the card's own height, all
// of which read wrong while the dashboard is still laying itself out.
const host = htl.html`<div style="width:100%;flex:1 1 auto;min-height:0;
align-self:stretch;display:flex;flex-direction:column;justify-content:flex-start"></div>`;
let lastW = 0, lastH = -1;
const paint = () => {
const w = Math.round(host.clientWidth);
if (!w) return;
// In the scrolling layout nothing hands the host a height. Its box is only
// as tall as what was last drawn into it, so sizing from it shrinks the
// chart a little on every pass until it reaches the floor. There the chart
// is given no height and picks its own.
if (isCompact()) {
if (w === lastW && lastH === null) return;
lastW = w; lastH = null;
host.replaceChildren(draw(w, null));
return;
}
const avail = Math.round(host.clientHeight);
if (w === lastW && avail === lastH) return;
lastW = w; lastH = avail;
if (!avail) { host.replaceChildren(draw(w, Math.round(w * fallbackRatio))); return; }
const measure = () => {
const n = host.querySelector(".chart-note");
// scrollHeight, not the rect: a note squeezed by the fill container
// reports its clipped height, which would under-reserve and clip it again
return n ? Math.max(n.scrollHeight, n.getBoundingClientRect().height) + 10 : 0;
};
let h = Math.max(minHeight, avail - measure());
host.replaceChildren(draw(w, h));
// the first pass draws at a guessed height, which can wrap the note
// differently; once it is laid out for real, correct the reserve once
const h2 = Math.max(minHeight, avail - measure());
if (Math.abs(h2 - h) > 6) host.replaceChildren(draw(w, h2));
};
new ResizeObserver(paint).observe(host);
requestAnimationFrame(paint);
[60, 200, 600].forEach(t => setTimeout(paint, t));
return host;
}
// A finger has no cursor to sit beside, and on a chart as narrow as a phone a
// floating readout lands on the marks it describes. There it takes a place in
// the card under the plot instead; the stylesheet does the rest.
dockTip = function (tip, box) {
const dock = box.width < NARROW && isCompact() && matchMedia("(hover: none)").matches;
tip.classList.toggle("docked", dock);
return dock;
}
placeTip = function (tip, ev, box) {
if (dockTip(tip, box)) return;
const x = ev.clientX - box.left, y = ev.clientY - box.top;
const w = tip.offsetWidth || 300, h = tip.offsetHeight || 90;
tip.style.left = (x + 18 + w <= box.width ? x + 18
: Math.max(4, x - 18 - w)) + "px";
tip.style.top = Math.max(4, Math.min(box.height - h - 4, y - 30)) + "px";
}
// A daily series with the gaps drawn as gaps, and each run of missing days
// shaded so a change that landed inside one is not read as a change on the day
// collection resumed.
gapChart = function (rows, gaps, opts = {}) {
const { width = 1320, height = 300, note = "", yUnit = "" } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing recorded.</div>`;
const W = width, H = height ?? selfHeight(W), narrow = W < NARROW;
const vals = rows.map(r => r.n).filter(v => v != null);
const yLo = Math.floor(Math.min(...vals) / 50) * 50;
const yTop = niceTop(Math.max(...vals));
const M = { t: 26, r: narrow ? 10 : 22, b: 34, l: narrow ? leftFor(yTop, yUnit) : 60 };
const idx = new Map(rows.map((r, i) => [r.d, i]));
const X = i => M.l + (i / (rows.length - 1)) * (W - M.l - M.r);
const Y = v => H - M.b - ((v - yLo) / (yTop - yLo)) * (H - M.t - M.b);
const ticks = yFracs(H - M.t - M.b).map(f => Math.round(yLo + (yTop - yLo) * f));
// one path per unbroken run, so a missing day leaves a real hole in the line
const paths = [];
let d = "", open = false;
rows.forEach((r, i) => {
if (r.n == null) { if (open) { paths.push(d); d = ""; open = false; } return; }
const x = X(i), y = Y(r.n);
d += open ? `H${x}V${y}` : `M${x} ${y}`;
open = true;
});
if (open) paths.push(d);
const tipEl = htl.html`<div class="ctip"></div>`;
// left to right, so a gap whose label would land on its neighbour's goes without
let lastLabel = -Infinity;
const bands = gaps.slice().sort((p, q) => p.start < q.start ? -1 : 1).map(g => {
const a = idx.get(g.start), b2 = idx.get(g.end);
if (a == null || b2 == null) return null;
const w0 = Math.max(7, X(b2) - X(a));
const cx = X(a) + w0 / 2;
const room = cx - lastLabel >= 38;
if (room) lastLabel = cx;
return mark(htl.svg`<g style="cursor:help">
<rect x=${X(a)} y=${M.t} width=${w0} height=${H - M.t - M.b}
class="sr1" fill="currentColor" fill-opacity=".15"/>
<line x1=${cx} x2=${cx} y1=${M.t} y2=${H - M.b} class="sr1" stroke="currentColor"
stroke-width="1" stroke-dasharray="3 3" opacity=".65"/>
${room ? htl.svg`<text x=${cx} y=${M.t - 8} text-anchor="middle" class="ser sr1"
fill="currentColor">${g.len} d</text>` : null}
</g>`, g);
}).filter(Boolean);
const years = rows.map((r, i) => ({ r, i })).filter(({ r }) => r.d.endsWith("-01-01"));
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t}${yUnit}</text></g>`)}
${years.map(({ r, i }) => htl.svg`<text x=${X(i)} y=${H - 12} text-anchor="middle"
class="ax">${r.d.slice(0, 4)}</text>`)}
${bands}
${paths.map(pd => htl.svg`<path d=${pd} fill="none" stroke="currentColor"
stroke-width="1.8" class="sr0"/>`)}
</svg>`;
function showGap(ev, g) {
const row = (k, v) => `<div class="ct-r"><span class="ct-k">${k}</span>` +
`<span class="ct-v">${v}</span></div>`;
tipEl.innerHTML = `<div class="ct-h">No snapshots, ${g.start} to ${g.end}</div>` +
row("days missing", plural(g.len, "day")) +
row("keys before", nf(g.before)) +
row("keys after", nf(g.after)) +
row("change across the gap",
(g.delta > 0 ? "+" : "") + nf(g.delta) + " keys") +
row("changes forced to the reopen day", nf(g.events)) +
`<div class="ct-more">Nothing can be dated inside a gap: the first day a change ` +
`can be seen is the day collection resumed, so it carries that date instead.</div>`;
tipEl.style.opacity = 1;
placeTip(tipEl, ev, svg.getBoundingClientRect());
}
const hideGap = () => { tipEl.style.opacity = 0; };
track(svg, ev => { const g = under(ev, svg); g ? showGap(ev, g) : hideGap(); }, hideGap);
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// Vertical columns, one per row. value/label/detail keep it independent of any
// one dataset, and every slot is drawn so gaps stay visible as gaps.
colChart = function (rows, opts = {}) {
const { width = 1320, height = 330, note = "", cls = "sr1", yLabel = "",
value = r => r.v, label = () => null, tick = label, detail = () => [],
emptyAt = null } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing recorded.</div>`;
const W = width, H = height ?? selfHeight(W), narrow = W < NARROW;
const yTop = niceTop(Math.max(...rows.map(value)));
const M = { t: 28, r: narrow ? 8 : 22, b: 34, l: narrow ? leftFor(yTop) : 66 };
const band = (W - M.l - M.r) / rows.length;
const bw = Math.max(3, band * 0.74);
const X = i => M.l + band * i + (band - bw) / 2;
const Y = v => H - M.b - (v / yTop) * (H - M.t - M.b);
const ticks = yFracs(H - M.t - M.b).map(f => Math.round(yTop * f));
// `tick` is an optional shorter spelling of a label, used only when the full
// one would not fit under its column
const xl = axisLabels(rows.map(label), band, rows.map(tick));
const bars = rows.map((r, i) => htl.svg`<rect x=${X(i)} y=${Y(value(r))} width=${bw}
height=${Math.max(0, H - M.b - Y(value(r)))} class="${cls}" fill="currentColor"
fill-opacity=".82"/>`);
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t}</text></g>`)}
<text x="2" y="11" class="axl">${yLabel}</text>
${xl.map((l, i) => l == null ? null
: htl.svg`<text x=${X(i) + bw / 2} y=${H - 12} text-anchor="middle"
class="ax">${l}</text>`)}
${bars}
</svg>`;
const hit = htl.svg`<rect x=${M.l} y=${M.t} width=${W - M.l - M.r}
height=${H - M.t - M.b} fill="transparent"/>`;
svg.append(hit);
track(hit, ev => {
const box = svg.getBoundingClientRect();
const px = (ev.clientX - box.left) / box.width * W;
const i = Math.max(0, Math.min(rows.length - 1, Math.floor((px - M.l) / band)));
const r = rows[i];
bars.forEach((b2, j) => b2.setAttribute("fill-opacity", j === i ? 1 : .28));
const head = `<div class="ct-h">${label(r) ?? r.band ?? r.ym ?? ""}</div>`;
tipEl.innerHTML = head + (emptyAt && value(r) === 0
? `<div class="ct-more">${emptyAt}</div>`
: detail(r).map(([k, v]) => `<div class="ct-r"><span class="ct-k">${k}</span>` +
`<span class="ct-v">${v}</span></div>`).join(""));
tipEl.style.opacity = 1;
placeTip(tipEl, ev, box);
}, () => {
bars.forEach(b2 => b2.setAttribute("fill-opacity", .82));
tipEl.style.opacity = 0;
});
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// --- row builders for the new analyses -------------------------------------
cols2rows = (obj) => {
const keys = Object.keys(obj);
const n = obj[keys[0]].length;
return Array.from({ length: n }, (_, i) =>
Object.fromEntries(keys.map(k => [k, obj[k][i]])));
}
SPREADROWS = cols2rows(SPREAD)
RELROWS = cols2rows(RELEASE)
STALEDROWS = cols2rows(STALED)
STALEMROWS = cols2rows(STALEM)
AGREEROWS = cols2rows(AGREE)
DRIFTROWS = cols2rows(DRIFT)
HEATROWS = cols2rows(HEAT)
PATROWS = cols2rows(PATTERNS)
PRECROWS = cols2rows(PREC)
FORMCHGROWS = cols2rows(FORMCHG)
OFFCHGROWS = cols2rows(OFFCHG)
WAITROWS = cols2rows(WAITBAND)
COVROWS = cols2rows(COV)
DISCEVROWS = cols2rows(DISCEV)
GAPROWS = cols2rows(BIGGAP)
CBROWS = cols2rows(CB)
// Every office on one axis, so the spread that depends purely on where a case
// was filed is visible as a distance rather than inferred from a table.
stripChart = function (rows, opts = {}) {
const { width = 1320, height = 300, note = "", empty = "Nothing published for this selection." } = opts;
if (!rows.length) return htl.html`<div class="empty">${empty}</div>`;
const W = width, H = height ?? selfHeight(W, .5, 210, 250), M = { t: 40, r: 26, b: 46, l: 26 };
const vals = rows.flatMap(r => [r.lo, r.up]).filter(v => v != null);
const hi = niceTop(Math.max(...vals));
const X = v => M.l + (v / hi) * (W - M.l - M.r);
const ticks = [0, .25, .5, .75, 1].map(f => Math.round(hi * f));
const laneU = M.t + (H - M.t - M.b) * 0.32;
const laneL = M.t + (H - M.t - M.b) * 0.72;
const quant = (arr, q) => {
const a = arr.slice().sort((x, y) => x - y);
return a[Math.min(a.length - 1, Math.floor(q * (a.length - 1)))];
};
const ups = rows.map(r => r.up).filter(v => v != null);
const q1 = quant(ups, .25), med = quant(ups, .5), q3 = quant(ups, .75);
// a deterministic offset per index keeps overlapping offices readable
const jitter = i => ((i * 37) % 11 - 5) * 2.1;
const tipEl = htl.html`<div class="ctip"></div>`;
const dotsU = rows.map((r, i) => r.up == null ? null :
mark(htl.svg`<circle cx=${X(r.up)} cy=${laneU + jitter(i)} r="4.6" class="pt sr1"
fill="currentColor" fill-opacity=".62"/>`, r));
const dotsL = rows.map((r, i) => r.lo == null ? null :
mark(htl.svg`<circle cx=${X(r.lo)} cy=${laneL + jitter(i)} r="4.6" class="pt sr0"
fill="currentColor" fill-opacity=".62"/>`, r));
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${X(t)} x2=${X(t)} y1=${M.t - 16} y2=${H - M.b} class="grid"/>
<text x=${X(t)} y=${H - M.b + 18} text-anchor="middle" class="ax">${t} mo</text></g>`)}
<rect x=${X(q1)} y=${M.t - 14} width=${Math.max(1, X(q3) - X(q1))}
height=${H - M.b - M.t + 14} class="sr1" fill="currentColor" fill-opacity=".07"/>
<line x1=${X(med)} x2=${X(med)} y1=${M.t - 14} y2=${H - M.b} class="rule"
stroke-width="1.5" stroke-dasharray="4 3"/>
<text x=${X(med)} y=${M.t - 20} text-anchor="middle" class="ser sr1"
fill="currentColor">median ${med} mo</text>
<text x=${M.l} y=${laneU - 26} class="axl">upper bound, one dot per office</text>
<text x=${M.l} y=${laneL - 24} class="axl">lower bound</text>
${dotsU.filter(Boolean)}${dotsL.filter(Boolean)}
</svg>`;
function show(ev, r) {
const rank = rows.filter(x => x.up != null && x.up < r.up).length + 1;
const n = rows.filter(x => x.up != null).length;
tipEl.innerHTML = `<div class="ct-h">${r.name} \u00b7 ${r.office}</div>` +
`<div class="ct-r"><i class="sr1" style="background:currentColor"></i>` +
`<span class="ct-k">upper bound</span><span class="ct-v">${r.up} mo</span></div>` +
`<div class="ct-r"><i class="sr0" style="background:currentColor"></i>` +
`<span class="ct-k">lower bound</span><span class="ct-v">${r.lo == null ? "not published" : r.lo + " mo"}</span></div>` +
`<div class="ct-r"><span class="ct-k">rank</span>` +
`<span class="ct-v">${rank} of ${n}, fastest first</span></div>`;
tipEl.style.opacity = 1;
placeTip(tipEl, ev, svg.getBoundingClientRect());
}
track(svg, ev => { const d = under(ev, svg); if (d) show(ev, d); },
() => { tipEl.style.opacity = 0; });
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// Added above the axis, removed below, net marked: the sign of a year is the
// thing being read, so it cannot be a single stacked total.
divergeChart = function (rows, opts = {}) {
const { width = 1320, height = 300, note = "", label = r => r.k,
up = r => r.added, down = r => r.removed, net = r => r.net,
unit = " mo", detail = () => [] } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing recorded.</div>`;
const W = width, H = height ?? selfHeight(W), narrow = W < NARROW;
const mag = Math.max(...rows.map(r => Math.max(Math.abs(up(r)), Math.abs(down(r)))));
const hi = niceTop(mag);
const side = { r: narrow ? 8 : 26, l: narrow ? leftFor(-hi) : 66 };
const band = (W - side.l - side.r) / rows.length;
const bw = Math.min(Math.max(8, band * 0.5), 62);
const X = i => side.l + band * i + (band - bw) / 2;
// Figures in full while a column is wide enough for them, in thousands when
// it is not, and left to the readout when even those would collide.
const full = v => (v > 0 ? "+" : "") + nf(v);
const brief = v => Math.abs(v) < 1000 ? full(v)
: (v > 0 ? "+" : "") + Math.round(v / 100) / 10 + "k";
const fig = [full, brief].find(f => band >= 6 + 6.8 *
Math.max(...rows.flatMap(r => [up(r), down(r), net(r)]).map(v => f(v).length)));
// with figures, the bottom margin holds two lines: the year, then its net
const M = { t: 22, b: fig ? 52 : 34, ...side };
const half = (H - M.t - M.b) / 2;
const mid = M.t + half;
// Each bar carries its figure just beyond its end. Drawn on the bar it would
// be the bar's own colour on itself, so the axis runs a little past the
// outermost gridline to leave the tallest bar room for one.
const ROOM = fig ? 17 : 0;
const Y = v => mid - (v / hi) * (half - ROOM);
const ticks = (half < 75 ? [-1, 0, 1] : [-1, -.5, 0, .5, 1]).map(f => Math.round(hi * f));
const xl = axisLabels(rows.map(label), band);
const bars = rows.flatMap((r, i) => [
htl.svg`<rect x=${X(i)} y=${Y(up(r))} width=${bw} height=${Math.max(0, mid - Y(up(r)))}
class="sr1" fill="currentColor" fill-opacity=".8"/>`,
htl.svg`<rect x=${X(i)} y=${mid} width=${bw} height=${Math.max(0, Y(down(r)) - mid)}
class="sr0" fill="currentColor" fill-opacity=".8"/>`]);
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t}</text></g>`)}
<line x1=${M.l} x2=${W - M.r} y1=${mid} y2=${mid} class="rule"/>
<text x="2" y="11" class="axl">months added / removed</text>
${bars}
${rows.map((r, i) => htl.svg`<line x1=${X(i) - 4} x2=${X(i) + bw + 4}
y1=${Y(net(r))} y2=${Y(net(r))} class="neutral" stroke="currentColor" stroke-width="2.5"/>`)}
${xl.map((l, i) => l == null ? null : htl.svg`<text x=${X(i) + bw / 2}
y=${H - (fig ? 30 : 12)} text-anchor="middle" class="ax">${l}</text>`)}
${fig ? htl.svg`<text x=${M.l - 8} y=${H - 10} text-anchor="end" class="axl">net</text>` : null}
${fig ? rows.map((r, i) => htl.svg`<g>
<text x=${X(i) + bw / 2} y=${Y(up(r)) - 5} text-anchor="middle" class="ser sr1"
fill="currentColor">${fig(up(r))}</text>
<text x=${X(i) + bw / 2} y=${Y(down(r)) + 13} text-anchor="middle" class="ser sr0"
fill="currentColor">${fig(down(r))}</text>
<text x=${X(i) + bw / 2} y=${H - 10} text-anchor="middle"
class="${"ser " + (net(r) > 0 ? "sr1" : "sr0")}" fill="currentColor">${fig(net(r))}</text>
</g>`) : null}
</svg>`;
const hit = htl.svg`<rect x=${M.l} y=${M.t} width=${W - M.l - M.r}
height=${H - M.t - M.b} fill="transparent"/>`;
svg.append(hit);
track(hit, ev => {
const box = svg.getBoundingClientRect();
const i = Math.max(0, Math.min(rows.length - 1,
Math.floor(((ev.clientX - box.left) / box.width * W - M.l) / band)));
bars.forEach((b2, j) => b2.setAttribute("fill-opacity",
(j === i * 2 || j === i * 2 + 1) ? 1 : .26));
tipEl.innerHTML = `<div class="ct-h">${label(rows[i])}</div>` +
detail(rows[i]).map(([k, v, c]) =>
`<div class="ct-r">${c ? `<i class="${c}" style="background:currentColor"></i>` : ""}` +
`<span class="ct-k">${k}</span><span class="ct-v">${v}</span></div>`).join("");
tipEl.style.opacity = 1;
placeTip(tipEl, ev, box);
}, () => {
bars.forEach(b2 => b2.setAttribute("fill-opacity", .8));
tipEl.style.opacity = 0;
});
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// A matrix reads magnitude, so it takes the sequential ramp, not the series steps.
heatChart = function (rows, opts = {}) {
const { width = 1320, height = 300, note = "", unit = " mo",
row = r => r.r, col = r => r.c, value = r => r.v, detail = () => [],
value2 = null, split = false } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing recorded.</div>`;
const rowKeys = [...new Set(rows.map(row))];
const colKeys = [...new Set(rows.map(col))].sort();
const vals = rows.map(value).filter(v => v != null);
const lo = Math.min(...vals), hi = Math.max(...vals);
// rank within the observed range, in six steps
const step = v => Math.min(5, Math.max(0, Math.floor(((v - lo) / (hi - lo || 1)) * 5.999)));
const W = width, narrow = W < NARROW;
const side = narrow ? { l: 36, r: 4 } : { l: 46, r: 14 };
const cw = (W - side.l - side.r) / colKeys.length;
// a column's name turns on its side once it is wider than the column
const longest = Math.max(...colKeys.map(k => String(k).length)) * 6.5;
const turned = cw < longest + 6;
const M = { t: turned ? longest + 14 : 26, b: 8, ...side };
const H = height ?? M.t + M.b + rowKeys.length * 30;
const ch = (H - M.t - M.b) / rowKeys.length;
// and a cell carries its figure only while the figure fits inside it
const figure = Math.max(...vals.map(v => String(v).length)) * 6.2 + 4;
const at = new Map(rows.map(r => [row(r) + "|" + col(r), r]));
const tipEl = htl.html`<div class="ctip"></div>`;
const half = (cw - 3) / 2;
const cells = rowKeys.flatMap((rk, i) => colKeys.flatMap((ck, j) => {
const r = at.get(rk + "|" + ck);
if (!r || value(r) == null) return [];
const y = M.t + ch * i + 1, h = Math.max(1, ch - 2);
if (!split || value2 == null || value2(r) == null)
return [mark(htl.svg`<rect x=${M.l + cw * j + 1} y=${y}
width=${Math.max(1, cw - 2)} height=${h} rx="2"
class="${"pt hc" + step(value(r))}" fill="currentColor"/>`, r)];
// left half is the lower bound, right half the upper, on one shared scale
return [
mark(htl.svg`<rect x=${M.l + cw * j + 1} y=${y} width=${half} height=${h} rx="2"
class="${"pt hc" + step(value2(r))}" fill="currentColor"/>`, r),
mark(htl.svg`<rect x=${M.l + cw * j + 2 + half} y=${y} width=${half} height=${h} rx="2"
class="${"pt hc" + step(value(r))}" fill="currentColor"/>`, r)];
}));
const labels = rowKeys.flatMap((rk, i) => colKeys.flatMap((ck, j) => {
const r = at.get(rk + "|" + ck);
if (!r || ch < 15 || value(r) == null) return [];
const cy = M.t + ch * i + ch / 2 + 3.5;
const halved = split && value2 != null && value2(r) != null;
if ((halved ? half : cw) < figure) return [];
if (!halved)
return [htl.svg`<text x=${M.l + cw * j + cw / 2} y=${cy} text-anchor="middle"
class="${"cellv" + (step(value(r)) >= 4 ? " on" : "")}"
pointer-events="none">${value(r)}</text>`];
return [
htl.svg`<text x=${M.l + cw * j + 1 + half / 2} y=${cy} text-anchor="middle"
class="${"cellv" + (step(value2(r)) >= 4 ? " on" : "")}"
pointer-events="none">${value2(r)}</text>`,
htl.svg`<text x=${M.l + cw * j + 2 + half + half / 2} y=${cy} text-anchor="middle"
class="${"cellv" + (step(value(r)) >= 4 ? " on" : "")}"
pointer-events="none">${value(r)}</text>`];
}));
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${colKeys.map((ck, j) => { const x = M.l + cw * j + cw / 2;
return turned
? htl.svg`<text x=${x} y=${M.t - 7} transform=${`rotate(-90 ${x} ${M.t - 7})`}
dominant-baseline="central" class="${cw < 13 ? "ax tight" : "ax"}">${ck}</text>`
: htl.svg`<text x=${x} y=${M.t - 9} text-anchor="middle" class="ax">${ck}</text>`; })}
${rowKeys.map((rk, i) => htl.svg`<text x=${M.l - 8} y=${M.t + ch * i + ch / 2 + 4}
text-anchor="end" class="ax">${rk}</text>`)}
${cells.filter(Boolean)}${labels.filter(Boolean)}
</svg>`;
function show(ev, r) {
tipEl.innerHTML = `<div class="ct-h">${row(r)} \u00b7 ${col(r)}</div>` +
detail(r).map(([k, v]) => `<div class="ct-r"><span class="ct-k">${k}</span>` +
`<span class="ct-v">${v}</span></div>`).join("");
tipEl.style.opacity = 1;
placeTip(tipEl, ev, svg.getBoundingClientRect());
}
track(svg, ev => { const d = under(ev, svg); if (d) show(ev, d); },
() => { tipEl.style.opacity = 0; });
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// Generic x against y with a no-difference diagonal.
scatterChart = function (pts, opts = {}) {
const { width = 1320, height = 300, note = "", xLabel = "", yLabel = "",
unit = " mo", detail = () => [], colOf = () => "sr0" } = opts;
if (!pts.length) return htl.html`<div class="empty">Nothing to compare.</div>`;
const W = width, H = height ?? selfHeight(W, .8, 240, 380), narrow = W < NARROW;
const M = { t: 14, r: narrow ? 12 : 24, b: 42, l: narrow ? 48 : 60 };
const hi = niceTop(Math.max(...pts.flatMap(p => [p.x, p.y])));
const X = v => M.l + (v / hi) * (W - M.l - M.r);
const Y = v => H - M.b - (v / hi) * (H - M.t - M.b);
const ticks = [0, .25, .5, .75, 1].map(f => Math.round(hi * f));
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<line x1=${X(t)} x2=${X(t)} y1=${M.t} y2=${H - M.b} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t}</text>
<text x=${X(t)} y=${H - 24} text-anchor="middle" class="ax">${t}</text></g>`)}
<line x1=${X(0)} y1=${Y(0)} x2=${X(hi)} y2=${Y(hi)} class="refline"
stroke-width="1.5" stroke-dasharray="5 4"/>
<text x=${(M.l + W - M.r) / 2} y=${H - 5} text-anchor="middle" class="axl">${xLabel}</text>
<text transform="rotate(-90)" x=${-(M.t + H - M.b) / 2} y=15 text-anchor="middle"
class="axl">${yLabel}</text>
${pts.map(p => mark(htl.svg`<circle cx=${X(p.x)} cy=${Y(p.y)} r="5" fill="currentColor"
fill-opacity=".62" stroke-width="1.4" class="${"pt " + colOf(p)}"/>`, p))}
</svg>`;
function show(ev, p) {
tipEl.innerHTML = `<div class="ct-h">${p.k}</div>` +
detail(p).map(([k, v, c]) =>
`<div class="ct-r">${c ? `<i class="${c}" style="background:currentColor"></i>` : ""}` +
`<span class="ct-k">${k}</span><span class="ct-v">${v}</span></div>`).join("");
tipEl.style.opacity = 1;
placeTip(tipEl, ev, svg.getBoundingClientRect());
}
track(svg, ev => { const d = under(ev, svg); if (d) show(ev, d); },
() => { tipEl.style.opacity = 0; });
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// Ranked horizontal bars, one row per item, with a per-row hover readout.
// value/label/detail keep it independent of any one dataset's field names.
rankChart = function (rows, opts = {}) {
const { width = 1320, height = 330, note = "", unit = " d", cls = "sr3",
value = r => r.v, label = r => r.k, detail = () => [],
signed = false } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing recorded.</div>`;
const W = width, T = 8, B = 24;
const longest = Math.max(...rows.map(r => String(label(r)).length));
const figure = r => (signed && value(r) > 0 ? "+" : "") + value(r) + unit;
const widest = Math.max(...rows.map(r => figure(r).length));
// a narrow column gives the names and the figures the room they need and no more
const sides = w => w < NARROW
? { l: Math.min(w * .42, longest * 6.5 + 14), r: widest * 6.8 + 14 }
: { l: Math.min(190, Math.max(64, longest * 6.4 + 14)), r: 104 };
// Rows closer together than a line of type run their names into each other.
// A card too short to hold every row in one column wraps them into as many
// columns as its width allows, read down and then across. If that is still
// not enough the chart keeps its rows apart and the card scrolls instead.
const PITCH = 12.5;
const fit = height == null ? rows.length : Math.max(1, Math.floor((height - T - B) / PITCH));
let ncol = Math.max(1, Math.min(Math.ceil(rows.length / fit), Math.floor(W / 200)));
while (ncol > 1 && W / ncol - sides(W / ncol).l - sides(W / ncol).r < 70) ncol--;
const per = Math.ceil(rows.length / ncol);
const colW = W / ncol, narrow = colW < NARROW;
const M = { t: T, b: B, ...sides(colW) };
const H = height == null ? T + B + per * 22 : Math.max(height, T + B + per * PITCH);
const rowH = (H - M.t - M.b) / per;
const bh = Math.min(rowH * 0.66, 22);
// signed bars grow either side of a zero line; unsigned grow from the left
const xTop = niceTop(Math.max(...rows.map(r => Math.abs(value(r)))));
const span = colW - M.l - M.r;
const L = v => (Math.abs(v) / xTop) * (signed ? span / 2 : span);
// a row is placed within its column, then the column is shifted into place
const left = i => Math.floor(i / per) * colW;
const zero = i => left(i) + (signed ? M.l + span / 2 : M.l);
const Y = i => M.t + rowH * (i % per) + (rowH - bh) / 2;
const ticks = signed ? [-1, -.5, 0, .5, 1].map(f => Math.round(xTop * f))
: [0, .25, .5, .75, 1].map(f => Math.round(xTop * f));
const tickX = t => signed ? M.l + span / 2 + (t / xTop) * (span / 2) : M.l + L(t);
const tl = axisLabels(ticks.map(t => t + unit), span / (ticks.length - 1));
const clsOf = r => signed ? (value(r) > 0 ? "sr1" : "sr0") : cls;
const bars = rows.map((r, i) => htl.svg`<rect
x=${signed && value(r) < 0 ? zero(i) - L(value(r)) : zero(i)} y=${Y(i)}
width=${Math.max(1, L(value(r)))}
height=${bh} rx="2" class="${clsOf(r)}" fill="currentColor" fill-opacity=".85"/>`);
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${Array.from({ length: ncol }, (_, c) => c * colW).flatMap(x0 => ticks.map((t, k) => htl.svg`<g>
<line x1=${x0 + tickX(t)} x2=${x0 + tickX(t)} y1=${M.t} y2=${H - M.b} class="grid"/>
${tl[k] == null ? null : htl.svg`<text x=${x0 + tickX(t)} y=${H - 8} text-anchor="middle"
class="ax">${tl[k]}</text>`}</g>`))}
${bars}
${rows.map((r, i) => htl.svg`<text x=${left(i) + M.l - 10}
y=${Y(i) + bh / 2 + 4} text-anchor="end" class="ax">${label(r)}</text>`)}
${rows.map((r, i) => {
const neg = signed && value(r) < 0;
// Beside a bar that runs left there is only the row's name to collide
// with, so a narrow column puts that figure on the clear side of zero.
const x = !neg ? zero(i) + L(value(r)) + 9 : narrow ? zero(i) + 7 : zero(i) - L(value(r)) - 9;
return htl.svg`<text x=${x} y=${Y(i) + bh / 2 + 4}
text-anchor="${neg && !narrow ? "end" : "start"}"
class="${"ser " + clsOf(r)}" fill="currentColor">${figure(r)}</text>`; })}
</svg>`;
rows.forEach((r, i) => svg.append(mark(htl.svg`<rect x=${left(i)} y=${M.t + rowH * (i % per)}
width=${colW} height=${rowH} fill="transparent"/>`, i)));
track(svg, ev => {
const i = under(ev, svg);
if (i === undefined) return;
bars.forEach((bar, j) => bar.setAttribute("fill-opacity", j === i ? 1 : .3));
tipEl.innerHTML = `<div class="ct-h">${label(rows[i])}</div>` +
detail(rows[i]).map(([k, v]) =>
`<div class="ct-r"><span class="ct-k">${k}</span>` +
`<span class="ct-v">${v}</span></div>`).join("");
tipEl.style.opacity = 1;
placeTip(tipEl, ev, svg.getBoundingClientRect());
}, () => {
bars.forEach(bar => bar.setAttribute("fill-opacity", .85));
tipEl.style.opacity = 0;
});
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
// Stacked columns: one column per row, one segment per named series, with a
// legend and a hover readout carrying the whole column.
stackChart = function (rows, opts = {}) {
const { width = 1320, height = 330, note = "", segments = [], yUnit = "",
label = r => r.k, detail = () => [] } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing recorded.</div>`;
const total = r => segments.reduce((a, seg) => a + (r[seg.key] ?? 0), 0);
const W = width, H = height ?? selfHeight(W), narrow = W < NARROW;
const yTop = niceTop(Math.max(...rows.map(total)));
const M = { t: 48, r: narrow ? 8 : 22, b: 34, l: narrow ? leftFor(yTop, yUnit) : 60 };
const band = (W - M.l - M.r) / rows.length;
const bw = Math.min(Math.max(6, band * 0.62), 74);
const X = i => M.l + band * i + (band - bw) / 2;
const Y = v => H - M.b - (v / yTop) * (H - M.t - M.b);
const ticks = yFracs(H - M.t - M.b).map(f => Math.round(yTop * f));
const xl = axisLabels(rows.map(label), band);
// one rect per segment, stacked from the baseline up
const cols = rows.map((r, i) => {
let acc = 0;
return segments.map(seg => {
const v = r[seg.key] ?? 0, y0 = acc; acc += v;
return htl.svg`<rect x=${X(i)} y=${Y(acc)} width=${bw}
height=${Math.max(0, Y(y0) - Y(acc))} class="${seg.cls}" fill="currentColor"
fill-opacity=".85"/>`;
});
});
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t}${yUnit}</text></g>`)}
<text x="2" y="11" class="axl">${opts.yLabel ?? ""}</text>
${segments.map((seg, k) => htl.svg`<g>
<rect x=${W - M.r - 78 * (segments.length - k)} y=${8} width="9" height="9" rx="2"
class="${seg.cls}" fill="currentColor" fill-opacity=".85"/>
<text x=${W - M.r - 78 * (segments.length - k) + 14} y=${16}
class="${"ser " + seg.cls}" fill="currentColor">${seg.name}</text></g>`)}
${xl.map((l, i) => l == null ? null : htl.svg`<text x=${X(i) + bw / 2} y=${H - 12}
text-anchor="middle" class="ax">${l}</text>`)}
${cols.flat()}
${rows.map((r, i) => htl.svg`<text x=${X(i) + bw / 2} y=${Y(total(r)) - 6}
text-anchor="middle" class="ax">${total(r)}</text>`)}
</svg>`;
const hit = htl.svg`<rect x=${M.l} y=${M.t} width=${W - M.l - M.r}
height=${H - M.t - M.b} fill="transparent"/>`;
svg.append(hit);
track(hit, ev => {
const box = svg.getBoundingClientRect();
const px = (ev.clientX - box.left) / box.width * W;
const i = Math.max(0, Math.min(rows.length - 1, Math.floor((px - M.l) / band)));
cols.forEach((set, j) => set.forEach(rect =>
rect.setAttribute("fill-opacity", j === i ? 1 : .28)));
tipEl.innerHTML = `<div class="ct-h">${label(rows[i])}</div>` +
detail(rows[i]).map(([k, v, c]) =>
`<div class="ct-r">${c ? `<i class="${c}" style="background:currentColor"></i>` : ""}` +
`<span class="ct-k">${k}</span><span class="ct-v">${v}</span></div>`).join("");
tipEl.style.opacity = 1;
placeTip(tipEl, ev, box);
}, () => {
cols.forEach(set => set.forEach(rect => rect.setAttribute("fill-opacity", .85)));
tipEl.style.opacity = 0;
});
return htl.html`<div class="chart-wrap">${svg}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
stepChart = function (rows, opts = {}) {
const { height = 340, width = 1320, yUnit = " mo", maxLabels = 5, note = "",
marks = [] } = opts;
if (!rows.length) return htl.html`<div class="empty">Nothing published for this selection.</div>`;
const months = [...new Set(rows.map(r => r.ym))].sort();
const groups = d3g(rows, r => r.key);
const keys = [...groups.keys()].sort((a, b) =>
(groups.get(b).at(-1).v ?? -1) - (groups.get(a).at(-1).v ?? -1));
const labelled = keys.length <= maxLabels;
const W = width, H = height ?? selfHeight(W), narrow = W < NARROW;
const yTop = niceTop(Math.max(...rows.map(r => r.v)));
// a narrow chart has no room to name each line at its end, so the names move
// to a key under the plot
const M = { t: 12, r: narrow ? 12 : labelled ? 190 : 26, b: 34,
l: narrow ? leftFor(yTop, yUnit) : 62 };
const X = m => M.l + (months.length < 2 ? 0
: months.indexOf(m) / (months.length - 1) * (W - M.l - M.r));
const Y = v => H - M.b - (v / yTop) * (H - M.t - M.b);
const ticks = yFracs(H - M.t - M.b).map(f => Math.round(yTop * f));
const jans = months.filter(m => m.endsWith("-01"));
const years = axisLabels(jans.map(m => m.slice(0, 4)),
jans.length > 1 ? X(jans[1]) - X(jans[0]) : W);
const colOf = k => SERIES_COLS[keys.indexOf(k) % SERIES_COLS.length];
const sorted = k => groups.get(k).slice().sort((a, b) => a.ym < b.ym ? -1 : 1);
const rule = htl.svg`<line y1=${M.t} y2=${H - M.b} class="rule" opacity="0"/>`;
const dots = keys.map((k, i) => htl.svg`<circle r="4" class="${"sr" + (i % 10)}"
fill="currentColor" opacity="0"/>`);
const tipEl = htl.html`<div class="ctip"></div>`;
// a series that ends does so for a reason; mark the month it happened
const markAt = new Map();
for (const m of marks) {
if (!months.includes(m.ym)) continue;
if (!markAt.has(m.ym)) markAt.set(m.ym, []);
markAt.get(m.ym).push(m);
}
let lastLabelX = -Infinity, row = 0;
const markRules = [...markAt.entries()]
.sort((a, b) => a[0] < b[0] ? -1 : 1)
.map(([ym, ms]) => {
const stopped = ms.filter(m => m.kind === "stopped").length;
const cls = stopped ? "sr1" : "sr2";
const x = X(ym);
// two rules a few weeks apart would print their labels on top of each other
row = (x - lastLabelX < 46) ? row + 1 : 0;
lastLabelX = x;
const ly = M.t + 4 + row * 12;
return htl.svg`<g>
<line x1=${x} x2=${x} y1=${ly + 6} y2=${H - M.b} class="${cls}"
stroke="currentColor" stroke-width="1.2" stroke-dasharray="4 3" opacity=".7"/>
<text x=${x} y=${ly} text-anchor="middle" class="${"ser " + cls}"
fill="currentColor">${stopped ? "\u2715" : "\u21ba"}${ms.length > 1 ? " " + ms.length : ""}</text>
</g>`;
});
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t}${yUnit}</text></g>`)}
${jans.map((m, k) => years[k] == null ? null :
htl.svg`<text x=${X(m)} y=${H - 12} text-anchor="middle" class="ax">${years[k]}</text>`)}
${keys.map(k => {
const pts = sorted(k);
let d = "";
pts.forEach((pt, j) => { const x = X(pt.ym), y = Y(pt.v);
d += (j ? `H${x}V${y}` : `M${x} ${y}`); });
return htl.svg`<path d=${d} fill="none" stroke="currentColor" stroke-width="2"
stroke-linejoin="round" opacity=".92" class="${"sr" + (keys.indexOf(k) % 10)}"/>`;
})}
${labelled && !narrow ? keys.map(k => { const last = sorted(k).at(-1);
return htl.svg`<text x=${X(last.ym) + 9} y=${Y(last.v) + 4} fill="currentColor"
class="${"ser sr" + (keys.indexOf(k) % 10)}">${k} ${last.v}${yUnit}</text>`; }) : null}
${markRules}${rule}${dots}
</svg>`;
const hit = htl.svg`<rect x=${M.l} y=${M.t} width=${W - M.l - M.r}
height=${H - M.t - M.b} fill="transparent"/>`;
svg.append(hit);
track(hit, ev => {
const box = svg.getBoundingClientRect();
const px = (ev.clientX - box.left) / box.width * W;
const idx = Math.max(0, Math.min(months.length - 1,
Math.round((px - M.l) / (W - M.l - M.r) * (months.length - 1))));
const ym = months[idx], x = X(ym);
rule.setAttribute("x1", x); rule.setAttribute("x2", x); rule.setAttribute("opacity", 1);
const readout = keys.map((k, i) => {
const pts = sorted(k);
let best = null;
for (const pt of pts) { if (pt.ym <= ym) best = pt; else break; }
const dot = dots[i];
if (best) { dot.setAttribute("cx", x); dot.setAttribute("cy", Y(best.v));
dot.setAttribute("opacity", 1); }
else dot.setAttribute("opacity", 0);
return best ? { k, v: best.v, c: "sr" + (i % 10) } : null;
}).filter(Boolean).sort((a, b) => b.v - a.v);
const ms = markAt.get(ym) ?? [];
const evLine = ms.length ? `<div class="ct-more">` +
ms.slice(0, 4).map(m => `${m.kind === "stopped" ? "Stopped" : "Returned"}: ` +
`${m.subtype} at ${officeName(m.office)}`).join("<br>") +
(ms.length > 4 ? `<br>and ${ms.length - 4} more` : "") + `</div>` : "";
tipEl.innerHTML = `<div class="ct-h">${ym}</div>` +
readout.slice(0, ms.length ? 6 : 10).map(r =>
`<div class="ct-r"><i class=${r.c} style="background:currentColor"></i>` +
`<span class="ct-k">${r.k}</span><span class="ct-v">${r.v}${yUnit}</span></div>`).join("") +
(readout.length > (ms.length ? 6 : 10)
? `<div class="ct-more">+${readout.length - (ms.length ? 6 : 10)} more</div>` : "") +
evLine;
tipEl.style.opacity = 1;
if (dockTip(tipEl, box)) return;
const left = ev.clientX - box.left;
tipEl.style.left = (left > box.width * .6 ? left - 252 : left + 18) + "px";
tipEl.style.top = Math.max(4, ev.clientY - box.top - 30) + "px";
}, () => {
rule.setAttribute("opacity", 0);
dots.forEach(d => d.setAttribute("opacity", 0));
tipEl.style.opacity = 0;
});
const key = labelled && narrow ? htl.html`<div class="chart-key">${keys.map((k, i) =>
htl.html`<span class="${"sr" + (i % 10)}"><i></i>${k} ${sorted(k).at(-1).v}${yUnit}</span>`)}</div>` : null;
return htl.html`<div class="chart-wrap">${svg}${key}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}
bandChart = function (rows, opts = {}) {
const { width = 1320, height = 300, title = "", note = "", marks = [] } = opts;
const pts = rows.slice().sort((a, b) => a.ym < b.ym ? -1 : 1);
if (!pts.length) return htl.html`<div class="empty">Nothing published for this selection.</div>`;
const months = pts.map(p => p.ym);
const W = width, H = height ?? selfHeight(W), narrow = W < NARROW;
const vals = pts.flatMap(p => [p.lo, p.up]).filter(v => v != null);
const yTop = niceTop(Math.max(...vals));
const M = { t: 14, r: narrow ? 12 : 132, b: 34, l: narrow ? leftFor(yTop, " mo") : 62 };
const X = m => M.l + (months.length < 2 ? 0
: months.indexOf(m) / (months.length - 1) * (W - M.l - M.r));
const Y = v => H - M.b - (v / yTop) * (H - M.t - M.b);
const ticks = yFracs(H - M.t - M.b).map(f => Math.round(yTop * f));
const jans = months.filter(m => m.endsWith("-01"));
const years = axisLabels(jans.map(m => m.slice(0, 4)),
jans.length > 1 ? X(jans[1]) - X(jans[0]) : W);
const UP = "sr1", LO = "sr0";
const both = pts.filter(p => p.lo != null && p.up != null);
let band = "";
if (both.length > 1) {
both.forEach((p, j) => { const x = X(p.ym), y = Y(p.up);
band += (j ? `H${x}V${y}` : `M${x} ${y}`); });
for (let j = both.length - 1; j >= 0; j--) band += `V${Y(both[j].lo)}H${X(both[j].ym)}`;
band += "Z";
}
const stepPath = acc => {
let d = "", started = false;
pts.forEach(p => { const v = acc(p); if (v == null) return;
const x = X(p.ym), y = Y(v); d += (started ? `H${x}V${y}` : `M${x} ${y}`); started = true; });
return d;
};
const last = pts.at(-1);
const rule = htl.svg`<line y1=${M.t} y2=${H - M.b} class="rule" opacity="0"/>`;
const dU = htl.svg`<circle r="4" class=${UP} fill="currentColor" opacity="0"/>`;
const dL = htl.svg`<circle r="4" class=${LO} fill="currentColor" opacity="0"/>`;
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W - M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<text x=${M.l - 8} y=${Y(t) + 4} text-anchor="end" class="ax">${t} mo</text></g>`)}
${jans.map((m, k) => years[k] == null ? null :
htl.svg`<text x=${X(m)} y=${H - 12} text-anchor="middle" class="ax">${years[k]}</text>`)}
${band ? htl.svg`<path d=${band} class=${UP} fill="currentColor" fill-opacity=".10"/>` : null}
<path d=${stepPath(p => p.up)} fill="none" stroke="currentColor" stroke-width="2.1"
stroke-linejoin="round" class="${UP}"/>
<path d=${stepPath(p => p.lo)} fill="none" stroke="currentColor" stroke-width="2.1"
stroke-linejoin="round" class="${LO}"/>
${!narrow && last.up != null ? htl.svg`<text x=${X(last.ym) + 9} y=${Y(last.up) + 4} fill="currentColor"
class="${"ser " + UP}">upper ${last.up} mo</text>` : null}
${!narrow && last.lo != null ? htl.svg`<text x=${X(last.ym) + 9} y=${Y(last.lo) + 4} fill="currentColor"
class="${"ser " + LO}">lower ${last.lo} mo</text>` : null}
${marks.filter(m => months.includes(m.ym)).map(m => htl.svg`<g>
<line x1=${X(m.ym)} x2=${X(m.ym)} y1=${M.t + 10} y2=${H - M.b}
class="${m.kind === "stopped" ? "sr1" : "sr2"}" stroke="currentColor"
stroke-width="1.2" stroke-dasharray="4 3" opacity=".7"/>
<text x=${X(m.ym)} y=${M.t + 4} text-anchor="middle"
class="${"ser " + (m.kind === "stopped" ? "sr1" : "sr2")}"
fill="currentColor">${m.kind === "stopped" ? "\u2715" : "\u21ba"}</text></g>`)}
${rule}${dU}${dL}
</svg>`;
const hit = htl.svg`<rect x=${M.l} y=${M.t} width=${W - M.l - M.r}
height=${H - M.t - M.b} fill="transparent"/>`;
svg.append(hit);
track(hit, ev => {
const box = svg.getBoundingClientRect();
const px = (ev.clientX - box.left) / box.width * W;
const idx = Math.max(0, Math.min(months.length - 1,
Math.round((px - M.l) / (W - M.l - M.r) * (months.length - 1))));
const p = pts[idx], x = X(p.ym);
rule.setAttribute("x1", x); rule.setAttribute("x2", x); rule.setAttribute("opacity", 1);
if (p.up != null) { dU.setAttribute("cx", x); dU.setAttribute("cy", Y(p.up)); dU.setAttribute("opacity", 1); }
else dU.setAttribute("opacity", 0);
if (p.lo != null) { dL.setAttribute("cx", x); dL.setAttribute("cy", Y(p.lo)); dL.setAttribute("opacity", 1); }
else dL.setAttribute("opacity", 0);
const gap = (p.lo != null && p.up != null)
? `<div class="ct-r"><i class="neutral" style="background:currentColor"></i><span class="ct-k">span</span>` +
`<span class="ct-v">${Math.round((p.up - p.lo) * 10) / 10} mo</span></div>` : "";
tipEl.innerHTML = `<div class="ct-h">${p.ym}${title ? " " + title : ""}</div>` +
`<div class="ct-r"><i class="${UP}" style="background:currentColor"></i><span class="ct-k">upper bound</span>` +
`<span class="ct-v">${p.up == null ? "\u2014" : p.up + " mo"}</span></div>` +
`<div class="ct-r"><i class="${LO}" style="background:currentColor"></i><span class="ct-k">lower bound</span>` +
`<span class="ct-v">${p.lo == null ? "not published" : p.lo + " mo"}</span></div>` + gap;
tipEl.style.opacity = 1;
if (dockTip(tipEl, box)) return;
const left = ev.clientX - box.left;
tipEl.style.left = (left > box.width * .6 ? left - 252 : left + 18) + "px";
tipEl.style.top = Math.max(4, ev.clientY - box.top - 30) + "px";
}, () => {
rule.setAttribute("opacity", 0);
dU.setAttribute("opacity", 0); dL.setAttribute("opacity", 0);
tipEl.style.opacity = 0;
});
const key = !narrow ? null : htl.html`<div class="chart-key">${[
last.up != null ? htl.html`<span class=${UP}><i></i>upper ${last.up} mo</span>` : null,
last.lo != null ? htl.html`<span class=${LO}><i></i>lower ${last.lo} mo</span>` : null]}</div>`;
return htl.html`<div class="chart-wrap">${svg}${key}${tipEl}${
note ? htl.html`<div class="chart-note">${note}</div>` : null}</div>`;
}Median wait now
18 mo
Longest wait
340 mo
Change since 2025
+3.5 mo
Forms tracked
44
Field offices
90
fitted((w, h) => heatChart(HEATROWS, {
width: w, height: h,
note: `Mean ${pickHeat.toLowerCase()} in months, by form and year. \
Reading down a column shows whether that form recovered or deteriorated. Darker is \
${pickHeat === "Span" ? "a wider gap between the two bounds" : "a longer wait"}, on a scale \
set by the highest and lowest cell shown. Hover or tap a cell for both bounds and the span.`,
row: r => r.yr, col: r => r.form_name,
value: r => pickHeat === "Lower bound" ? r.ml
: pickHeat === "Span" ? r.sp : r.mu,
detail: r => [["mean lower bound", r.ml == null ? "not published" : r.ml + " mo"],
["mean upper bound", r.mu + " mo"],
["span between them", r.sp == null ? "not published" : r.sp + " mo"],
["observations", nf(r.n)]]
}), { fallbackRatio: 0.42 }){
const mo = v => v == null ? "not published" : v + " mo";
return htl.html`<div class="dt-scroll"><table class="dt">
<thead><tr><th>Form</th><th>Office</th><th>Subtype</th>
<th class="r">Was</th><th class="r">Now</th><th class="r">Change</th></tr></thead>
<tbody>${RELROWS.map(r => htl.html`<tr>
<td class="w">${r.form}</td><td class="w">${r.office}</td>
<td class="k">${r.subtype}</td>
<td class="r m">${mo(r.pu)}</td><td class="r b">${mo(r.nu)}</td>
<td class="${"r " + (r.du > 0 ? "neg" : "pos")}">${
(r.du > 0 ? "+" : "") + r.du} mo</td>
</tr>`)}</tbody></table>
<div class="chart-note">${NOTE2.release[0]}</div></div>`;
}| Form | Office | Subtype | Date | From | To | Jump |
|---|---|---|---|---|---|---|
| I-485 | Nebraska Service Center | 131C | 2023-03-24 | 45.5 mo | 287.5 mo | +242 mo |
| I-485 | Nebraska Service Center | 131A-HRIFA | 2023-06-10 | 58.5 mo | 286 mo | +227.5 mo |
| I-485 | Nebraska Service Center | 131A-HRIFA | 2023-03-24 | 58.5 mo | 285 mo | +226.5 mo |
| I-485 | Nebraska Service Center | 131A-HRIFA | 2022-12-01 | 30 mo | 250.5 mo | +220.5 mo |
| I-130 | Nebraska Service Center | 134B-F31 | 2023-05-05 | 68.5 mo | 288 mo | +219.5 mo |
| I-130 | All Field Offices | 134B-F24 | 2026-06-24 | 170.5 mo | 340 mo | +169.5 mo |
| I-130 | All Field Offices | 134B-F11 | 2026-06-24 | 216.5 mo | 378 mo | +161.5 mo |
| I-130 | All Field Offices | 134B-F24 | 2025-06-17 | 134.5 mo | 294 mo | +159.5 mo |
| N-600K | Los Angeles County CA | 161B | 2024-12-21 | 26.5 mo | 175 mo | +148.5 mo |
| I-130 | All Field Offices | 134A-F21 | 2026-05-23 | 153.5 mo | 272.5 mo | +119 mo |
| I-130 | All Field Offices | 134B-F24 | 2025-01-30 | 164 mo | 281.5 mo | +117.5 mo |
| N-600 | Las Vegas NV | 161A | 2023-03-24 | 22.5 mo | 124 mo | +101.5 mo |
| N-600K | Baltimore MD | 161B | 2024-06-26 | 48.5 mo | 147.5 mo | +99 mo |
| I-130 | Nebraska Service Center | 134B-F31 | 2023-08-24 | 106 mo | 199.5 mo | +93.5 mo |
| N-600K | Harlingen TX | 161B | 2023-09-30 | 20.5 mo | 113 mo | +92.5 mo |
| I-130 | All Field Offices | 134B-F41 | 2025-12-19 | 183.5 mo | 261.5 mo | +78 mo |
| N-600K | Albany NY | 161B | 2026-05-23 | 33 mo | 110.5 mo | +77.5 mo |
| I-130 | Texas Service Center | 134B-F31 | 2024-06-04 | 54 mo | 130 mo | +76 mo |
| N-600K | Providence RI | 161B | 2023-08-24 | 17.5 mo | 90 mo | +72.5 mo |
| I-102 | Nebraska Service Center | 142 | 2025-06-17 | 55 mo | 127 mo | +72 mo |
| N-600 | Minneapolis-St. Paul MN | 161A | 2026-03-17 | 5 mo | 75.5 mo | +70.5 mo |
| N-600K | Portland OR | 161B | 2023-08-24 | 16 mo | 85.5 mo | +69.5 mo |
| N-600K | San Francisco CA | 161B | 2025-10-22 | 49 mo | 116.5 mo | +67.5 mo |
| N-600 | Memphis TN | 161A | 2026-02-19 | 15.5 mo | 82 mo | +66.5 mo |
| I-485 | All Field Offices | 131C | 2026-06-24 | 24.5 mo | 90 mo | +65.5 mo |
viewof pickForm = Inputs.select(FORMS.form_name, {
label: "Form", value: "I-130", width: 250,
format: f => f + " (" + FORMS.offices[FORMS.form_name.indexOf(f)] + " office" +
(FORMS.offices[FORMS.form_name.indexOf(f)] === 1 ? "" : "s") + ")"
})
viewof pickSub = Inputs.select(
["All subtypes", ...new Set(ALLROWS.filter(r => r.form === pickForm)
.map(r => r.subtype))].filter((v,i,a) => a.indexOf(v) === i),
{ label: "Subtype", width: 250,
format: s => s === "All subtypes" ? s : s + ", " + shortDesc(pickForm, s) })
viewof pickOff = Inputs.select(
["All offices", ...new Set(ALLROWS
.filter(r => r.form === pickForm && (pickSub === "All subtypes" || r.subtype === pickSub))
.map(r => r.office))],
{ label: "Office", width: 250,
format: o => o === "All offices" ? o : officeName(o) })
viewof pickMetric = Inputs.radio(["Upper bound", "Lower bound", "Span"],
{ label: "Show", value: "Upper bound" }){
const rows = ALLROWS.filter(r =>
r.form === pickForm &&
(pickSub === "All subtypes" || r.subtype === pickSub) &&
(pickOff === "All offices" || r.office === pickOff));
const keys = new Set(rows.map(r => r.subtype + "|" + r.office));
// events for exactly the keys on screen, so a rule always explains a line
const marks = DISCEVROWS.filter(e => e.form === pickForm && keys.has(e.subtype + "|" + e.office));
// One series selected -> show BOTH bounds as a band. Otherwise upper bound only.
if (keys.size === 1) {
const one = rows.filter(r => r.up != null || r.lo != null);
return fitted((w, h) => bandChart(one, {
width: w, height: h, marks,
title: pickSub + " · " + officeName(pickOff === "All offices" ? one[0].office : pickOff),
note: htl.html`Both published bounds for a single series. The shaded span is the agency's
own uncertainty, widen the selection to compare series on the upper bound alone.`
}), { fallbackRatio: 0.24 });
}
const pick = r => pickMetric === "Lower bound" ? r.lo
: pickMetric === "Span" ? (r.lo == null || r.up == null ? null : r.up - r.lo)
: r.up;
const series = rows.map(r => ({ key: r.subtype + " · " + officeName(r.office), ym: r.ym,
v: pick(r) == null ? null : Math.round(pick(r) * 10) / 10 }))
.filter(r => r.v != null);
return fitted((w, h) => stepChart(series,
{ width: w, height: h, marks,
note: htl.html`Showing the ${pickMetric.toLowerCase()}. Hover or tap anywhere to read every
series at that month. Narrow to a single subtype <em>and</em> office to see both
bounds together as a band.` }), { fallbackRatio: 0.24 });
}{
const sel = ALLROWS.filter(r =>
r.form === pickForm &&
(pickSub === "All subtypes" || r.subtype === pickSub) &&
(pickOff === "All offices" || r.office === pickOff));
if (!sel.length) return htl.html`<div class="empty">Nothing published for this selection.</div>`;
const lastYm = [...new Set(sel.map(r => r.ym))].sort().pop();
const now = sel.filter(r => r.ym === lastYm);
return htl.html`<div class="dt-scroll"><table class="dt">
<thead><tr><th>Subtype</th><th>Office</th><th>What it covers</th>
<th class="r">Lower bound</th><th class="r">Upper bound</th><th class="r">Span</th></tr></thead>
<tbody>${now.sort((a,b) => (b.up ?? -1) - (a.up ?? -1)).map(r => htl.html`<tr>
<td class="k">${r.subtype}</td>
<td class="w">${officeName(r.office)} <span class="k">${r.office}</span></td>
<td class="w">${shortDesc(r.form, r.subtype)}</td>
<td class="r">${r.lo == null ? htl.html`<span class="m">not published</span>` : r.lo + " mo"}</td>
<td class="r b">${r.up == null ? "not published" : r.up + " mo"}</td>
<td class="r m">${r.lo == null || r.up == null ? "not published"
: (Math.round((r.up - r.lo) * 10) / 10) + " mo"}</td>
</tr>`)}</tbody></table></div>`;
}{
const rows = OFFROWS.filter(r => r.label === pickOffice);
const wantUp = pickBound !== "Lower bound";
const wantLo = pickBound !== "Upper bound";
const both = pickBound === "Both bounds";
// one plotted point per office per bound: x is the first observation,
// y is the current one, so the diagonal is "unchanged"
const mk = (set, bound) => set.map(r => ({ r, bound,
x: bound === "upper" ? r.fu : r.fl,
y: bound === "upper" ? r.lu : r.ll }))
.filter(p => p.x != null && p.y != null);
const pts = [...(wantUp ? mk(rows, "upper") : []), ...(wantLo ? mk(rows, "lower") : [])];
if (!pts.length) return htl.html`<div class="empty">Nothing published for this selection.</div>`;
// with one bound on screen colour carries direction; with both it has to
// carry which bound is which, and the diagonal alone shows direction
const colOf = p => both ? (p.bound === "upper" ? "sr1" : "sr0")
: (p.y > p.x ? "sr1" : "sr0");
const tally = b => {
const set = pts.filter(p => p.bound === b);
return { n: set.length, worse: set.filter(p => p.y > p.x).length };
};
const u = tally("upper"), l = tally("lower");
const phrase = (t, name) => `${t.worse} of ${t.n} offices carry a longer ${name} bound now`;
const noteTxt = both
? `${phrase(u, "upper")}, and ${phrase(l, "lower")}. Both bounds share one pair of axes here, so an office whose two points sit close together moved as a block, while a pair pulling apart means the agency grew less certain rather than simply slower.`
: pickBound === "Upper bound"
? `${phrase(u, "upper")}. Anything above the dashed line got slower, in red, and anything below it got faster, in blue.`
: `${phrase(l, "lower")}. The lower bound is the earlier percentile, so it moves when the quickest cases slow down. Red is slower, blue faster.`;
const draw = (W, h) => {
const narrow = W < NARROW, H = h ?? selfHeight(W, .8, 240, 380);
const M = {t: 14, r: narrow ? 12 : 24, b: 42, l: narrow ? 48 : 60};
const hi = niceTop(Math.max(...pts.flatMap(p => [p.x, p.y])));
const X = v => M.l + (v/hi)*(W-M.l-M.r);
const Y = v => H - M.b - (v/hi)*(H-M.t-M.b);
const ticks = [0,.25,.5,.75,1].map(f => Math.round(hi*f));
const tipEl = htl.html`<div class="ctip"></div>`;
const svg = htl.svg`<svg viewBox="0 0 ${W} ${H}" class="ojs-chart">
${ticks.map(t => htl.svg`<g>
<line x1=${M.l} x2=${W-M.r} y1=${Y(t)} y2=${Y(t)} class="grid"/>
<line x1=${X(t)} x2=${X(t)} y1=${M.t} y2=${H-M.b} class="grid"/>
<text x=${M.l-8} y=${Y(t)+4} text-anchor="end" class="ax">${t}</text>
<text x=${X(t)} y=${H-24} text-anchor="middle" class="ax">${t}</text></g>`)}
<line x1=${X(0)} y1=${Y(0)} x2=${X(hi)} y2=${Y(hi)} class="refline"
stroke-width="1.5" stroke-dasharray="5 4"/>
<text x=${(M.l+W-M.r)/2} y=${H-5} text-anchor="middle" class="axl">first observation (months)</text>
<text transform="rotate(-90)" x=${-(M.t+H-M.b)/2} y=15 text-anchor="middle" class="axl">current (months)</text>
${both ? [
htl.svg`<g><circle cx=${W-M.r-140} cy=${M.t+9} r="5" class="sr1" fill="currentColor"
fill-opacity=".72"/><text x=${W-M.r-129} y=${M.t+13} class="ser sr1"
fill="currentColor">upper</text></g>`,
htl.svg`<g><circle cx=${W-M.r-72} cy=${M.t+9} r="5" class="sr0" fill="currentColor"
fill-opacity=".72"/><text x=${W-M.r-61} y=${M.t+13} class="ser sr0"
fill="currentColor">lower</text></g>`] : null}
${pts.map(p => mark(htl.svg`<circle cx=${X(p.x)} cy=${Y(p.y)} r="5.5"
fill="currentColor" fill-opacity=".72" stroke-width="1.6"
class="${"pt " + colOf(p)}"/>`, p))}
</svg>`;
// the readout always carries both bounds, whichever one was hovered, so the
// two movements can be compared without switching the control
const pair = (a, b2) => (a == null || b2 == null) ? "not published"
: `${a} mo <span class="ct-ar">to</span> <b>${b2} mo</b>`;
const signed = d => d == null ? "not published"
: (d > 0 ? "+" : "") + Math.round(d * 10) / 10 + " mo";
const cls = d => d == null ? "" : d > 0 ? "neg" : d < 0 ? "pos" : "";
function showPt(ev, p) {
const r = p.r;
const line = (c, k, v, extra = "") =>
`<div class="ct-r"><i class="${c}" style="background:currentColor"></i>` +
`<span class="ct-k">${k}</span><span class="ct-v ${extra}">${v}</span></div>`;
tipEl.innerHTML =
`<div class="ct-h">${r.name} · ${r.code}</div>` +
line("sr1", "upper bound", pair(r.fu, r.lu)) +
line("sr1", "upper change", signed(r.du), cls(r.du)) +
line("sr0", "lower bound", pair(r.fl, r.ll)) +
line("sr0", "lower change", signed(r.dl), cls(r.dl));
tipEl.style.opacity = 1;
placeTip(tipEl, ev, svg.getBoundingClientRect());
}
const hidePt = () => { tipEl.style.opacity = 0; };
track(svg, ev => { const p = under(ev, svg); p ? showPt(ev, p) : hidePt(); }, hidePt);
return htl.html`<div class="chart-wrap">${svg}${tipEl}
<div class="chart-note">${noteTxt} Hover or tap a point for both bounds.</div></div>`;
};
return fitted(draw, { fallbackRatio: 0.42 });
}{
const rows = SPREADROWS.filter(r => (r.form + " \u00b7 " + r.subtype) === pickOffice);
const ups = rows.map(r => r.up).filter(v => v != null).sort((a, b) => a - b);
const fast = rows.find(r => r.up === ups[0]);
const slow = rows.find(r => r.up === ups[ups.length - 1]);
const note = !ups.length ? "" : htl.html`Every office publishing this form at the
latest snapshot, one dot each, on a single axis. The upper bound runs from
<b>${ups[0]} months</b> at ${fast.name} to <b>${ups[ups.length - 1]} months</b> at
${slow.name}, a spread of ${Math.round((ups[ups.length - 1] / ups[0]) * 10) / 10}
times that depends only on where a case was filed. The shaded band is the middle half.`;
// some subtypes are published only at directorate level, where there is no
// per-office spread to show; say so rather than looking broken
const empty = `This form and subtype is currently published only at directorate
level, so it has no per-office spread. Subtypes of the same form that are
broken out by field office will show one.`;
return fitted((w, h) => stripChart(rows, { width: w, height: h, note, empty }),
{ fallbackRatio: 0.24 });
}{
const rows = OFFROWS.filter(r => r.label === pickOffice)
.sort((a, b) => (b.lu ?? -1) - (a.lu ?? -1));
const mo = v => v == null ? htl.html`<span class="m">not published</span>` : v + " mo";
const chg = d => d == null ? htl.html`<span class="m">not published</span>`
: htl.html`<span class="${d > 0 ? "neg" : d < 0 ? "pos" : ""}">${
(d > 0 ? "+" : "") + Math.round(d * 10) / 10} mo</span>`;
return htl.html`<div class="dt-scroll"><table class="dt">
<thead><tr><th>Office</th>
<th class="r">Lower at start</th><th class="r">Lower now</th><th class="r">Lower change</th>
<th class="r">Upper at start</th><th class="r">Upper now</th><th class="r">Upper change</th>
</tr></thead>
<tbody>${rows.map(r => htl.html`<tr>
<td class="w">${r.name} <span class="k">${r.code}</span></td>
<td class="r m">${mo(r.fl)}</td>
<td class="r">${mo(r.ll)}</td>
<td class="r">${chg(r.dl)}</td>
<td class="r m">${mo(r.fu)}</td>
<td class="r b">${mo(r.lu)}</td>
<td class="r">${chg(r.du)}</td>
</tr>`)}</tbody></table></div>`;
}fitted((w, h) => colChart(WAITROWS, {
width: w, height: h, note: NOTE3.waits[0], yLabel: "keys published", cls: "sr0",
label: r => r.band, tick: r => r.band.replace(" to ", "\u2013"),
value: r => r.keys,
detail: r => [["keys in this band", nf(r.keys)],
["share of all live keys", r.pct + "%"],
["forms involved", nf(r.forms)],
["most common", r.top],
["fastest here", r.fastest],
["slowest here", r.slowest]]
}), { fallbackRatio: 0.3 })fitted((w, h) => divergeChart(DRIFTROWS, {
width: w, height: h, note: NOTE2.drift[0], label: r => r.yr,
up: r => r.added, down: r => r.removed, net: r => r.net,
detail: r => [["revisions", nf(r.revisions)],
["months added", "+" + nf(r.added), "sr1"],
["months removed", nf(r.removed), "sr0"],
["net", (r.net > 0 ? "+" : "") + nf(r.net) + " mo"]]
}), { fallbackRatio: 0.5 })fitted((w, h) => rankChart(FORMCHGROWS, {
width: w, height: h, note: NOTE2.formchg[0], unit: " mo", signed: true,
value: r => r.chg, label: r => r.form,
detail: r => [["earlier year mean", r.prev + " mo"],
["latest year mean", r.now + " mo"],
["change", (r.chg > 0 ? "+" : "") + r.chg + " mo"]]
}), { fallbackRatio: 0.52 })Backward moves
13,204
Keys affected
854
Worst single move
20.1 yr
fitted((w, h) => colChart(BWROWS, {
width: w, height: h, note: BWNOTE.monthly[0], yLabel: "keys moved backwards",
value: r => r.keys,
label: r => r.ym.endsWith("-01") ? r.ym.slice(0, 4) : null,
emptyAt: "No backward moves recorded this month.",
detail: r => [["keys moved back", nf(r.keys)],
["release days", nf(r.days)],
["mean days lost", nf(r.mean_d) + " d"],
["worst single move", nf(r.worst_d) + " d"]]
}))fitted((w, h) => rankChart(BWFROWS, { width: w, height: h, note: BWNOTE.perform[0],
value: r => r.mean_d, label: r => r.form, unit: " d",
detail: r => [["backslides recorded", nf(r.events)], ["offices involved", nf(r.offices)],
["mean days lost", nf(r.mean_d) + " d"], ["median days lost", nf(r.med_d) + " d"],
["worst single move", nf(r.worst_d) + " d"]] }), { fallbackRatio: 0.66 })fitted((w, h) => stackChart(DYROWS, {
width: w, height: h, note: DISCNOTE.byyear[0], yLabel: "keys withdrawn",
label: r => r.year,
segments: [{ key: "confirmed", cls: "sr1", name: "confirmed" },
{ key: "provisional", cls: "sr3", name: "provisional" }],
detail: r => [
["confirmed", nf(r.confirmed), "sr1"],
["provisional", nf(r.provisional), "sr3"],
["forms affected", nf(r.forms)],
["offices affected", nf(r.offices)],
["heaviest day", r.peak_day + ", " + nf(r.peak_n)]
]
}), { fallbackRatio: 0.52 })fitted((w, h) => rankChart(DFROWS, {
width: w, height: h, note: DISCNOTE.byform[0], unit: "", cls: "sr0",
value: r => r.keys, label: r => r.form,
detail: r => [
["keys withdrawn", nf(r.keys)],
["offices affected", nf(r.offices)],
["subtypes affected", nf(r.subtypes)],
["first withdrawal", r.first],
["last withdrawal", r.last]
]
}), { fallbackRatio: 0.62 })| Form | Office | Subtype | Lower | Upper | Observations |
|---|---|---|---|---|---|
| I-129 | California Service Center | 137-H2A | 3.45 mo | 3 mo | 400 |
| I-129 | California Service Center | 137-H2A | 3.45 mo | 1 mo | 327 |
| I-129 | Service Center Operations (SCOPS) | 137-H2AN | 15 mo | 4 mo | 225 |
| I-765 | Service Center Operations (SCOPS) | 147-C8I | 1 mo | 0.92 mo | 177 |
| I-821D | Vermont Service Center | 149-CAT | 29.5 mo | 3.5 mo | 153 |
| I-765 | Vermont Service Center | 147-C33 | 128 mo | 3.5 mo | 120 |
| I-821D | Nebraska Service Center | 149-CAT | 4 mo | 3.5 mo | 117 |
| I-765 | Nebraska Service Center | 147-C33 | 4 mo | 3.5 mo | 95 |
| I-765 | Vermont Service Center | 147-C33 | 23 mo | 3.5 mo | 78 |
| I-821D | Vermont Service Center | 149-CAT | 23 mo | 3.5 mo | 78 |
| I-129 | California Service Center | 137-H2A | 3.45 mo | 1.5 mo | 73 |
| I-765 | Vermont Service Center | 147-C33 | 15.5 mo | 3.5 mo | 68 |
fitted((w, h) => scatterChart(
AGREEROWS.map(r => ({ x: r.published, y: r.implied, k: r.form + " \u00b7 " + r.office,
g: r.gap, s: r.subtype })), {
width: w, height: h, note: NOTE2.agree[0],
xLabel: "published upper bound, months",
yLabel: "implied, months",
colOf: p2 => Math.abs(p2.g) <= 1 ? "sr0" : "sr1",
detail: p2 => [["subtype", p2.s],
["published upper bound", p2.x + " mo"],
["implied by inquiry date", p2.y + " mo"],
["difference", (p2.g > 0 ? "+" : "") + p2.g + " mo"]]
}), { fallbackRatio: 0.5 })| Quantity | Input | Rule | Result |
|---|---|---|---|
| Bound in months | the bound and its unit string | weeks x 7 / 30.44, days / 30.44, months left alone | 1,062,035 values normalised |
| Missing bound | the same fields, nothing usable published | NA with a reason code, never 0 | 7,621 rows carry a reason |
| Range validity | the two normalised bounds | invalid when the lower exceeds the upper, checked after normalising | 2,894 observations across 9 keys fail |
| Revision event | consecutive snapshots of a key | dated to the first day seen, bounded by the previous snapshot | 23,489 events, landing every 23 days |
| Revision direction | delta on the upper bound | judged on the upper bound alone | 11,087 worsened, 8,207 improved, 4,195 unchanged |
| Inquiry-date movement | inquiry date day to day | one day of advance expected per elapsed day | 13,204 backward moves across 854 keys |
| Withdrawal | a key vanishing | confirmed only after two present snapshots both lack the key | 599 confirmed, 12 provisional, 12 retracted |
| Collection gap | the coverage table | recorded absent, never counted as a withdrawal | 44 days absent, longest run 17 |
| Retired service centres | office code against its successor | mapped to the successor so consolidation does not break a series | 5 centres, last seen 2025-10-21 |
| Age of the estimate | snapshot date minus publication date | no rounding; the age on the day it was collected | mean 18 days, oldest 74 |
| Office spread | latest snapshot, field offices | pseudo office codes excluded | 90 offices compared |
| Break in a line | a run of days with no snapshot | drawn as a hole and shaded, never bridged; a change inside one carries the date collection resumed, not the date it happened | 44 days in 25 runs, widest 17 days |
| Rule | Value |
|---|---|
| Percentile basis | USCIS 80% completion method; the bounds are percentile positions, never averaged |
| Published precision | half a month, 99.8% of values land on .0 or .5 |
| Withdrawal confirmation | two consecutive snapshots that exist and both lack the key |
| Backslide ranking floor | 40 recorded backslides before a form is ranked on severity |
| Forms with a per-office dimension | N-400, I-485, N-600, N-600K |
| Office codes treated as directorates | SCD, NBC, FOD, IPO, IOP, EFC |
| Comparison year pair | 2025 against 2026 |
| Freeze gate on the monthly grain | intra-cycle edits are flagged rather than merged into the next revision |
{
const rows = CBROWS.filter(r => r.tbl === pickTable);
// lets a long name break after an underscore without adding a character to
// it, so what is copied out of the cell is still the name
const breakable = name => name.split("_").flatMap((part, i, all) =>
i < all.length - 1 ? [part + "_", htl.html`<wbr>`] : [part]);
return htl.html`<div class="dt-scroll"><table class="dt">
<thead><tr><th>Variable</th><th>Type</th><th>Description</th>
<th>Example</th><th class="r">Missing</th></tr></thead>
<tbody>${rows.map(r => htl.html`<tr>
<td class="k">${breakable(r.name)}</td>
<td class="m">${r.type}</td>
<td class="prose">${r.desc}</td>
<td class="k">${r.example}</td>
<td class="${"r " + (r.pct_na > 0 ? "m" : "")}">${r.pct_na}%</td>
</tr>`)}</tbody></table></div>`;
}{
const cols = CB_SAMPLE[pickTable].cols, rows = CB_SAMPLE[pickTable].rows;
return htl.html`<div class="dt-scroll"><table class="dt">
<thead><tr>${cols.map(c => htl.html`<th>${c}</th>`)}</tr></thead>
<tbody>${rows.map(r => htl.html`<tr>${r.map(v =>
htl.html`<td class="${v === null ? "m k" : "k"}">${v === null ? "NA" : v}</td>`)}</tr>`)}
</tbody></table></div>`;
}