doxa-maps/plugin-designs/cities-internet/scripts/build-adm1-internet.mjs
Doxa Maps a31295945c India by state: all 36 states, Mizoram and Nagaland included
The India layer drew on UPG-100's curated partition (34 of 36 units), so
Mizoram and Nagaland had figures but no geometry. studio staged the complete
ADM1 beside it (data/boundaries/gb-IND-ADM1-full.geojson: gbOpen
IND-ADM1-1811400, DataMeet / ECI, CC BY 2.5 IN, with its .meta.json). The
curated file is untouched.

- connectivity-map.vue fetches boundaries/gb-IND-ADM1-full.geojson.
- The crosswalk maps Mizoram -> "Mizoram" and Nagaland -> "Nāgāland" (the
  boundary file's spelling) instead of null. The generator validates against the
  full file, and the table's meta names it with its licence.
- The regenerated table has 36 areas drawn and none missing, so the legend's
  "no boundary yet" line no longer shows (it renders only when that list is
  non-empty).

Headless: build and tree OK; both boundary files are staged; the fill
expression (maplibre-gl-style-spec) colours 36 of 36 states (Mizoram women
67.6, Nagaland women 49.9).
2026-09-26 08:13:34 +00:00

118 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* build-adm1-internet.mjs — internet use by first-level administrative area (ADM1), from data.
*
* node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs
*
* READS data/internet/nfhs5-internet-by-state.csv India, NFHS-5 2019–21 (see data/internet/README.md)
* data/internet/adm1-crosswalk-IND.json source names -> boundary shapeName (strict)
* data/boundaries/gb-IND-ADM1-full.geojson the boundary file the map draws (all 36)
* WRITES data/tables/internet-by-adm1.json served as assets/tables/internet-by-adm1.json
*
* THE TABLE IS GENERIC ON PURPOSE (other plugins and go-int reuse it). One row per
* (iso3, adm1, measure, sex, area): no India-specific shape, so a second country or a second
* measure is more rows, not a new format. `meta.boundaries[iso3]` names the geometry file and the
* property its `adm1` joins on; `meta.measures` and `meta.sources` say what each number is.
*
* STRICT. The run FAILS when a source name is missing from the crosswalk, when a crosswalk target
* is not a shapeName in the boundary file, or when a value is not a number in 0–100. A name is
* never guessed. Source rows that have no geometry (crosswalk `adm1: null`) are KEPT, keyed by
* their source name, and listed in meta.boundaries[iso3].missing, so nothing is silently dropped.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const CSV = join(ROOT, 'data/internet/nfhs5-internet-by-state.csv')
const CROSSWALK = join(ROOT, 'data/internet/adm1-crosswalk-IND.json')
const BOUNDARY = join(ROOT, 'data/boundaries/gb-IND-ADM1-full.geojson')
const OUT = join(ROOT, 'data/tables/internet-by-adm1.json')
const fail = (msg) => { console.error('build-adm1-internet: FAIL — ' + msg); process.exit(1) }
// A quoted-field CSV reader (the NFHS file quotes names that contain commas).
function parseCsv(text) {
const rows = []; let row = []; let f = ''; let q = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (q) { if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++ } else q = false } else f += c }
else if (c === '"') q = true
else if (c === ',') { row.push(f); f = '' }
else if (c === '\n') { row.push(f); rows.push(row); row = []; f = '' }
else if (c !== '\r') f += c
}
if (f || row.length) { row.push(f); rows.push(row) }
const head = rows.shift()
return rows.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]])))
}
const src = parseCsv(readFileSync(CSV, 'utf8'))
const crosswalk = JSON.parse(readFileSync(CROSSWALK, 'utf8')).map
const shapeNames = new Set(JSON.parse(readFileSync(BOUNDARY, 'utf8')).features.map((f) => f.properties.shapeName))
const SEX = { 'Women who have ever used the internet (%)': 'women', 'Men who have ever used the internet (%)': 'men' }
const AREAS = { total: 'nfhs5_total', urban: 'nfhs5_urban', rural: 'nfhs5_rural' }
const rows = []
const national = []
const missing = new Set()
for (const r of src) {
const sex = SEX[r.indicator.replace(/^\d+\.\s*/, '')]
if (!sex) fail(`unexpected indicator "${r.indicator}"`)
const cw = crosswalk[r.state]
if (!cw) fail(`"${r.state}" is not in data/internet/adm1-crosswalk-IND.json`)
if (cw.adm1 && !shapeNames.has(cw.adm1)) fail(`crosswalk maps "${r.state}" to "${cw.adm1}", which is not a shapeName in gb-IND-ADM1-full.geojson`)
if (!cw.adm1 && !cw.reason) fail(`crosswalk entry for "${r.state}" has no adm1 and no reason`)
for (const [area, col] of Object.entries(AREAS)) {
const raw = String(r[col] ?? '').trim()
if (raw === '' || raw === '*' || raw === 'na') continue // not reported for this cell
const value = Number(raw)
if (!Number.isFinite(value) || value < 0 || value > 100) fail(`"${r.state}" ${sex} ${area}: "${raw}" is not a share in 0–100`)
const row = { iso3: 'IND', measure: 'ever_used_internet', sex, area, value, source: 'nfhs5', year: '2019-21' }
if (cw.national) { national.push(row); continue }
if (!cw.adm1) missing.add(r.state)
rows.push({ ...row, adm1: cw.adm1 || null, adm1_name: r.state })
}
}
if (!rows.length) fail('no rows (vacuous run)')
const out = {
meta: {
title: 'Internet use by first-level administrative area',
grain: 'one row per iso3 x adm1 x measure x sex x area',
measures: {
ever_used_internet: {
label: 'Ever used the internet',
unit: '%',
population: 'people aged 15–49, women and men asked separately',
note: 'A survey SHARE OF PEOPLE (0–100). Not a subscription count: never put it on one scale with subscriptions per 100.',
},
},
sources: {
nfhs5: {
name: 'National Family Health Survey 5 (NFHS-5), IIPS / Ministry of Health and Family Welfare, India',
year: '2019-21',
url: 'http://rchiips.org/nfhs/',
via: 'https://github.com/pratapvardhan/NFHS-5 (CSV transcription of the state fact sheets)',
licence: 'CC BY 4.0',
attribution: 'NFHS-5 (IIPS/MoHFW) state fact sheets, via pratapvardhan/NFHS-5 (CC BY 4.0)',
},
},
boundaries: {
IND: {
file: 'boundaries/gb-IND-ADM1-full.geojson',
key: 'shapeName',
licence: 'geoBoundaries gbOpen IND-ADM1-1811400 (DataMeet / ECI): CC BY 2.5 IN',
missing: [...missing].sort(),
missing_reason: 'no feature for it in the boundary file',
},
},
national,
generated_by: 'plugin-designs/cities-internet/scripts/build-adm1-internet.mjs',
},
rows,
}
writeFileSync(OUT, JSON.stringify(out, null, 1) + '\n')
const drawn = new Set(rows.filter((r) => r.adm1).map((r) => r.adm1)).size
console.log(`build-adm1-internet: ${rows.length} rows · IND ${drawn} ADM1 areas drawn · ${missing.size} without geometry (${[...missing].join(', ') || 'none'}) · national ${national.length} rows`)