# @rcpch/imd-map

A browser-first UK deprivation map library. Renders IMD choropleth tiles using [MapLibre GL JS](https://maplibre.org) with optional patient scatter and lead-centre overlays.

Authored in TypeScript. Consuming applications only need plain JavaScript.

Maintainers and coding agents: see `AGENTS.md` for project structure, test locations, tile contracts, and release workflow.

Upstream tile/data source: [`rcpch/rcpch-census-platform`](https://github.com/rcpch/rcpch-census-platform)

Live demo: [rcpch.github.io/rcpch-census-platform/](https://rcpch.github.io/rcpch-census-platform/)

Release integrity: run `npm pack --json` for npm `shasum` / `integrity`, or `npm run release:checksums` after packing to generate `release-checksums.json` with SHA hashes for the tarball and built bundles.

## CI and release automation

- Pushes to `main` and all pull requests run automated validation via `.github/workflows/ci.yml`:
  - `npm ci`
  - `npm test`
  - `npm run build`
- Publishing is handled by `.github/workflows/release.yml` when a GitHub release is published:
  - `npm ci`
  - `npm test`
  - `npm run build`
  - `npm publish --provenance --access public`

For npm publish to succeed, configure npm Trusted Publishing for this GitHub repository/package pair.

---

## What problem does this solve?

Standard server-side mapping tools (Plotly, Folium) render finished map HTML on the server. For a vector tile choropleth—where the browser streams tiles from a tile server and renders them using WebGL—this is the wrong boundary. This library moves all map internals to the browser and lets the backend focus on preparing plain data.

---

## Quick start — npm + bundler

```bash
npm install @rcpch/imd-map
# maplibre-gl is a peer dependency
npm install maplibre-gl
```

```js
import "maplibre-gl/dist/maplibre-gl.css";
import { createImdMap } from "@rcpch/imd-map";

const map = createImdMap({
  container: "map",
  tilesBaseUrl: "https://your-tile-server.example.com",
  initialNation: "all",
});

map.setPatients([
  { id: "p1", lat: 51.5074, lon: -0.1278 },
  { id: "p2", lat: 53.4808, lon: -2.2426 },
]);

map.setLeadCentre({ lat: 51.5202, lon: -0.1049, label: "Lead Centre" });
```

---

## Quick start — static HTML (CDN)

The UMD bundle includes MapLibre GL. No separate script tag required.

```html
<div id="map" style="height: 600px"></div>

<script src="https://cdn.jsdelivr.net/npm/@rcpch/imd-map@0.5.0/dist/umd/rcpch-imd-map.min.js"
        integrity="sha512-Udl5igLQTnxSJGcneRNBfS+zFpzYUcNZW2kA+dkVf/9mNpmS9numEnCaFdtWLqndBqGUJisvJ4QSYudGOK5oYg=="
        crossorigin="anonymous"></script>
<script>
  const map = RcpchImdMap.createImdMap({
    container: "map",
    tilesBaseUrl: "https://your-tile-server.example.com",
    initialNation: "all",
    style: {
      tooltip: { areaLabel: "Area", decileLabel: "IMD decile" },
    },
  });
</script>
```

If you need release-grade checksum verification for the published bundle or packed tarball, generate hashes locally with `npm pack --json` and `npm run release:checksums` before publishing.

---

## Quick start — Django / HTMX template

The backend prepares plain data. The template embeds it with `json_script`. A small script initializes the map.

**Django view:**

```python
context = {
    "map_payload": {
        "patients": [
            {"id": "p1", "lat": 51.5074, "lon": -0.1278},
            {"id": "p2", "lat": 53.4808, "lon": -2.2426},
        ],
        "leadCentre": {"lat": 51.5202, "lon": -0.1049, "label": "Lead Centre"},
        "style": {
            "decileColors": ["#7a0036","#a3004b","#c92e6f","#de5f92","#eba0ba",
                             "#f3bfd0","#f8d7e2","#fce8ef","#fff0f6","#fff5f8"],
            "boundaryColor": "#0d0d58",
        },
    }
}
```

**Django template (partial):**

```html
{% load static %} {% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% elif info %}
<div class="alert alert-info">{{ info }}</div>
{% else %}
<div id="organisation-cases-map" style="width:100%;height:32rem;"></div>
{{ map_payload|json_script:"organisation-cases-map-payload" }} {% endif %}

<script src="{% static 'vendor/rcpch-imd-map.min.js' %}"></script>
<script>
  (function () {
    var el = document.getElementById("organisation-cases-map");
    if (!el) return;

    // Destroy any previous instance to prevent leaks on HTMX swaps
    if (window._npdaMapInstance) {
      window._npdaMapInstance.destroy();
      window._npdaMapInstance = null;
    }

    var payload = JSON.parse(
      document.getElementById("organisation-cases-map-payload").textContent,
    );

    // Django-safe literal tokens for the map library's tooltip interpolation
    var token = {
      patientLabel:
        "{% templatetag openvariable %}patientLabel{% templatetag closevariable %}",
      id: "{% templatetag openvariable %}id{% templatetag closevariable %}",
      leadCentreLabel:
        "{% templatetag openvariable %}leadCentreLabel{% templatetag closevariable %}",
      label:
        "{% templatetag openvariable %}label{% templatetag closevariable %}",
    };

    var map = RcpchImdMap.createImdMap({
      container: "organisation-cases-map",
      tilesBaseUrl: window.RCPCH_DEPRIVATION_TILES_URL,
      initialNation: "all",
      style: {
        choropleth: { fallbackDecileColors: payload.style.decileColors },
        boundaries: { localAuthorityColor: payload.style.boundaryColor },
        tooltip: {
          areaLabel: "Local area",
          patientLabel: "Child",
          patientTooltipText: token.patientLabel + ": " + token.id,
          leadCentreTooltipText: token.leadCentreLabel + ": " + token.label,
          backgroundColor: "#0d0d58",
          textColor: "#ffffff",
        },
      },
      onWarning: function (w) {
        console.warn("[rcpch-imd-map]", w.code, w.message);
      },
    });

    map.setPatients(payload.patients);
    map.setLeadCentre(payload.leadCentre);

    window._npdaMapInstance = map;
  })();
</script>
```

**Copy the built UMD bundle into your Django static directory:**

```bash
# From the library repo (after npm run build)
cp dist/umd/rcpch-imd-map.min.js /path/to/npda/project/static/vendor/
```

Set `window.RCPCH_DEPRIVATION_TILES_URL` before the script runs, for example in your base template:

```html
<script>
  window.RCPCH_DEPRIVATION_TILES_URL = "{{ TILES_BASE_URL }}";
</script>
```

---

## Runtime tile configuration

Tile URL resolution precedence:

1. `tilesBaseUrl` option passed to `createImdMap`.
2. `window.RCPCH_DEPRIVATION_TILES_URL` global (for static/script-tag use).
3. Nothing — a warning is logged and choropleth tiles will not load.

The library source contains **no hardcoded tile URLs**.

Optional tile auth query options:

- `tilesApiKey`: appends a query value to all choropleth and overlay tile requests
- `tilesApiKeyParam`: query parameter name for `tilesApiKey` (default: `api_key`)

Example:

```js
createImdMap({
  container: "map",
  tilesBaseUrl: "https://your-tile-server.example.com",
  tilesApiKey: "switchable-browser-token",
  tilesApiKeyParam: "key",
});
```

This is useful for revoking abusive traffic quickly, but browser-delivered keys must still be treated as non-secret.

### Overlay boundary tile contract

Boundary overlays (local authority, NHSER, ICB, LHB) are requested from schema-qualified table ids and rendered with the same schema-qualified `source-layer` name. Example:

- URL table id: `public.la_tiles_z5_7`
- `source-layer`: `public.la_tiles_z5_7`

If you self-host boundary tiles, ensure each overlay PBF exposes the exact same layer name string as the table id used in the URL path.

Bring your own overlay configuration (custom overlay table/layer names via library options) could be enabled in a future release. If you need this, please open a GitHub issue so contributors can prioritise and scope it.

---

## Nation and era rules

The `era` option refers to the boundary year used for the LSOA geography, not the IMD publication year.

For England and Channel Islands, the supported pairings are:

- `2011` era = 2011 LSOA boundaries + 2019 IMD data (England only)
- `2021` era = 2021 LSOA boundaries + 2025 IMD data (England only); Channel Islands on 2024 boundaries

| Nation             | Requested era    | Effective era                          |
| ------------------ | ---------------- | -------------------------------------- |
| `all`              | `2011` or `2021` | as requested                           |
| `england`          | `2011` or `2021` | as requested                           |
| `channel_islands`  | `2011` or `2021` | as requested (2024 boundaries in both) |
| `wales`            | any              | always `2011`                          |
| `scotland`         | any              | always `2011`                          |
| `northern_ireland` | any              | always `2011`                          |

When the effective era differs from the requested era, `onWarning` is called with code `ERA_OVERRIDE`.

For all-UK maps, `initialEra: '2021'` now uses the mixed-vintage `uk_master_2021_*` tables: England renders with 2021 LSOA boundaries and 2025 IMD data, Channel Islands renders with 2024 boundaries, while Wales, Scotland, and Northern Ireland continue to render from their existing older datasets within the same UK tile family. Use `initialEra: '2011'` when you want the older England 2011 LSOA + 2019 IMD view alongside the existing Welsh and other nation data.

This means you can instantiate two separate UK maps in the same application, choosing the England boundary/IMD pairing by era:

```js
const historicalMap = createImdMap({
  container: "map-2011",
  tilesBaseUrl: "https://your-tile-server.example.com",
  initialNation: "all",
  initialEra: "2011",
});

const currentMap = createImdMap({
  container: "map-2021",
  tilesBaseUrl: "https://your-tile-server.example.com",
  initialNation: "all",
  initialEra: "2021",
});
```

In a patient-facing application, a common pattern would be:

- patients before 2025 or 2026 cutoff: `initialEra: '2011'`
- patients in the newer cohort: `initialEra: '2021'`

---

## Styling

All style options are optional and merge on top of built-in RCPCH defaults.

```js
createImdMap({
  container: 'map',
  tilesBaseUrl: '...',
  style: {
    choropleth: {
      // Auto-generate a 10-step ramp from one base color per nation
      baseColorByNation: {
        england: '#d7191c',
        wales: '#1a9641',
        scotland: '#2b83ba',
        northern_ireland: '#7f7f7f',
        channel_islands: '#d1d5db',
      },
      // 10 hex colors, index 0 = decile 1 (most deprived)
      fallbackDecileColors: ['#7a0036', ...],
      fillOpacity: 0.7,
      borderColor: '#ffffff',
      borderWidth: 0.5,
    },
    boundaries: {
      localAuthorityColor: '#0d0d58',
      icbColor: '#3d3d3d',
      localAuthorityWidth: 1,
    },
    patients: {
      circleColor: '#0d0d58',
      circleRadius: 5,
      circleOpacity: 0.8,
    },
    leadCentre: {
      color: '#e00087',
      radius: 10,
    },
    legend: {
      backgroundColor: '#ffffff',
      textColor: '#0d0d58',
      borderColor: '#d8dde6',
      borderRadius: 8,
      width: 220,
      toggleOnColor: '#0d0d58',
      toggleOffColor: '#6b7280',
    },
    tooltip: {
      backgroundColor: '#0d0d58',
      textColor: '#ffffff',
      areaLabel: 'Area',
      decileLabel: 'IMD decile',
      nationLabel: 'Nation',
      patientLabel: 'Patient',
      leadCentreLabel: 'Lead centre',
      areaTooltipText:
        '<strong>{{areaName}}</strong><br/>' +
        '<span>{{decileLabel}}: {{imdDecile}}</span><br/>' +
        '<span>{{nationLabel}}: {{nation}}</span>',
      patientTooltipText: '{{patientLabel}}',
      leadCentreTooltipText: '{{leadCentreLabel}}: {{label}}',
    },
  },
});
```

### Tooltip templates

`areaTooltipText`, `patientTooltipText`, and `leadCentreTooltipText` support `{{token}}` interpolation.

If you are writing inline JavaScript inside a Django template, Django will try to
evaluate `{{...}}` first. Use one of these patterns so the map library still
receives literal tokens:

1. Use `{% templatetag openvariable %}` and `{% templatetag closevariable %}`
   to emit literal `{{` and `}}` (shown in the Django example above).
2. Wrap only the relevant JavaScript block in `{% verbatim %}...{% endverbatim %}`
   when you do not need Django variable interpolation inside that block.
3. Build the token string server-side (for example in your view context) and pass
   it in your JSON payload.

**Patient tokens** (`patientTooltipText`):

| Token              | Value                                                           |
| ------------------ | --------------------------------------------------------------- |
| `{{patientLabel}}` | The `patientLabel` style option (default `"Patient"`)           |
| `{{id}}`           | The `id` field from `setPatients([{ id, lat, lon }])`           |
| `{{group}}`        | The `group` field from `setPatients([{ id, lat, lon, group }])` |

Examples:

```js
// Show the patient id
patientTooltipText: "Patient ID: {{id}}";

// Show a custom label with id
patientTooltipText: "{{patientLabel}} — ref: {{id}}";

// Show group
patientTooltipText: "Group: {{group}}";
```

**Lead-centre tokens** (`leadCentreTooltipText`):

| Token                 | Value                                                        |
| --------------------- | ------------------------------------------------------------ |
| `{{leadCentreLabel}}` | The `leadCentreLabel` style option (default `"Lead centre"`) |
| `{{label}}`           | The `label` field from `setLeadCentre({ label, lat, lon })`  |

**Area tokens** (`areaTooltipText`):

| Token              | Value                                             |
| ------------------ | ------------------------------------------------- |
| `{{areaCode}}`     | Area code from tile `code`                        |
| `{{areaName}}`     | Area name from tile `area_name`                   |
| `{{areaType}}`     | Area type from tile `area_type` (fallback `LSOA`) |
| `{{nation}}`       | Nation from tile `nation`                         |
| `{{imdDecile}}`    | IMD decile from tile `imd_decile`                 |
| `{{imdYear}}`      | IMD publication year from tile `imd_year`         |
| `{{boundaryYear}}` | Boundary year from tile `year`                    |
| `{{laCode}}`       | Local authority code from tile `la_code`          |
| `{{laName}}`       | Local authority name from tile `la_name`          |
| `{{laYear}}`       | Local authority year from tile `la_year`          |
| `{{nhserCode}}`    | NHS England region code from tile `nhser_code`    |
| `{{nhserName}}`    | NHS England region name from tile `nhser_name`    |
| `{{icbCode}}`      | ICB code from tile `icb_code`                     |
| `{{icbName}}`      | ICB name from tile `icb_name`                     |
| `{{lhbCode}}`      | Local health board code from tile `lhb_code`      |
| `{{lhbName}}`      | Local health board name from tile `lhb_name`      |
| `{{decileLabel}}`  | The `decileLabel` style option                    |
| `{{nationLabel}}`  | The `nationLabel` style option                    |

Style can also be updated at runtime:

```js
map.setStyle({ tooltip: { areaLabel: "Local area" } });
```

---

## API reference

### `createImdMap(options)` → `ImdMapInstance`

| Option                        | Type                                                           | Default        | Description                                                        |
| ----------------------------- | -------------------------------------------------------------- | -------------- | ------------------------------------------------------------------ |
| `container`                   | `string \| HTMLElement`                                        | —              | DOM element ID or element reference                                |
| `tilesBaseUrl`                | `string`                                                       | —              | Base URL of the tile server                                        |
| `tilesApiKey`                 | `string`                                                       | —              | Optional API key appended to tile URLs as a query parameter        |
| `tilesApiKeyParam`            | `string`                                                       | `'api_key'`    | Query parameter name used for `tilesApiKey`                        |
| `initialNation`               | `Nation`                                                       | `'all'`        | Starting nation filter                                             |
| `initialEra`                  | `Era`                                                          | `'2021'`       | Requested era (may be overridden)                                  |
| `enableLocalAuthorityOverlay` | `boolean`                                                      | `false`        | Show local authority boundary overlay at startup                   |
| `enableHealthOverlays`        | `boolean`                                                      | `false`        | Show NHSER, ICB, and LHB boundary overlays at startup              |
| `showLegend`                  | `boolean`                                                      | `true`         | Show the collapsible legend control                                |
| `legendPosition`              | `'top-left' \| 'top-right' \| 'bottom-left' \| 'bottom-right'` | `'top-right'`  | Legend control position inside map container                       |
| `legendCollapsed`             | `boolean`                                                      | `false`        | Start with legend content collapsed                                |
| `legendTitle`                 | `string`                                                       | `'Map layers'` | Legend header title text                                           |
| `showLegendLocalAuthority`    | `boolean`                                                      | `true`         | Show/hide local authority legend toggle row                        |
| `showLegendNhser`             | `boolean`                                                      | `true`         | Show/hide NHS England regions legend toggle row                    |
| `showLegendIcb`               | `boolean`                                                      | `true`         | Show/hide ICB legend toggle row                                    |
| `showLegendLhb`               | `boolean`                                                      | `true`         | Show/hide local health boards legend toggle row                    |
| `mapStyleUrl`                 | `string`                                                       | Carto Positron | MapLibre base style URL                                            |
| `center`                      | `[lon, lat]`                                                   | UK center      | Initial map center                                                 |
| `zoom`                        | `number`                                                       | `5`            | Initial zoom level                                                 |
| `style`                       | `MapStyleOptions`                                              | RCPCH defaults | Visual style overrides                                             |
| `areaTooltipMode`             | `'default' \| 'template' \| 'none'`                            | `'default'`    | Built-in area tooltip, template tooltip, or no built-in area popup |
| `onViewChange`                | `function`                                                     | —              | Called when nation or era changes                                  |
| `onAreaHover`                 | `function`                                                     | —              | Called on choropleth feature hover (includes pointer `lngLat`)     |
| `onAreaClick`                 | `function`                                                     | —              | Called on choropleth feature click                                 |
| `onWarning`                   | `function`                                                     | —              | Called for non-fatal issues                                        |

### Area tooltip mode

```js
createImdMap({
  container: "map",
  tilesBaseUrl: "...",
  areaTooltipMode: "template",
  style: {
    tooltip: {
      areaTooltipText:
        "<strong>{{areaName}}</strong><br/>" +
        "<span>{{decileLabel}}: {{imdDecile}}</span><br/>" +
        "<span>{{nationLabel}}: {{nation}}</span>",
    },
  },
});
```

- `default`: current built-in area tooltip rows.
- `template`: render `style.tooltip.areaTooltipText` with token interpolation.
- `none`: no built-in area popup; `onAreaHover`/`onAreaClick` still fire for fully external tooltips.

### Instance methods

| Method                          | Description                                                                                                                                                |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `setView({ nation?, era? })`    | Update nation and/or era                                                                                                                                   |
| `setNation(nation)`             | Change the nation filter                                                                                                                                   |
| `setEra(era)`                   | Change the requested era                                                                                                                                   |
| `setStyle(style)`               | Update visual style at runtime                                                                                                                             |
| `setOverlayVisibility({...})`   | Show/hide boundary overlays (`localAuthority`, `nhser`, `icb`, `lhb`)                                                                                      |
| `setPatients(data, options?)`   | Set patient scatter data                                                                                                                                   |
| `clearPatients()`               | Remove patient overlay                                                                                                                                     |
| `setLeadCentre(data, options?)` | Set single lead-centre marker (scatter companion)                                                                                                          |
| `clearLeadCentre()`             | Remove single lead-centre marker                                                                                                                           |
| `setLeadCentres(data[], options?)` | Set multi-centre proportional symbol (bubble) map — see below                                                                                          |
| `clearLeadCentres()`            | Remove bubble map layer and source                                                                                                                         |
| `getState()`                    | Return current map state snapshot                                                                                                                          |
| `resize()`                      | Trigger MapLibre resize (use after container resize)                                                                                                       |
| `fitToData(options?)`           | Fit to lead centre and/or patient points. Uses bounds with default 50px padding for multi-point data; single-point fallback uses zoom 6 unless overridden. |
| `destroy()`                     | Remove all layers, sources, listeners, and map instance                                                                                                    |

Legend notes:

- The legend is collapsible and includes clickable rows to toggle overlays.
- A compact key is shown below toggles with boundary line swatches and an IMD decile color ramp.
- Rows can be hidden per overlay type using `showLegendLocalAuthority`, `showLegendNhser`, `showLegendIcb`, and `showLegendLhb`.
- Nation-specific rows stay visible but are disabled when not applicable (for example, `England only` or `Wales only`).
- The legend panel hides automatically when `nation === 'channel_islands'` (no boundary overlays apply).

---

## Multi-centre bubble map (`setLeadCentres`)

For national overview views, `setLeadCentres()` renders an array of lead centres as a proportional symbol (bubble) map alongside the choropleth. Bubble radius encodes one numeric metric (e.g. patient count) and bubble colour encodes a second (e.g. median HbA1c). All values are pre-computed server-side.

```js
map.setLeadCentres([
  { lat: 51.52, lon: -0.10, label: 'GOSH',     total_patients: 312, median_hba1c: 58, dominant_type: 'type1', pct_type1: 68, pct_type2: 18, pct_mody: 10, pct_cfrd: 4 },
  { lat: 53.48, lon: -2.24, label: 'Manchester', total_patients: 198, median_hba1c: 63, dominant_type: 'type2', pct_type1: 30, pct_type2: 52, pct_mody: 10, pct_cfrd: 8 },
], { strict: false });
```

Configure rendering via `style.leadCentres`:

```js
// Continuous colour mode (default) — numeric colorField → gradient
createImdMap({
  style: {
    leadCentres: {
      sizeField: 'total_patients',  sizeLabel: 'Patients',
      colorField: 'median_hba1c',   colorLabel: 'Median HbA1c', colorUnit: 'mmol/mol',
      colorMode: 'continuous',
      colorScale: ['#2166ac', '#f7f7f7', '#d6604d'],  // blue → white → red
      minRadius: 8, maxRadius: 40,
    },
  },
});

// Categorical colour mode — string colorField → discrete palette + tooltip breakdown bars
createImdMap({
  style: {
    leadCentres: {
      sizeField: 'total_patients', sizeLabel: 'Patients',
      colorField: 'dominant_type', colorLabel: 'Dominant diabetes type',
      colorMode: 'categorical',
      colorByCategory: { type1: '#4e79a7', type2: '#f28e2b', mody: '#59a14f', cfrd: '#e15759' },
      breakdownFields: [
        { field: 'pct_type1', label: 'Type 1', color: '#4e79a7' },
        { field: 'pct_type2', label: 'Type 2', color: '#f28e2b' },
        { field: 'pct_mody',  label: 'MODY',   color: '#59a14f' },
        { field: 'pct_cfrd',  label: 'CFRD',   color: '#e15759' },
      ],
    },
  },
});
```

Switching between metrics (e.g. "colour by % Type 1") does not require a data reload:

```js
map.setStyle({ leadCentres: { colorMode: 'continuous', colorField: 'pct_type1', colorLabel: 'Type 1 %', colorUnit: '%' } });
```

`setLeadCentre()` (singular) is unaffected — existing single-centre scatter views work as before.

---

## HTMX / partial swap cleanup

When a map container is replaced by an HTMX swap, call `destroy()` first to prevent memory leaks:

```js
document.addEventListener("htmx:beforeSwap", function (e) {
  if (
    window._npdaMapInstance &&
    e.detail.target.contains(document.getElementById("organisation-cases-map"))
  ) {
    window._npdaMapInstance.destroy();
    window._npdaMapInstance = null;
  }
});
```

---

## Patient data format

Accepted as:

- Array of plain objects: `[{ id, lat, lon, group?, ...extraProps }]`
- GeoJSON `FeatureCollection<Point>`
- Array of GeoJSON `Feature<Point>`

Invalid records are skipped and surfaced via `onWarning`. Pass `{ strict: true }` as the second argument to `setPatients` to throw on the first invalid record instead.

---

## License

MIT
