immersive map representation of nepal
nepal has 165 parliamentary constituencies, 77 districts, 7 provinces, and 754 municipalities. i wanted to see all of it on one map. nothing like that existed, so i built it.
you can check it out at electionatlas.ankurgajurel.com.np/immersive.
data sources
constituency boundaries: akashadhikari/eon_election_analysis. geojson with all 165 constituency polygons + election result csvs from 2079 bs with victory margins and party winners.
municipality boundaries: younginnovations/nepal-locallevel-map. simplified geojson of all 754 municipalities. no constituency field, so i had to join them later.
candidate data: ratemyneta.com rest api. provinces, districts, constituencies, candidates with names, photos, party affiliations, vote counts. 3,405 candidates total. scraped with local file caching and 200ms delay between requests.
dead ends: mesaugat/geoJSON-Nepal, okfnepal/localboundaries, nepal national geoportal, humanitarian data exchange. outdated boundaries or wrong admin levels. ekantipur's election map was useful for design reference.
data pipeline
all node scripts in scripts/. zero runtime backend, all static json output.
- geojson to topojson: raw constituency geojson was 25mb. simplified (3% quantile) + quantized with
topojson-server,topojson-simplify,topojson-client. result: ~1mb. - national parks: original data had all parks as one multipolygon. split into 13 individual features and labeled manually.
- nepali-english mapping: election results in nepali, geojson in english. wrote a 77-entry district mapping table by hand.
- municipality-constituency join: single-constituency districts are a trivial lookup. multi-constituency districts (kathmandu has 10) used
@turf/boolean-point-in-polygonto test each municipality centroid against constituency polygons. - candidate splitting: bulk
candidates.jsonsplit into 167 individual files so each constituency page loads only its own data.
stack
next.js 16, react 19, d3.js 7, topojson, tailwind css 4, shadcn/ui.
the main map
d3 svg map with geoMercator projection, d3-zoom for pan/zoom, three color modes (party winners, province colors, victory margin gradient). click a constituency to see all candidates with photos, party symbols, and vote counts in a side panel. municipality boundaries rendered as a zoom-gated layer, interactive only past 2x zoom.
the immersive view
full-screen dark map with progressive drill-down. useReducer state machine:
country (7 colored provinces)
> click province > province (districts as color shades)
> click district > district (municipality outlines)
back button, escape key, and breadcrumb navigation to go up levels. "view constituencies" toggle at province/district level.
single projection, zoom transforms only. mercator projection computed once to fit the country. navigation uses d3.transition().duration(800).ease(d3.easeCubicInOut) with transforms computed from pathGenerator.bounds(). no projection recomputation.
province and district polygons derived on the fly. topojson.merge(topology, geometries.filter(STATE === "Bagmati")) merges constituency geometries into province/district shapes. zero extra data files.
constituency overlay uses topojson.mesh() for clean internal boundaries (each shared edge drawn once). each constituency gets a distinct color via hsl hue rotation from the province base color.
national parks rendered with diagonal svg hatching patterns, hover glow effects, area-gated labels.
label sizing
labels on maps are hard. fixed font sizes break at different zoom levels. fontSize / zoomLevel makes all areas look the same. scaling to polygon width breaks because svg units don't change with zoom.
final solution: compute the polygon's screen pixel width, calculate target font size as a clamped percentage, convert back to svg units. targetPx = clamp(screenWidth * 0.12, min, max), svgFontSize = targetPx / zoomLevel. area-gated rendering for dense municipality labels. all labels use an svg filter (feGaussianBlur + feColorMatrix) for a dark halo behind white text.
gotchas
- pointer events: constituency overlay with
pointerEvents="fill"blocked clicks to districts underneath. fix:pointerEvents="none"on overlay. - district name casing: constituencies use
"KATHMANDU", municipalities use"Kathmandu". normalize to uppercase. - province numbers vs names: municipalities use
province: 3, constituencies useSTATE: "Bagmati". bridging map required.
numbers
- 165 constituencies, 77 districts, 7 provinces, 754 municipalities, 13 national parks
- 3,405 candidates with photos and party data
- ~1.7mb total static data
- zero runtime api calls