# india-basemap

[![npm version](https://img.shields.io/npm/v/india-basemap)](https://www.npmjs.com/package/india-basemap)

A React component that renders an India-focused basemap on [MapLibre GL JS](https://maplibre.org/). It filters a global OpenMapTiles style down to India — India-only place labels and state boundaries, country/state outlines drawn from bundled OSM-derived GeoJSON — with a zoom-staged label hierarchy (a single "INDIA" label at low zoom, then states, then cities/towns).

## Install

```sh
npm install india-basemap maplibre-gl
```

`react >= 16.8`, `react-dom`, and `maplibre-gl ^6` are peer dependencies.

## Use

```jsx
import IndiaBasemap from 'india-basemap'

function Page() {
  return (
    <div style={{ position: 'relative', height: '100%' }}>
      <IndiaBasemap />
    </div>
  )
}
```

The component fills its nearest positioned ancestor (`position: absolute; inset: 0`), so render it inside a container with `position: relative` and a real height.

With no props it fetches its three data files (~0.9 MB total) from the jsDelivr npm CDN (`https://cdn.jsdelivr.net/npm/india-basemap@1/data`). To self-host instead, copy the files from this package's `data/` directory into your static assets and pass `dataBasePath`:

```jsx
<IndiaBasemap dataBasePath="/maps" />
```

## Production builds: ship MapLibre's worker files (required)

MapLibre v6 runs tile parsing in a web worker loaded from **separate sibling files** in its package (`maplibre-gl-worker.mjs`, which imports `maplibre-gl-shared.mjs`), resolved at runtime relative to `import.meta.url`. Dev servers serve MapLibre straight from `node_modules/`, where those siblings exist — so dev works. A production bundle inlines MapLibre into a hashed chunk, the siblings are never emitted, the worker request 404s, and everything worker-dependent **hangs silently**: the style loads but the map's `load` event never fires and no console error appears.

The fix has two parts:

1. Make your build copy both files from `node_modules/maplibre-gl/dist/` into your output directory (so they always match the installed MapLibre version — don't vendor manual copies). In Vite:

   ```js
   // vite.config.js
   import { copyFileSync } from 'node:fs'
   import { resolve } from 'node:path'

   export default defineConfig({
     plugins: [
       {
         name: 'copy-maplibre-workers',
         closeBundle() {
           for (const f of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs']) {
             copyFileSync(
               resolve('node_modules/maplibre-gl/dist', f),
               resolve('dist', f),
             )
           }
         },
       },
     ],
   })
   ```

2. Tell MapLibre where the worker lives via the `workerUrl` prop, in production only:

   ```jsx
   <IndiaBasemap workerUrl={import.meta.env.PROD ? '/maplibre-gl-worker.mjs' : null} />
   ```

   Leave it unset in dev so MapLibre's default resolution (which works there) is used. The two files must be deployed side by side, unhashed, at the URL you pass.

**Verify with a production build served locally** (e.g. `vite preview`) — this failure mode is invisible in dev.

## Vite hosts: exclude maplibre-gl from pre-bundling

```js
// vite.config.js
optimizeDeps: {
  exclude: ['maplibre-gl'],
},
```

Without this the basemap does not load properly under the Vite dev server.

## Props

All props are optional. Each default is also exported as a named constant (e.g. `import IndiaBasemap, { ZOOM_RANGE_BY_LAYER } from 'india-basemap'`) so you can extend a default rather than redefine it.

| Prop | Default (exported as) | Notes |
| --- | --- | --- |
| `styleUrl` | `STYLE_URL` (`'https://tiles.openfreemap.org/styles/liberty'`) | Basemap style. Layer-ID coupling: see below. |
| `center` | `CENTER` (`INDIA_CENTER`, `[78.9629, 22.5937]`) | Initial map center. |
| `initialZoom` | `INITIAL_ZOOM` (`3.5`) | Initial map zoom. |
| `dataBasePath` | `DATA_BASE_PATH` (jsDelivr CDN URL) | Base path the three GeoJSON/JSON data files are fetched from. |
| `countryLabelSwapZoom` | `COUNTRY_LABEL_SWAP_ZOOM` (`3.5`) | Zoom at which the custom "India" label hands off to the basemap's own country labels. |
| `countryLabelText` | `COUNTRY_LABEL_TEXT` (`'India'`) | Text of the custom country label. |
| `countryLabelCenter` | `INDIA_CENTER` | Position of the custom country label. |
| `countryLabelTextSize` | `COUNTRY_LABEL_TEXT_SIZE` (`10`) | Fixed text size of the custom country label. |
| `zoomRangeByLayer` | `ZOOM_RANGE_BY_LAYER` | Per-layer zoom-range overrides; keys must match the style's layer IDs. |
| `stateTextSize` | `STATE_LABEL_TEXT_SIZE` | Zoom-interpolated text-size expression for state labels. |
| `townTextSize` | `TOWN_LABEL_TEXT_SIZE` | Zoom-interpolated text-size expression for town/village labels. |
| `placeTextSize` | `PLACE_LABEL_TEXT_SIZE` | Zoom-interpolated text-size expression for city labels. |
| `fontStack` | `FONT_STACK` (`['Noto Sans Regular']`) | Must be fonts available from the style's glyph source — the default style only serves Noto Sans Regular. |
| `priorityStates` | `PRIORITY_STATES` (`['Karnataka', 'West Bengal']`) | States placed first in label-collision priority (MapLibre hides colliding labels in tile-order otherwise). An empty array disables the override. |
| `countryBoundaryFile` | `COUNTRY_BOUNDARY_FILE` (`'india-country-boundary.geojson'`) | Filename of the country outline, fetched from `dataBasePath`. See [Country boundary depiction](#country-boundary-depiction). |
| `workerUrl` | `null` | URL of `maplibre-gl-worker.mjs` — required in production builds, see above. |
| `onMapReady` | — | Callback invoked once with the `maplibregl.Map` instance after the map's `load` event and this component's sources/layers are set up. The hook for adding your own sources, layers, and event handlers on top. |
| `maskOutside` | `false` | `'hide'` covers everything outside the country boundary with an opaque fill (the map appears clipped to India); `'dim'` uses a translucent fill so surrounding context stays faintly visible. `true` = `'hide'`. See [Clipping to the boundary](#clipping-to-the-boundary). |
| `maskColor` | `'#ffffff'` | Fill color of the outside mask. |
| `maskOpacity` | `null` | Mask opacity; `null` derives it from the mode (1 for `'hide'`, 0.7 for `'dim'`). |
| `constrainBounds` | `false` | `true` limits panning/zooming to the boundary's bbox plus a 1° margin (also preventing tile fetches far outside India); or pass explicit bounds `[[west, south], [east, north]]`. |

**Mount-only contract:** all props are read once, at mount. Changing a prop on a re-render does **not** update a live map — remount the component (e.g. via a `key` prop) to apply new values.

## Behavior details

- **Zoom-staged labels.** Below z3.5: only a custom "INDIA" label pinned at the country's visual center (OpenMapTiles places its own India label point off-center). z3.5–4.5: state labels at reduced size, stepping up to full size past 4.5. z5+: cities; towns (z6+) and villages (z9+) keep the style's defaults. Tune via `ZOOM_RANGE_BY_LAYER` and `COUNTRY_LABEL_SWAP_ZOOM`.
- **Flash prevention.** All place-label layers are hidden until the precomputed India place-name list loads, so the unfiltered global basemap never flashes. If that fetch fails, the map degrades to hidden place labels and logs an error.
- **StrictMode-safe** (guarded against double instantiation) and cleans up the map on unmount.

## Clipping to the boundary

Tiles are square and served on a fixed grid, so a map can never *fetch* India-shaped tiles — but it can look that way:

```jsx
<IndiaBasemap maskOutside="hide" constrainBounds />
```

`maskOutside` draws an inverse mask (a world-spanning polygon with the country boundary as a hole) above the basemap layers but below this component's outlines, labels, and anything you add in `onMapReady` — hiding neighbors and ocean along the exact boundary line. `'dim'` is the softer variant that keeps surrounding context faintly visible for orientation. `constrainBounds` complements it by stopping the user panning off into blank mask, and is what actually prevents tile requests far outside India; border-straddling tiles still load (they contain India's edge).

When either feature is enabled, the component fetches the boundary GeoJSON itself (one request — reused for the outline, the mask, and the bbox), so the boundary source/outline appear a beat later than with both off; if that fetch fails, the map degrades to its normal unmasked, unconstrained behavior.

## Country boundary depiction

The bundled country outline, `india-country-boundary.geojson`, is a geoBoundaries ADM0 extract following India's official claimed extent, including all of Jammu & Kashmir and Ladakh (0.1 MB). It is simplified to ~2,000 vertices — light and fast, but visibly angular when zoomed into coastlines.

If you need a full-detail outline instead, self-host your own GeoJSON alongside the other data files and point the component at it:

```jsx
<IndiaBasemap dataBasePath="/maps" countryBoundaryFile="my-detailed-boundary.geojson" />
```

Note the basemap's own tile-drawn boundary line (`boundary_2`, filtered to India) follows OSM's *de facto* depiction regardless of this prop, so Kashmir's tile-drawn line differs from the official outline drawn above it.

## External-service coupling

The component filters the style by **layer ID** (`label_state`, `label_city`, `boundary_2`, `boundary_3`, …), matching the OpenFreeMap styles served from `tiles.openfreemap.org`. A custom `styleUrl` must use the same OpenMapTiles layer-ID conventions or the filtering silently no-ops. If your app sets CSP headers, allow the tile origin (and the CDN data origin, if used) for fetch/XHR, and allow workers.

## Data licensing

`india-states.geojson` and `india-place-names-by-class.json` are derived from [OpenStreetMap](https://www.openstreetmap.org/copyright) data, © OpenStreetMap contributors, available under the [Open Database License (ODbL)](https://opendatacommons.org/licenses/odbl/). `india-country-boundary.geojson` is sourced from [geoBoundaries](https://www.geoboundaries.org) (Runfola et al.), available under CC BY 4.0. The code is MIT-licensed.
