Skip to content

rcpch/rcpch-mapping-component

Repository files navigation

@rcpch/imd-map

A browser-first UK deprivation map library. Renders IMD choropleth tiles using MapLibre GL JS 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

Live demo: 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

npm install @rcpch/imd-map
# maplibre-gl is a peer dependency
npm install maplibre-gl
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.

<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:

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):

{% 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:

# 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:

<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:

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:

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.

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:

// 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:

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

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.

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:

// 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:

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:

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

About

No description, website, or topics provided.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors