datasetRegistry = (await FileAttachment("data/processed/datasets.json").json()).datasets
releases = await FileAttachment("data/processed/releases.json").json()
loadBundle = async (year, dir) => {
const base = `data/processed/years/${year}/${dir}/`;
const get = async f => {
const r = await fetch(base + f);
if (!r.ok) throw new Error(`Could not load ${base + f} (${r.status})`);
return r.json();
};
const [regions, counties, stats, countyStats, summary] = await Promise.all(
["regions.geojson", "counties.geojson", "stats.json", "county_stats.json", "summary.json"].map(get));
return { regions, counties, stats, countyStats, summary };
}
// Natural Earth 50m land, cropped to the region — a static presentation
// backdrop rendered beneath the Kenya layers in the map. Not part of either
// dataset bundle, so it's loaded once here.
landBackdrop = await FileAttachment("assets/geo/ne_land_africa.geojson").json()
// Dataset selector: the FIRST field of the indicator row (Compare uses a
// segmented control in the header tab bar instead).
// Built here as a select styled exactly like the Indicator select, and
// adopted by `viewof metricSelector` (§3), which rebuilds its row whenever the
// dataset changes and re-inserts this same node at the front each time. The
// viewof returns a hidden holder that carries the value. The choice persists
// across Explore ⟷ Compare via localStorage (key "usawaDataset": "school" |
// "household").
viewof dataset = {
// Built from the registry; an entry is enabled when pages.explore is "available".
const options = datasetRegistry.map(d => ({
label: d.label, store: d.key, hint: d.pending_hint,
enabled: d.pages.explore === "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: the SECOND field of the indicator row (after Dataset), adopted by
// `viewof metricSelector` like the dataset field. Lists releases.years, newest
// first, defaults to releases.latest, and persists in localStorage "usawaYear".
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;
}
activeData = loadBundle(surveyYear, datasetRegistry.find(d => d.key === dataset).processed_dir)
// 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;
}
regions = activeData.regions
counties = activeData.counties
stats = activeData.stats
countyStats = activeData.countyStats
summary = activeData.summary
L = require("leaflet@1.9.4")
// Phones: without this, one-finger touch on the map pans it instead of
// scrolling the page (Leaflet's drag handler intercepts the touch). The
// plugin requires a global `L` to attach to — set it explicitly rather than
// rely on require()'s UMD side effects, since that's the documented contract
// the plugin's own script expects. Map init below gates the option on
// viewport width so desktop drag/scroll-wheel behaviour is untouched.
gestureHandlingReady = {
window.L = L;
if (window.__leafletGestureHandlingLoaded) return true;
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet-gesture-handling@1.2.2/dist/leaflet-gesture-handling.min.js';
script.onload = () => { window.__leafletGestureHandlingLoaded = true; resolve(true); };
script.onerror = () => resolve(false);
document.head.appendChild(script);
});
}isMobileViewport = {
const mq = window.matchMedia('(max-width: 768px)');
return Generators.observe(notify => {
notify(mq.matches);
const onChange = (e) => notify(e.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
});
}
// Mutable state for navigation: Country → Region → County
mutable selectedRegion = null
// View level
viewLevel = selectedRegion ? "region" : "country"
isCountryView = viewLevel === "country"
isRegionView = viewLevel === "region"
// Labels that change based on drill-down level
unitTypeLabel = isRegionView ? "County" : "Region"
unitTypeLabelPlural = isRegionView ? "Counties" : "Regions"
averageTypeLabel = isRegionView ? "Region Average" : "National Average"
// Hover state
mutable hoveredItem = null
setHovered = (id) => {
if (window._dashHoverTimer) {
clearTimeout(window._dashHoverTimer);
window._dashHoverTimer = null;
}
mutable hoveredItem = id;
}
clearHovered = () => {
if (window._dashHoverTimer) clearTimeout(window._dashHoverTimer);
window._dashHoverTimer = setTimeout(() => {
mutable hoveredItem = null;
window._dashHoverTimer = null;
}, 50);
}
// A drill (bar, map or county search) redraws the chart under a still pointer,
// so no mouseout fires: the old region's tooltip stayed up and every county
// dimmed against a hoveredItem none of them matched.
hoverResetOnDrill = {
selectedRegion;
if (window._dashHoverTimer) { clearTimeout(window._dashHoverTimer); window._dashHoverTimer = null; }
const tt = document.getElementById('usawa-bar-tooltip');
if (tt) tt.style.display = 'none';
mutable hoveredItem = null;
return null;
}availableMetrics = summary.metrics
// Group metrics by their declared `base`, with one sub-axis per entry in the
// dataset's `facet_config` (School: stream × gender; Household: grade × view).
// Facet values are read from each metric's explicit `facets` field emitted by
// the pipeline — no label-text parsing — so this code is dataset-agnostic.
metricDimensions = {
const facetConfig = summary.facet_config || [];
const orderOf = (fc, v) => {
if (v == null) return -1; // null ("All"/"Both") sorts first
const i = (fc.order || []).indexOf(v);
return i < 0 ? 99 : i;
};
const groups = new Map();
const baseOrder = [];
for (const m of availableMetrics) {
const base = m.base;
if (!groups.has(base)) {
groups.set(base, { base, metrics: [], axes: facetConfig.map(() => new Set()) });
baseOrder.push(base);
}
const g = groups.get(base);
const facetVals = facetConfig.map(fc => (m.facets && m.facets[fc.field] != null) ? m.facets[fc.field] : null);
g.metrics.push({ key: m.key, suffix: m.suffix, facetVals });
facetConfig.forEach((fc, i) => g.axes[i].add(facetVals[i]));
}
for (const g of groups.values()) {
g.axisLists = facetConfig.map((fc, i) =>
[...g.axes[i]].sort((a, b) => orderOf(fc, a) - orderOf(fc, b))
);
// Prefer the facet's configured default (facet_config `default`, e.g.
// Grade 3) when this base offers it; then its "not split" value when the
// facet asks for that (`defaultNull`, e.g. Gender: Both); otherwise the
// first non-null value in configured order; null when the axis is "not
// applicable".
g.defaults = facetConfig.map((fc, i) => {
const list = g.axisLists[i];
if (fc.default != null && list.includes(fc.default)) return fc.default;
if (fc.defaultNull && list.includes(null)) return null;
const nonNull = list.filter(v => v != null);
return nonNull.length ? nonNull[0] : null;
});
}
return { groups, baseOrder, facetConfig };
}
// County search: type a county, pick it, and the view drills into its
// region with the county highlighted and the report link pointing at it. The
// request goes through `mutable countySearchRequest`, which resetReportCounty
// honours (it would otherwise clear the county the moment the region changes).
mutable countySearchRequest = null
countySearchField = {
const names = [...new Set(countyStats.map(d => d.county_name))].sort((a, b) => a.localeCompare(b));
const listId = "county-list";
const input = html`<input list="${listId}" class="dash-dropdown dash-search" placeholder="Find a county" aria-label="Find a county" autocomplete="off">`;
const list = html`<datalist id="${listId}">${names.map(n => `<option value="${n}"></option>`).join('')}</datalist>`;
const go = () => {
const typed = input.value.trim().toLowerCase();
const county = countyStats.find(d => d.county_name.toLowerCase() === typed);
if (!county) return;
mutable countySearchRequest = { name: county.county_name, region: county.region_name, used: false };
mutable selectedRegion = county.region_name;
setHovered(county.county_name);
input.value = "";
input.blur();
};
// Typing must not reach the selector container: its input events drive
// `viewof metricSelector`, and every keystroke would redraw the map.
// Picking a suggestion fills the box with an exact county name and only
// fires `input` (Safari sends `change` on Enter alone), so an exact match
// jumps at once instead of needing a click and then Enter.
// Superseded: input.addEventListener('input', (e) => e.stopPropagation());
input.addEventListener('input', (e) => {
e.stopPropagation();
if (names.includes(input.value.trim())) go();
});
input.addEventListener('change', (e) => { e.stopPropagation(); go(); });
return html`<div class="dash-field dash-field--search">
<span class="dash-field__label">County</span>${input}${list}
</div>`;
}
viewof metricSelector = {
const { groups, baseOrder, facetConfig } = metricDimensions;
const NULL_TOKEN = "__null__";
const labelFor = (fc, v) => v == null ? (fc.nullLabel || "—") : v;
// Grouped by theme in the registry's order of first appearance
// (config/indicators/*.csv); a dataset without themes (Household) keeps one
// plain list.
const themeOf = b => availableMetrics.find(m => m.base === b)?.theme ?? null;
const themes = [...new Set(baseOrder.map(themeOf))];
const optionHtml = b => `<option value="${b}">${b}</option>`;
const baseOptions = themes.every(t => t == null)
? baseOrder.map(optionHtml).join('')
: themes.map(t => {
const opts = baseOrder.filter(b => themeOf(b) === t).map(optionHtml).join('');
return t == null ? opts : `<optgroup label="${t}">${opts}</optgroup>`;
}).join('');
const baseSelect = html`<select class="dash-dropdown" aria-label="Select indicator">
${baseOptions}
</select>`;
const facetSelects = facetConfig.map(fc =>
html`<select class="dash-dropdown dash-dropdown--sub" aria-label="Select ${fc.label}"></select>`
);
// Not appended to the row: the description sits under the chart title
// (see mapPanelHeader). Kept so refreshDescription has a target.
const descriptionBox = html`<div class="metric-description" role="note" aria-live="polite"></div>`;
// 11px uppercase field labels above each select — a labelled holder per
// control rather than
// an aria-label ::before fallback, so the sub-facet labels stay
// data-driven off facetConfig.
const baseField = html`<div class="dash-field">
<span class="dash-field__label">Indicator</span>${baseSelect}
</div>`;
const facetFields = facetConfig.map((fc, i) => html`<div class="dash-field dash-field--sub">
<span class="dash-field__label">${fc.label}</span>${facetSelects[i]}
</div>`);
// The dataset and survey-year fields are owned by `viewof dataset` and
// `viewof surveyYear` (§1) and re-adopted here on every rebuild so they always
// lead the row.
const datasetView = viewof dataset;
const yearView = viewof surveyYear;
// Phone filter drawer: a toggle at the head of the row that shows on
// phones only (styles/usawa.scss §11); the fields stay hidden until it is
// tapped, so the stat band and first chart fit the first screen. The
// drawer is this container itself, not a wrapper, so the viewof value is
// untouched.
const drawerToggle = html`<button type="button" class="filter-drawer__toggle" aria-expanded="false"></button>`;
const container = html`<div class="view-selector-container filter-drawer" id="filter-drawer">
${drawerToggle}${datasetView.field}${yearView.field}${baseField}${facetFields}${countySearchField}
</div>`;
drawerToggle.addEventListener('click', () => {
const open = container.classList.toggle('is-open');
drawerToggle.setAttribute('aria-expanded', String(open));
});
const refreshToggle = () => {
const facetText = facetConfig
.map((fc, i) => facetSelects[i].style.display === 'none' ? null : facetSelects[i].selectedOptions[0]?.textContent)
.filter(Boolean);
drawerToggle.textContent = `Filters · ${[baseSelect.value, ...facetText].join(' · ')}`;
};
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 resolveKey = () => {
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.key : g.metrics[0].key;
};
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';
facetFields[i].style.display = show ? '' : 'none';
if (show) populate(facetSelects[i], fc, list, g.defaults[i]);
});
};
const refreshDescription = () => {
const m = availableMetrics.find(m => m.key === container.value);
descriptionBox.textContent = m && m.description ? m.description : '';
};
const update = () => {
container.value = resolveKey();
refreshDescription();
refreshToggle();
container.dispatchEvent(new Event('input', { bubbles: true }));
};
baseSelect.addEventListener('change', () => { rebuildSub(); update(); });
facetSelects.forEach(s => s.addEventListener('change', update));
baseSelect.value = baseSelect.options[0].value;
rebuildSub();
container.value = resolveKey();
refreshDescription();
refreshToggle();
return container;
}
// Ensure the form value is a string, handle first load
selectedMetricKey = typeof metricSelector === 'string' ? metricSelector : availableMetrics[0].key;
selectedMetric = availableMetrics.find(m => m.key === selectedMetricKey) || availableMetrics[0]
metricColumn = selectedMetric.key
// All metric keys that share the current base indicator (e.g. all six variants
// of Learner Absenteeism Rate). Used to anchor the colour scale so switching
// stream/gender within a base keeps the same value-to-colour mapping.
metricSiblingKeys = {
const sel = availableMetrics.find(m => m.key === selectedMetricKey);
for (const g of metricDimensions.groups.values()) {
if (g.metrics.some(m => m.key === selectedMetricKey)) {
// Pool only siblings sharing the same units (suffix) so a percentage
// proficiency and a percentage-point gap never share a colour domain.
return g.metrics.filter(m => !sel || m.suffix === sel.suffix).map(m => m.key);
}
}
return [selectedMetricKey];
}
// Dynamic CSS — borderless dropdowns, field labels, plain-text description.
// Closes with a hairline + padding
// below, added on .view-selector-container itself.
html`<style>
.view-selector-container {
position: relative;
z-index: 500;
margin-bottom: 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-end;
}
.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 {
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--sub {
min-width: 90px;
font-size: 15px;
}
/* A closed select is as wide as its longest option, and the disabled
"Senior School — Data not yet collected" made Dataset 337px, wrapping the
row (and shortening the bar pane) at 1024. Phones keep full width. */
@media (min-width: 769px) {
.view-selector-container .dash-dropdown[aria-label="Select dataset"] {
max-width: 200px;
}
}
/* County search: a text field in the dropdowns' style, no caret. */
.dash-search {
min-width: 160px;
font-size: 15px;
cursor: text;
background-image: none;
padding-right: 0;
}
.dash-search::placeholder {
color: var(--ink-mute);
font-weight: 400;
}
.dash-search:focus {
border-bottom-color: var(--brand);
}
.dash-dropdown:focus {
outline: none;
}
.metric-description {
flex: 1 1 280px;
min-width: 220px;
max-width: 46ch;
padding: 0;
border: none;
background-color: transparent;
color: var(--ink-mute);
font-family: 'IBM Plex Sans', sans-serif;
font-size: 13.5px;
line-height: 1.45;
display: flex;
align-items: center;
}
.metric-description:empty {
display: none;
}
.view-selector-container ._value,
.view-selector-container ._label {
display: none !important;
}
</style>`filteredCounties = selectedRegion
? counties.features.filter(f => f.properties.region_name === selectedRegion)
: []
filteredCountyStats = selectedRegion
? countyStats.filter(d => d.region_name === selectedRegion)
: []
// Current map data
currentGeoData = isRegionView
? counties
: regions
// Current stats data
currentStats = isRegionView ? filteredCountyStats : stats
// Values of the metric currently on screen (selected indicator at the current
// map level). They do not set the colour classes, which are fixed per
// indicator (binEdges); they feed the quantile fallback and the averages.
metricValues = currentStats
.map(d => d[metricColumn])
.filter(v => v != null && !isNaN(v))
metricExtent = metricValues.length > 0 ? d3.extent(metricValues) : [0, 100]
// Colouring value: indicators colour by value; colour_by "distance_from_<c>"
// colours by |value − c| instead. The gap views use c = 0 (parity); c = 1 is
// supported but no indicator currently uses it. Closer to c is better, so no
// red is ever drawn unless the metric also has a standard.
colourCentre = {
const m = /^distance_from_(-?[\d.]+)$/.exec(selectedMetric.colour_by || "");
return m ? Number(m[1]) : null;
}
isDistanceColour = colourCentre != null
// How the centre is named in legends: the reference line's label when one
// sits at the centre ("parity"), else the number ("1.00").
colourCentreWord = isDistanceColour
? (selectedMetric.reference && selectedMetric.reference.value === colourCentre
? String(selectedMetric.reference.label).toLowerCase()
: colourCentre.toFixed(selectedMetric.digits))
: null
// isDistanceFrom1 = selectedMetric.colour_by === "distance_from_1"
// colourValue = (v) => isDistanceFrom1 ? Math.abs(v - 1) : v
// colourHigherIsBetter = isDistanceFrom1 ? false : selectedMetric.higher_is_better
isDistanceFrom1 = isDistanceColour // the cells below keep the historical name
colourValue = (v) => isDistanceColour ? Math.abs(v - colourCentre) : v
colourHigherIsBetter = isDistanceColour ? false : selectedMetric.higher_is_better
colourValues = metricValues.map(colourValue)
colourExtent = colourValues.length > 0 ? d3.extent(colourValues) : [0, 100]
// Averages
referenceAverage = {
const values = currentStats.map(d => d[metricColumn]).filter(v => v != null && !isNaN(v));
return values.length > 0 ? d3.mean(values) : null;
}
nationalAverage = summary.national_averages[metricColumn]
// The dashed rule is the colour standard in every view, so red and purple bars
// always fall on their own side of it (in a drilled region, a region-mean rule
// would put counties between it and the national average on the wrong side;
// the region mean stays in the stat band). Indicators with no standard or no
// direction fall back to the survey-weighted national average in the national
// view and the unweighted mean of the visible county values in a region view.
ruleIsStandard = standardValue != null && colourHigherIsBetter != null
ruleAverage = ruleIsStandard ? standardValue : (isRegionView ? referenceAverage : nationalAverage)
ruleLabel = ruleIsStandard
? (selectedMetric.standard?.basis === "threshold" ? "Usawa standard" : "National average")
: averageTypeLabel
// Format helper
formatMetric = (value) => {
if (value == null || isNaN(value)) return "No data";
return value.toFixed(selectedMetric.digits) + selectedMetric.suffix;
}
// Metric label with every dropdown-facet bracket stripped (same rule as
// barAxisLabel below) but WITHOUT the unit suffix appended — feeds the map
// panel title/subtitle, the ranking subtitle and the legend labels.
strippedMetricLabel = {
let label = selectedMetric.label;
for (const v of Object.values(selectedMetric.facets || {})) {
if (v != null) label = label.replace(` (${v})`, "");
}
return label;
}
// Map panel title + subtitle, mounted via DOM ids rather than inline
// markdown interpolation — matches the reactive-update path already used by
// the map controls (mapLegend, breadcrumb, back button) elsewhere on this
// page. Reruns on every metric change/drill-down
// since it reads strippedMetricLabel, unitTypeLabel, isRegionView and
// selectedMetric, all reactive.
mapPanelHeader = {
const titleEl = document.getElementById('map-panel-title');
const subtitleEl = document.getElementById('map-panel-subtitle');
if (titleEl) titleEl.textContent = `${strippedMetricLabel} by ${unitTypeLabel.toLowerCase()}`;
// The description sits under the lead chart's title (rankingSubtitle); the
// map keeps its title only.
// if (subtitleEl) subtitleEl.textContent = selectedMetric.description || "";
if (subtitleEl) subtitleEl.textContent = "";
return null;
}
// Single source of truth — reads the CSS custom properties so every chart
// always matches the stylesheet.
TOKENS = {
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'),
performance: [get('--performance-red'), get('--performance-orange'),
get('--performance-yellow'), get('--performance-lime'), get('--performance-green')],
seq: [get('--seq-1'), get('--seq-2'), get('--seq-3'), get('--seq-4'), get('--seq-5')],
flag: get('--flag'),
// Retired: the grey ramp for no-direction indicators.
// neutral: [get('--neutral-1'), get('--neutral-2'), get('--neutral-3'), get('--neutral-4'), get('--neutral-5')],
stepRed: [get('--step-red-1'), get('--step-red-2'), get('--step-red-3')],
// stepPurple: [get('--step-purple-1'), get('--step-purple-2'), get('--step-purple-3')],
stepBetter: [get('--step-better-1'), get('--step-better-2'), get('--step-better-3')],
performanceOpacity: Number(get('--performance-fill-opacity')),
performanceHoverOpacity: Number(get('--performance-hover-opacity')),
mapOutline: get('--map-outline-colour'),
mapOutlineWidth: Number(get('--map-outline-width')),
mapBoundaryWidth: Number(get('--map-boundary-width')),
mapHoverWidth: Number(get('--map-hover-width'))
};
}
// Superseded by the fixed classes below; kept for reference.
// binScale = colourValues.length > 0
// ? d3.scaleQuantile().domain(colourValues).range([0, 1, 2, 3, 4])
// : null
// Fixed classes: five ranges per indicator from its 2025 county
// distribution (the metric's `bins`, scripts/compute_bins.R), so a colour
// means the same thing in the national, region and county views. An
// indicator without bins (added before compute_bins.R ran) falls back to
// quantiles of the values on screen and says so in the console.
binEdges = {
if (Array.isArray(selectedMetric.bins) && selectedMetric.bins.length === 6) return selectedMetric.bins;
if (!colourValues.length) return null;
console.warn(`No fixed colour bins for ${selectedMetric.key}: using quantiles of the values on screen (run scripts/compute_bins.R).`);
return [d3.min(colourValues),
...d3.scaleQuantile().domain(colourValues).range([0, 1, 2, 3, 4]).quantiles(),
d3.max(colourValues)];
}
// Class 0–4 of a colouring value: below the second edge → 0, at or above the
// fifth → 4 (an edge belongs to the class above it, as d3.scaleQuantile does).
binIndexFor = (cv) => binEdges.slice(1, 5).filter(e => cv >= e).length
// Decimals for the legend ranges: the metric's digits, more only where that
// would print two edges alike (compute_bins.R adds them for tight spreads).
binDigits = {
if (!binEdges) return selectedMetric.digits;
let d = selectedMetric.digits;
while (d < selectedMetric.digits + 3 && new Set(binEdges.map(e => e.toFixed(d))).size < new Set(binEdges).size) d++;
return d;
}
// Label colour for a fill, ported from compare.qmd §2 with one change: the
// fill composited over --paper at its drawn opacity gets whichever of paper
// or ink CONTRASTS MORE, not a fixed luminance cut-off (0.45 picked paper on
// --seq-4 and --flag at 65%, 2.98:1 and 3.0:1; ink gives 5.15:1 and 5.1:1).
// Only --seq-5 takes paper.
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.paper);
return 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.paper) > contrast(TOKENS.ink) ? TOKENS.paper : TOKENS.ink;
}
// The indicator's standard (registry `standard`): provisionally its national
// average. Values on the worse side of it are red.
standardValue = selectedMetric.standard?.value ?? null
// Six steps around the standard: three reds on the worse side; on the
// better side purple (nearest the standard) then two greens,
// 1 = nearest the standard. The metric's `steps` are four fixed edges,
// ascending in value, two either side of the standard (registry step_edges,
// scripts/compute_bins.R), so a colour means the same value in every view.
// An edge belongs to the step above it in value. Null when the metric has no
// steps, no standard or no direction: binFor then falls back to the five
// fixed classes.
stepEdges = {
const e = selectedMetric.steps;
if (!Array.isArray(e) || e.length !== 4 || standardValue == null || colourHigherIsBetter == null || isDistanceFrom1) return null;
return e;
}
// { side: "worse" | "better", step: 1–3 } of a value.
stepOf = (v) => {
const [a, b, c, d] = stepEdges, std = standardValue;
if (colourHigherIsBetter) {
return v < std ? { side: "worse", step: v < a ? 3 : v < b ? 2 : 1 }
: { side: "better", step: v >= d ? 3 : v >= c ? 2 : 1 };
}
return v > std ? { side: "worse", step: v >= d ? 3 : v >= c ? 2 : 1 }
: { side: "better", step: v < a ? 3 : v < b ? 2 : 1 };
}
// Shared by map fills, map labels and ranking bars: a single hue, darker
// = better, from the fixed classes; red for the worse side of the standard.
// An indicator with no direction (higher_is_better null) shades darker =
// higher and is never red. A missing value is "No data".
binFor = (value) => {
if (value == null || isNaN(value))
return { fill: TOKENS.paper2, text: TOKENS.inkMute, noData: true };
if (!binEdges)
return { fill: TOKENS.paper2, text: TOKENS.ink };
if (stepEdges) {
const s = stepOf(value);
const fill = (s.side === "worse" ? TOKENS.stepRed : TOKENS.stepBetter)[s.step - 1];
return { fill, text: labelColourForFill(fill, TOKENS.performanceOpacity), worse: s.side === "worse", step: s.step };
}
const cv = colourValue(value);
const directed = colourHigherIsBetter != null;
const std = directed && standardValue != null ? colourValue(standardValue) : null;
const worse = std != null && (colourHigherIsBetter ? cv < std : cv > std);
const i = binIndexFor(cv);
// const fill = worse ? TOKENS.flag : TOKENS.seq[colourHigherIsBetter === false ? 4 - i : i];
// Previous version, where no direction used the grey ramp:
// const fill = worse ? TOKENS.flag : directed ? TOKENS.seq[colourHigherIsBetter === false ? 4 - i : i] : TOKENS.neutral[i];
// No direction keeps the same purple ramp as every other bar chart; its
// legend reads "Darker is higher".
const fill = worse ? TOKENS.flag : TOKENS.seq[colourHigherIsBetter === false ? 4 - i : i];
return { fill, text: labelColourForFill(fill, TOKENS.performanceOpacity), worse };
}
getMapColor = (value) => binFor(value).fillactiveCountConfig = (summary.count_config || [])
.filter(c => !c.facet || (selectedMetric.facets && selectedMetric.facets[c.facet] != null))
summaryStats = {
const data = currentStats;
const values = data.map(d => d[metricColumn]).filter(v => v != null && !isNaN(v));
// Count callouts are data-driven: each bundle's summary.json declares its own
// count fields (Schools/Learners/Teachers for Primary School; Children
// assessed for Household) in `count_config`. Sum whatever is declared over
// the rows in the current view; zero/absent fields simply don't render.
// const counts = (summary.count_config || [])
const counts = activeCountConfig
.map(c => ({ label: c.label, value: d3.sum(data.map(d => d[c.field] || 0)) }))
.filter(c => c.value > 0);
return {
unitCount: data.length,
// "1 County" for Nairobi, the one single-county region (was "1 Counties").
unitLabel: data.length === 1 ? (isRegionView ? "County" : "Region") : unitTypeLabelPlural,
counts,
// Weighted national average nationally, unweighted county mean when
// drilled. This is not the dashed rule's value: the rule is the colour
// standard, so a drilled region shows its own mean here and the national
// average on the chart.
// avgValue: ruleAverage ?? (values.length > 0 ? d3.mean(values) : null)
avgValue: (isRegionView ? referenceAverage : nationalAverage) ?? (values.length > 0 ? d3.mean(values) : null)
};
}
// Counts are formatted "en-GB" everywhere (934,400), not in the visitor's
// locale, which can print 934.400.
html`
<div class="stat-band">
<div class="stat-cell stat-cell--lead">
<span class="stat-value">${formatMetric(summaryStats.avgValue)}</span>
<span class="stat-label">${averageTypeLabel}</span>
</div>
<div class="stat-cell">
<span class="stat-value">${summaryStats.unitCount}</span>
<span class="stat-label">${summaryStats.unitLabel}</span>
</div>
${summaryStats.counts.map(c => html`<div class="stat-cell">
<span class="stat-value">${c.value.toLocaleString("en-GB")}</span>
<span class="stat-label">${c.label}</span>
</div>`)}
</div>`truncateLabel = (text, maxLen) => {
if (!text) return "";
if (text.length <= maxLen) return text;
return text.substring(0, maxLen - 1).trim() + "…";
}
// Bar value labels: the x-axis title already states "(%)" for percent
// metrics, so the % suffix is dropped from the labels to avoid saying it
// twice. Non-percent units are untouched; tooltips and
// the stats callout keep formatMetric's full unit — they have no axis
// carrying it.
formatBarValue = (value) => {
if (value == null || isNaN(value)) return "No data";
const suffix = selectedMetric.suffix === "%" ? "" : selectedMetric.suffix;
return value.toFixed(selectedMetric.digits) + suffix;
}
// Axis title: the panel header already carries every bracketed
// dropdown-derived qualifier (grade, view, stream, gender — whatever the
// dataset's facets produce, e.g. "(Boys)", "(Male) (JS)", "(Grade 3)") —
// drop them ALL here so the axis states only the bare measure name + unit.
// Brackets that
// are part of the metric's name rather than a facet value — units and base
// qualifiers like "(Last Month)" — are untouched.
barAxisLabel = {
let label = selectedMetric.label;
for (const v of Object.values(selectedMetric.facets || {})) {
if (v != null) label = label.replace(` (${v})`, "");
}
return label + (selectedMetric.suffix ? " (" + selectedMetric.suffix + ")" : "");
}
// Ranking panel subtitle: ONE sentence under the lead chart's title — the
// first sentence of the registry definition with its asides in brackets
// dropped. The full definition stays in the Methodology catalogue. A metric
// with no definition falls back to the stripped metric name + the active facet
// values, lowercased and comma-joined, closing with the survey year — e.g.
// "learner-teacher ratio, primary, 2025".
rankingSubtitle = {
const desc = String(selectedMetric.description || "").trim();
if (desc) {
const first = (desc.match(/^.*?\.(?=\s|$)/) || [desc])[0];
return first.replace(/\s*\([^)]*\)/g, "").replace(/\s+([.,])/g, "$1");
}
const facetVals = Object.values(selectedMetric.facets || {})
.filter(v => v != null)
.map(v => String(v).toLowerCase());
const text = [strippedMetricLabel.toLowerCase(), ...facetVals, String(surveyYear)].join(", ");
return text.charAt(0).toUpperCase() + text.slice(1);
}
// Ranking panel title + subtitle, mounted via DOM ids (same reasoning as
// mapPanelHeader above) — reruns on every metric change/drill-down.
rankingPanelHeader = {
const titleEl = document.getElementById('ranking-panel-title');
const subtitleEl = document.getElementById('ranking-panel-subtitle');
if (titleEl) titleEl.textContent = `${unitTypeLabelPlural} ranked`;
if (subtitleEl) subtitleEl.textContent = rankingSubtitle;
return null;
}
// The bars panel is wider since it leads, so labels truncate later.
maxLabelChars = 24
barData = currentStats
.map(d => {
const name = isRegionView ? d.county_name : d.region_name;
return {
id: name,
name: name,
displayName: truncateLabel(name, maxLabelChars),
value: d[metricColumn],
// counts: (summary.count_config || [])
counts: activeCountConfig
.map(c => ({ label: c.label, value: d[c.field] }))
.filter(c => c.value != null),
// Nairobi region is one county: no drill-down from its bar.
isClickable: isCountryView && name !== "Nairobi"
};
})
// Areas with no value stay in the list: they sort last and show
// "No data" instead of a bar, so a missing value never looks like zero.
.map(d => ({ ...d, noData: d.value == null || isNaN(d.value) }))
// Best value first (top), matching the map legend's "better" direction.
// Distance-from-1 ratios sort by raw
// value, largest first: the 1.0 reference line, not the order, carries meaning.
.sort((a, b) => (a.noData - b.noData) ||
(a.noData ? 0 : (isDistanceFrom1 || selectedMetric.higher_is_better ? b.value - a.value : a.value - b.value)))
barDataIdToDisplay = new Map(barData.map(d => [d.id, d.displayName]))
// Mobile: a short chart can't show ~47 readable bars at once — yAxisTicks
// below already thins labels when bars don't fit, but that still draws every
// bar as a hairline. Cap to the top/bottom 10 (already sorted largest→
// smallest) with a "Show all" expander instead. Desktop is never capped.
barCapThreshold = 20
mutable showAllBars = false
displayedBarData = (!isMobileViewport || barData.length <= barCapThreshold || showAllBars)
? barData
: [...barData.slice(0, 10), ...barData.slice(-10)]
// A spacer row between the top-10 and bottom-10 groups so the gap between
// them reads as a deliberate split, not a broken sort. The band scale
// reserves space for every id in this domain even though no bar mark
// targets "__gap__" — Plot.barX's data has no such row, so that band stays
// empty.
isBarDataCapped = displayedBarData !== barData
barYDomain = isBarDataCapped
? [...displayedBarData.slice(0, 10).map(d => d.id), "__gap__", ...displayedBarData.slice(10).map(d => d.id)]
: displayedBarData.map(d => d.id)
// Width and height both track the container (Plot needs explicit pixels for
// each). A row-count-only height left the ranking pane short of its box on a
// fitted page, with dead space under the chart while the map filled its own
// pane. So that the two panes read as a matched pair, the chart STRETCHES to
// its container and falls back to the row-count height only when that is
// taller (county views), where the container scrolls.
chartWidth = {
const container = document.getElementById('explore-bar-chart-container');
if (!container) return 500;
return Generators.observe(notify => {
let raf = 0;
const emit = () => notify(Math.max(300, container.clientWidth - 20));
emit();
const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(emit); };
const ro = new ResizeObserver(schedule);
ro.observe(container);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
});
}
chartHeight = {
const container = document.getElementById('explore-bar-chart-container');
if (!container) return 360;
// The container is flex-sized by its panel and holds nothing but the chart,
// so measuring it cannot feed back into its own height — no resize loop.
return Generators.observe(notify => {
let raf = 0;
const emit = () => notify(Math.max(200, container.clientHeight - 10));
emit();
const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(emit); };
const ro = new ResizeObserver(schedule);
ro.observe(container);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
});
}
// Row-count height (bar thickness ≈ 26px with ≈ 9px row gap) is now the FLOOR,
// not the target: at 8 regions the pane is taller than the rows need, so the
// chart takes the pane and the bars thicken to match the map's weight; at
// county counts the rows win and #explore-bar-chart-container scrolls.
// Margins are the fixed top (34) + bottom (44) below.
//
// In the fitted layout (styles §11.2 gate) the floor drops to 24px a row: at
// 1024×693, or wherever Household's extra fields wrap the selector row, the
// pane is ~290px and the eight regions scrolled North Eastern out of view.
// Stacked layouts size the pane from the chart, so they keep 35.
chartDimensions = {
const rowCount = displayedBarData.length;
const fitted = window.matchMedia('(min-width: 901px) and (min-height: 600px)').matches;
// const intrinsicHeight = rowCount * 35 + 34 + 44;
const rowPitch = fitted ? 24 : 35;
const intrinsicHeight = rowCount * rowPitch + 34 + 44;
// return { width: chartWidth, height: Math.max(chartHeight, intrinsicHeight), rowPitch };
// Stacked layouts (styles §11.1) size the pane from the chart, so they take the
// row-count height alone: measuring a box that grows with its content would
// shrink the chart 10px a frame until it hit the floor.
const stacked = window.matchMedia('(max-width: 900px)').matches;
return { width: chartWidth, height: stacked ? intrinsicHeight : Math.max(chartHeight, intrinsicHeight), rowPitch };
}
yAxisTicks = {
const numBars = displayedBarData.length;
const availableHeight = chartDimensions.height - 78;
// const labelHeight = 35;
// The floor's row pitch: at 24px every name still fits (11.5px type).
const labelHeight = chartDimensions.rowPitch;
const maxLabels = Math.max(1, Math.floor(availableHeight / labelHeight));
if (numBars <= maxLabels) return displayedBarData.map(d => d.id);
const skipFactor = Math.ceil(numBars / maxLabels);
return displayedBarData.filter((d, i) => i % skipFactor === 0).map(d => d.id);
}
// The bar chart's side margins, shared with barLabelPlan below.
barMargins = ({ left: isRegionView ? 160 : 104, right: 64 })
// The x domain. A signed gap view has bars below zero, so the axis
// extends left of 0 by the same 5% headroom; otherwise it starts at 0.
// The rule may sit beyond every bar in a drilled region.
barXDomain = {
const vals = displayedBarData.filter(d => !d.noData).map(d => d.value);
const hi = Math.max(...vals, ruleAverage ?? 0, selectedMetric.reference?.value ?? 0, 1) * 1.05;
const min = Math.min(0, ...vals, ruleAverage ?? 0);
if (min >= 0) return [0, hi];
// A negative bar's label sits to its left, where the positive side has the
// 64px right margin: extend the domain so the longest label (its characters
// at ≈10px, plus the 10px gap) fits inside the plot area.
const plotW = chartDimensions.width - barMargins.left - barMargins.right;
const labelPx = 10 + 10 * Math.max(...vals.filter(v => v < 0).map(v => formatBarValue(v).length), 0);
const room = labelPx * (hi - min) / Math.max(1, plotW - labelPx); // data units
return [min - room, hi];
}
// Value labels placed in data units: a label starts 10px past its bar end,
// on the outside; when
// that would put it across the dashed average line or the reference line it
// steps to 6px past the line instead, so no label ever sits on a line.
// Negative bars (gap views) label to the left of the bar end.
barLabelPlan = {
const [lo, hi] = barXDomain;
const ppu = (chartDimensions.width - barMargins.left - barMargins.right) / (hi - lo);
const gap = 10, clear = 6, charPx = 10; // 17px Instrument Sans 600 ≈ 10px a character
const lines = [ruleAverage, selectedMetric.reference?.value].filter(v => v != null).sort((a, b) => a - b);
const place = (d) => {
const w = formatBarValue(d.value).length * charPx / ppu;
const neg = d.value < 0;
let start = neg ? d.value - gap / ppu : d.value + gap / ppu;
let end = neg ? start - w : start + w;
for (const L of neg ? [...lines].reverse() : lines) {
const [a, b] = neg ? [end, start] : [start, end];
if (L >= a - clear / ppu && L <= b + clear / ppu) {
start = neg ? L - clear / ppu : L + clear / ppu;
end = neg ? start - w : start + w;
}
}
return { ...d, labelX: start, neg };
};
return displayedBarData.filter(d => !d.noData).map(place);
}
// "No data" steps past any line it would cross, as the value labels do.
// For example, on Junior School's Grade 9 location gap the national average
// is 2.7pp, so the dashed line would otherwise run through Nairobi's
// "No data" (Nairobi has no rural children).
noDataLabelX = {
const [lo, hi] = barXDomain;
const ppu = (chartDimensions.width - barMargins.left - barMargins.right) / (hi - lo);
const clear = 6, w = 7 * 8 / ppu; // "No data", 14px italic ≈ 8px a character
const lines = [ruleAverage, selectedMetric.reference?.value].filter(v => v != null).sort((a, b) => a - b);
let start = 6 / ppu; // the old dx: 6 past the baseline
for (const L of lines) if (L > start - clear / ppu && L < start + w + clear / ppu) start = L + clear / ppu;
return start;
}
// Bracketed tags keep their case ("(JS)", not "(js)"). Only text outside
// parentheses is lowercased.
lowercaseOutsideBrackets = (s) => String(s).replace(/\([^)]*\)|[^(]+/g, part => part.startsWith("(") ? part : part.toLowerCase())
// Named so barClickHandler and barCrossHighlight take the chart
// they decorate from here instead of searching the page after a fixed wait:
// a size-driven redraw (window resize, the second draw on load) replaced the
// SVG and left the new bars without click, hover or focus handlers.
// Plot.plot({
exploreBarChart = Plot.plot({
width: chartDimensions.width,
height: chartDimensions.height,
// marginLeft: isRegionView ? 160 : 104,
// marginRight: 64,
marginLeft: barMargins.left,
marginRight: barMargins.right,
// A metric with a reference line stacks its chip above the average chip.
marginTop: selectedMetric.reference ? 52 : 34,
marginBottom: 44,
className: "dash-bar-chart",
style: { background: "transparent", fontFamily: "'IBM Plex Sans', sans-serif", fontSize: "11.5px" },
x: {
// domain: [0, Math.max(...displayedBarData.map(d => d.noData ? 0 : d.value), ruleAverage ?? 0, 1) * 1.05],
domain: barXDomain,
grid: false,
axis: "bottom",
// label: barAxisLabel.toLowerCase(),
label: lowercaseOutsideBrackets(barAxisLabel),
labelAnchor: "right",
labelArrow: "none",
labelOffset: 34,
tickSize: 6
},
y: {
label: null,
domain: barYDomain,
ticks: yAxisTicks,
tickFormat: id => id === "__gap__" ? "" : (barDataIdToDisplay.get(id) || id),
tickSize: 0
},
marks: [
// Softer 65% fills (TOKENS.performanceOpacity).
Plot.barX(displayedBarData.filter(d => !d.noData), {
x: "value",
y: "id",
fill: d => binFor(d.value).fill,
fillOpacity: TOKENS.performanceOpacity,
title: d => isCountryView ? "Click to drill down to counties" : null
}),
// No bar for a missing value: a muted "No data" at the baseline instead.
Plot.text(displayedBarData.filter(d => d.noData), {
// x: 0,
// dx: 6,
x: () => noDataLabelX,
y: "id",
text: () => "No data",
textAnchor: "start",
fontSize: 14,
fontStyle: "italic",
fill: TOKENS.inkMute,
fontFamily: "'IBM Plex Sans', sans-serif"
}),
// Value labels always sit outside the bar end, never white-on-fill.
// Plot.text(displayedBarData.filter(d => !d.noData), {
// x: "value",
// y: "id",
// text: d => formatBarValue(d.value),
// textAnchor: "start",
// dx: 10,
// ...
// }),
// Positions from barLabelPlan, clear of the dashed line. Plot's dx and
// textAnchor are one constant per mark, so positive and negative bars are
// two marks.
Plot.text(barLabelPlan.filter(d => !d.neg), {
x: "labelX",
y: "id",
text: d => formatBarValue(d.value),
textAnchor: "start",
fontSize: 17,
fontWeight: 600,
fill: TOKENS.ink,
fontFamily: "'Instrument Sans', sans-serif"
}),
Plot.text(barLabelPlan.filter(d => d.neg), {
x: "labelX",
y: "id",
text: d => formatBarValue(d.value),
textAnchor: "end",
fontSize: 17,
fontWeight: 600,
fill: TOKENS.ink,
fontFamily: "'Instrument Sans', sans-serif"
}),
// The average line is the protagonist: a near-black (TOKENS.ink) rule with
// an on-chart chip label on a paper knockout stroke, in both views. It is
// DASHED, with the dash pattern and width taken from Compare's rules, so
// the two pages read alike.
ruleAverage != null ? Plot.ruleX([ruleAverage], {
stroke: TOKENS.ink, strokeWidth: 2, strokeDasharray: "4,4"
}) : null,
ruleAverage != null ? Plot.text([ruleAverage], {
x: d => d, frameAnchor: "top",
text: d => `${ruleLabel.toLowerCase()} ${formatBarValue(d)}`,
dy: -12, fontSize: 11.5, fontWeight: 600,
fill: TOKENS.ink, stroke: TOKENS.paper, strokeWidth: 5,
fontFamily: "'IBM Plex Sans', sans-serif"
}) : null,
// Optional fixed reference line from the indicator config (e.g. 1.0 for
// teachers per classroom); its chip sits one line above the average chip so
// the two never collide however close the average is to the reference.
selectedMetric.reference ? Plot.ruleX([selectedMetric.reference.value], { stroke: TOKENS.inkMute, strokeWidth: 1.5 }) : null,
selectedMetric.reference ? Plot.text([selectedMetric.reference.value], {
x: d => d, frameAnchor: "top",
text: () => selectedMetric.reference.label,
dy: -30, fontSize: 11.5, fontWeight: 600,
fill: TOKENS.inkMute, stroke: TOKENS.paper, strokeWidth: 5,
fontFamily: "'IBM Plex Sans', sans-serif"
}) : null
].filter(Boolean)
})html`
${isMobileViewport && barData.length > barCapThreshold ? html`
<div style="display: flex; justify-content: center; padding: 6px 0 0;">
<button type="button" class="bars-mobile-toggle" onclick=${() => { mutable showAllBars = !showAllBars; }}>
${showAllBars ? "Show top/bottom 10 only" : `Show all ${barData.length}`}
</button>
</div>
` : ''}
`barClickHandler = {
// Must match what Plot.barX actually rendered (displayedBarData, which on a
// capped mobile view is a top/bottom-10 subset of barData) — indexing
// against the full barData here would misalign every bar past the cap.
// Only rows with a value draw a rect; missing rows sort last, so the rects
// (sorted by y) line up with the leading non-missing rows.
const dataSnapshot = displayedBarData.filter(d => !d.noData);
const countryView = isCountryView;
// await new Promise(r => setTimeout(r, 1200));
//
// let svg = null;
// const allSvgs = document.querySelectorAll('.chart-container svg');
// for (const candidateSvg of allSvgs) {
// const rects = candidateSvg.querySelectorAll('rect');
// if (rects.length >= dataSnapshot.length && rects.length <= dataSnapshot.length + 2) {
// svg = candidateSvg;
// break;
// }
// }
// The chart itself, so this cell re-runs on every redraw (see exploreBarChart).
const svg = exploreBarChart instanceof SVGSVGElement ? exploreBarChart : exploreBarChart.querySelector('svg.dash-bar-chart');
if (!svg) return null;
const allRects = svg.querySelectorAll('rect');
let bars = Array.from(allRects).filter(r => {
const fill = r.getAttribute('fill');
return (fill && fill !== 'none' && fill !== 'transparent') || r.style.fill;
});
bars.sort((a, b) => parseFloat(a.getAttribute('y') || 0) - parseFloat(b.getAttribute('y') || 0));
if (bars.length === 0) return null;
const barLookup = new Map();
bars.forEach((bar, i) => {
bar.setAttribute('data-bar-index', i);
bar.style.pointerEvents = 'all';
bar.style.transition = 'opacity 150ms ease';
bar.setAttribute('tabindex', '0');
if (dataSnapshot[i]) {
const itemId = dataSnapshot[i].id;
barLookup.set(itemId, bar);
if (dataSnapshot[i].isClickable) bar.style.cursor = 'pointer';
}
});
svg._dashBarLookup = barLookup;
// y-tick <text> elements, matched by their rendered label text (the same
// "match by label text" approach the map's district labels use) so the
// cross-highlight can dim/promote the hovered row's name too.
const tickLookup = new Map();
svg.querySelectorAll('g[aria-label="y-axis tick label"] text').forEach(t => {
const id = [...barDataIdToDisplay.entries()].find(([, name]) => name === t.textContent)?.[0];
if (id != null) {
t.style.transition = 'opacity 150ms ease, fill 150ms ease';
tickLookup.set(id, t);
}
});
svg._dashTickLookup = tickLookup;
// Keyboard parity: focus/blur mirror mouseover/mouseout on each bar.
bars.forEach((bar, i) => {
const dataItem = dataSnapshot[i];
if (!dataItem) return;
bar.addEventListener('focus', () => setHovered(dataItem.id));
bar.addEventListener('blur', () => clearHovered());
});
if (svg._dashBarHandler) {
svg.removeEventListener('click', svg._dashBarHandler);
svg.removeEventListener('mouseover', svg._dashBarHoverHandler);
svg.removeEventListener('mouseout', svg._dashBarOutHandler);
svg.removeEventListener('touchstart', svg._dashBarTouchHandler);
}
svg._dashBarHandler = (e) => {
const rect = e.target.closest('rect[data-bar-index]');
if (!rect) return;
const dataItem = dataSnapshot[parseInt(rect.getAttribute('data-bar-index'), 10)];
if (!dataItem || !dataItem.isClickable) return;
e.stopPropagation();
if (countryView) mutable selectedRegion = dataItem.id;
};
let tooltip = document.getElementById('usawa-bar-tooltip');
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'usawa-bar-tooltip';
tooltip.className = 'dash-tooltip';
tooltip.style.cssText = 'position: fixed; display: none; z-index: 1000; padding: 8px 12px; font-size: 12px; pointer-events: none; min-width: 150px;';
document.body.appendChild(tooltip);
}
// Styling on hover/focus is left entirely to barCrossHighlight (below), which
// reacts to hoveredItem — this handler only positions the tooltip.
svg._dashBarHoverHandler = (e) => {
const rect = e.target.closest('rect[data-bar-index]');
if (!rect) { tooltip.style.display = 'none'; return; }
const dataItem = dataSnapshot[parseInt(rect.getAttribute('data-bar-index'), 10)];
if (!dataItem) return;
setHovered(dataItem.id);
tooltip.style.display = 'block';
tooltip.innerHTML = `
<div style="font-family: 'IBM Plex Sans', sans-serif;">
<strong style="font-size: 13px;">${dataItem.name}</strong>
<hr style="margin: 6px 0; border: none; border-top: 1px solid var(--rule);">
<div style="line-height: 1.5;">
<div><span style="color: var(--ink-mute);">${selectedMetric.label}:</span> <strong>${formatMetric(dataItem.value)}</strong></div>
${(dataItem.counts || []).map(c => `<div><span style="color: var(--ink-mute);">${c.label}:</span> <strong>${c.value.toLocaleString("en-GB")}</strong></div>`).join('')}
${countryView && dataItem.isClickable ? '<div style="margin-top: 8px; color: var(--ink-mute);">Click to see counties</div>' : ''}
</div>
</div>
`;
const x = e.clientX + 15;
const y = e.clientY + 15;
tooltip.style.left = Math.min(x, window.innerWidth - 200) + 'px';
tooltip.style.top = Math.min(y, window.innerHeight - 100) + 'px';
};
svg._dashBarOutHandler = (e) => {
const rect = e.target.closest('rect[data-bar-index]');
if (!rect) return;
clearHovered();
tooltip.style.display = 'none';
};
// Tap-to-pin (mobile): touch has no hover state, so a tap shows the same
// tooltip the mouseover handler would, pinned until the next tap — either
// on another bar (which just re-pins on the new one) or anywhere else
// (handled once, globally, below).
svg._dashBarTouchHandler = (e) => {
const rect = e.target.closest('rect[data-bar-index]');
if (!rect) return;
e.stopPropagation();
const touch = e.touches[0];
svg._dashBarHoverHandler({ target: e.target, clientX: touch.clientX, clientY: touch.clientY });
};
if (!window.__dashTooltipDismissBound) {
window.__dashTooltipDismissBound = true;
document.addEventListener('touchstart', (e) => {
if (e.target.closest('rect[data-bar-index]')) return;
const tt = document.getElementById('usawa-bar-tooltip');
if (tt) tt.style.display = 'none';
clearHovered();
}, { passive: true });
}
svg.addEventListener('click', svg._dashBarHandler);
svg.addEventListener('mouseover', svg._dashBarHoverHandler);
svg.addEventListener('mouseout', svg._dashBarOutHandler);
svg.addEventListener('touchstart', svg._dashBarTouchHandler, { passive: true });
return `attached_${bars.length}_${Date.now()}`;
}
// Hovering (or keyboard-focusing) a bar dims every other bar and y-tick
// name to opacity .3, promotes the hovered row's name, and shows its
// distance from the average inside the bar.
barCrossHighlight = {
const _ = barClickHandler;
const currentHoveredItem = hoveredItem;
// let svg = null;
// const allSvgs = document.querySelectorAll('.chart-container svg');
// for (const candidateSvg of allSvgs) {
// if (candidateSvg._dashBarLookup) { svg = candidateSvg; break; }
// }
const svg = exploreBarChart instanceof SVGSVGElement ? exploreBarChart : exploreBarChart.querySelector('svg.dash-bar-chart');
if (!svg || !svg._dashBarLookup) return null;
const barLookup = svg._dashBarLookup;
const tickLookup = svg._dashTickLookup || new Map();
if (svg._dashDeltaText) {
svg._dashDeltaText.remove();
svg._dashDeltaText = null;
}
if (!currentHoveredItem) {
barLookup.forEach((bar) => { bar.style.opacity = ''; });
tickLookup.forEach((t) => { t.style.opacity = ''; t.style.fill = ''; t.style.fontWeight = ''; });
return null;
}
const hoveredBar = barLookup.get(currentHoveredItem);
barLookup.forEach((bar, id) => {
bar.style.opacity = id === currentHoveredItem ? '1' : '0.3';
});
tickLookup.forEach((t, id) => {
if (id === currentHoveredItem) {
t.style.opacity = '1';
t.style.fill = TOKENS.ink;
t.style.fontWeight = '600';
} else {
t.style.opacity = '0.3';
t.style.fill = '';
t.style.fontWeight = '';
}
});
if (hoveredBar && ruleAverage != null) {
const dataItem = displayedBarData.find(d => d.id === currentHoveredItem);
if (dataItem && dataItem.value != null) {
const d = dataItem.value - ruleAverage;
const label = `${d >= 0 ? "+" : "−"}${Math.abs(d).toFixed(selectedMetric.digits)} ${d >= 0 ? "above" : "below"} avg`;
const bbox = hoveredBar.getBBox();
const svgNS = 'http://www.w3.org/2000/svg';
const textEl = document.createElementNS(svgNS, 'text');
textEl.setAttribute('x', bbox.x + bbox.width - 12);
textEl.setAttribute('y', bbox.y + bbox.height / 2);
textEl.setAttribute('text-anchor', 'end');
textEl.setAttribute('dominant-baseline', 'middle');
textEl.setAttribute('font-size', '11.5');
textEl.setAttribute('font-weight', '500');
textEl.setAttribute('font-family', "'IBM Plex Sans', sans-serif");
textEl.setAttribute('fill', binFor(dataItem.value).text);
textEl.style.pointerEvents = 'none';
textEl.textContent = label;
svg.appendChild(textEl);
svg._dashDeltaText = textEl;
}
}
return null;
}// Breadcrumb and back button ABOVE the map.
html`
<div class="map-panel-nav" style="display: ${isCountryView ? 'none' : 'grid'};">
<div class="map-navigation__breadcrumbs">
${isRegionView ? html`
<div style="display: flex; align-items: center; gap: 14px; font-size: 17px;">
${countryBreadcrumb}
<span style="color: var(--ink-mute); font-size: 20px; font-weight: 300;">›</span>
<span style="color: var(--ink); font-weight: 600;">${selectedRegion}</span>
<span style="color: var(--ink-mute); font-size: 20px; font-weight: 300;">›</span>
<span style="color: var(--ink-mute); font-weight: 500;">${filteredCounties.length} ${filteredCounties.length === 1 ? "County" : "Counties"}</span>
</div>
` : html``}
<!-- The national-view drill hint is retired; bars and
areas show a pointer and drill on click.
<span class="breadcrumb-hint" style="font-size: 12.5px;">
Select a region to drill down to county level (Nairobi is a single county).
</span>
-->
</div>
<div class="map-panel-nav__button-slot" style="display: ${isCountryView ? 'none' : 'flex'};">
${backButton}
</div>
</div>
`leafletMap = {
await document.fonts.ready;
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 50)));
const container = document.getElementById('leaflet-map-container');
if (!container) return null;
container.innerHTML = '';
const mapDiv = document.createElement('div');
mapDiv.id = 'usawa-leaflet-map';
// min-height is a last-resort floor only. It must stay BELOW the smallest
// box the fit-to-viewport Explore layout can hand the map (~240px at the
// 780px viewport-height floor, usawa.scss §11.2) — at the old 300px it
// exceeded the container on short screens and Leaflet overflowed into the
// legend strip, where the panel's overflow:hidden clipped the map's bottom.
mapDiv.style.cssText = 'width: 100%; height: 100%; min-height: 160px; background: var(--paper);';
container.appendChild(mapDiv);
const boundsGeoData = isRegionView
? ({ type: "FeatureCollection", features: filteredCounties })
: currentGeoData;
const tempLayer = L.geoJSON(boundsGeoData);
const initialBounds = tempLayer.getBounds();
const map = L.map('usawa-leaflet-map', {
zoomControl: false,
scrollWheelZoom: true,
doubleClickZoom: true,
touchZoom: true,
boxZoom: true,
keyboard: true,
dragging: true,
// One-finger touch scrolls the page; two fingers pan the map; pinch still
// zooms (touchZoom isn't gated by this). Desktop mouse/scroll-wheel is
// unaffected — gestureHandling only kicks in below this breakpoint.
gestureHandling: isMobileViewport && gestureHandlingReady,
attributionControl: false,
zoomSnap: 0,
zoomDelta: 0.5
});
if (initialBounds.isValid()) {
map.fitBounds(initialBounds, { padding: isMobileViewport ? [20, 20] : [5, 5] });
} else {
map.setView([0.5, 37.5], 6); // Kenya centre
}
// Land backdrop — first layer added, so it
// sits beneath every Kenya region/county fill and label. Non-interactive
// and excluded from fitBounds (computed above from Kenya data only).
L.geoJSON(landBackdrop, {
interactive: false,
style: { fillColor: TOKENS.paper2, fillOpacity: 1, color: TOKENS.paper, weight: 1 }
}).addTo(map);
// No tile basemap, no +/− zoom control — the choropleth reads on its own.
const style = (feature) => {
const isSelected = !isRegionView || feature.properties.region_name === selectedRegion;
if (!isSelected) {
return {
fillColor: TOKENS.paper2,
weight: 1,
opacity: 1,
color: TOKENS.rule,
fillOpacity: 1
};
}
const value = feature.properties[metricColumn];
const bin = binFor(value);
// No data: neutral fill with a dashed outline, so it never reads as a value.
if (bin.noData) {
return { fillColor: TOKENS.paper2, weight: 1.5, opacity: 1, color: TOKENS.rule,
dashArray: "3,3", fillOpacity: 1 };
}
const colour = bin.fill;
// Coloured boundary over a wider white outline.
return {
fillColor: colour,
weight: TOKENS.mapBoundaryWidth,
opacity: 1,
color: colour,
fillOpacity: TOKENS.performanceOpacity
};
};
// Draw beneath the interactive polygons so outlines never intercept clicks.
L.geoJSON(currentGeoData, {
interactive: false,
style: {
fill: false,
color: TOKENS.mapOutline,
weight: TOKENS.mapOutlineWidth,
opacity: 1,
className: 'map-boundary-outline'
}
}).addTo(map);
const polygonTooltip = (props) => {
const name = isRegionView ? props.county_name : props.region_name;
// Nairobi is a single county, so there is nothing to drill into.
const clickHint = isCountryView && name !== "Nairobi"
? '<div style="margin-top: 8px; color: var(--ink-mute);">Click to see counties</div>'
: '';
return `
<div style="font-family: 'IBM Plex Sans', sans-serif; min-width: 150px; color: var(--ink);">
<strong style="font-size: 13px;">${name}</strong>
<hr style="margin: 6px 0; border: none; border-top: 1px solid var(--rule);">
<div style="font-size: 12px; line-height: 1.5;">
<div><span style="color: var(--ink-mute);">${selectedMetric.label}:</span> <strong>${formatMetric(props[metricColumn])}</strong></div>
${activeCountConfig.map(c => props[c.field] != null ? `<div><span style="color: var(--ink-mute);">${c.label}:</span> <strong>${props[c.field].toLocaleString("en-GB")}</strong></div>` : '').join('')}
</div>
${clickHint}
</div>
`;
};
const layerLookup = new Map();
const geoLayer = L.geoJSON(currentGeoData, {
style: style,
onEachFeature: (feature, layer) => {
const isSelected = !isRegionView || feature.properties.region_name === selectedRegion;
const featureId = isRegionView ? feature.properties.county_name : feature.properties.region_name;
layerLookup.set(featureId, layer);
if (isSelected) {
layer.bindTooltip(polygonTooltip(feature.properties), { sticky: true, className: 'dash-tooltip' });
} else {
const tooltipHtml = `<div style="font-family: 'IBM Plex Sans', sans-serif; font-size: 13px; color: var(--ink);">${feature.properties.county_name} (${feature.properties.region_name})<div style="margin-top: 4px; color: var(--ink-mute);">Click to view region</div></div>`;
layer.bindTooltip(tooltipHtml, { sticky: true, className: 'dash-tooltip' });
}
// Hover: a stronger ink stroke on the hovered feature, restored on mouseout
// (cross-highlight dimming of every OTHER feature is mapCrossHighlight, below).
layer.on('mouseover', function() {
this.setStyle({ weight: TOKENS.mapHoverWidth, color: TOKENS.ink, fillOpacity: TOKENS.performanceHoverOpacity });
this.bringToFront();
// map._dashRaiseNairobi?.(); (retired: see the Nairobi marker)
if (isSelected) setHovered(featureId);
});
layer.on('mouseout', function() {
geoLayer.resetStyle(this);
if (isSelected) {
clearHovered();
}
});
// Nairobi region (one county) is not clickable in the country view.
const drillable = isCountryView ? feature.properties.region_name !== "Nairobi" : !isSelected;
if (drillable) {
layer.on('click', function() {
mutable selectedRegion = feature.properties.region_name;
});
layer.getElement && layer.getElement()?.classList.add('clickable-polygon');
}
}
}).addTo(map);
map._dashLayerLookup = layerLookup;
map._dashGeoLayer = geoLayer;
// Keyboard parity, best effort: focus/blur mirror mouseover/mouseout on
// each region path. Leaflet paths don't take tabindex until
// their SVG element exists, which it does once addTo(map) above resolves.
layerLookup.forEach((layer, featureId) => {
const el = layer.getElement && layer.getElement();
if (!el) return;
el.setAttribute('tabindex', '0');
el.addEventListener('focus', () => { layer.fire('mouseover'); });
el.addEventListener('blur', () => { layer.fire('mouseout'); });
});
// Fixed GEOGRAPHIC anchors for labels that would clip, collide or sit on
// illegible ground at their polygon centre. A CSS pixel offset depends on
// the pane's height (the same offset lands somewhere else at another
// viewport height); a lat/lng reprojects correctly at any pane size, so the
// label always lands on the same ground. The anchors were captured by
// rendering at 1440×950 and converting each label's correct on-screen
// centre back via map.containerPointToLatLng(), then checked at 1440 wide ×
// 700/810/950 tall and in the 390×844 mobile map. All lie inside Kenya's
// bounds (lat -4.7..5.0, lng 33.9..41.9). Regions not listed here keep
// their ordinary polygon-centre anchor.
// - North Eastern: left/down so its wide "...RN" tail stays on its own
// landmass rather than spilling onto pale background.
// - Eastern: south into its wide lower lobe, clear of North Eastern.
// - Nairobi: south onto legible pale ground (ink text reads on both the
// page and Rift Valley's neutral fill), off Central's dark fill.
// - Central: slightly offset to stay clear of Nairobi's label.
// - Western / Nyanza: in the much squarer 390×844 mobile pane, polygon
// centres and the other anchors put Central/Nyanza, Rift Valley/Western
// and Coast/Nairobi close enough to overlap, so Western and Nyanza have
// anchors of their own (and Nairobi and Central sit further onto
// legible neighbouring ground).
// - Nyanza sits well inside its own interior: an anchor near the
// Nyanza/Rift Valley border made the planar ray-cast
// (pixelPointInFeature, below) disagree with what Leaflet painted at
// the mobile projection, so the label took Rift Valley's text colour
// while sitting on Nyanza's fill. Verified with
// SVGGeometryElement.isPointInFill against the rendered geometry at
// 700/810/950×1440 and 390×844.
const LABEL_ANCHOR = {
"Nairobi": [-3.30, 36.90],
"Central": [-0.05, 37.40],
"North Eastern": [0.5147, 39.4268],
"Eastern": [-1.5123, 37.9420],
"Western": [0.05, 34.30],
"Nyanza": [-0.70, 34.40]
};
// A moved label can sit on a NEIGHBOUR region's fill, and that ground
// fill changes with the selected indicator — colouring a label by its OWN
// bin can then be illegible (e.g. Nairobi's label on Rift Valley's dark
// green). Instead, each label takes the AA text colour of whichever
// polygon its FINAL on-screen position actually sits over.
//
// Two details matter:
// (1) Point-in-polygon is a planar ray-cast over each ring projected to
// CONTAINER PIXEL space (via latLngToContainerPoint), matching the
// STRAIGHT-EDGED Mercator path Leaflet renders. d3.geoContains is
// SPHERICAL (great-circle) and disagrees right at an edge, exactly
// where a moved label tends to sit.
// (2) The test point is the span's REAL rendered position, read via
// getBoundingClientRect(), which already includes Leaflet's marker
// placement and any CSS transform. Reconstructing it from
// marker.getLatLng() + iconAnchor is a few px off (the span is a
// top-aligned block, not centred in the 20px-tall icon box), enough
// to misplace Nairobi's label in a ~40px-wide neck of Rift Valley's
// polygon.
const pixelPointInRing = (px, py, ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i].x, yi = ring[i].y;
const xj = ring[j].x, yj = ring[j].y;
const crosses = (yi > py) !== (yj > py);
if (crosses && px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside;
}
return inside;
};
const pixelPointInFeature = (px, py, feature) => {
const geom = feature.geometry;
if (!geom) return false;
const toRing = (coords) => coords.map(([lng, lat]) => map.latLngToContainerPoint([lat, lng]));
const polygons = geom.type === 'Polygon' ? [geom.coordinates]
: geom.type === 'MultiPolygon' ? geom.coordinates
: [];
for (const rings of polygons) {
const [outer, ...holes] = rings.map(toRing);
if (!outer || !pixelPointInRing(px, py, outer)) continue;
if (holes.some(h => pixelPointInRing(px, py, h))) continue;
return true;
}
return false;
};
// The ground under a point, as drawn: the feature's fill composited over
// --paper at the map fill opacity, or the bare map background.
const groundAt = (px, py) => {
const hit = currentGeoData.features.find(f => pixelPointInFeature(px, py, f));
if (!hit) return parseColourRGB(TOKENS.paper2);
const fill = parseColourRGB(binFor(hit.properties[metricColumn]).fill);
const paper = parseColourRGB(TOKENS.paper);
const a = TOKENS.performanceOpacity;
return fill.map((c, i) => c * a + paper[i] * (1 - a));
};
const contrast = (a, b) => {
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// A dark class needs light text, and a label in a small region often
// spills onto a neighbour or the background. Sample the text's left,
// centre and right and take whichever of ink / paper reads best on its
// worst ground.
const labelColourOver = (points) => {
const grounds = points.map(([x, y]) => groundAt(x, y));
const worst = (c) => Math.min(...grounds.map(g => contrast(parseColourRGB(c), g)));
return worst(TOKENS.paper) > worst(TOKENS.ink) ? TOKENS.paper : TOKENS.ink;
};
// Recompute every label's ground-aware colour from its CURRENT on-screen
// anchor. Must be re-run any time the view is refitted — the container's
// box (and therefore the map's projection) settles asynchronously after
// the map is first created (see fitMapToBounds below), so a one-off
// colour computed before that settle goes stale: a label could be
// assigned its ground colour against a transitional projection, then
// Leaflet silently repositions the marker under it once the real layout
// resolves, leaving the label coloured for ground it no longer sits on.
const updateLabelColours = () => {
const labelLookup = map._dashLabelLookup;
if (!labelLookup) return;
const groundColourLookup = new Map();
const containerRect = map.getContainer().getBoundingClientRect();
labelLookup.forEach((marker, name) => {
const span = marker.getElement()?.querySelector('span');
if (!span) return;
// The span's true rendered centre, in the SAME container-pixel space
// that latLngToContainerPoint (used above to project ring vertices)
// returns — already includes the nudge transform, no need to add it.
const spanRect = span.getBoundingClientRect();
const cx = spanRect.left + spanRect.width / 2 - containerRect.left;
const cy = spanRect.top + spanRect.height / 2 - containerRect.top;
// The text's own extent (the span box is wider than its text).
const range = document.createRange();
range.selectNodeContents(span);
const tb = range.getBoundingClientRect();
const left = tb.left - containerRect.left + 2, right = tb.right - containerRect.left - 2;
const textColour = labelColourOver([[left, cy], [cx, cy], [right, cy]]);
groundColourLookup.set(name, textColour);
span.style.color = textColour;
});
// Reused by the cross-highlight delta line (mapCrossHighlight) so it inherits the same
// ground-aware colour as the label it sits under, rather than the
// region's own (possibly illegible) bin colour.
map._dashLabelGroundColour = groundColourLookup;
};
if (isCountryView) {
const labelLookup = new Map();
currentGeoData.features.forEach(feature => {
const name = feature.properties.region_name;
const anchor = LABEL_ANCHOR[name];
const center = anchor ? L.latLng(anchor[0], anchor[1]) : L.geoJSON(feature).getBounds().getCenter();
const labelIcon = L.divIcon({
className: 'district-label',
html: `<span style="color: ${TOKENS.ink};">${name}</span>`,
iconSize: [100, 20],
iconAnchor: [50, 10]
});
const marker = L.marker(center, { icon: labelIcon, interactive: false, keyboard: false }).addTo(map);
labelLookup.set(name, marker);
// Nairobi's polygon is a speck at country scale, so it gets a dot at
// its centroid (not the label anchor), filled with its class colour and
// carrying the polygon's tooltip: a larger dot on a paper halo, plus a
// leader line to its label so the two read as one.
// The marker must stay ON TOP of its neighbours and be easy to hover.
// It is an HTML marker (divIcon) in Leaflet's marker
// pane, above every area polygon, so a hovered area brought to the
// front can never cover it. Two earlier tries are kept below, disabled:
// a separate SVG pane (its renderer stayed 0×0, hiding the dot) and
// re-lifting the dot after each bringToFront (moving SVG nodes mid-hover
// made Safari drop mouseout, so area tooltips stuck open).
if (name === "Nairobi") {
const [lng, lat] = d3.geoCentroid(feature);
// Stops just short of the label (anchored south of the dot) so the
// line does not run through the text.
L.polyline([[lat, lng], [center.lat + 0.22, center.lng]], {
color: TOKENS.ink, weight: 1, opacity: 0.8, interactive: false, className: 'nairobi-leader'
}).addTo(map);
// 30 px icon = the hover target; the 18 px dot sits at its centre on
// a 3 px paper halo (styles: .nairobi-marker).
const dot = L.marker([lat, lng], {
icon: L.divIcon({
className: 'nairobi-marker-icon',
html: `<span class="nairobi-marker" style="background:${binFor(feature.properties[metricColumn]).fill}"></span>`,
iconSize: [30, 30], iconAnchor: [15, 15]
}),
keyboard: false, zIndexOffset: 1000
}).addTo(map);
dot.bindTooltip(polygonTooltip(feature.properties), { sticky: true, className: 'dash-tooltip' });
dot.on('mouseover', () => setHovered(name));
dot.on('mouseout', () => clearHovered());
}
// // The marker must stay ON TOP of its neighbours.
// // Hovering an area calls bringToFront() on it (here and in
// // mapCrossHighlight), which drew Central/Eastern over the dot, so both
// // call map._dashRaiseNairobi() afterwards to lift the marker back. (A
// // separate Leaflet pane was tried: its SVG renderer was sized before
// // the map settled and stayed 0×0, hiding the dot.) A wider invisible
// // ring (r 15) carries the hover and tooltip so the dot is easy to hit.
// if (name === "Nairobi") {
// const pane = 'overlayPane';
// const [lng, lat] = d3.geoCentroid(feature);
// // Stops just short of the label (anchored south of the dot) so the
// // line does not run through the text.
// const leader = L.polyline([[lat, lng], [center.lat + 0.22, center.lng]], {
// pane, color: TOKENS.ink, weight: 1, opacity: 0.8, interactive: false, className: 'nairobi-leader'
// }).addTo(map);
// const halo = L.circleMarker([lat, lng], {
// pane, radius: 12, stroke: false, fillColor: TOKENS.paper, fillOpacity: 0.95, interactive: false
// }).addTo(map);
// const dot = L.circleMarker([lat, lng], {
// // radius: 7, color: TOKENS.ink, weight: 1.5,
// pane, radius: 9, color: TOKENS.ink, weight: 2,
// fillColor: binFor(feature.properties[metricColumn]).fill, fillOpacity: 1,
// interactive: false, className: 'nairobi-marker'
// }).addTo(map);
// const hit = L.circleMarker([lat, lng], {
// pane, radius: 15, stroke: false, fillColor: TOKENS.paper, fillOpacity: 0,
// interactive: true, className: 'nairobi-hit'
// }).addTo(map);
// hit.bindTooltip(polygonTooltip(feature.properties), { sticky: true, className: 'dash-tooltip' });
// hit.on('mouseover', () => setHovered(name));
// hit.on('mouseout', () => clearHovered());
// map._dashRaiseNairobi = () => [leader, halo, dot, hit].forEach(l => l.bringToFront());
// }
});
map._dashLabelLookup = labelLookup;
updateLabelColours();
}
const fitMapToBounds = () => {
if (!initialBounds.isValid()) return;
map.invalidateSize({ animate: false, pan: false });
map.fitBounds(initialBounds, {
padding: isMobileViewport ? [20, 20] : [5, 5],
animate: false
});
// The fit just above is the point at which the container's real,
// settled box (and hence the map's projection) becomes authoritative —
// re-derive ground colours against it rather than trusting whatever was
// computed at label-creation time.
if (isCountryView) updateLabelColours();
};
map.whenReady(fitMapToBounds);
// Re-fit whenever the container's box changes (initial flex settle, window resize,
// panel reflow). ResizeObserver replaces the old setTimeout cascade, which raced
// against late layout passes and never recovered if the final retry fired early.
const resizeObserver = new ResizeObserver(() => fitMapToBounds());
resizeObserver.observe(container);
invalidation.then(() => resizeObserver.disconnect());
// On mobile the aspect-ratio: 1/1 container height resolves after a CSS
// layout pass that may occur after whenReady fires. One deferred refit
// ensures the full country is visible on initial open.
if (isMobileViewport) setTimeout(fitMapToBounds, 400);
return map;
}
// Numeric ranges and swatches use exactly the same bins as the map and bars.
mapLegend = {
const el = document.getElementById('map-legend-strip');
if (!el) return null;
if (!binEdges && !stepEdges) { el.innerHTML = ''; return null; }
const edges = binEdges;
// A range clipped at the standard can be narrower than the metric's
// digits (0.786–0.79 would print "0.79–0.79"): add decimals until its two
// ends differ.
const rangeDigits = (a, b) => {
let d = binDigits;
while (d < binDigits + 3 && a !== b && a.toFixed(d) === b.toFixed(d)) d++;
return d;
};
// Distance-from-1 ranges read as "±0.05–0.12 from 1.0".
const rangeLabel = (a, b) => {
const d = rangeDigits(a, b);
// return isDistanceFrom1
// ? `±${a.toFixed(d)}–${b.toFixed(d)} from 1.0`
// : `${a.toFixed(d)}–${b.toFixed(d)}${selectedMetric.suffix}`;
return isDistanceColour
? `±${a.toFixed(d)}–${b.toFixed(d)}${selectedMetric.suffix} from ${colourCentreWord}`
: `${a.toFixed(d)}–${b.toFixed(d)}${selectedMetric.suffix}`;
};
// The red class first, named after the standard; then the hue classes
// on the better side of it, the nearest one clipped to start at the
// standard. Without a standard (or a direction) all five classes show.
const directed = colourHigherIsBetter != null;
const std = directed && standardValue != null ? colourValue(standardValue) : null;
const basisWord = selectedMetric.standard?.basis === "threshold" ? "the Usawa standard" : "national average";
const noData = currentStats.some(d => d[metricColumn] == null || isNaN(d[metricColumn]));
const swatch = ({ fill, label, dashed }) => `
<div class="map-legend-strip__item">
<span class="map-legend-strip__swatch" style="background:${fill}${dashed ? `; border: 1px dashed ${TOKENS.inkMute}` : `; opacity: ${TOKENS.performanceOpacity}`}" aria-hidden="true"></span>
<span class="map-legend-strip__label">${label}</span>
</div>`;
// Six-step legend: the standard is named, then the six steps from the worst
// to the best ("over 49.3" … "under 34.0" for a lower-is-better ratio).
if (stepEdges) {
const [a, b, c, d] = stepEdges, s = standardValue;
// Edges print at the precision compute_bins.R stored them with (it adds
// decimals where the metric's digits would print two edges alike).
const dp = Math.min(selectedMetric.digits + 3, Math.max(selectedMetric.digits,
...stepEdges.map(e => (String(e).split('.')[1] || '').length)));
const one = (v) => v.toFixed(dp) + selectedMetric.suffix;
const rangeLabel = (lo, hi) => `${lo.toFixed(dp)}–${hi.toFixed(dp)}${selectedMetric.suffix}`;
const items = colourHigherIsBetter
? [{ fill: TOKENS.stepRed[2], label: `under ${one(a)}` }, { fill: TOKENS.stepRed[1], label: rangeLabel(a, b) },
{ fill: TOKENS.stepRed[0], label: rangeLabel(b, s) }, { fill: TOKENS.stepBetter[0], label: rangeLabel(s, c) },
{ fill: TOKENS.stepBetter[1], label: rangeLabel(c, d) }, { fill: TOKENS.stepBetter[2], label: `${one(d)} and over` }]
: [{ fill: TOKENS.stepRed[2], label: `${one(d)} and over` }, { fill: TOKENS.stepRed[1], label: rangeLabel(c, d) },
{ fill: TOKENS.stepRed[0], label: rangeLabel(s, c) }, { fill: TOKENS.stepBetter[0], label: rangeLabel(b, s) },
{ fill: TOKENS.stepBetter[1], label: rangeLabel(a, b) }, { fill: TOKENS.stepBetter[2], label: `under ${one(a)}` }];
// The worse group, named after the standard, then the better group;
// "No data" after both.
// <span class="map-legend-strip__note">Red: … (x); purple then green: better</span>
el.innerHTML = `
<div class="map-legend-strip__items">
<div class="map-legend-strip__group map-legend-strip__group--worse">
<span class="map-legend-strip__group-title">${colourHigherIsBetter ? "Below" : "Above"} ${basisWord} (${formatMetric(standardValue)})</span>
${items.slice(0, 3).map(swatch).join('')}
</div>
<div class="map-legend-strip__group">
<span class="map-legend-strip__group-title">Better</span>
${items.slice(3).map(swatch).join('')}
</div>
${noData ? swatch({ fill: TOKENS.paper2, label: "No data", dashed: true }) : ''}
</div>`;
return null;
}
const classes = Array.from({ length: 5 }, (_, i) => ({
lo: edges[i], hi: edges[i + 1],
// fill: TOKENS.seq[colourHigherIsBetter === false ? 4 - i : i]
// fill: directed ? TOKENS.seq[colourHigherIsBetter === false ? 4 - i : i] : TOKENS.neutral[i]
fill: TOKENS.seq[colourHigherIsBetter === false ? 4 - i : i]
}));
const items = [];
if (std != null) {
items.push({ fill: TOKENS.flag,
label: `${colourHigherIsBetter ? "Below" : "Above"} ${basisWord} (${formatMetric(standardValue)})` });
for (const c of classes) {
if (colourHigherIsBetter && c.hi > std) items.push({ fill: c.fill, label: rangeLabel(Math.max(c.lo, std), c.hi) });
if (!colourHigherIsBetter && c.lo < std) items.push({ fill: c.fill, label: rangeLabel(c.lo, Math.min(c.hi, std)) });
}
} else {
for (const c of classes) items.push({ fill: c.fill, label: rangeLabel(c.lo, c.hi) });
}
// Its own entry whenever an area in view has no value.
if (currentStats.some(d => d[metricColumn] == null || isNaN(d[metricColumn]))) {
items.push({ fill: TOKENS.paper2, label: "No data", dashed: true });
}
// const note = isDistanceFrom1 ? "Darker is closer to 1.0" : "Darker is better";
// const note = isDistanceFrom1 ? "Darker is closer to 1.0" : directed ? "Darker is better" : "Darker is higher";
const note = isDistanceColour ? `Darker is closer to ${colourCentreWord}` : directed ? "Darker is better" : "Darker is higher";
el.innerHTML = `
<div class="map-legend-strip__items">
<span class="map-legend-strip__note">${note}</span>
${items.map(({ fill, label, dashed }) => `
<div class="map-legend-strip__item">
<span class="map-legend-strip__swatch" style="background:${fill}${dashed ? `; border: 1px dashed ${TOKENS.inkMute}` : `; opacity: ${TOKENS.performanceOpacity}`}" aria-hidden="true"></span>
<span class="map-legend-strip__label">${label}</span>
</div>`).join('')}
</div>`;
return null;
}
// Hovering (or keyboard-focusing) a region dims every other region to
// opacity .3 in both views, promotes the hovered row, and reveals its
// distance from the average.
mapCrossHighlight = {
if (!leafletMap) return null;
const map = leafletMap;
const layerLookup = map._dashLayerLookup;
const labelLookup = map._dashLabelLookup;
const mapContainer = document.getElementById('leaflet-map-container');
if (!layerLookup) return null;
if (!hoveredItem) {
layerLookup.forEach((layer) => map._dashGeoLayer.resetStyle(layer));
if (mapContainer) mapContainer.classList.remove('map-dimmed');
if (labelLookup) {
labelLookup.forEach((marker) => {
const span = marker.getElement()?.querySelector('span');
if (!span) return;
span.style.opacity = '';
const delta = span.querySelector('.district-label__delta');
if (delta) delta.remove();
});
}
return null;
}
layerLookup.forEach((layer, id) => {
if (id === hoveredItem) {
layer.setStyle({ fillOpacity: TOKENS.performanceHoverOpacity, weight: TOKENS.mapHoverWidth, color: TOKENS.ink });
layer.bringToFront();
} else {
layer.setStyle({ fillOpacity: 0.3 });
}
});
// map._dashRaiseNairobi?.(); (retired: see the Nairobi marker)
if (mapContainer) mapContainer.classList.add('map-dimmed');
if (labelLookup) {
labelLookup.forEach((marker, name) => {
const span = marker.getElement()?.querySelector('span');
if (!span) return;
const existingDelta = span.querySelector('.district-label__delta');
if (existingDelta) existingDelta.remove();
if (name !== hoveredItem) { span.style.opacity = ''; return; }
span.style.opacity = '1';
const idField = isRegionView ? 'county_name' : 'region_name';
const value = currentStats.find(d => d[idField] === name)?.[metricColumn];
if (value == null || ruleAverage == null) return;
const d = value - ruleAverage;
const label = `${d >= 0 ? '+' : '−'}${Math.abs(d).toFixed(selectedMetric.digits)} ${d >= 0 ? 'above' : 'below'} avg`;
const deltaSpan = document.createElement('span');
deltaSpan.className = 'district-label__delta';
deltaSpan.textContent = label;
// Ground-aware colour: inherit the SAME colour as the
// label it sits under, not the region's own bin — the delta sits on
// whatever fill the nudged label sits on, which may be a neighbour's.
deltaSpan.style.color = map._dashLabelGroundColour?.get(name) ?? binFor(value).text;
span.appendChild(deltaSpan);
});
}
return null;
}countryBreadcrumb = {
const span = document.createElement('span');
span.className = 'breadcrumb-link';
span.textContent = 'Kenya';
span.addEventListener('click', () => { mutable selectedRegion = null; });
return span;
}
backButton = {
const button = document.createElement('button');
button.className = 'back-button';
button.textContent = '← Back to Kenya';
button.addEventListener('click', () => { mutable selectedRegion = null; });
return button;
}reportsManifest = {
try {
const m = await FileAttachment("reports/reports_manifest.json").json();
return (m && Array.isArray(m.reports)) ? m : { reports: [] };
} catch (e) {
return { reports: [] };
}
}
// Matches the localStorage values written by the dataset toggle ("school" /
// "household"), not the display labels — the manifest keys on the former.
reportDatasetKey = dataset
// County selection for the report link ONLY. Deliberately separate from
// `selectedRegion`: picking a county changes which PDF the link points at and
// nothing else — not the map, not the ranked bar chart, not the stats column.
// Without this the 94 county reports would be unreachable from any view state,
// and the reachability check in reporting/scripts/check_links.R would fail.
mutable reportCounty = null
// Drilling to another region, or switching dataset, invalidates the choice.
resetReportCounty = {
selectedRegion;
dataset;
// A county search asks for its county once its region is on screen.
const req = countySearchRequest;
if (req && !req.used && req.region === selectedRegion) {
req.used = true;
mutable reportCounty = req.name;
} else {
mutable reportCounty = null;
}
return null;
}
currentReport = {
// Reports exist only for the latest survey round: an
// earlier round gets no download link rather than a PDF of another year.
if (surveyYear !== releases.latest) return null;
const level = isRegionView ? (reportCounty ? "county" : "region") : "country";
const entity = level === "country" ? summary.country
: level === "region" ? selectedRegion
: reportCounty;
if (!entity) return null;
return reportsManifest.reports.find(r =>
r.dataset === reportDatasetKey && r.level === level && r.entity_id === entity) || null;
}
// Built as its own cell and interpolated into the footer template, following the
// `backButton` / `countryBreadcrumb` idiom above: the imperative part (the change
// listener) lives in the cell, the footer stays a declarative template.
//
// The absent case returns an EMPTY html`` fragment, never null. Observable's
// `html` tag interpolates a Node as a node but everything else through string
// concatenation, so `null` would print the four characters "null" into the
// footer.
//
// Only in a drilled-in region view, where counties exist to pick.
reportScopePicker = {
if (!isRegionView || filteredCountyStats.length === 0) return html``;
const names = filteredCountyStats.map(d => d.county_name).sort((a, b) => a.localeCompare(b));
const select = html`<select class="report-scope-select" aria-label="Report scope">
<option value="">Whole region</option>
${names.map(n => html`<option value="${n}" ${n === reportCounty ? "selected" : ""}>${n}</option>`)}
</select>`;
select.addEventListener('change', () => {
mutable reportCounty = select.value || null;
});
return html`<span class="separator">·</span>${select}`;
}
// Renders NOTHING when no record resolves — never a dead link, never a disabled
// control the user can click at. Absence is the failure mode. Empty html``
// rather than null, for the reason given on reportScopePicker.
reportDownloadLink = {
if (!currentReport) return html``;
const mb = (currentReport.bytes / 1e6).toFixed(1);
return html`<span class="separator">·</span><a class="doc-link" href="${currentReport.path}" target="_blank" rel="noopener" download>Download report (PDF, ${mb} MB)</a>`;
}// Why Explore's household count differs from Compare's is explained only in
// docs-explore.qmd (Sample sizes), not in a footer link. The removed link was
// exploreCountNote = activeCountConfig.length ? (datasetRegistry.find(d => d.key === dataset)?.explore_count_note ?? null) : null
// interpolated after the count span as
// ${exploreCountNote ? html`<a class="doc-link" href="${exploreCountNote.href}">${exploreCountNote.text}</a> …` : html``}
html`<footer class="dashboard-footer">
<div class="footer-content">
<span>Usawa Agenda FLANA ${surveyYear} — ${summary.dataset}</span>
<span class="separator">·</span>
${activeCountConfig.length ? html`<span>Aggregated results, ${(summary.totals?.[activeCountConfig[0].field] ?? 0).toLocaleString("en-GB")} ${lowercaseOutsideBrackets(activeCountConfig[0].label)}</span>
<span class="separator">·</span>` : html``}
<span>Updated ${summary.last_updated || "date not recorded"}</span>
<span class="separator">·</span>
<a class="doc-link" href="docs-explore.html">Technical notes</a>
${reportScopePicker}
${reportDownloadLink}
</div>
</footer>`