// -----------------------------------------------------------------------------
// INLINE CSS — .dash-dropdown rules are copied per page (they live inline in
// index.qmd, not in usawa.scss).
// -----------------------------------------------------------------------------
html`<style>
.dash-dropdown {
padding: 2px 18px 6px 0;
border-radius: 0;
border: none;
border-bottom: 1.5px solid var(--ink);
font-family: 'Instrument Sans', sans-serif;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
background-color: transparent;
cursor: pointer;
width: auto;
min-width: 120px;
appearance: none;
/* Solid triangle caret sitting close to the
value text, rather than a stroked chevron pinned to a wide field's far
edge — width:auto + the reduced min-width above let the field hug its
content so the caret lands ~8px after the text. */
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3e%3cpath d='M0 0L10 0L5 6Z' fill='%23221B2B'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right center;
background-size: 8px 5px;
}
.dash-dropdown:focus {
outline: none;
}
/* Labelled field holder (label above select) — same rules as index.qmd's,
used here by the dataset selector row. */
.dash-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.dash-field__label {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--ink-mute);
}
.dash-dropdown:disabled {
color: var(--ink-mute);
cursor: default;
background-color: transparent;
}
/* Scatter selector row. */
.view-selector-container {
position: relative;
z-index: 500;
margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.compare-axis-selector .dash-dropdown {
min-width: 120px;
}
.compare-axis-selector .dash-dropdown--sub {
min-width: 90px;
font-size: 0.9rem;
}
.view-selector-container ._value,
.view-selector-container ._label {
display: none !important;
}
</style>`releases = await FileAttachment("data/processed/releases.json").json()
fetchJSON = async (path) => {
const r = await fetch(path);
if (!r.ok) throw new Error(`Could not load ${path} (${r.status})`);
return r.json();
}
// Dataset registry (config/datasets.json, copied by generate_data.R): drives
// the Dataset dropdown; Compare enables an entry when pages.compare is "available".
datasetRegistry = (await FileAttachment("data/processed/datasets.json").json()).datasets
// Dataset selector: a select in a labelled field, mounted into the row
// directly beneath the header tabs (#dataset-field-row) — the same control
// and the same top-left position as Explore's, which leads its indicator row.
// The viewof returns a hidden holder that carries the value. The choice persists
// across Explore ⟷ Compare via localStorage (key "usawaDataset": "school" |
// "household").
viewof dataset = {
const options = datasetRegistry.map(d => ({
label: d.label, store: d.key, hint: d.pending_hint,
enabled: d.pages.compare === "available"
}));
const stored = (() => {
try { return localStorage.getItem("usawaDataset"); } catch (e) { return null; }
})();
// A stored key that is not enabled on this page falls back to the first enabled option.
const initial = options.find(o => o.store === stored && o.enabled) || options.find(o => o.enabled);
const holder = html`<span style="display:none"></span>`;
const select = html`<select class="dash-dropdown" aria-label="Select dataset">
${options.map(o => o.enabled
? `<option value="${o.store}">${o.label}</option>`
: `<option value="${o.store}" disabled>${o.label} — ${o.hint || "Not available"}</option>`).join('')}
</select>`;
holder.field = html`<div class="dash-field">
<span class="dash-field__label">Dataset</span>${select}
</div>`;
// The holder's value is the registry KEY (e.g. "household").
const set = (opt, emit = true) => {
holder.value = opt.store;
select.value = opt.store;
try { localStorage.setItem("usawaDataset", opt.store); } catch (e) {}
if (emit) holder.dispatchEvent(new Event("input", { bubbles: true }));
};
select.addEventListener("change", () => {
set(options.find(o => o.store === select.value));
});
set(initial, false);
return holder;
}
// Survey year: same control as Explore's (localStorage "usawaYear"), newest first.
viewof surveyYear = {
const years = [...releases.years].sort((a, b) => b - a);
const stored = (() => {
try { return Number(localStorage.getItem("usawaYear")); } catch (e) { return NaN; }
})();
const initial = years.includes(stored) ? stored : releases.latest;
const holder = html`<span style="display:none"></span>`;
const select = html`<select class="dash-dropdown" aria-label="Select survey year">
${years.map(y => `<option value="${y}">${y}</option>`).join('')}
</select>`;
holder.field = html`<div class="dash-field">
<span class="dash-field__label">Survey year</span>${select}
</div>`;
const set = (y, emit = true) => {
holder.value = y;
select.value = String(y);
try { localStorage.setItem("usawaYear", String(y)); } catch (e) {}
if (emit) holder.dispatchEvent(new Event("input", { bubbles: true }));
};
select.addEventListener("change", () => set(Number(select.value)));
set(initial, false);
return holder;
}
// Both fields mount into the row beneath the header tabs, Dataset first. One
// cell does it so neither control can clear the other.
datasetFieldRow = {
const slot = document.getElementById("dataset-field-row");
if (slot) { slot.innerHTML = ""; slot.append(viewof dataset.field, viewof surveyYear.field); }
return null;
}
// Tab 2 bundle for the selected round: years/<year>/tab2/<key>.json.
activeBundle = fetchJSON(`data/processed/years/${surveyYear}/tab2/${dataset}.json`)
// Explore's summary.json for the same round, used for the footer's dataset label,
// count_config totals and last_updated date (one source of truth with Explore).
activeSummary = fetchJSON(`data/processed/years/${surveyYear}/${datasetRegistry.find(d => d.key === dataset).processed_dir}/summary.json`)
// Masthead subtitle: the year is the Survey year control's, so it follows
// the rounds listed in config/data_release.json.
brandSubtitle = {
const el = document.getElementById("brand-subtitle");
if (el) el.textContent = `FLANA ${surveyYear} · Kenya · schools and households`;
return null;
}FIG = ({
purple: "#6F2B63",
navy: "#1F4388",
teal: "#4CA688",
green: "#45A348",
red: "#DB282F",
gold: "#FFC42F",
brown: "#AA6026",
charcoal: "#3D4644"
})
// Fixed series → colour maps.
SERIES_COLOURS = ({
// proficiency: { "English": FIG.navy, "Mathematics": FIG.teal, "Both": FIG.gold },
// Series are named by skill (Literacy, Numeracy), not by subject.
proficiency: { "Literacy": FIG.navy, "Numeracy": FIG.teal, "Both": FIG.gold },
schooling_status: { "In school": FIG.teal, "Never Enrolled": FIG.gold, "Dropout": FIG.red },
teacher_gender: { "Female": FIG.purple, "Male": FIG.navy }
})
// Wealth as a colour channel: Poor→Rich reads red→teal.
WEALTH_COLOURS = ({ "Poor": FIG.red, "Middle": FIG.gold, "Rich": FIG.teal })
// Ordered category sets rendered as a brand-colour ramp (brown→gold→green).
RAMP_INDICATORS = ["mother_education", "head_education"]
RAMP_COMPARISONS = ["perf_band"]
// Fixed assignment order for everything else — never re-cycled mid-figure.
COLOUR_CYCLE = [FIG.navy, FIG.teal, FIG.gold, FIG.red, FIG.purple, FIG.green, FIG.brown, FIG.charcoal]
// Series 9–16: a fixed second cycle, validated with a palette checker against
// --paper (all ≥ 3:1; lightness, chroma, adjacent CVD and normal-vision checks
// pass). Slots 9–11 are each ≥ ΔE 10 (OKLab) from every first-cycle colour.
// Brightening the first cycle instead pushes series 9–11 to near-white on
// paper (e.g. #ffff41 at 1.02:1), so their legend swatches look missing.
COLOUR_CYCLE_2 = ["#3C7DC4", "#B03A7A", "#6E7F12", "#9467BD", "#8C2B1E", "#E05A9C", "#5E6FCF", "#C8601A"]
// Region identity colours for the county scatter: the brand cycle's
// hue families re-stepped for colour-blind separation, validated with a
// palette checker across all 28 region pairs (worst pair: CVD ΔE 4.1,
// normal vision 13.7). Eight categories on a scatter cannot fully clear the
// all-pairs floors — no eight-colour set can — so the tooltip's county-and-
// region naming remains the identity backstop. Fixed catalogue order; colours
// follow the region, never its rank.
REGION_COLOURS = ({
"Coast": "#2a78d6",
"North Eastern": "#eb6834",
"Eastern": "#1baf7a",
"Central": "#eda100",
"Rift Valley": "#c85b9b",
"Western": "#006300",
"Nyanza": "#4a3aa7",
"Nairobi": "#a11e2a"
})
// Token bridge — identical to index.qmd's TOKENS, named TOKENS_C here
// to avoid any name clash between the two independently-rendered pages.
TOKENS_C = {
const cs = getComputedStyle(document.documentElement);
const get = n => cs.getPropertyValue(n).trim();
return {
paper: get('--paper'), paper2: get('--paper-2'), rule: get('--rule'),
ink: get('--ink'), inkMute: get('--ink-mute'),
brand: get('--brand'), brandGreen: get('--brand-green'),
green: [get('--g2'), get('--g3'), get('--g4'), get('--g5')],
purple: [get('--p2'), get('--p3'), get('--p4'), get('--p5')],
n0: get('--n0'),
seq: [get('--seq-1'), get('--seq-2'), get('--seq-3'), get('--seq-4'), get('--seq-5')],
// Compare's bars match Explore's: the shared fill opacity and no outline.
fillOpacity: Number(get('--performance-fill-opacity'))
};
}
// Relative-luminance label colour — computed from the
// ACTUAL rendered fill, not a stop index. A ramp/categorical stop drawn at
// fillOpacity < 1 composites with the page background and can land far
// paler than its own hex, so a fixed per-stop label colour silently fails
// contrast (e.g. white on a 65%-opacity pale green). Parses hex or rgb()/
// rgba() (rampColours/cycleColours can emit either), composites over
// --paper at the given alpha, and picks paper/ink by WCAG relative
// luminance (threshold ~0.45) on linearised channels.
parseColourRGB = (colour) => {
if (colour.startsWith('#')) {
const h = colour.slice(1);
const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = colour.match(/rgba?\(([^)]+)\)/);
return m ? m[1].split(',').slice(0, 3).map(v => parseFloat(v)) : [0, 0, 0];
}
relativeLuminance = ([r, g, b]) => {
const lin = (c) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
labelColourForFill = (colour, alpha = 1) => {
const fillRGB = parseColourRGB(colour);
const rgb = alpha >= 1 ? fillRGB : (() => {
const paperRGB = parseColourRGB(TOKENS_C.paper);
return fillRGB.map((c, i) => c * alpha + paperRGB[i] * (1 - alpha));
})();
return relativeLuminance(rgb) < 0.45 ? TOKENS_C.paper : TOKENS_C.ink;
}
// Ink or paper, whichever contrasts more with the composited fill. Used for
// the --seq ramp: the luminance cut-off in labelColourForFill picks
// paper on --seq-4 at 65% (2.98:1, below the 3:1 floor) where ink reads at
// 5.15:1. Other figures keep labelColourForFill unchanged.
bestLabelColour = (colour, alpha = 1) => {
const fillRGB = parseColourRGB(colour);
const paperRGB = parseColourRGB(TOKENS_C.paper);
const rgb = fillRGB.map((c, i) => c * alpha + paperRGB[i] * (1 - alpha));
const contrast = (c) => {
const [hi, lo] = [relativeLuminance(rgb), relativeLuminance(parseColourRGB(c))].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
return contrast(TOKENS_C.paper) > contrast(TOKENS_C.ink) ? TOKENS_C.paper : TOKENS_C.ink;
}
// Value-graded performance ramp for single-series figures: the Explore
// map's single-hue scale (light → dark = worst → best). Reference rows
// (National / Average) stay charcoal; indicators with
// higher_is_better = false flip the ramp so "best" always reads darkest.
// Former ramp (n0 + four greens), superseded 2026-10-27:
// PERF_RAMP = [TOKENS_C.n0, TOKENS_C.green[0], TOKENS_C.green[1], TOKENS_C.green[2], TOKENS_C.green[3]]
PERF_RAMP = TOKENS_C.seq
// Comparisons whose categories carry no natural order — the only ones where
// ranking rows worst → best is legitimate. Ordered comparisons (wealth, age
// group, performance band) and binary ones keep their catalogue order.
NOMINAL_COMPS = ["region", "county", "school_type"]
// In-bar labels on stacked segments are hidden below this share (percent).
STACK_LABEL_MIN = 5
// Bar-row height budget (px) per render mode.
ROW_HEIGHTS = ({ stacked: 58, simple: 44, groupedSub: 22, groupedPad: 26, dumbbell: 40 })
rampColours = (n) => {
// Cool brand progression (navy → teal → green) for ordered category sets —
// reads low → high left to right, keeps white in-bar labels legible on
// every step, and stays inside the dashboard's core hues.
const interp = d3.interpolateRgbBasis([FIG.navy, FIG.teal, FIG.green]);
return n === 1 ? [interp(0)] : d3.range(n).map(i => interp(i / (n - 1)));
}
cycleColours = (n) => d3.range(n).map(i => {
const all = [...COLOUR_CYCLE, ...COLOUR_CYCLE_2];
return all[i % all.length];
})
// Former rule: series beyond the first cycle were brightened.
// : d3.color(COLOUR_CYCLE[i % COLOUR_CYCLE.length]).brighter(0.9).formatHex()
// Hug-the-dots axis rule: on every scatter-type
// figure the axis domain tracks the PLOTTED dots' extent with AXIS_PAD
// padding — never forced to zero, never stretched to the raw data range.
// Degenerate extents (a single x value) get a minimal half-span so a lone
// dot column still renders.
AXIS_PAD = 0.05
hugDomain = (values) => {
const lo = d3.min(values), hi = d3.max(values);
const span = hi - lo;
if (!(span > 0)) {
const half = Math.max(0.5, Math.abs(lo ?? 0) * AXIS_PAD);
return [(lo ?? 0) - half, (hi ?? 0) + half];
}
return [lo - span * AXIS_PAD, hi + span * AXIS_PAD];
}
// Clip a pipeline-shipped CI band grid to a domain: keep in-range points and
// linearly interpolate between the two adjacent shipped points at each
// boundary so the band meets the plot edges — display geometry only, no
// statistics run in the browser.
clipBand = (band, [lo, hi]) => {
if (!band || band.length === 0) return null;
const at = (a, b, x) => {
const t = (x - a.x) / (b.x - a.x);
return { x, lo: a.lo + (b.lo - a.lo) * t, hi: a.hi + (b.hi - a.hi) * t };
};
const out = [];
for (let i = 0; i < band.length; i++) {
const p = band[i];
if (p.x < lo) {
if (band[i + 1] && band[i + 1].x > lo) out.push(at(p, band[i + 1], lo));
} else if (p.x > hi) {
if (band[i - 1] && band[i - 1].x < hi) out.push(at(band[i - 1], p, hi));
break;
} else out.push(p);
}
return out.length ? out : null;
}// -----------------------------------------------------------------------------
// 3. OPTION DERIVATION — the valid indicator / comparison / split-by options
// come ONLY from which keys exist in `tables` ("indicator|comp1" or
// "indicator|comp1|comp2"), so an invalid combination can never be offered.
// meta catalogues supply labels and display order.
// -----------------------------------------------------------------------------
builderOptions = {
const meta = activeBundle.meta;
const oneAxis = new Set();
const twoAxis = new Set();
for (const k of Object.keys(activeBundle.tables)) {
(k.split("|").length === 2 ? oneAxis : twoAxis).add(k);
}
const indicators = meta.indicators.filter(ind =>
meta.comparisons.some(c => oneAxis.has(`${ind.key}|${c.key}`)));
const comp1For = (indKey) =>
meta.comparisons.filter(c => oneAxis.has(`${indKey}|${c.key}`));
// County as the first split offers no second split; the
// county × second-split tables stay in the bundle, unoffered.
const comp2For = (indKey, c1Key) => c1Key === "county" ? []
: meta.comparisons.filter(c => twoAxis.has(`${indKey}|${c1Key}|${c.key}`));
return { indicators, comp1For, comp2For };
}// -----------------------------------------------------------------------------
// 4. SENTENCE SELECTOR — "Show me … compared by …, split by …". Rebuilt from
// scratch on a dataset switch (defaults reapply); on an indicator change the
// dependent dropdowns keep their selection when still valid, otherwise reset
// to defaults (first valid comparison / "None").
// -----------------------------------------------------------------------------
viewof builderSelection = {
const { indicators, comp1For, comp2For } = builderOptions;
const NONE = "__none__";
const indSelect = html`<select class="dash-dropdown figure-sentence__select" aria-label="Select indicator">
${indicators.map(i => `<option value="${i.key}">${i.label}</option>`).join("")}
</select>`;
const c1Select = html`<select class="dash-dropdown figure-sentence__select" aria-label="Select comparison group"></select>`;
const c2Select = html`<select class="dash-dropdown figure-sentence__select" aria-label="Select second comparison group"></select>`;
const container = html`<div class="figure-sentence">
<span class="figure-sentence__word">Show me</span>
${indSelect}
<span class="figure-sentence__word">compared by</span>
${c1Select}
<span class="figure-sentence__word">split by</span>
${c2Select}
</div>`;
const rebuildC1 = () => {
const keep = c1Select.value;
const opts = comp1For(indSelect.value);
c1Select.innerHTML = opts.map(c => `<option value="${c.key}">${c.label}</option>`).join("");
c1Select.value = opts.some(c => c.key === keep) ? keep : opts[0].key;
};
const rebuildC2 = () => {
const keep = c2Select.value;
const opts = comp2For(indSelect.value, c1Select.value);
c2Select.innerHTML = [`<option value="${NONE}">None</option>`,
...opts.map(c => `<option value="${c.key}">${c.label}</option>`)].join("");
c2Select.value = opts.some(c => c.key === keep) ? keep : NONE;
c2Select.disabled = opts.length === 0;
};
const emit = () => {
container.value = {
indicator: indSelect.value,
comp1: c1Select.value,
comp2: c2Select.value === NONE ? null : c2Select.value
};
container.dispatchEvent(new Event("input", { bubbles: true }));
};
indSelect.addEventListener("change", () => { rebuildC1(); rebuildC2(); emit(); });
c1Select.addEventListener("change", () => { rebuildC2(); emit(); });
c2Select.addEventListener("change", emit);
indSelect.value = indicators[0].key;
rebuildC1();
rebuildC2();
container.value = {
indicator: indSelect.value,
comp1: c1Select.value,
comp2: c2Select.value === NONE ? null : c2Select.value
};
return container;
}figureModel = {
const meta = activeBundle.meta;
const sel = builderSelection;
const ind = meta.indicators.find(i => i.key === sel.indicator);
const comp1 = meta.comparisons.find(c => c.key === sel.comp1);
const comp2 = sel.comp2 ? meta.comparisons.find(c => c.key === sel.comp2) : null;
const key = comp2 ? `${ind.key}|${comp1.key}|${comp2.key}` : `${ind.key}|${comp1.key}`;
const rows = activeBundle.tables[key] || [];
// One-axis tables carry a trailing total row (National / Average).
const totalLabel = meta.total_label;
const hasTotal = rows.some(r => r.c1 === totalLabel);
const groups = [...comp1.categories, ...(hasTotal ? [totalLabel] : [])];
const isStacked = ind.type === "distribution" || ind.type === "share_gender";
const mode = isStacked ? "stacked"
: (ind.type === "multi_percent" || comp2) ? "grouped"
: "simple";
// Sub-category axis: indicator series (colour channel) or comparison 2.
const subs = ind.type === "multi_percent" || isStacked
? [...ind.series]
: comp2 ? [...comp2.categories] : null;
const subOf = (r) => r.s !== undefined ? r.s : (r.c2 !== undefined ? r.c2 : null);
const cells = rows.map(r => ({ c1: r.c1, sub: subOf(r), v: r.v, n: r.n, sup: r.sup }));
const present = new Map(cells.map(c => [`${c.c1}|${c.sub}`, c]));
// Suppression targets: flagged cells, plus expected cells absent from the
// table. Stacked groups suppress as a whole (the n behind every share is the
// group n); grouped/simple suppress per cell.
const suppressed = [];
if (isStacked) {
for (const g of groups) {
const groupCells = cells.filter(c => c.c1 === g);
// absent: no cell at all, i.e. zero observations → "no data".
if (groupCells.length === 0) suppressed.push({ c1: g, sub: null, absent: true });
else if (groupCells.some(c => c.sup)) suppressed.push({ c1: g, sub: null, absent: false });
}
} else {
for (const g of groups) {
for (const s of (subs ?? [null])) {
const c = present.get(`${g}|${s}`);
if (!c || c.sup) suppressed.push({ c1: g, sub: s, absent: !c });
}
}
}
const drawCells = cells.filter(c => !c.sup && c.v != null);
// By County (never with a second split), a figure shows the ten best and
// ten worst counties (§6) — read here so the footnote (§7) and the page
// layout agree. A bundled-series indicator (e.g. Proficiency by County)
// ranks by its "Both" series, else the mean.
const countyRanked = new Set(drawCells.filter(c => c.c1 !== totalLabel).map(c => c.c1)).size;
const countyCapped = comp1.key === "county" && !comp2 && mode !== "stacked" && countyRanked > 20;
const countyRankSeries = mode === "grouped" && (subs ?? []).includes("Both") ? "Both" : null;
// The suppressed cells the figure actually draws, for the footnote (§7).
// A capped County figure draws no suppressed row in simple mode, and in
// grouped mode only those of the twenty counties it keeps (same ranking as
// §6). Without this, Junior School numeracy by county (a county under
// n = 50 outside the twenty) would get a footnote for a row nobody sees.
let visibleSuppressed = suppressed;
if (countyCapped) {
if (mode === "simple") visibleSuppressed = suppressed.filter(s => s.c1 === totalLabel);
else {
const body = drawCells.filter(d => d.c1 !== totalLabel);
const score = countyRankSeries
? new Map(body.filter(d => d.sub === countyRankSeries).map(d => [d.c1, d.v]))
: d3.rollup(body, v => d3.mean(v, d => d.v), d => d.c1);
const order = [...score.keys()].sort((a, b) =>
ind.higher_is_better === false ? score.get(a) - score.get(b) : score.get(b) - score.get(a));
const keep = new Set([...order.slice(0, 10), ...order.slice(-10)]);
visibleSuppressed = suppressed.filter(s => s.c1 === totalLabel || keep.has(s.c1));
}
}
return { ind, comp1, comp2, key, mode, groups, subs, cells, drawCells, suppressed, hasTotal,
countyCapped, countyRanked, countyRankSeries, visibleSuppressed };
}
// Colour range for the sub-category (colour) channel, per the config maps.
figureColours = {
const { ind, comp2, subs, mode } = figureModel;
if (mode === "simple" || !subs) return null;
if (SERIES_COLOURS[ind.key]) return subs.map(s => SERIES_COLOURS[ind.key][s]);
if (RAMP_INDICATORS.includes(ind.key)) return rampColours(subs.length);
if (comp2) {
if (comp2.key === "wealth") return subs.map(s => WEALTH_COLOURS[s]);
if (RAMP_COMPARISONS.includes(comp2.key)) return rampColours(subs.length);
}
return cycleColours(subs.length);
}
// Value formatting straight from the catalogue's digits; axis label is
// "Percent" for percentage units, otherwise the unit itself.
fmtValue = (v) => v == null ? "" : v.toFixed(figureModel.ind.digits)
// Unweighted sample-size label — in square brackets;
// thousands-separated, no decimals, subordinate to the value labels. The
// figure footnote (§7) explains the brackets.
fmtN = (n) => n == null ? "" : `[${n.toLocaleString("en-GB")}]`
xAxisLabel = figureModel.ind.unit === "%"
? "Percent"
: figureModel.ind.unit.charAt(0).toUpperCase() + figureModel.ind.unit.slice(1)
// Heading carries the indicator's unit in brackets — "%" reads "(%)", any
// other unit verbatim, e.g. "(learners per teacher)". Name-intrinsic, so
// Explore's axis-title bracket-stripping rule is unaffected.
figureTitle = {
const { ind, comp1, comp2 } = figureModel;
const unit = ind.unit ? ` (${ind.unit})` : "";
return `${ind.label} by ${comp1.label.toLowerCase()}${comp2 ? " and " + comp2.label.toLowerCase() : ""}${unit}`;
}titleMount = {
const el = document.getElementById("figure-builder-title");
if (el) el.textContent = figureTitle;
return null;
}
// A County figure (twenty bars or bar sets) is too
// tall for the fitted desktop window, so while one is shown the page drops
// its fit and scrolls; nothing scrolls inside the panel. The figure-size
// observers see --compare-fit change and redraw at natural height.
compareNaturalHeight = {
const shell = document.querySelector(".compare-shell");
if (shell) shell.classList.toggle("compare-shell--natural", figureModel.countyCapped);
return figureModel.countyCapped;
}
// Observe the CSS-allocated panel space, independently of the SVG's height.
compareChartSize = (id) => {
const el = document.getElementById(id);
return Generators.observe(notify => {
let raf = 0;
let previous = "";
const emit = () => {
const fit = getComputedStyle(el).getPropertyValue('--compare-fit').trim() === '1';
const size = { width: Math.max(480, el.clientWidth - 8), height: fit ? el.clientHeight : null };
const key = JSON.stringify(size);
if (key !== previous) { previous = key; notify(size); }
};
const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(emit); };
const ro = new ResizeObserver(schedule);
ro.observe(el);
window.addEventListener('resize', schedule);
emit();
return () => { cancelAnimationFrame(raf); ro.disconnect(); window.removeEventListener('resize', schedule); };
});
}
figureSize = compareChartSize("figure-chart-mount")
figureWidth = figureSize.width
// Dense groupings keep readable row heights and scroll inside the panel.
fitBuilderHeight = (natural, rows, rowFloor = 22, legendSpace = 34) =>
figureSize.height == null ? natural : Math.max(rows * rowFloor + 78, figureSize.height - legendSpace)
builderFigure = {
const { ind, comp1, mode, groups, subs, drawCells, suppressed } = figureModel;
const marginLeft = Math.min(200, 24 + 8.2 * d3.max(groups, g => g.length));
const fontFamily = "'IBM Plex Sans', sans-serif";
const isPercent = ind.unit === "%";
// Sample-size labels: one shared style, margins sized from the widest
// "[26,819]" string (≈6.2 px/char at 11px Plex) so a bar reaching the
// axis cap still leaves its n fully visible.
// const N_STYLE = { fill: "#111827", fontWeight: 500, fontSize: 11 };
// Sample sizes sit in a lighter column at the right edge, at the x-domain
// end, headed "n", rather than trailing each bar.
const N_STYLE = { fill: TOKENS_C.inkMute, fontWeight: 500, fontSize: 11 };
const nWidth = Math.ceil(6.2 * (d3.max(drawCells, d => fmtN(d.n).length) ?? 0));
// In a faceted (grouped) figure the header belongs to the first bar set
// only; an unfaceted mark would repeat in every facet.
const nHeader = (x, fy = null) => Plot.text([{ x, fy }], {
frameAnchor: "top", x: "x", ...(fy == null ? {} : { fy: "fy" }), dx: 10, dy: -6, text: () => "n",
textAnchor: "start", fill: TOKENS_C.inkMute, fontSize: 11, fontWeight: 600
});
// The dashed national-average line is labelled on the chart, as Explore's
// rule is, instead of a legend line reading "National Average (61;
// [44,680])". The chip sits in the top
// margin over the line; near either edge it anchors inward so it never runs
// into the n column header or off the plot. `plotPx` is the plot's inner
// width; `fy` names the one facet that carries the chip in a grouped figure.
const ruleChip = (v, xDomain, plotPx, dy, fy = null) => {
const text = `National average ${fmtValue(v)}`;
const w = text.length * 6.8; // 12px Plex semibold ≈ 6.8px a character
const px = plotPx * (v - xDomain[0]) / (xDomain[1] - xDomain[0]);
const anchor = px + w / 2 > plotPx ? "end" : px - w / 2 < 0 ? "start" : "middle";
return Plot.text([{ x: v, fy }], {
frameAnchor: "top", x: "x", ...(fy == null ? {} : { fy: "fy" }), dy,
dx: anchor === "end" ? -4 : anchor === "start" ? 4 : 0, textAnchor: anchor,
text: () => text, fill: TOKENS_C.brand, fontSize: 12, fontWeight: 600,
stroke: TOKENS_C.paper, strokeWidth: 4, ariaLabel: "rule label"
});
};
// Value labels clear of every dashed line, by the same rule as Explore's
// (index.qmd barLabelPlan). Each label's extent is worked out in data
// units, and a label that would cross
// a line steps 6px past it in the direction it reads: an outside label away
// from its bar or dot, a label inside a bar back towards the axis (or out
// past the bar end when the bar is too short to hold it). When a stepped
// label would reach the n column the domain end grows, so the passes repeat
// at the new scale. items: { d, v, len, charPx, gap, side: out | in | left }.
const planLabels = (items, lines, domain, plotPx) => {
const sorted = [...lines].sort((a, b) => a - b);
let hi = domain[1], placed = [];
for (let pass = 0; pass < 3; pass++) {
const ppu = plotPx / (hi - domain[0]), clear = 6 / ppu;
const hits = (a, b, L) => L >= a - clear && L <= b + clear;
const stepRight = (start, w) => { for (const L of sorted) if (hits(start, start + w, L)) start = L + clear; return start; };
const stepLeft = (end, w) => { for (const L of [...sorted].reverse()) if (hits(end - w, end, L)) end = L - clear; return end; };
placed = items.map(it => {
const w = it.len * it.charPx / ppu, g = it.gap / ppu;
if (it.side === "left") return { ...it, anchor: "end", x: stepLeft(it.v - g, w) };
if (it.side === "in") {
const end = stepLeft(it.v - g, w);
if (end - w >= domain[0] + 4 / ppu) return { ...it, anchor: "end", x: end, inside: true };
return { ...it, anchor: "start", x: stepRight(it.v + g, w), inside: false };
}
return { ...it, anchor: "start", x: stepRight(it.v + g, w) };
});
const reach = d3.max(placed.filter(q => q.anchor === "start"), q => q.x + q.len * q.charPx / ppu) ?? -Infinity;
if (reach <= hi) break;
hi = reach + 2 / ppu;
}
return { placed, domain: [domain[0], hi] };
};
let plot;
let extraLegend = null;
if (mode === "stacked") {
const height = fitBuilderHeight(groups.length * ROW_HEIGHTS.stacked + 110, groups.length, 26, 56);
const suppressedGroups = new Set(suppressed.map(s => s.c1));
const stackOpts = { x: "v", y: "c1", z: "sub", order: null };
// In-bar label colour per segment, from that segment's ACTUAL rendered
// fill (its figureColours swatch composited at fillOpacity 0.65) rather
// than a fixed white — some series/ramp colours (e.g. gold) are too
// light for white text once blended.
const subColourMap = new Map(subs.map((s, i) => [s, figureColours[i]]));
// At the shared fill opacity the luminance cut-off can pick the weaker of
// ink and paper, so stacked labels take the better contrast too.
// const stackLabelColour = (d) => labelColourForFill(subColourMap.get(d.sub) ?? TOKENS_C.ink, 0.65);
const stackLabelColour = (d) => bestLabelColour(subColourMap.get(d.sub) ?? TOKENS_C.ink, TOKENS_C.fillOpacity);
// One n per row, placed past the stack end (segments share the group n).
const nRows = groups.filter(g => !suppressedGroups.has(g)).map(g => {
const rowCells = drawCells.filter(c => c.c1 === g);
return { c1: g, end: d3.sum(rowCells, c => c.v), n: rowCells[0]?.n };
});
// Schooling status has an unambiguous "best" per row (the in-school
// share), so nominal groupings rank worst → best bottom → top. Other
// distributions have no single best and keep catalogue order.
let yDomain = groups;
if (ind.key === "schooling_status" && NOMINAL_COMPS.includes(comp1.key)) {
const totalLabel = activeBundle.meta.total_label;
const inSchool = new Map(drawCells.filter(d => d.sub === "In school").map(d => [d.c1, d.v]));
yDomain = [...groups].filter(g => g !== totalLabel)
.sort((a, b) => (inSchool.get(b) ?? -1) - (inSchool.get(a) ?? -1));
if (groups.includes(totalLabel)) yDomain.push(totalLabel);
}
plot = Plot.plot({
width: figureWidth,
height,
marginLeft,
// marginRight: Math.max(20, nWidth + 16),
marginRight: nWidth + 24,
marginTop: 16,
marginBottom: 52,
className: "usawa-figure-plot",
style: { background: "transparent", fontSize: "14px", fontFamily },
x: {
domain: [0, 100],
ticks: [0, 25, 50, 75, 100],
label: "Percent",
labelAnchor: "center",
labelArrow: "none",
labelOffset: 44,
tickSize: 0
},
y: { domain: yDomain, label: null, padding: 0.3, tickSize: 0 },
color: { domain: subs, range: figureColours, legend: true },
marks: [
// Plot.barX(drawCells, { ...stackOpts, fill: "sub", fillOpacity: 0.65, stroke: "sub", strokeWidth: 1.5 }),
Plot.barX(drawCells, { ...stackOpts, fill: "sub", fillOpacity: TOKENS_C.fillOpacity }),
Plot.text(drawCells, Plot.stackX({
...stackOpts,
text: d => d.v >= STACK_LABEL_MIN ? fmtValue(d.v) : "",
fill: stackLabelColour,
fontWeight: 700,
fontSize: 15
})),
Plot.text(nRows, {
x: 100, y: "c1",
text: d => fmtN(d.n),
textAnchor: "start", dx: 10,
...N_STYLE
}),
nHeader(100),
Plot.text([...suppressedGroups], {
x: 0, y: d => d,
text: d => suppressed.some(s => s.c1 === d && s.absent) ? "no data" : "insufficient sample",
textAnchor: "start", dx: 4,
fill: TOKENS_C.inkMute, fontStyle: "normal", fontSize: 13
})
]
});
} else if (mode === "grouped") {
// National / Average leaves the bar sets and draws as a dashed rule
// (the reference must not disappear under a split).
// Split-by figures draw ONE charcoal rule at the overall national
// average (per-series rules read as clutter) — the same
// total row the unsplit view shows, from the one-axis ind|comp1 table.
// Bundled-series figures keep one rule per series in the series colour:
// their series are distinct indicators, so no single average exists.
const totalLabel = activeBundle.meta.total_label;
const comp2 = figureModel.comp2;
const overallCell = comp2
? (activeBundle.tables[`${ind.key}|${comp1.key}`] || [])
.find(r => r.c1 === totalLabel && !r.sup) ?? null
: null;
const natCells = comp2
? []
: subs.map(s => drawCells.find(d => d.c1 === totalLabel && d.sub === s))
.filter(Boolean);
let bodyCells = drawCells.filter(d => d.c1 !== totalLabel);
let bodySuppressed = suppressed.filter(s => s.c1 !== totalLabel);
let bodyGroups = groups.filter(g => g !== totalLabel);
// By County, the ten best and ten worst bar sets (ranked by "Both"
// where the bundle has it, else by the set mean), a "__gap__" band between.
let countyDomain = null;
if (figureModel.countyCapped) {
const rs = figureModel.countyRankSeries;
const score = rs
? new Map(bodyCells.filter(d => d.sub === rs).map(d => [d.c1, d.v]))
: d3.rollup(bodyCells, v => d3.mean(v, d => d.v), d => d.c1);
const order = [...score.keys()].sort((a, b) =>
ind.higher_is_better === false ? score.get(a) - score.get(b) : score.get(b) - score.get(a));
const keep = [...order.slice(0, 10), ...order.slice(-10)];
countyDomain = [...order.slice(0, 10), "__gap__", ...order.slice(-10)];
bodyCells = bodyCells.filter(d => keep.includes(d.c1));
bodySuppressed = bodySuppressed.filter(s => keep.includes(s.c1));
bodyGroups = keep;
}
const height = fitBuilderHeight(bodyGroups.length * (subs.length * ROW_HEIGHTS.groupedSub + ROW_HEIGHTS.groupedPad) + 110, bodyGroups.length * (subs.length + 1), 18, 80);
const xMax = d3.max([...drawCells, ...natCells, ...(overallCell ? [overallCell] : [])], d => d.v) ?? 1;
const xDomain = [0, isPercent ? Math.min(100, xMax * 1.15) : xMax * 1.15];
// Former placement: n after the value label at a fixed per-figure offset
// sized from the widest value.
// Retired (n now sits in the right-hand column):
// const valueWidth = Math.ceil(8.2 * (d3.max(bodyCells, d => fmtValue(d.v).length) ?? 0));
// const nDx = 5 + valueWidth + 8;
// Nominal groupings rank bar sets worst → best bottom → top by the set's
// mean across the shown series (higher_is_better flips direction).
let fyDomain = bodyGroups;
if (NOMINAL_COMPS.includes(comp1.key)) {
const setMean = d3.rollup(bodyCells, v => d3.mean(v, d => d.v), d => d.c1);
const sink = ind.higher_is_better === false ? Infinity : -Infinity;
const dir = ind.higher_is_better === false ? 1 : -1;
fyDomain = [...bodyGroups]
.sort((a, b) => dir * ((setMean.get(a) ?? sink) - (setMean.get(b) ?? sink)));
}
if (countyDomain) fyDomain = countyDomain;
// Grouped bars carry every series' national line in every facet, plus the
// overall line; the plan widens xDomain in place when a label needs room.
const groupedPlan = (comp2 && subs.length === 2) ? null : planLabels(
bodyCells.map(d => ({ d, v: d.v, len: fmtValue(d.v).length, charPx: 7.8, gap: 5, side: "out" })),
[...natCells.map(c => c.v), ...(overallCell ? [overallCell.v] : [])],
xDomain, figureWidth - marginLeft - (nWidth + 24));
if (groupedPlan) xDomain[1] = groupedPlan.domain[1];
if (comp2 && subs.length === 2) {
// A two-group split draws as a dumbbell — one row per
// comparison-1 group, a grey link between the two category dots, the
// lower value labelled left of its dot and the higher right. A row with
// one side suppressed keeps its one dot and names the missing side.
// n sits in two right-hand columns headed by the category names.
const [sa, sb] = subs;
const cellOf = new Map(bodyCells.map(d => [`${d.c1}|${d.sub}`, d]));
const pairs = fyDomain.map(g => ({ c1: g, a: cellOf.get(`${g}|${sa}`), b: cellOf.get(`${g}|${sb}`) }));
const links = pairs.filter(p => p.a && p.b).map(p => ({ c1: p.c1, x1: p.a.v, x2: p.b.v }));
const otherOf = (d) => { const p = pairs.find(q => q.c1 === d.c1); return d.sub === sa ? p.b : p.a; };
const labelLeft = (d) => { const o = otherOf(d); return !!o && (d.v < o.v || (d.v === o.v && d.sub === sa)); };
// One note per row with a missing side; both missing reads as before.
const rowNotes = bodyGroups.flatMap(g => {
const miss = bodySuppressed.filter(s => s.c1 === g);
if (!miss.length) return [];
if (miss.length === 2) return [{ c1: g, text: miss.every(m => m.absent) ? "no data" : "insufficient sample" }];
return [{ c1: g, text: `${miss[0].sub}: ${miss[0].absent ? "no data" : "insufficient sample"}` }];
});
// Each n column fits its widest n or its header (≈6.6 px/char, 11px semibold).
const colW = Math.ceil(Math.max(nWidth, 6.6 * Math.max(sa.length, sb.length))) + 14;
const rowsN = fyDomain.length;
// 92 px below the plot: the colour legend, the national-average line and
// the 14 px taller top margin that holds the n column headers.
const height = fitBuilderHeight(rowsN * ROW_HEIGHTS.dumbbell + 110, rowsN, 26, 92);
const dumbbellRight = 10 + 2 * colW + 8;
const dumbbellPlan = planLabels(
bodyCells.map(d => ({ d, v: d.v, len: fmtValue(d.v).length, charPx: 7.8, gap: 10, side: labelLeft(d) ? "left" : "out" })),
overallCell ? [overallCell.v] : [], xDomain, figureWidth - marginLeft - dumbbellRight);
xDomain[1] = dumbbellPlan.domain[1];
plot = Plot.plot({
width: figureWidth,
height,
marginLeft,
marginRight: dumbbellRight,
marginTop: 30,
marginBottom: 52,
className: "usawa-figure-plot",
style: { background: "transparent", fontSize: "14px", fontFamily },
x: { domain: xDomain, ticks: 5, label: xAxisLabel, labelAnchor: "center", labelArrow: "none", labelOffset: 44, tickSize: 0 },
y: { domain: fyDomain, label: null, padding: 0.3, tickSize: 0 },
color: { domain: subs, range: figureColours, legend: true },
marks: [
overallCell ? Plot.ruleX([overallCell.v], {
stroke: TOKENS_C.brand, strokeDasharray: "4,4", strokeWidth: 2, strokeOpacity: 0.5
}) : null,
// The chip sits below the two n column headers (dy −18) in the 30px margin.
overallCell ? ruleChip(overallCell.v, xDomain, figureWidth - marginLeft - dumbbellRight, -4) : null,
Plot.link(links, { y1: "c1", y2: "c1", x1: "x1", x2: "x2", stroke: TOKENS_C.rule, strokeWidth: 3 }),
Plot.dot(bodyCells, { y: "c1", x: "v", fill: "sub", r: 6, stroke: TOKENS_C.paper, strokeWidth: 1.5 }),
// Plot's dx is one constant per mark, so left and right labels,
// and the two n columns, are separate marks.
// Plot.text(bodyCells.filter(labelLeft), {
// y: "c1", x: "v", text: d => fmtValue(d.v), textAnchor: "end", dx: -10,
// fill: FIG.charcoal, fontWeight: 700, fontSize: 13
// }),
// Plot.text(bodyCells.filter(d => !labelLeft(d)), {
// y: "c1", x: "v", text: d => fmtValue(d.v), textAnchor: "start", dx: 10,
// fill: FIG.charcoal, fontWeight: 700, fontSize: 13
// }),
...["end", "start"].map(anchor => Plot.text(dumbbellPlan.placed.filter(q => q.anchor === anchor), {
y: q => q.d.c1, x: "x", text: q => fmtValue(q.v), textAnchor: anchor,
fill: FIG.charcoal, fontWeight: 700, fontSize: 13
})),
...[sa, sb].flatMap((s, i) => [
Plot.text(bodyCells.filter(d => d.sub === s), {
y: "c1", x: xDomain[1], text: d => fmtN(d.n), textAnchor: "start", dx: 10 + i * colW,
fill: TOKENS_C.inkMute, fontWeight: 500, fontSize: 11
}),
Plot.text([s], {
frameAnchor: "top", x: xDomain[1], text: d => d, textAnchor: "start", dx: 10 + i * colW, dy: -18,
fill: TOKENS_C.inkMute, fontSize: 11, fontWeight: 600, ariaLabel: "n column header"
})
]),
Plot.text(rowNotes, {
y: "c1", x: 0, text: "text", textAnchor: "start", dx: 4,
fill: TOKENS_C.inkMute, fontStyle: "normal", fontSize: 12
})
].filter(Boolean)
});
} else
plot = Plot.plot({
width: figureWidth,
height,
marginLeft,
// marginRight: Math.max(44, nDx + nWidth + 8),
marginRight: nWidth + 24,
// marginTop: 16,
marginTop: overallCell ? 30 : 16, // room for the rule chip
marginBottom: 52,
className: "usawa-figure-plot",
style: { background: "transparent", fontSize: "14px", fontFamily },
x: {
domain: xDomain,
ticks: 5,
label: xAxisLabel,
labelAnchor: "center",
labelArrow: "none",
labelOffset: 44,
tickSize: 0
},
// First sub-category at the BOTTOM of each set — hence the reversed
// domain.
y: { domain: [...subs].reverse(), axis: null, padding: 0.12 },
fy: { domain: fyDomain, label: null, padding: 0.14, tickFormat: g => g === "__gap__" ? "" : g },
color: { domain: subs, range: figureColours, legend: true },
marks: [
// Plot.barX(bodyCells, { fy: "c1", y: "sub", x: "v", fill: "sub", fillOpacity: 0.65, stroke: "sub", strokeWidth: 1.5 }),
Plot.barX(bodyCells, { fy: "c1", y: "sub", x: "v", fill: "sub", fillOpacity: TOKENS_C.fillOpacity }),
natCells.length ? Plot.ruleX(natCells, {
x: "v", stroke: "sub",
strokeDasharray: "4,4", strokeWidth: 2, strokeOpacity: 0.6
}) : null,
overallCell ? Plot.ruleX([overallCell.v], {
stroke: TOKENS_C.brand, strokeDasharray: "4,4",
strokeWidth: 2, strokeOpacity: 0.5
}) : null,
overallCell ? ruleChip(overallCell.v, xDomain, figureWidth - marginLeft - (nWidth + 24), -8, fyDomain[0]) : null,
// Plot.text(bodyCells, {
// fy: "c1", y: "sub", x: "v",
// text: d => fmtValue(d.v),
// textAnchor: "start", dx: 5,
// fill: FIG.charcoal, fontWeight: 700, fontSize: 13
// }),
Plot.text(groupedPlan.placed, {
fy: q => q.d.c1, y: q => q.d.sub, x: "x",
text: q => fmtValue(q.v),
textAnchor: "start",
fill: FIG.charcoal, fontWeight: 700, fontSize: 13
}),
Plot.text(bodyCells, {
fy: "c1", y: "sub", x: xDomain[1],
text: d => fmtN(d.n),
textAnchor: "start", dx: 10,
...N_STYLE
}),
nHeader(xDomain[1], fyDomain[0]),
Plot.text(bodySuppressed, {
fy: "c1", y: "sub", x: 0,
text: d => d.absent ? "no data" : "insufficient sample",
textAnchor: "start", dx: 4,
fill: TOKENS_C.inkMute, fontStyle: "normal", fontSize: 12
})
].filter(Boolean)
});
// Legend line mirroring the simple-mode treatment. Split-by figures show
// the single overall value; bundled-series figures one swatch per series
// in the series colour (the colour legend above names the series).
// A single overall rule is labelled on the chart (ruleChip); no legend.
// if (overallCell) {
// extraLegend = html`<div class="figure-extra-legend" ...>
// <svg ...dashed line.../></svg>
// <span>National Average (${fmtValue(overallCell.v)}; ${fmtN(overallCell.n)})</span>
// </div>`;
// } else if (natCells.length) {
if (!overallCell && natCells.length) {
// One rule per series: chips would collide where series averages are
// close, so the legend stays, without the sample sizes (the n column has them).
const colourOf = new Map(subs.map((s, i) => [s, figureColours[i]]));
extraLegend = html`<div class="figure-extra-legend" style="display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 6px 14px; padding: 8px 0 2px; font-size: 13px; color: var(--ink-mute); font-weight: 500;">
<span>National Average:</span>
${natCells.map(d => html`<span style="display: inline-flex; align-items: center; gap: 6px;">
<svg width="24" height="16"><line x1="0" y1="8" x2="24" y2="8" stroke="${colourOf.get(d.sub)}" stroke-width="2.5" stroke-dasharray="4,4" stroke-opacity="0.6"/></svg>
<span>${fmtValue(d.v)}</span>
</span>`)}
</div>`;
}
} else {
// Ranked-bar treatment matching the Explore page: soft fills with solid
// borders, quantile-graded ramp, National/Average as a dashed rule.
// Nominal groupings order rows worst → best bottom → top (goodness order:
// higher_is_better decides which end is best); ordered comparisons keep
// their catalogue order, with colours still graded by goodness rank.
const totalLabel = activeBundle.meta.total_label;
const totalCell = drawCells.find(d => d.c1 === totalLabel);
const bestFirst = ind.higher_is_better === false
? (a, b) => a.v - b.v
: (a, b) => b.v - a.v;
const goodnessSorted = drawCells.filter(d => d.c1 !== totalLabel).slice().sort(bestFirst);
const { countyCapped } = figureModel;
// The ten best and ten worst counties, a blank "__gap__" band between
// them (Explore §6's capped-bar idiom).
const rankCells = countyCapped
? [...goodnessSorted.slice(0, 10), ...goodnessSorted.slice(-10)]
: NOMINAL_COMPS.includes(comp1.key)
? goodnessSorted
: drawCells.filter(d => d.c1 !== totalLabel);
const suppressedRows = countyCapped ? [] : suppressed.filter(s => s.c1 !== totalLabel);
const rankedGroups = countyCapped
? [...rankCells.slice(0, 10).map(d => d.c1), "__gap__", ...rankCells.slice(10).map(d => d.c1)]
: [...rankCells.map(d => d.c1), ...suppressedRows.map(s => s.c1)];
const height = fitBuilderHeight(rankedGroups.length * ROW_HEIGHTS.simple + 110, rankedGroups.length);
const xMax = d3.max(drawCells, d => d.v) ?? 1;
const xDomain = [0, isPercent ? Math.min(100, xMax * 1.15) : xMax * 1.15];
// Quantile-rank bins by goodness position (Explore's getMapColor
// convention: every hue present, spread by rank rather than distance) —
// position 0 is best and always reads darkest.
const nRank = goodnessSorted.length;
const binIndex = new Map(goodnessSorted.map((d, i) =>
[d.c1, nRank > 1 ? 4 - Math.min(4, Math.floor(i * 5 / nRank)) : 2]));
const perfFill = (d) => PERF_RAMP[binIndex.get(d.c1) ?? 2];
// In-bar value labels: computed from the bar's ACTUAL rendered colour
// (the stop composited at fillOpacity 0.65 over the page background),
// not the raw stop index — a fixed per-stop rule breaks because the
// opacity blend lightens every stop.
const perfLabelColour = (d) => bestLabelColour(perfFill(d), TOKENS_C.fillOpacity);
const labelInside = (d) => d.v > xMax * 0.2;
const rankedPlan = planLabels(
rankCells.map(d => ({ d, v: d.v, len: fmtValue(d.v).length, charPx: 8.4, gap: 6, side: labelInside(d) ? "in" : "out" })),
totalCell ? [totalCell.v] : [], xDomain, figureWidth - marginLeft - (nWidth + 24));
xDomain[1] = rankedPlan.domain[1];
// Former placement: n just past the bar end, shifted right of the widest
// outside value label (short bars) at a fixed per-figure offset.
// Retired (n now sits in the right-hand column):
// const outValueWidth = Math.ceil(8.5 *
// (d3.max(rankCells.filter(d => !labelInside(d)), d => fmtValue(d.v).length) ?? 0));
// const nDxOutside = 6 + outValueWidth + 8;
plot = Plot.plot({
width: figureWidth,
height,
marginLeft,
// marginRight: Math.max(52, nWidth + 20),
marginRight: nWidth + 24,
// marginTop: 16,
marginTop: totalCell ? 30 : 16, // room for the rule chip
marginBottom: 52,
className: "usawa-figure-plot",
style: { background: "transparent", fontSize: "14px", fontFamily },
x: {
domain: xDomain,
ticks: 5,
label: xAxisLabel,
labelAnchor: "center",
labelArrow: "none",
labelOffset: 44,
tickSize: 0
},
y: { domain: rankedGroups, label: null, padding: 0.3, tickSize: 0, tickFormat: g => g === "__gap__" ? "" : g },
marks: [
Plot.barX(rankCells, {
y: "c1", x: "v",
// fill: perfFill, fillOpacity: 0.65,
// stroke: perfFill, strokeWidth: 2
fill: perfFill, fillOpacity: TOKENS_C.fillOpacity
}),
totalCell ? Plot.ruleX([totalCell.v], {
stroke: TOKENS_C.brand, strokeDasharray: "4,4",
strokeWidth: 2, strokeOpacity: 0.5
}) : null,
totalCell ? ruleChip(totalCell.v, xDomain, figureWidth - marginLeft - (nWidth + 24), -8) : null,
// Plot.text(rankCells.filter(labelInside), {
// y: "c1", x: "v",
// text: d => fmtValue(d.v),
// textAnchor: "end", dx: -6,
// fill: perfLabelColour, fontWeight: 600, fontSize: 14
// }),
// Plot.text(rankCells.filter(d => !labelInside(d)), {
// y: "c1", x: "v",
// text: d => fmtValue(d.v),
// textAnchor: "start", dx: 6,
// fill: "#374151", fontWeight: 600, fontSize: 14
// }),
Plot.text(rankedPlan.placed.filter(q => q.inside), {
y: q => q.d.c1, x: "x",
text: q => fmtValue(q.v),
textAnchor: "end",
fill: q => perfLabelColour(q.d), fontWeight: 600, fontSize: 14
}),
Plot.text(rankedPlan.placed.filter(q => !q.inside), {
y: q => q.d.c1, x: "x",
text: q => fmtValue(q.v),
textAnchor: "start",
fill: "#374151", fontWeight: 600, fontSize: 14
}),
Plot.text(rankCells, {
y: "c1", x: xDomain[1],
text: d => fmtN(d.n),
textAnchor: "start", dx: 10,
...N_STYLE
}),
nHeader(xDomain[1]),
Plot.text(suppressedRows, {
y: "c1", x: 0,
text: d => d.absent ? "no data" : "insufficient sample",
textAnchor: "start", dx: 4,
fill: TOKENS_C.inkMute, fontStyle: "normal", fontSize: 13
})
].filter(Boolean)
});
// The rule is labelled on the chart (ruleChip above); no legend line.
// if (totalCell) {
// extraLegend = html`<div class="figure-extra-legend" ...>
// <svg ...dashed line.../></svg>
// <span>National Average (${fmtValue(totalCell.v)}; ${fmtN(totalCell.n)})</span>
// </div>`;
// }
}
const mount = document.getElementById("figure-chart-mount");
if (mount) {
mount.innerHTML = "";
mount.appendChild(plot);
if (extraLegend) mount.appendChild(extraLegend);
}
return null;
}// -----------------------------------------------------------------------------
// 7. FIGURE FOOTNOTES — the square-bracket explainer whenever any sample size
// is drawn, plus the suppression note when the current figure hides at least
// one cell.
// -----------------------------------------------------------------------------
figureFootnotes = {
const slot = document.getElementById("figure-footnote-slot");
if (!slot) return null;
slot.innerHTML = "";
if (figureModel.drawCells.some(d => d.n != null)) {
slot.appendChild(html`<div class="figure-footnote">
Numbers in square brackets are unweighted sample sizes.
</div>`);
}
// Explore leaves the refugee community schools out; Compare
// keeps them for its refugee-status split, so its averages differ slightly.
if (dataset === "school") {
slot.appendChild(html`<div class="figure-footnote">
Averages include the refugee community schools, which Explore leaves out, so they can differ slightly from Explore’s.
</div>`);
}
// Junior School is a slice of the household assessment, and a Grade
// comparison shows only some grades: say what the line covers.
if (dataset === "junior_school") {
// The grades come from the bundle's universe, not fixed text. Former wording:
// Junior School results come from the household assessment of children in Grades 7 and 9. The national average covers both grades.
const universe = (activeBundle.meta?.universe ?? "").replace(/^Children in /, "").replace(/ in the household survey$/, "");
slot.appendChild(html`<div class="figure-footnote">
Junior School results come from the household assessment of children in ${universe}. The national average covers all of them.
</div>`);
} else if (dataset === "household" && (figureModel.comp1?.key === "grade" || figureModel.comp2?.key === "grade")) {
slot.appendChild(html`<div class="figure-footnote">
Grade shows the grades Explore reports (3, 4 and 6); the national average covers children of every grade with a result.
</div>`);
}
// if (figureModel.suppressed.some(s => !s.absent)) {
if (figureModel.visibleSuppressed.some(s => !s.absent)) {
const n = activeBundle.meta.suppress_n;
const reason = (typeof n === "number")
? `fewer than ${n} unweighted observations`
: `too few observations`;
slot.appendChild(html`<div class="figure-footnote">
Groups marked “insufficient sample” have ${reason} and are suppressed.
</div>`);
}
// if (figureModel.suppressed.some(s => s.absent)) {
if (figureModel.visibleSuppressed.some(s => s.absent)) {
slot.appendChild(html`<div class="figure-footnote">
Groups marked “no data” have no observations at all.
</div>`);
}
if (figureModel.countyCapped) {
slot.appendChild(html`<div class="figure-footnote">
Showing the ten highest and ten lowest of ${figureModel.countyRanked} counties${figureModel.countyRankSeries ? `, ranked by ${figureModel.countyRankSeries}` : ""}.
</div>`);
}
if (figureModel.ind.definition) {
slot.appendChild(html`<div class="figure-footnote">${figureModel.ind.definition}</div>`);
}
return null;
}scatterAll = fetchJSON(`data/processed/years/${surveyYear}/compare.json`)
// scatterDatasetKey = ({ school: "primary_school", household: "household" })[dataset]
// Junior School has its own county universe.
scatterDatasetKey = ({ school: "primary_school", household: "household", junior_school: "junior_school" })[dataset]
scatterDataset = scatterAll.index.find(d => d.key === scatterDatasetKey)
scatterUnits = scatterAll.datasets[scatterDatasetKey].units
scatterMeta = scatterAll.datasets[scatterDatasetKey].meta
scatterFits = scatterAll.datasets[scatterDatasetKey].scatter ?? null
scatterFacetConfig = scatterDataset.facet_config || []
// Per-variable value formatting and unit-word helpers.
scatterFmt = (v, vbl) => {
if (v == null || isNaN(v)) return "No data";
return v.toFixed(vbl.digits) + vbl.suffix;
}
scatterUnitsWord = (n) => {
const u = scatterDataset.unit_label.toLowerCase();
return n === 1 ? u : (u.endsWith("y") ? u.slice(0, -1) + "ies" : u + "s");
}
// Group each dataset's metrics by base indicator × declared facets — feeds the
// selector factory below.
scatterMetricDimensions = {
const orderOf = (fc, v) => {
if (v == null) return -1;
const i = (fc.order || []).indexOf(v);
return i < 0 ? 99 : i;
};
const groups = new Map();
const baseOrder = [];
for (const m of scatterMeta) {
const base = m.base;
if (!groups.has(base)) {
groups.set(base, { base, metrics: [], axes: scatterFacetConfig.map(() => new Set()) });
baseOrder.push(base);
}
const g = groups.get(base);
const facetVals = scatterFacetConfig.map(fc => (m[fc.field] != null) ? m[fc.field] : null);
g.metrics.push({ key: m.key, facetVals, meta: m });
scatterFacetConfig.forEach((fc, i) => g.axes[i].add(facetVals[i]));
}
for (const g of groups.values()) {
g.axisLists = scatterFacetConfig.map((fc, i) =>
[...g.axes[i]].sort((a, b) => orderOf(fc, a) - orderOf(fc, b))
);
g.defaults = scatterFacetConfig.map((fc, i) => {
const nonNull = g.axisLists[i].filter(v => v != null);
return nonNull.length ? nonNull[0] : null;
});
}
return { groups, baseOrder, facetConfig: scatterFacetConfig };
}
// Reusable X/Y selector factory: a base-indicator dropdown + facet
// sub-dropdowns that only appear when a base has more than one variant.
scatterSelectorView = function (dims, axisLabel, defaultKey) {
const { groups, baseOrder, facetConfig } = dims;
const NULL_TOKEN = "__null__";
const labelFor = (fc, v) => v == null ? (fc.nullLabel || "—") : v;
const baseSelect = html`<select class="dash-dropdown" aria-label="Select ${axisLabel} indicator">
${baseOrder.map(b => `<option value="${b}">${b}</option>`).join('')}
</select>`;
const facetSelects = facetConfig.map(fc =>
html`<select class="dash-dropdown dash-dropdown--sub" aria-label="Select ${axisLabel} ${fc.label}"></select>`
);
const container = html`<div class="compare-axis-selector">
<span class="compare-axis-tag">${axisLabel}</span>${baseSelect}
<div class="compare-axis-facets">${facetSelects}</div>
</div>`;
const populate = (select, fc, options, preferred) => {
select.innerHTML = options.map(v =>
`<option value="${v == null ? NULL_TOKEN : v}">${labelFor(fc, v)}</option>`
).join('');
select.value = preferred == null ? NULL_TOKEN : preferred;
};
const resolveMetric = () => {
const g = groups.get(baseSelect.value);
const sel = facetConfig.map((fc, i) => {
const s = facetSelects[i];
if (s.style.display === 'none') return g.defaults[i];
return s.value === NULL_TOKEN ? null : s.value;
});
const match = g.metrics.find(m => m.facetVals.every((v, i) => v === sel[i]));
return match ? match.meta : g.metrics[0].meta;
};
const rebuildSub = () => {
const g = groups.get(baseSelect.value);
facetConfig.forEach((fc, i) => {
const list = g.axisLists[i];
const show = list.length > 1;
facetSelects[i].style.display = show ? '' : 'none';
if (show) populate(facetSelects[i], fc, list, g.defaults[i]);
});
};
const update = () => {
container.value = resolveMetric();
container.dispatchEvent(new Event('input', { bubbles: true }));
};
baseSelect.addEventListener('change', () => { rebuildSub(); update(); });
facetSelects.forEach(s => s.addEventListener('change', update));
const seed = (() => {
for (const b of baseOrder) {
const g = groups.get(b);
const m = g.metrics.find(m => m.key === defaultKey);
if (m) return { base: b, facetVals: m.facetVals };
}
return { base: baseOrder[0], facetVals: groups.get(baseOrder[0]).metrics[0].facetVals };
})();
baseSelect.value = seed.base;
rebuildSub();
facetConfig.forEach((fc, i) => {
const s = facetSelects[i];
if (s.style.display !== 'none' && seed.facetVals[i] != null) s.value = seed.facetVals[i];
});
container.value = resolveMetric();
return container;
}
viewof scatterXSelector = scatterSelectorView(scatterMetricDimensions, "X", scatterDataset.defaults?.x)
viewof scatterYSelector = scatterSelectorView(scatterMetricDimensions, "Y", scatterDataset.defaults?.y)
xVar = scatterXSelector
yVar = scatterYSelector
// Log-scale toggle (chosen over dropping counties): county
// covariates can be skewed (children out of school: median 2.3%, the arid
// north 10–26%), and a log axis spreads the low cluster with every county kept.
// Household only: the school line is pre-computed on the linear scale.
viewof scatterLog = {
const box = html`<input type="checkbox" aria-label="Log scale">`;
const node = html`<label class="compare-log-toggle">${box}<span>Log scale</span></label>`;
node.value = false;
box.addEventListener('change', () => {
node.value = box.checked;
node.dispatchEvent(new Event('input', { bubbles: true }));
});
return node;
}
scatterSelectorRow = {
// The tick box sits in the title row, not under the selectors: a line of its
// own there squeezed the builder figure beside it (e.g. at 1430×695).
const logSlot = document.getElementById('scatter-log-mount');
if (logSlot) {
// logSlot.style.display = scatterDatasetKey === "household" ? "" : "none";
// Every county universe (Household, Junior School) offers the log scale.
logSlot.style.display = scatterDatasetKey !== "primary_school" ? "" : "none";
if (!logSlot.contains(viewof scatterLog)) logSlot.appendChild(viewof scatterLog);
}
const node = html`<div class="view-selector-container">
${viewof scatterXSelector}
<span class="compare-axis-vs">vs</span>
${viewof scatterYSelector}
</div>`;
const slot = document.getElementById('scatter-selector-mount');
if (slot) { slot.innerHTML = ''; slot.appendChild(node); }
return node;
}// -----------------------------------------------------------------------------
// 9. SCATTER MODEL — pure lookups, split by universe.
// school ("binned") — everything comes pre-computed from the pipeline's
// scatter entry: ~20 equal-count bins of x (mean x /
// mean y / n per bin), the OLS line coefficients fitted
// on ALL pairwise-complete schools, and the classical
// 95% CI band. Nothing statistical runs in the browser.
// household ("dots") — one raw dot per county (47 nameable units; binning
// them destroys information), with the in-browser
// Plot.linearRegressionY fit.
// -----------------------------------------------------------------------------
scatterModel = {
const same = xVar.key === yVar.key;
if (same) return { same, mode: null, nPaired: 0 };
if (scatterDatasetKey === "primary_school") {
const entry = scatterFits?.[`${xVar.key}|${yVar.key}`] ?? null;
if (!entry) return { same, mode: "binned", nPaired: 0, bins: [], line: null, band: null };
// Hug-the-dots: domains come from the binned dots (±AXIS_PAD), never the
// raw x range — the fit stays estimated on all schools, but the line and
// band are clipped to the visible domain. Evaluating the shipped
// coefficients at the domain ends is the only arithmetic ever applied to
// them.
const xDom = hugDomain(entry.bins.map(b => b.x));
const line = entry.slope != null
? xDom.map(x => ({ x, y: entry.intercept + entry.slope * x }))
: null;
const band = clipBand(entry.band ?? null, xDom);
const yDom = hugDomain([
...entry.bins.map(b => b.y),
...(band ?? []).flatMap(b => [b.lo, b.hi])
]);
return {
same, mode: "binned",
nPaired: entry.n,
bins: entry.bins,
line, band, xDom, yDom
};
}
const paired = scatterUnits
.filter(d => d[xVar.key] != null && !isNaN(d[xVar.key])
&& d[yVar.key] != null && !isNaN(d[yVar.key]))
.map(d => ({
unit: d.unit,
county: d.county,
region: d.region,
x: d[xVar.key],
y: d[yVar.key]
}));
// Log scale: an axis goes on log10 only when every value on it is above
// zero (gap views in pp can be negative). Dots sit at px/py, the plotted
// values; tooltips keep x/y; the in-browser fit runs on the plotted values.
const logX = scatterLog && paired.length > 0 && paired.every(d => d.x > 0);
const logY = scatterLog && paired.length > 0 && paired.every(d => d.y > 0);
const plotted = paired.map(d => ({
...d,
px: logX ? Math.log10(d.x) : d.x,
py: logY ? Math.log10(d.y) : d.y
}));
// Same hug rule on the raw county dots (Plot's default domain sits exactly
// on the dot extent, leaving edge dots on the frame; the pad lifts them off).
return {
same, mode: "dots", nPaired: paired.length, paired: plotted,
logAsked: scatterLog, logX, logY,
xDom: paired.length ? hugDomain(plotted.map(d => d.px)) : null,
yDom: paired.length ? hugDomain(plotted.map(d => d.py)) : null
};
}LOG_TICK_GAP = ({ x: 64, y: 36 })
logAxis = (dom, px, minGap) => {
const [lo, hi] = [10 ** dom[0], 10 ** dom[1]];
const pos = v => (Math.log10(v) - dom[0]) / (dom[1] - dom[0]) * px;
const fits = t => t.length >= 3 && t.every((v, i) => i === 0 || pos(v) - pos(t[i - 1]) >= minGap);
const decades = d3.range(Math.floor(dom[0]), Math.ceil(dom[1]) + 1);
const perDecade = mults => decades.flatMap(k => mults.map(m => +(m * 10 ** k).toPrecision(6)))
.filter(v => v >= lo && v <= hi);
const sets = [[1, 2, 5], [1, 3], [1]].map(perDecade);
for (let n = Math.max(2, Math.floor(px / minGap)); n >= 2; n--) sets.push(d3.ticks(lo, hi, n));
let ticks = sets.find(fits);
if (!ticks) {
// A short axis: thin the round values from the top (where a log axis
// crowds), keeping each at least minGap below the last kept, e.g. 80/40/20.
const pool = [...new Set([...perDecade([1, 2, 5]), ...d3.ticks(lo, hi, 10)])].sort((a, b) => b - a);
ticks = [];
for (const v of pool) if (!ticks.length || pos(ticks[ticks.length - 1]) - pos(v) >= minGap) ticks.push(v);
ticks.reverse();
}
return {
ticks: ticks.map(v => Math.log10(v)),
tickFormat: v => d3.format("~g")(+(10 ** v).toPrecision(6))
};
}
// Linear y ticks spaced by the same rule (Plot's default can draw a tick
// every 5 points on a 150–300px axis): the densest round step whose ticks sit
// at least minGap px apart.
linearTicks = (dom, px, minGap) => {
for (let n = Math.max(2, Math.floor(px / minGap)); n >= 1; n--) {
const t = d3.ticks(dom[0], dom[1], n);
const step = t.length > 1 ? (t[1] - t[0]) / (dom[1] - dom[0]) * px : Infinity;
if (t.length >= 2 && step >= minGap) return t;
}
return d3.ticks(dom[0], dom[1], 1);
}
scatterSize = compareChartSize("scatter-chart-mount")
scatterWidth = scatterSize.width
scatterFigure = {
const m = scatterModel;
const fontFamily = "'IBM Plex Sans', sans-serif";
const titleEl = document.getElementById("scatter-title");
if (titleEl) {
titleEl.textContent = m.same
? "Pick two different variables"
: `${xVar.label} vs ${yVar.label}`;
}
const mount = document.getElementById("scatter-chart-mount");
const foot = document.getElementById("scatter-footnote-slot");
if (!mount) return null;
// Render-complete marker for automated testing: which X|Y pair this
// figure reflects.
mount.dataset.pair = `${xVar.key}|${yVar.key}`;
mount.innerHTML = "";
if (foot) foot.innerHTML = "";
if (m.same || m.nPaired === 0) {
mount.appendChild(html`<div class="figure-footnote" style="padding: 2rem 1rem;">
${m.same
? "The X and Y variables are identical — choose a different one on either axis."
: `Too few ${scatterUnitsWord(2)} have both variables to draw this scatter.`}
</div>`);
return null;
}
const axis = (vbl) => (vbl.axis_label ?? vbl.label) + (vbl.suffix ? " (" + vbl.suffix + ")" : "");
// Gap axes (pp) explain their sign in a footnote rather than the axis label:
// appended to the label it clipped at desktop widths (694px label, 580px chart
// at 1249 wide). Former label:
// ? `${vbl.label} (pp; ${vbl.gap_note})`
const gapNotes = [["X", xVar], ["Y", yVar]]
.filter(([, v]) => v.suffix === "pp" && v.gap_note)
.map(([ax, v]) => `${ax} axis: ${v.gap_note}`);
// Line, band and in-browser regression are clipped to the hugged domains
// (dots are not — a dot centre on the domain edge may keep its full circle).
const marks = [];
if (m.mode === "binned") {
if (m.band) {
marks.push(Plot.areaY(m.band, {
x: "x", y1: "lo", y2: "hi",
fill: "#5F2868", fillOpacity: 0.12, clip: true
}));
}
if (m.line) {
marks.push(Plot.line(m.line, { x: "x", y: "y", stroke: "#5F2868", strokeWidth: 2.5, clip: true }));
}
marks.push(Plot.dot(m.bins, {
x: "x", y: "y", r: "n",
fill: "#48AD89", fillOpacity: 0.55,
title: d =>
`${d.n.toLocaleString("en-GB")} ${scatterUnitsWord(d.n)} (equal-count bin)` +
`\nMean ${xVar.label}: ${scatterFmt(d.x, xVar)}` +
`\nMean ${yVar.label}: ${scatterFmt(d.y, yVar)}`
}));
} else {
if (m.nPaired >= 3) {
marks.push(Plot.linearRegressionY(m.paired, {
x: "px", y: "py", stroke: "#5F2868", ci: 0.95, fillOpacity: 0.12, clip: true
}));
}
// County dots carry their region's identity colour; the white
// ring keeps overlapping dots separable now that fills are more opaque.
marks.push(Plot.dot(m.paired, {
x: "px", y: "py", r: 4.5,
fill: d => REGION_COLOURS[d.region] ?? "#48AD89", fillOpacity: 0.8,
stroke: "#ffffff", strokeWidth: 1,
title: d =>
`${scatterDataset.unit_label} ${d.unit}` +
(d.unit !== d.county ? ` (${d.county}, ${d.region})` : ` (${d.region})`) +
`\n${xVar.label}: ${scatterFmt(d.x, xVar)}\n${yVar.label}: ${scatterFmt(d.y, yVar)}`
}));
}
const plotHeight = scatterSize.height == null ? 440 : Math.max(220, scatterSize.height - (m.mode === "dots" ? 58 : 0));
// Inner axis lengths for logAxis: the plot box less the margins below.
const xPx = scatterWidth - 60 - 24, yPx = plotHeight - 24 - 52;
const plot = Plot.plot({
width: scatterWidth,
height: plotHeight,
marginLeft: 60,
marginRight: 24,
marginTop: 24,
marginBottom: 52,
className: "usawa-figure-plot",
style: { background: "transparent", fontSize: "14px", fontWeight: "500", fontFamily },
x: { domain: m.xDom, label: axis(xVar), grid: true, labelOffset: 42, tickSize: 6, ...(m.logX ? logAxis(m.xDom, xPx, LOG_TICK_GAP.x) : {}) },
y: { domain: m.yDom, label: axis(yVar), grid: true, tickSize: 6, ...(m.logY ? logAxis(m.yDom, yPx, LOG_TICK_GAP.y) : { ticks: linearTicks(m.yDom, yPx, LOG_TICK_GAP.y) }) },
r: { range: [2.5, 8] },
marks
});
mount.appendChild(plot);
if (m.mode === "dots") {
// Pointer cursor invites the hover tooltip on the 47 nameable counties.
// Only the dot marks — the regression line stays default.
plot.querySelectorAll("circle").forEach(c => { c.style.cursor = "pointer"; });
// Compact region legend, catalogue order — identity, never rank.
mount.appendChild(html`<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 16px; padding: 8px 0 2px; font-size: 12.5px; color: var(--ink-mute); font-weight: 500;">
${Object.entries(REGION_COLOURS).map(([region, colour]) =>
html`<span style="display: inline-flex; align-items: center; gap: 6px;"><span style="width: 10px; height: 10px; border-radius: 50%; background: ${colour}; display: inline-block;"></span>${region}</span>`)}
</div>`);
}
if (foot) {
foot.appendChild(html`<div class="figure-footnote">Each point compares two indicators for the same ${scatterDataset.unit_label.toLowerCase()}. The chart shows association, not cause.</div>`);
if (gapNotes.length) {
foot.appendChild(html`<div class="figure-footnote">${gapNotes.join(". ")}.</div>`);
}
if (m.mode === "dots" && m.logAsked) {
const logged = [m.logX && "X", m.logY && "Y"].filter(Boolean);
const linear = [!m.logX && "X", !m.logY && "Y"].filter(Boolean);
const parts = [];
if (logged.length) parts.push(`Log scale on the ${logged.join(" and ")} ${logged.length > 1 ? "axes" : "axis"}: each gridline step multiplies the value, so the low values spread out. The line is fitted on the log values.`);
if (linear.length) parts.push(`The ${linear.join(" and ")} ${linear.length > 1 ? "axes stay" : "axis stays"} linear: ${linear.length > 1 ? "they have" : "it has"} values at or below zero.`);
foot.appendChild(html`<div class="figure-footnote">${parts.join(" ")}</div>`);
}
if (m.mode === "binned") {
foot.appendChild(html`<div class="figure-footnote">
Each dot is one of ${m.bins.length} equal-count bins of the
${m.nPaired.toLocaleString("en-GB")} schools with both variables (dot area =
schools in the bin). ${m.line
? "The line and 95% band are fitted on all individual schools, pre-computed in the data pipeline."
: "No trend line: the X variable takes a single value in this data."}
</div>`);
}
if (m.nPaired < 30) {
foot.appendChild(html`<div class="figure-footnote">
Small sample: only ${m.nPaired.toLocaleString("en-GB")} ${scatterUnitsWord(m.nPaired)}
have both variables — interpret the relationship with caution.
</div>`);
}
}
return null;
}// The footer counts the records Compare's tables run on (76,219 children;
// 1,527 schools including the refugee schools), not Explore's count
// (activeSummary.totals[count_config[0].field], e.g. 22,213 children).
// The noun comes from config/datasets.json `compare_count_label`: Explore's
// household label, "Children assessed (Grades 3, 4 and 6)", is wrong for
// Compare's 76,219. Former noun, Explore's count_config label, lowercased:
// ${(activeSummary.count_config?.[0]?.label || "").toLowerCase()}
// Why the two counts differ is explained only in the Technical Notes
// (Sample sizes), not in the footer.
html`<footer class="dashboard-footer">
<div class="footer-content">
<span>Usawa Agenda FLANA ${surveyYear} — ${activeSummary.dataset}</span>
<span class="separator">·</span>
<span>Aggregated results, ${(activeBundle.meta?.n_obs ?? 0).toLocaleString("en-GB")} ${datasetRegistry.find(d => d.key === dataset)?.compare_count_label ?? "records"}</span>
<span class="separator">·</span>
<span>Updated ${activeSummary.last_updated || "date not recorded"}</span>
<span class="separator">·</span>
<span class="note">Weighting is described in the <a class="doc-link" href="docs-compare.html">Technical notes</a>.</span>
</div>
</footer>`