Nepal, as a
dot-grid map.

A TypeScript library that renders routes, networks and coverage across Nepal. Zero runtime dependencies. The entire geometry layer is 42 KB.

npm i nepal-naksha See it working

This is a presentation-layer map, not a navigation map. Leaflet and Mapbox own turn-by-turn routing and street detail. naksha takes an ordered list of stops and draws it beautifully — it has no pathfinding, no tiles, and no geocoding, by design.

42 KBwhole geometry layer
0runtime dependencies
77districts, English + नेपाली
SSRsafe, synchronous
MITfree for commercial use

Live demo

Everything below runs the real library in your browser — nothing here is pre-rendered.

MapWhat the map is showing
ViewportWhere it is looking
InteractionHow it answers the pointer
ColoursEvery layer is a theme prop

How it works

The naive approach ships 2.1 MB of GeoJSON and re-runs 55,000-vertex point-in-polygon tests every time the map changes. naksha does the geometry once, at build time — and settles a few decisions further up the stack that are easy to get quietly wrong.

Geometry becomes a bitmap

District polygons are rasterised at build time into an indexed image where the pixel value is the district id and 0 means outside Nepal. At runtime, one array lookup answers inside or outside and which district at the same time — no turf, no proj4, no polygons.

1775 × 1024 cells · 0.45 km per cell · 42 KB shipped

Bounded cost, whatever you draw

The dot field is sampled per viewport at a fixed budget rather than resolved on one global grid — a global grid fine enough for all 753 local levels would need ~14,700 dots, which is a solid fill, not a dotted map. Cost is bounded by the dot count, so it never scales with geometry complexity.

Interaction doesn't undo that. Dots collapse into one path per colour, and the dot under the pointer is found by arithmetic — the viewBox is 0 0 cols rows, so the cell is floor(x), floor(y). Hover and click add no nodes; the highlight adds one.

~1,110 dots · built in < 2 ms · ~94 DOM nodes

Pins snap and cluster by default

At a national view one dot covers ~134 km², so three points across the Kathmandu valley land on the same dot. Pins that silently stacked would be a lie about your own data, so snapping and clustering are one operation and the badge always reports what is underneath.

Snapping knows its districts, too. A dot belongs to whichever district covers most of its cell, so a border town can fall in a cell its neighbour wins — Lahan is in Siraha, but its cell is mostly Saptari. naksha moves the pin to the nearest dot of the district the coordinate is really in, never further than one dot spacing, so a pin can't contradict its own label.

38 locations · 7 provinces · 5 in Kathmandu on one dot

Synchronous, so SSR just works

The raster is run-length encoded and inlined as base64. Deflate would be 35% smaller but needs an async inflate, which forces every consumer into a loading state or a client-only boundary. naksha decodes synchronously, so it renders inside a React server component with no "use client".

No Image · no canvas · no fetch · no top-level await

Labels erase the data they cover

A label needs a halo of the background colour to stay readable over the field — and that halo deletes the dots underneath it. On a dot map the field is the data, so this is not cosmetic. Measured across all 77 district labels, the default above erases a mean of 11.7 dots per label — and 74 of the 77 bury dots of the very district they name.

So labelPlacement is a choice about what to give up, not a style preference. avoid-region refuses to cover the district the label names and spills onto a neighbour; clear refuses to cover any dot and walks off the outline, trailing a leader line. Both cost distance from the pin. Try all four in the demo.

own dots erased · above 5.0 · avoid-region 0.1 · clear 0.0

Every district headquarters, placed

74 of the 77 districts ship their headquarters as a real coordinate on Region.hqAt, in both scripts, alongside the OCHA p-code. Pin a district town without owning a gazetteer — and without a geocoder, which naksha deliberately does not have.

The three without one are Nawalparasi East and both Rukums: the upstream point set predates the 2015 splits, so no point in it honestly belongs to them. Reaching for regionAnchor instead would put a town at its district's centre of mass, which is a different place — so naksha returns null rather than a plausible lie.

74/77 HQ coordinates · 77 p-codes · 2 scripts

Use it

Four entry points onto the same core. Pick whichever one your build already speaks — there is no wrong door, and no configuration behind any of them.

React

import { useState } from "react";
import { Naksha } from "nepal-naksha/react";

const KTM = { lng: 85.3591, lat: 27.6966, label: "Kathmandu" };
const PKR = { lng: 83.9820, lat: 28.2010, label: "Pokhara" };

export function RouteMap() {
  const [hovered, setHovered] = useState(null);

  return (
    <>
      <Naksha
        routes={[{ stops: [KTM, PKR] }]}
        points={[KTM, PKR]}
        theme={{ route: "#0ea5e9" }}
        labels
        animate
        highlight
        onRegionEnter={(district) => setHovered(district)}
        onRegionLeave={() => setHovered(null)}
        onPointClick={(cluster) => console.log(cluster.points)}
      />
      <p>{hovered ? hovered.name : "Hover a district"}</p>
    </>
  );
}

Anywhere else — ESM

import {
  renderNepal,
  nepalGrid,
  attachInteractions,
  districtById,
} from "nepal-naksha";

const locations = [
  { lng: 85.3591, lat: 27.6966, label: "Kathmandu" },
  { lng: 83.9820, lat: 28.2010, label: "Pokhara" },
];

const el = document.querySelector("#map");

// Returns a self-contained SVG string — no external references.
const grid = nepalGrid();
el.innerHTML = renderNepal({ points: locations, labels: true });

// Hover and click, without one DOM node per dot.
attachInteractions(el.querySelector("svg"), grid, {
  points: locations,
  highlight: true,
  onRegionEnter: (dot) => {
    console.log(districtById(dot.region)?.name);
  },
});

CommonJS

const { renderNepal, districtAt, VIEWS } = require("nepal-naksha");

// The same API, compiled to ES2018 — so webpack 4,
// CRA 4 and older Node parse it without a transform.
const svg = renderNepal({ bbox: VIEWS.bagmati });

// districtAt returns undefined outside Nepal, so check.
const d = districtAt({ lng: 85.3591, lat: 27.6966 });
if (d) console.log(d.name, d.nameNp); // Kathmandu काठमाडौँ

No build step at all

<div id="map"></div>
<script src="https://unpkg.com/nepal-naksha"></script>
<script>
  // One global. 44 KB gzipped, no loader,
  // no bundler, nothing to configure.
  var stops = [
    { lng: 85.3591, lat: 27.6966, label: "Kathmandu" },
    { lng: 83.9820, lat: 28.2010, label: "Pokhara" },
  ];

  document.getElementById("map").innerHTML = naksha.renderNepal({
    routes: [{ stops: stops }],
    points: stops,
    labels: true,
    theme: { route: "#0ea5e9" },
  });
</script>

What's supported

Node
18 and up — imported, required and script-loaded on 18 in CI
React
16.14 → 19, an optional peer — server-rendered once per major
Formats
ESM · CommonJS · <script> global
Bundlers
anything reading exports, plus webpack 4 and CRA 4
TypeScript
node · node16 · nodenext · bundler — declarations ship for both import and require
Browsers
ES2018 and up
SSR
synchronous — no "use client", no loading state

Ordered stops in, rendered map out. naksha never asks how you get from one stop to the next — whether anything actually travels that way is your data's problem, not the map's.