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(" · ")// 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;
}// 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.
viewof indicatorKey = {
const opts = new Map(indicatorList.map(d => [d.label ?? d.indicator, d.indicator]));
const sel = hasIndicators
? Inputs.select(opts, {label: "Indicator"})
: Inputs.select(new Map([["", ""]]), {label: ""});
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 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: 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">${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 ${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, " + 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()}, ${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)},
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, ${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)},
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: "d"},
// 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}
${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 ${cohortYear}`,
caption: chartSource,
width: panelWidth,
height: 420,
marginLeft: 64,
x: {label: "Age", tickFormat: "d"},
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: "d"},
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: "d"},
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: "d"},
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}
${d.year}: ${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: "d"},
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${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 " + rankedAreas.length + " " + levelWords + ", " +
selectedBand.label.toLowerCase() + ", " + yearSel + ". " +
(areaLevel.get(areaCode) === rankLevel
? "The highlighted bar is " + areaName.get(areaCode) + "."
: "No bar is highlighted; " + areaName.get(areaCode) + " is an aggregate.")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. Each five-year step 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, not a separate model, which is why the tiers agree.
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.