Nepal Population Explorer
// Sized from the panel container, never from `width` — see rule 2. Measuring
// the container rather than the window is what keeps the two panels side by
// side instead of wrapping: the content column is narrower than the viewport.
// Re-measured whenever the window changes, so rotating a phone or resizing a
// window redraws the charts at the new size instead of leaving them stranded.
// Plot has no responsive mode: a chart is drawn at whatever width it was given.
viewportWidth = Generators.observe(notify => {
const measure = () => notify(document.documentElement.clientWidth);
measure();
window.addEventListener("resize", measure);
window.addEventListener("orientationchange", measure);
return () => {
window.removeEventListener("resize", measure);
window.removeEventListener("orientationchange", measure);
};
})panelWidth = {
const el = document.querySelector(".panels");
const avail = (el && el.clientWidth) || viewportWidth;
// Never wider than what is actually available: a fixed minimum would push the
// chart off the side of a phone, which is the one thing a reader cannot undo.
return isNarrow
? Math.max(240, avail - 8)
: Math.max(280, Math.min(620, Math.floor((avail - 48) / 2)));
}// Put inside every chart, so it survives a screenshot. Nobody downloads from
// this page, but they do snip it -- and a pyramid circulating with no source is
// how a number gets attributed to the wrong projection or the wrong vintage.
chartSource = [meta.title, meta.citation].filter(d => d).join(" · ")// Devanagari digits, not just Devanagari labels -- Western digits only where
// HTML itself forces them: the Year and Cohort born number boxes are native
// <input type="number">, and a browser always renders those in Western
// digits regardless of what this page does. Everything else routes through
// this map.
devanagariDigits = ({"0":"०","1":"१","2":"२","3":"३","4":"४","5":"५","6":"६","7":"७","8":"८","9":"९"})// Organisational marks, if the country named any.
//
// Built and returned by THIS cell rather than defined in a hidden one and
// referenced here: a cell whose body is a bare reference to a variable holding a
// DOM node renders an *inspector* -- a collapsed "HTMLDivElement {}" -- and
// leaves the node itself stranded at zero size inside the hidden cell.
//
// Built as DOM rather than an html`` template because an <img> inside a nested
// ${...} would be a nested template literal, which breaks Quarto's cell parser
// and takes the whole page down. See rule 5 at the top of this file.
{
const marks = meta.logos ?? [];
const bar = document.createElement("div");
bar.className = "brandbar";
if (!marks.length) bar.style.display = "none";
for (const m of marks) {
const img = document.createElement("img");
img.src = m.src;
img.alt = m.alt ?? "";
if (m.url) {
const a = document.createElement("a");
a.href = m.url;
a.target = "_blank";
a.rel = "noopener";
a.appendChild(img);
bar.appendChild(a);
} else {
bar.appendChild(img);
}
}
return bar;
}// The page's own definition of its starting state, named once so the first
// render and Reset can never drift apart -- see the note by resetSeed below.
// None of these may be hard-coded to one country's value; test-neutrality.R
// enforces that every value here is read from meta/regions/the registry.
defaults = ({
areaCode: String(meta.national_code ?? regions[0].geo_code),
bandKey: "all",
// The Sex control only has three legal values -- both/f/m. Nepal's own
// registry is the reason this isn't `defaultIndicatorInfo.default_sex ||
// "both"`: several of its indicators carry default_sex "all" (no sex
// breakdown exists at all, not "both sexes together"), which isn't one of
// the three and would otherwise be handed straight to Inputs.select as an
// initial value with no matching option. Observable falls back to the
// first option when that happens -- "Both" -- so it LOOKED right on
// Zedland, for the wrong reason: nothing there has a non-"both" registry
// value to expose the gap.
sexSel: (defaultIndicatorInfo &&
(defaultIndicatorInfo.default_sex === "f" || defaultIndicatorInfo.default_sex === "m"))
? defaultIndicatorInfo.default_sex
: "both",
yearSel: meta.years[0],
followCohort: false,
cohortYear: meta.years[0],
indicatorKey: hasIndicators ? indicatorList[0].indicator : ""
})// Bumped by Reset. Referencing it (without using the value) inside each
// viewof cell below forces that cell to rerun and rebuild its Inputs element
// from `defaults` -- which is what actually moves the reactive graph. Setting
// an <input>/<select>'s .value from JS and dispatching an input event does
// NOT do this in Quarto's OJS runtime: the DOM updates, nothing downstream
// recomputes, and there is no error anywhere.
mutable resetSeed = 0// i18n mechanism (#3). Shared UI chrome and band labels only -- title,
// scenario_label, about.md and a country's own band labels stay in that
// country's config.yml, keyed the same way, and are not read here. This file
// ships with popsymapp itself (staged next to styles.css, not written by
// popsym_prep_web_data()), so it is not the fixture and not country data.
i18nTable = FileAttachment("i18n.json").json()// meta.languages is a country's declared offer. jsonlite auto-unboxes a
// single-element vector to a bare string (most countries today: "en"), so
// this normalises both shapes rather than assuming an array.
langCodes = {
const v = meta.languages;
if (v == null) return ["en"];
return Array.isArray(v) ? v : [v];
}// Missing-string behaviour, decided here pending sign-off (see PR): fall back
// to English VISIBLY -- a "(en)" suffix -- rather than silently or blocking
// the release. Silent fallback hides an incomplete translation until a reader
// who cannot read English finds the gap; blocking means one missing string
// holds the whole language back. English itself is never marked, so the
// English UI is unaffected regardless of this decision.
t = (key) => {
const table = i18nTable[lang] || {};
if (table[key] != null) return {text: table[key], fallback: false};
const en = (i18nTable.en || {})[key];
return {text: en ?? key, fallback: lang !== "en"};
}// A band label is shared UI chrome only for the nine keys popsym_web_bands()
// defines. A country's own appended band (a national education stage) has no
// entry under this key in i18n.json by construction, so it is left exactly as
// the country wrote it in config.yml -- never partially translated.
bandLabel = (b) => {
const key = "band." + b.key;
if ((i18nTable.en || {})[key] == null) return b.label;
return tLabel(key);
}// One object per rule 1. Recomputes whenever `lang` changes; applyLabels
// below is what pushes the new text into the DOM.
labels = ({
area: tLabel("control.area"),
ageGroup: tLabel("control.age_group"),
sex: tLabel("control.sex"),
both: tLabel("sex.both"),
female: tLabel("sex.female"),
male: tLabel("sex.male"),
year: tLabel("control.year"),
followCohort: tLabel("control.follow_cohort"),
cohortBorn: tLabel("control.cohort_born"),
indicator: tLabel("control.indicator"),
language: tLabel("control.language"),
reset: tLabel("control.reset")
})// Deliberately NOT dependent on `lang`/`labels`: a `viewof` cell rebuilds its
// element from scratch on every dependency change, and rebuilding on a
// language switch would reset this control to `defaults.areaCode` the same
// way it does on resetSeed -- silently discarding whatever the reader had
// selected. applyLabels (below the controls) retextes it instead, in place,
// which touches no `.value` and disturbs nothing downstream.
viewof areaCode = {
resetSeed;
const sel = Inputs.select(
new Map(regions.map(r => [r.name, String(r.geo_code)])),
{label: "Area", value: defaults.areaCode}
);
sel.classList.add("i18n-area");
return sel;
}// Writes through the viewof binding by bumping resetSeed, which every control
// above depends on -- see the note there. This is the one write; every read
// stays in the cells that already own each control. Placed right after Sex,
// not at the end of the control bar: it reads immediately, on the same row,
// beside the controls it resets, rather than trailing after Indicator/
// Language where a reader has to look for it.
viewof resetBtn = {
const btn = Inputs.button("Reset", {
value: null,
reduce: () => { mutable resetSeed = mutable resetSeed + 1; }
});
btn.classList.add("i18n-reset");
return btn;
}// The slider must land only on years the data actually has -- every filter in
// this page matches by d.year === yearSel, so a step the data doesn't have
// would silently show "--" at that position. Nepal and India project every
// five years, China annually; meta.year_values carries the real spacing so
// neither is hard-coded here. When the spacing is uneven, the smallest gap is
// used, so the slider can still reach every year present rather than skipping
// some of them -- landing between two of them still resolves gracefully (the
// stat tiles show "--") rather than breaking.
yearStep = {
const ys = meta.year_values;
if (!ys || ys.length < 2) return 1;
return d3.min(ys.slice(1).map((y, i) => y - ys[i]));
}// A cohort is everyone born in the same year. Because a cohort ages a year for
// every year that passes, following one means walking a diagonal through the
// data -- which is what the trend panel does when this is on.
viewof followCohort = {
resetSeed;
const sel = Inputs.toggle({label: "Follow a cohort", value: defaults.followCohort});
sel.classList.add("i18n-follow");
return sel;
}// Not the same bug as Year: a birth year is a query against single-year-of-age
// pyramid data, which is always annual, regardless of whether the projection
// itself is five-yearly or annual. Step 1 here is correct for every country,
// not a hard-coded assumption -- unlike Year, this doesn't have to match the
// spacing of the years the projection actually reports.
viewof cohortYear = {
resetSeed;
const sel = Inputs.range(
[meta.years[0] - meta.ages[1], meta.years[1]],
{label: "Cohort born", step: 1, value: defaults.cohortYear}
);
sel.classList.add("i18n-cohort");
return sel;
}// Hidden when the contract ships no indicators, for the same reason as the
// dimension toggle: a viewof has to exist for anything downstream to read it.
// Indicator NAMES come from the contract's registry, not from i18n.json --
// out of scope here, same as the About text and a country's scenario label.
viewof indicatorKey = {
resetSeed;
const opts = new Map(indicatorList.map(d => [d.label ?? d.indicator, d.indicator]));
const sel = hasIndicators
? Inputs.select(opts, {label: "Indicator", value: defaults.indicatorKey})
: Inputs.select(new Map([["", ""]]), {label: ""});
sel.classList.add("i18n-indicator");
if (!hasIndicators) sel.style.display = "none";
return sel;
}// Shown only when the contract carries an extra dimension. The label comes from
// meta.json, so this is not an "education toggle" — it is whatever dimension
// that country declared. Hidden, rather than omitted, when there is none: a
// `viewof` has to exist for anything downstream to reference it.
viewof showDim = {
const t = Inputs.toggle({label: dimInfo ? dimInfo.label : "", value: false});
if (!dimInfo) t.style.display = "none";
return t;
}// The switcher itself. Placed in the control bar rather than by the title --
// an open question in #3 -- because it needs no new layout: `.controls`
// already wraps and sticks. Easy to relocate later; nothing else depends on
// where this cell sits in the document.
//
// Languages are keyed by ISO 639 code -- en, ne -- never by English language
// name. test-neutrality.R fails on the literal word "Nepali"; a language's
// own display name (नेपाली) lives in i18n.json next to the strings it names,
// not in this template. Deliberately not dependent on resetSeed: a language
// preference is not one of the seven controls issue #2 names, and Reset
// should not silently switch a reader back to English mid-session.
viewof lang = {
let saved = null;
try { saved = localStorage.getItem("popsym_lang"); } catch (e) { /* private mode, etc. */ }
const initial = (saved && langCodes.includes(saved)) ? saved : langCodes[0];
const opts = new Map(langCodes.map(c => [(i18nTable[c] && i18nTable[c].language_name) || c, c]));
const sel = Inputs.select(opts, {label: "Language", value: initial});
sel.classList.add("i18n-lang");
if (langCodes.length < 2) sel.style.display = "none";
sel.addEventListener("input", () => {
try { localStorage.setItem("popsym_lang", sel.value); } catch (e) { /* ignore */ }
});
return sel;
}// The one place text is pushed into already-created controls. Deliberately
// separate from the cells that create them: those depend only on resetSeed
// (issue #2) so that switching language never rebuilds a control and resets
// its value the way Reset does. This cell depends on `labels`/`lang` instead
// and only ever sets .textContent -- never .value -- so it cannot touch the
// reactive graph Generators.input watches. Runs once at load (correcting the
// English text every control is created with, before a reader perceives it),
// again on every language change, AND again on Reset -- Reset rebuilds each
// control from scratch (issue #2), in literal English, so without this
// dependency a reader mid-session in नेपाली would see every label snap back
// to unmarked English the moment they pressed Reset, even though `lang`
// itself never changed.
//
// Run as a microtask, not inline: this cell and the control-rebuilding cells
// are siblings, both triggered by the same resetSeed write, and Observable
// gives no ordering guarantee between siblings -- only between a cell and
// what it references. Caught empirically: inline, this cell sometimes ran
// BEFORE a control had rebuilt itself, retexted the element about to be
// discarded, and the freshly-rebuilt one was left in unmarked English.
// Promise.resolve().then() queues the actual DOM writes after every cell in
// this reactive flush has finished its synchronous body, including the
// rebuilds resetSeed triggered.
applyLabels = {
resetSeed;
const snapshot = labels;
const currentLang = lang;
// A macrotask, not a microtask: Observable's own runtime appears to use
// microtask-scheduled reruns internally too, so a Promise.resolve().then()
// here raced it -- sometimes this cell's callback ran before a sibling
// control had rebuilt itself, retexted the element about to be discarded,
// and the fresh one was left in unmarked English. Reproduced with a real
// click and a 1s wait, so it was a real bug, not a screenshot timing
// artifact. setTimeout(0) queues after every microtask has drained,
// including whatever the runtime used to process this same resetSeed
// write, which a microtask does not guarantee.
setTimeout(() => {
const setLabel = (cls, text) => {
const form = document.querySelector("." + cls);
if (!form) return;
const lab = form.querySelector("label");
if (lab) lab.textContent = text;
};
// Inputs.select gives each <option> an INDEX as its value ("0", "1", ...),
// not the Map's real value -- the option's own text is the only reliable
// handle, so options are retexted by position, in the same order the Map
// that built them was constructed in.
const setOptions = (cls, textsInOrder) => {
const form = document.querySelector("." + cls);
const select = form && form.querySelector("select");
if (!select) return;
const opts = select.options;
for (let i = 0; i < opts.length && i < textsInOrder.length; i++) {
opts[i].textContent = textsInOrder[i];
}
};
setLabel("i18n-area", snapshot.area);
setLabel("i18n-band", snapshot.ageGroup);
setOptions("i18n-band", meta.bands.map(b => bandLabel(b)));
setLabel("i18n-sex", snapshot.sex);
setOptions("i18n-sex", [snapshot.both, snapshot.female, snapshot.male]);
setLabel("i18n-year", snapshot.year);
setLabel("i18n-follow", snapshot.followCohort);
setLabel("i18n-cohort", snapshot.cohortBorn);
setLabel("i18n-indicator", snapshot.indicator);
setLabel("i18n-lang", snapshot.language);
const resetForm = document.querySelector(".i18n-reset");
const resetBtnEl = resetForm && resetForm.querySelector("button");
if (resetBtnEl) resetBtnEl.textContent = snapshot.reset;
document.documentElement.lang = currentLang;
}, 0);
return null;
}The selected group
// Everything for the selected indicator, this area. Values come from popsymr --
// this page never recomputes a formula that exists there. The dependency ratio
// used to be recomputed here from the age bands, which made it the third
// implementation of one definition; popsymr's is now the only one.
indicatorSeries = indicatorRows
.filter(d => d.indicator === indicatorKey && String(d.geo_code) === areaCode)
// A dependency ratio split by sex is computable but answers a different
// question from the one the indicator is asked, so popsymr marks it
// default_sex = "both" and only that series is drawn. Indicators where the
// differential IS the reading -- population, the shares, growth -- are marked
// "all" and keep all three.
.filter(d => (indicatorInfo && indicatorInfo.default_sex === "both")
? d.sex === "both" : true)
.map(d => ({...d, label: d.sex === "f" ? "Female" : d.sex === "m" ? "Male" : "Both"}))
.sort((a, b) => a.year - b.year)// A flow is keyed to a period, not an instant: popsymr carries period_years so
// the app never has to infer the interval from the spacing of years. Labelling a
// flow with an instant is off by a whole period.
indicatorIsFlow = indicatorSeries.some(d => d.period_years != null && !isNaN(d.period_years))// The sex the cards show. An indicator typed both_only -- a sex ratio -- has no
// per-sex value, so asking for one would silently show nothing.
// `default_sex` answers "what should be shown"; `by_sex` answers "what can be
// computed". They are different questions and popsymr keeps them in different
// columns, so this reads default_sex alone -- for a both_only indicator it is
// always "both", which means there is no special case here.
indicatorSex = (indicatorInfo && indicatorInfo.default_sex === "both") ? "both"
: (indicatorInfo && indicatorInfo.by_sex === "both_only") ? "both"
: (sexSel === "both" ? "both" : sexSel)indicatorNow = {
const hit = indicatorSeries.filter(d => d.sex === indicatorSex && d.year === yearSel);
if (!hit.length) return null;
const dp = indicatorInfo && indicatorInfo.decimals != null ? indicatorInfo.decimals : 1;
return {value: hit[0].value, text: localizeNum(d3.format("," + "." + dp + "f")(hit[0].value))};
}// Kept as separate series rather than summed: "both" should show the two sexes,
// not hide them. The stat cards above already give the total.
trendSeries = Array.from(
d3.rollup(
bandsData.filter(d => String(d.geo_code) === areaCode && d.band === bandKey &&
(sexSel === "both" ? true : d.sex === sexSel)),
v => d3.sum(v, d => d.population),
d => d.year, d => d.sex
),
([year, bySex]) => Array.from(bySex, ([sex, population]) => ({
year, sex, population, label: sex === "f" ? "Female" : "Male"
}))
).flat().sort((a, b) => a.year - b.year)// Grouped into one object because a cell defines exactly one name — rule 1.
stats = ({
areaTotal: d3.sum(allRows.filter(d => d.year === yearSel), d => d.population),
selected: d3.sum(bandRows.filter(d => d.year === yearSel), d => d.population),
baseline: d3.sum(bandRows.filter(d => d.year === meta.years[0]), d => d.population)
})html`<div class="stat-grid">
<div class="stat"><div class="stat-label">Population</div>
<div class="stat-value">${fmt(stats.selected)}</div>
<div class="stat-note">${fmtInt(yearSel)}</div></div>
<div class="stat"><div class="stat-label">Share of area</div>
<div class="stat-value">${stats.areaTotal ? pctShare(stats.selected / stats.areaTotal) : "—"}</div>
<div class="stat-note">of all ages</div></div>
<div class="stat"><div class="stat-label">Change since ${fmtInt(meta.years[0])}</div>
<div class="stat-value">${stats.baseline ? pctChange(stats.selected / stats.baseline - 1) : "—"}</div>
<div class="stat-note">selected group</div></div>
<div class="stat"><div class="stat-label">${indicatorInfo ? indicatorInfo.label : "Area total"}</div>
<div class="stat-value">${hasIndicators ? (indicatorNow ? indicatorNow.text : "—") : fmt(stats.areaTotal)}</div>
<div class="stat-note">${hasIndicators ? ((indicatorInfo && indicatorInfo.unit) ? indicatorInfo.unit : "") : "all ages, " + fmtInt(yearSel)}</div></div>
</div>`// Rows for the stacked pyramid: one bar per age, split by category. Males are
// negative so the two sexes face away from each other.
dimPyramid = dimRows
.filter(d => d.year === yearSel)
.map(d => ({
...d,
cat: dimLabel.get(d.category) ?? d.category,
signed: d.sex === "m" ? -d.population : d.population
}))// Rows for the stacked-area trend: summed over the ages in the chosen band, so
// the band control still means something in this view.
dimTrend = Array.from(
d3.rollup(
dimRows.filter(d => d.age >= selectedBand.lo && d.age <= selectedBand.hi),
v => d3.sum(v, d => d.population),
d => d.year, d => d.sex, d => d.category
),
([year, bySex]) => Array.from(bySex, ([sex, byCat]) =>
Array.from(byCat, ([category, population]) => ({
year, sex, category,
cat: dimLabel.get(category) ?? category,
sexLabel: sex === "f" ? "Female" : "Male",
population
}))
)
).flat(2).sort((a, b) => a.year - b.year)dimOn ? Plot.plot({
title: `Age, sex and ${dimInfo.label.toLowerCase()}, ${fmtInt(yearSel)}`,
caption: chartSource,
width: panelWidth,
height: 420,
marginLeft: 48,
x: {label: "Population →", tickFormat: v => fmt(Math.abs(v))},
y: {label: "Age", reverse: true, ticks: d3.range(0, 101, 10), tickFormat: fmtInt},
color: {legend: true, domain: dimDomain, range: dimRange},
marks: [
Plot.ruleX([0]),
Plot.barX(dimPyramid, {
x: "signed", y: "age", fill: "cat",
insetTop: 0.2, insetBottom: 0.2,
// Ordered so the stack reads in the same direction on both sides.
order: dimDomain,
title: d => `${d.cat}
${fmt(d.population)}`
})
]
}) : Plot.plot({
title: `Age and sex, ${fmtInt(yearSel)}`,
caption: chartSource,
width: panelWidth,
height: 420,
marginLeft: 48,
x: {label: "Population →", tickFormat: v => fmt(Math.abs(v))},
// Single years of age give 101 bands; label every tenth or the axis is a smear.
y: {label: "Age", reverse: true, ticks: d3.range(0, 101, 10), tickFormat: fmtInt},
color: {
legend: true,
domain: ["f", "m"],
range: ["#0072B2", "#D55E00"],
tickFormat: s => s === "f" ? "Female" : "Male"
},
marks: [
Plot.ruleX([0]),
Plot.barX(pyramidYear, {
x: "signed", y: "age", fill: "sex",
fillOpacity: d => d.inBand ? 1 : 0.25,
insetTop: 0.2, insetBottom: 0.2
}),
// Following a cohort means watching this bar climb while the line beside it
// falls, so the cohort's current age is outlined here rather than only
// implied by the other panel.
Plot.barX(followCohort ? pyramidYear.filter(d => d.age === yearSel - cohortYear) : [], {
x: "signed", y: "age", fill: "none", stroke: "#1a1a1a", strokeWidth: 1.2,
insetTop: 0.2, insetBottom: 0.2
})
]
})dimOn ? Plot.plot({
title: `${selectedBand.label} by ${dimInfo.label.toLowerCase()}`,
caption: chartSource,
// Two columns when both sexes are shown, one when a single sex is chosen.
width: sexSel === "both" ? panelWidth : Math.min(panelWidth, 420),
height: 420,
marginLeft: 64,
x: {label: "Year", tickFormat: fmtInt},
// A stacked area has to be anchored at zero, or the bottom band is clipped
// and every layer above it is read off the wrong baseline.
y: {label: "Population", grid: true, tickFormat: fmt, zero: true},
fx: {label: null},
color: {legend: true, domain: dimDomain, range: dimRange},
marks: [
Plot.areaY(dimTrend, {
x: "year", y: "population", fill: "cat",
fx: sexSel === "both" ? "sexLabel" : null,
order: dimDomain,
title: d => `${d.cat}
${fmtInt(d.year)}: ${fmt(d.population)}`
}),
Plot.ruleY([0])
]
}) : followCohort ? Plot.plot({
// Following a cohort means x is AGE, not year: watching the pyramid bar climb
// while this line falls is the whole point, so both have to be on screen.
title: `The cohort born in ${fmtInt(cohortYear)}`,
caption: chartSource,
width: panelWidth,
height: 420,
marginLeft: 64,
x: {label: "Age", tickFormat: fmtInt},
y: {label: "Population", grid: true, tickFormat: fmt, zero: true},
marks: [
Plot.ruleY([0]),
Plot.line(cohortByAge, {x: "age", y: "population", stroke: "#0072B2", strokeWidth: 2}),
Plot.dot(cohortByAge.filter(d => d.age === yearSel - cohortYear),
{x: "age", y: "population", fill: "#0072B2", r: 4}),
Plot.tip(cohortByAge, Plot.pointerX({x: "age", y: "population", format: {y: fmt}}))
]
}) : Plot.plot({
title: `${selectedBand.label} over time`,
caption: chartSource,
width: panelWidth,
height: 420,
marginLeft: 64,
// Room on the right for the series labels, which replace a legend.
marginRight: 62,
// No `nice`: it stretched the axis to 2055 for data that stops at 2051.
x: {label: "Year", tickFormat: fmtInt},
y: {label: "Population", grid: true, tickFormat: fmt, zero: true},
color: {domain: ["f", "m"], range: ["#0072B2", "#D55E00"], legend: false},
marks: [
Plot.ruleY([0]),
// The year chosen above, marked so the cards and the curve line up.
Plot.ruleX([yearSel], {stroke: "#5b6165", strokeDasharray: "2,3"}),
// Solid female, dashed male, each labelled at its own end: the series stay
// apart for a reader who cannot distinguish the two colours, and nobody has
// to look away at a legend to read the chart.
//
// Two marks rather than one with a strokeDasharray function: Plot takes
// strokeDasharray as a constant option, not a channel, so the function was
// quietly ignored and both lines came out solid -- which is exactly the
// redundancy this was added to provide.
Plot.line(trendSeries.filter(d => d.sex === "f"), {
x: "year", y: "population", stroke: "sex", strokeWidth: 2
}),
Plot.line(trendSeries.filter(d => d.sex === "m"), {
x: "year", y: "population", stroke: "sex", strokeWidth: 2,
strokeDasharray: "5,3"
}),
Plot.dot(trendSeries.filter(d => d.year === yearSel),
{x: "year", y: "population", fill: "sex", r: 3.5}),
Plot.text(trendEnds, {
x: "year", y: "population", text: "label", fill: "sex",
textAnchor: "start", dx: 6, fontWeight: 600
}),
Plot.tip(trendSeries, Plot.pointerX({
x: "year", y: "population", stroke: "sex",
format: {y: fmt, stroke: false, x: "d"}
}))
]
})Indicators
indicatorNote = !hasIndicators ? "" :
(indicatorInfo ? indicatorInfo.label : indicatorKey) +
((indicatorInfo && indicatorInfo.unit) ? ", " + indicatorInfo.unit : "") + "." +
(indicatorIsFlow
? " Each point covers the period beginning in the year shown, not the year itself."
: "") +
((indicatorInfo && indicatorInfo.definition) ? " " + indicatorInfo.definition : "")// How the selected indicator is calculated, in popsymr's own words. The formula
// is never restated here: `definition` comes from index_indicator, so the page
// cannot drift from the implementation the way a copied formula would.
//
// A <details> rather than a hover tooltip: it works with a thumb on a phone,
// needs no library, and is keyboard-reachable.
{
const d = document.createElement("details");
d.className = "indicator-help";
if (!hasIndicators || !indicatorInfo) { d.style.display = "none"; return d; }
const sum = document.createElement("summary");
sum.textContent = "How is this calculated?";
d.appendChild(sum);
const body = document.createElement("div");
const row = (k, v) => {
if (v == null || v === "") return;
const p = document.createElement("p");
const b = document.createElement("b");
b.textContent = k + ": ";
p.appendChild(b);
p.appendChild(document.createTextNode(String(v)));
body.appendChild(p);
};
row("Formula", indicatorInfo.definition);
row("Unit", indicatorInfo.unit);
row("Measured", indicatorInfo.class === "flow"
? "over a period, labelled by the year it begins"
: "at a point in time");
row("Type", indicatorInfo.measure_type);
row("UN WPP name", indicatorInfo.wpp_name);
row("WCDE name", indicatorInfo.wcde_name);
row("Also known as", indicatorInfo.alias);
const src = document.createElement("p");
src.className = "indicator-help-src";
src.textContent = "Computed by popsymr from the projection contract, not by this page.";
body.appendChild(src);
d.appendChild(body);
return d;
}!hasIndicators ? html`<span></span>` : Plot.plot({
title: indicatorInfo ? indicatorInfo.label : "",
caption: chartSource,
width: fullWidth,
height: 380,
marginLeft: 72,
marginRight: 62,
x: {label: indicatorIsFlow ? "Period beginning" : "Year", tickFormat: fmtInt},
y: {label: (indicatorInfo && indicatorInfo.unit) ? indicatorInfo.unit : "Value",
grid: true,
// Only a count is meaningfully anchored at zero; a ratio or a median age
// is not, and forcing zero there flattens the very change being shown.
zero: !!(indicatorInfo && indicatorInfo.measure_type === "count")},
color: {domain: ["f", "m", "both"], range: ["#0072B2", "#D55E00", "#1a1a1a"], legend: false},
marks: [
Plot.ruleX([yearSel], {stroke: "#5b6165", strokeDasharray: "2,3"}),
Plot.line(indicatorSeries.filter(d => d.sex === "both"),
{x: "year", y: "value", stroke: "sex", strokeWidth: 2.5}),
Plot.line(indicatorSeries.filter(d => d.sex === "f"),
{x: "year", y: "value", stroke: "sex", strokeWidth: 1.5}),
Plot.line(indicatorSeries.filter(d => d.sex === "m"),
{x: "year", y: "value", stroke: "sex", strokeWidth: 1.5,
strokeDasharray: "5,3"}),
Plot.text(
Array.from(d3.group(indicatorSeries, d => d.sex),
([sex, rows]) => rows[rows.length - 1]),
{x: "year", y: "value", text: "label", fill: "sex",
textAnchor: "start", dx: 6, fontWeight: 600}
),
Plot.tip(indicatorSeries, Plot.pointerX({x: "year", y: "value", stroke: "sex"}))
]
})// A ratio or a share is a number about the age structure, and the structure is
// what a reader actually wants to see: the working-age band widening then
// narrowing while the older band grows. So the composition is shown beneath the
// ratio rather than left to be inferred from it.
//
// 0-14, 15-64 and 65+ partition the population, so the stack sums to the total.
// Only the working-age band is split by sex -- that is the denominator of every
// support and dependency ratio, and splitting all three would give six series
// for a chart whose point is the shape of three.
// The parts an indicator decomposes into, named by popsymr's registry -- tdr is
// ydr + odr, and the parts sum to the whole. Declared there rather than known
// here: which indicators decompose, and into what, is a fact about the
// indicators, not about this page.
indicatorParts = {
const c = indicatorInfo && indicatorInfo.components;
if (!c) return [];
const names = Array.isArray(c) ? c : String(c).split(/[,;]\s*/).filter(d => d);
// Only parts that are actually in the contract; a declared component with no
// values would leave a gap in the stack that reads as a real dip.
return names.filter(n => indicatorMeta.has(n));
}// Rows for the decomposition, all on the same denominator. A dependency ratio
// splits by WHO is dependent, never by whose working-age population supports
// them: a dependent child is supported by the working-age population as a whole.
partRows = !showParts ? [] : indicatorRows
.filter(d => indicatorParts.includes(d.indicator) &&
String(d.geo_code) === areaCode && d.sex === indicatorSex)
.map(d => ({...d, part: (indicatorMeta.get(d.indicator) || {}).label || d.indicator}))
.sort((a, b) => a.year - b.year)compositionRows = !showComposition ? [] : (() => {
const rows = bandsData.filter(d => String(d.geo_code) === areaCode);
const pick = (band, sex) => d3.rollup(
rows.filter(d => d.band === band && (sex == null || d.sex === sex)),
v => d3.sum(v, d => d.population), d => d.year);
const child = pick("child", null);
const workF = pick("work", "f");
const workM = pick("work", "m");
const old = pick("old65", null);
const out = [];
for (const year of Array.from(child.keys()).sort((a, b) => a - b)) {
out.push({year, part: "Children 0-14", value: child.get(year) ?? 0});
out.push({year, part: "Working age, female", value: workF.get(year) ?? 0});
out.push({year, part: "Working age, male", value: workM.get(year) ?? 0});
out.push({year, part: "Aged 65 and over", value: old.get(year) ?? 0});
}
return out;
})()!showParts ? html`<span></span>` : Plot.plot({
title: "What makes up the total",
caption: chartSource,
width: fullWidth,
height: 380,
marginLeft: 72,
x: {label: "Year", tickFormat: fmtInt},
y: {label: (indicatorInfo && indicatorInfo.unit) ? indicatorInfo.unit : "Value",
grid: true, zero: true},
color: {legend: true, domain: partOrder, range: ["#0072B2", "#D55E00", "#c7d0d8", "#5b6165"]},
marks: [
Plot.areaY(partRows, {
x: "year", y: "value", fill: "part", order: partOrder,
title: d => `${d.part}
${fmtInt(d.year)}: ${localizeNum(d.value.toFixed(1))}`
}),
Plot.ruleX([yearSel], {stroke: "#1a1a1a", strokeDasharray: "2,3"}),
Plot.ruleY([0])
]
})!showComposition ? html`<span></span>` : Plot.plot({
title: "The age structure behind it",
caption: chartSource,
width: fullWidth,
height: 380,
marginLeft: 72,
x: {label: "Year", tickFormat: fmtInt},
y: {label: "People", grid: true, tickFormat: fmt, zero: true},
color: {
legend: true,
domain: compositionOrder,
// Grey for the dependent groups at either end, the Okabe-Ito pair for the
// working-age band that both ratios divide by.
range: ["#c7d0d8", "#0072B2", "#D55E00", "#5b6165"]
},
marks: [
Plot.areaY(compositionRows, {
x: "year", y: "value", fill: "part", order: compositionOrder,
// A template literal, not a quoted string: a line break is legal in one
// and a syntax error in the other, and a single broken cell stops the
// whole module importing -- every chart on the page, silently.
title: d => `${d.part}\n${fmtInt(d.year)}: ${fmt(d.value)}`
}),
Plot.ruleX([yearSel], {stroke: "#1a1a1a", strokeDasharray: "2,3"}),
Plot.ruleY([0])
]
})How areas compare
// The finest tier this country publishes -- districts for Nepal, states for
// India. Read from meta.json rather than named here.
//
// Falls back to the last row of regions.csv, which is written in display order,
// so a contract built by an older popsymapp still works. Without the fallback a
// missing key threw "Cannot read properties of undefined (reading 'length')" and
// took the ENTIRE page down, not just this chart -- one stale build, no page.
geoLevels = (meta.geo_levels && meta.geo_levels.length)
? meta.geo_levels.map(String)
: Array.from(new Set(regions.map(r => String(r.geo_level))))// Which tier to rank: the one the reader has selected, so the chart answers
// "how does this area compare with its peers". Nepal therefore shows the seven
// provinces or the 77 districts depending on what is picked, rather than always
// the finest tier. A tier holding a single area -- national -- has no peers, so
// drop to the next one down.
rankLevel = {
const sel = areaLevel.get(areaCode);
const peers = regions.filter(r => String(r.geo_level) === sel).length;
if (peers > 1) return sel;
const i = geoLevels.indexOf(sel);
return geoLevels[Math.min(i + 1, geoLevels.length - 1)];
}rankedAreas = rankLevel == null ? [] : Array.from(
d3.rollup(
bandsData.filter(d => d.band === bandKey && d.year === yearSel &&
d.geo_level === rankLevel &&
(sexSel === "both" ? true : d.sex === sexSel)),
v => d3.sum(v, d => d.population),
d => String(d.geo_code)
),
([code, population]) => ({
code, population,
name: areaName.get(code) ?? code,
selected: code === areaCode
})
).sort((a, b) => b.population - a.population)rankedAreas.length < 2 ? html`<span></span>` : Plot.plot({
caption: chartSource,
width: fullWidth,
height: Math.max(220, 22 * rankedAreas.length),
marginLeft: isNarrow ? 96 : 150,
marginRight: isNarrow ? 44 : 70,
x: {label: "People →", tickFormat: fmt, grid: true},
y: {label: null, domain: rankedAreas.map(d => d.name)},
marks: [
Plot.barX(rankedAreas, {
y: "name", x: "population",
fill: d => d.selected ? "#0072B2" : "#c7d0d8",
title: d => `${d.name}
${fmt(d.population)}`
}),
Plot.text(rankedAreas.filter(d => d.selected), {
y: "name", x: "population", text: d => fmt(d.population),
textAnchor: "start", dx: 6, fill: "#0072B2", fontWeight: 600
}),
Plot.ruleX([0])
]
})// Built with plain concatenation, deliberately. A nested template literal inside
// an html`` tag breaks Quarto's OJS cell parser -- the whole module then fails
// to import and EVERY cell on the page silently renders nothing. Found the hard
// way; keep interpolations in this file one level deep.
// geo_level codes are machine-readable ("state_residence"), so soften them for
// prose rather than printing "state_residences" at a reader.
levelWords = rankLevel == null ? "areas" :
rankLevel.replace(/_/g, " ") + (/s$/.test(rankLevel) ? "" : "s")rankNote = rankedAreas.length < 2 ? "" :
"All " + fmtInt(rankedAreas.length) + " " + levelWords + ", " +
selectedBand.label.toLowerCase() + ", " + fmtInt(yearSel) + ". " +
(areaLevel.get(areaCode) === rankLevel
? "The highlighted bar is " + areaName.get(areaCode) + "."
: "No bar is highlighted; " + areaName.get(areaCode) + " is an aggregate.")// meta.built_at is left in Western digits: a build timestamp is machine
// metadata, not reader-facing demographic content, and localizing it risks
// reading as a claim about when the DATA is from rather than the build.
html`<p class="explorer-footer">
${fmtInt(meta.n_regions)} areas · ${fmtInt(meta.years[0])}–${fmtInt(meta.years[1])} ·
built ${meta.built_at}${meta.citation ? " · " + meta.citation : ""}
</p>`About this dashboard
What this shows. The projected resident population of Nepal, its 7 provinces and 77 districts, by single year of age and sex, from the 2021 census base to 2051.
Resident, not total. These are people present in Nepal. Absentee population — the roughly 2.19 million counted by the 2021 census as living abroad — is not included. Nepal’s district totals therefore read lower than a de jure count, and the gap is largest in the districts that send the most migrants. Treat any comparison with a de jure source with that in mind.
Source. National Statistics Office (2025). National Population and Housing Census 2021: Population Projections for Nepal, 2021–2051. First edition. Kathmandu: National Statistics Office. ISBN 978-9937-9844-2-3. Lead analyst: Samir KC. The method is the multi-state cohort-component approach documented in KC, S. et al. (2016).
How the projection works. The projection runs in single-year steps, by single year of age. Each year ages the population forward, applies survival, adds births from age-specific fertility rates, and moves people between districts by domestic migration. Every district is projected in its own right; the national figure is the sum of the districts, not a separate model, which is why the tiers agree. The step is visible in the output itself: the survivors of age 40 in one year are exactly the population aged 41 the next.
Three tiers, three runs. Nepal is projected separately at district, palika and ward level, each at the same annual step, rather than once and then aggregated. This dashboard shows the district run.
Scenario. The reference scenario shown here is the medium variant: the central assumptions for fertility, mortality and migration.
Age groups. Alongside the standard demographic groups, this dashboard carries Nepal’s education stages — early childhood (3–4), basic education (5–12) and secondary (14–17). These follow Nepal’s school structure and are not comparable with other countries’ stages without care.
Two tiers, deliberately. Nepal is projected separately at district, palika and ward level. This dashboard shows districts, because that is the tier policy is written at and the tier where the figures behave: population, births, deaths and international migration all add up cleanly between tiers, and district is the coarsest level at which internal migration means one thing. Below it, a move between two wards of the same municipality becomes a visible flow, so ward-level migration counts are larger without anything having moved differently.
Where ward detail earns its place is in space, not in tables — so the Nepal Population Grid puts ward projections on a 100 m raster, conserving every ward total exactly. Use this dashboard for how many and who; use the grid for where.
Limitations. A projection is not a forecast. It shows what follows from a stated set of assumptions, and the further out it runs the more the assumptions matter relative to the base population. District-level figures are more uncertain than national ones, and migration is the most uncertain component of all.
Credits
Samir KC was lead analyst for the National Statistics Office projections these figures come from. The population model behind this dashboard is the work of Samir KC and Jibesh Acharya.
The earlier Nepal population dashboard, which this one succeeds, was the responsibility of Divya Shakya, who did much of the work on it, with further contributions from Jibesh Acharya and Ashim Paudel. Much of how these results are presented — the choice of indicators, the scenario comparisons, the way places are compared — comes from that dashboard.
Developed at PSR HUB as part of PopSyM, a population-based systems model for Nepal, within Khoj Nepal — the wider programme of demographic research on Nepal.
How to cite
KC, S. (2026). Nepal Population Explorer: projected population by age, sex and area, 2021–2051. PSR HUB / PopSyM.
The framework is described in K.C., S. (2026), “Population-Based Systems Models for National Planning”, Populations 2(3): 16, https://doi.org/10.3390/populations2030016.