Package Exports
- @ministryofjustice/hmpps-electronic-monitoring-components
- @ministryofjustice/hmpps-electronic-monitoring-components/map
- @ministryofjustice/hmpps-electronic-monitoring-components/map/layers
- @ministryofjustice/hmpps-electronic-monitoring-components/map/ordnance-survey-auth
Readme
hmpps-electronic-monitoring-components
A native Web Component for rendering maps with OpenLayers (default) or MapLibre GL.
Includes a small layer API for common overlays (locations, tracks, circles, numbering).
Browser Support
| Browser | Support |
|---|---|
| Chrome (evergreen) | ✅ |
| Firefox (evergreen) | ✅ |
| Safari 15+ | ✅ |
| Edge (Chromium) | ✅ |
| IE11 | ❌ |
Fallback Strategy
This component targets modern browsers only.
- IE11 is not supported (no native Web Components).
- Polyfilling for IE11 is not recommended (performance/compat issues).
- If legacy support is required, render a fallback view from your server-side templates.
Getting Started with <em-map>
<em-map> is an embeddable map component. It uses Ordnance Survey vector tiles by default via a small server middleware and provides a typed API for adding layers from your app code.
1) Install
npm install hmpps-open-layers-mapRegister the custom element once (e.g. in your client entry file):
import 'hmpps-open-layers-map'Optionally import types if you’ll interact with the map in TypeScript:
import type { EmMap } from 'hmpps-open-layers-map'2) Server middleware (Ordnance Survey Vector Tiles API)
This package exports an Express middleware that securely proxies Ordnance Survey Vector Tiles (OAuth2 + caching).
Mount it in your server app, e.g.:
// server/app.ts
import express from 'express'
import { CacheClient, emOrdnanceSurveyAuth } from 'hmpps-open-layers-map/ordnance-survey-auth'
const app = express()
// Optional - connect Redis client for OS tile caching
if (config.redis.enabled) {
const redisClient: CacheClient | undefined = createRedisClient()
redisClient.connect?.().catch((err: Error) => logger.error(`Error connecting to Redis`, err))
}
app.use(
emOrdnanceSurveyAuth({
apiKey: process.env.OS_API_KEY!, // from Ordance Survey
apiSecret: process.env.OS_API_SECRET!, // from Ordnance Survey
// Optional: Redis cache + expiry override
// redisClient, // connected redis client
// cacheExpiry: 3600, // seconds; default is 7 days in production, 0 in dev
}),
)Notes
- cacheExpiry: In production the default is 7 days (can be overridden). In development it defaults to 0 (no caching) unless you set a value.
- If you provide a redisClient, the middleware enables server-side caching for tiles and static assets (glyphs/sprites).
It also sets ETag and Cache-Control headers so browsers can handle their own client-side caching and revalidation.
3) Nunjucks setup
Point Nunjucks at the component templates:
// e.g. server/utils/nunjucksSetup.ts
nunjucks.configure(['<your-app-views>', 'node_modules/hmpps-open-layers-map/nunjucks'])Render the element with the macro:
{% from "components/em-map/macro.njk" import emMap %}
{{ emMap({
alerts: alerts,
cspNonce: cspNonce
}) }}Ensure the host element has a non-zero height (OpenLayers won’t render otherwise).
Host CSS height example:
.map-container {
height: 450px;
}4) CSP (Content Security Policy)
In your server/app.ts, update the Helmet configuration to include cdn.jsdelivr.net in both the style-src and font-src directives, and allow inline styles for OpenLayers’ dynamic controls (e.g. scale bar updates):
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", 'cdn.jsdelivr.net', "'unsafe-inline'"], // Change this
fontSrc: ["'self'", 'cdn.jsdelivr.net'], // Change this
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
},
},
}),
)Why this is needed
cdn.jsdelivr.net— allows the browser to load OpenLayers’@fontsourceCSS and font files.'unsafe-inline'— required because OpenLayers applies small inlinestyleattributes (e.g. updating the width of the scale bar dynamically).
This configuration keeps security strict for scripts (the script-src directive remains nonce-based) while allowing OpenLayers and MapLibre to function correctly.
Macro Parameters (updated)
| Parameter | Type / Values | Description |
|---|---|---|
positions |
Array | New input data for the map |
usesInternalOverlays |
boolean | If true, enables built-in overlay and pointer interaction. |
cspNonce |
string | Optional CSP nonce used by inline styles. |
renderer |
'openlayers' | 'maplibre' |
Select rendering library (default 'openlayers'). |
controls |
object | Map controls config (see below). |
enable3DBuildings |
boolean | MapLibre only: adds a 🏙 toggle for 3D buildings. |
alerts |
array | Optional list of Moj Design System alerts to render into the alerts slot. |
controls object
| Property | Type / Values | Description |
|---|---|---|
grabCursor |
boolean | If true (default), shows MapLibre-style grab/grabbing cursor on pan. |
rotateControl |
true | false | 'auto-hide' |
Show the rotate/compass control; 'auto-hide' hides it until rotated. |
zoomSlider |
boolean | Show the zoom slider. |
scaleControl |
'bar' | 'line' | false |
Scale bar/line. |
locationDisplay |
'dms' | 'latlon' | false |
Coordinate readout at the bottom near the scale bar. |
Component Attributes (for raw HTML)
| Attribute | Type / Values | Description |
|---|---|---|
uses-internal-overlays |
boolean | Enables built-in overlay + pointer interaction. |
csp-nonce |
string | Nonce for inline styles. |
renderer |
openlayers | maplibre |
Renderer choice (default openlayers). |
rotate-control |
false | auto-hide | true |
Rotate/compass control. |
zoom-slider |
boolean (presence enables) | Zoom slider control. |
scale-control |
bar | line |
Scale control style. |
location-display |
dms | latlon |
Coordinate readout style. |
enable-3d-buildings |
boolean (presence enables) | MapLibre only: toggle for 3D buildings. |
grab-cursor |
boolean (presence enables) | MapLibre-style panning cursor. |
Example (Nunjucks)
{% from "components/em-map/macro.njk" import emMap %}
{{ emMap({
alerts: alerts,
cspNonce: cspNonce,
positions: positions,
usesInternalOverlays: true,
renderer: 'maplibre',
controls: {
scaleControl: 'bar',
locationDisplay: 'dms',
rotateControl: 'auto-hide',
zoomSlider: true,
grabCursor: false
},
enable3DBuildings: true
}) }}Map Lifecycle (map:ready)
The component fires map:ready once initialised:
import type { EmMap } from 'hmpps-open-layers-map'
const emMap = document.querySelector('em-map') as EmMap
await new Promise<void>(resolve => {
emMap.addEventListener('map:ready', () => resolve(), { once: true })
})
// OpenLayers map instance (if using OpenLayers renderer)
const map = emMap.olMapInstance
// The positions payload you provided
const positions = emMap.positionsAdding Layers (OpenLayers renderer)
Import layer classes from hmpps-open-layers-map/layers.
Each layer accepts:
positions— an array of position objects (required)visible?: boolean— whether the layer should be shown initiallyzIndex?: number— draw order (higher numbers appear above lower ones)- Other layer-specific options
Available layers
LocationsLayer— renders Point positions as circles.TracksLayer— composite layer for LineString data:- lines (
LinesLayer), and - optional arrows (
ArrowsLayer) indicating direction.
- lines (
CirclesLayer— renders Point positions as Circle geometries with a radius derived from a property (e.g."confidence").NumberingLayer— paints numbers as text labels next to points.
Full example
import type { EmMap } from 'hmpps-open-layers-map'
import { LocationsLayer, TracksLayer, CirclesLayer, NumberingLayer } from 'hmpps-open-layers-map/layers'
import { isEmpty } from 'ol/extent'
const emMap = document.querySelector('em-map') as EmMap
await new Promise<void>(resolve => {
emMap.addEventListener('map:ready', () => resolve(), { once: true })
})
const map = emMap.olMapInstance!
const positions = emMap.positions // your array of positions
if (!positions?.length) throw new Error('No positions provided to <em-map>')
// 1) Locations
const locationsLayer = emMap.addLayer(
new LocationsLayer({
positions,
}),
)!
// 2) Tracks (lines + arrows)
const tracksLayer = emMap.addLayer(
new TracksLayer({
positions,
visible: false,
lines: {},
arrows: { enabled: true },
}),
)!
// 3) Circles
emMap.addLayer(
new CirclesLayer({
positions,
id: 'confidence',
title: 'Confidence circles',
radiusProperty: 'confidence',
visible: false,
zIndex: 20,
}),
)
// 4) Numbering
emMap.addLayer(
new NumberingLayer({
positions,
numberProperty: 'sequenceNumber',
title: 'Location numbering',
visible: false,
zIndex: 30,
}),
)
// Fit view to locations
const source = locationsLayer?.getSource()
if (source) {
const extent = source.getExtent()
if (!isEmpty(extent)) {
map.getView().fit(extent, {
maxZoom: 16,
padding: [30, 30, 30, 30],
size: map.getSize(),
})
}
}Visibility defaults
LocationsLayer:visible: trueTracksLayer:visible: falseCirclesLayer:visible: falseNumberingLayer:visible: false
zIndex
- Higher z-index draws above lower ones.
TracksLayerplaces arrows atzIndex + 1so they render above lines.
Layer Reference
LocationsLayer(options)
positions: Position[](required)id?: string(default:"locations")title?: stringvisible?: boolean(default:true)zIndex?: numberstyle?: { radius: number; fill: string; stroke: { color: string; width: number } }
TracksLayer(options)
positions: Position[](required)id?: string(default:"tracks")title?: stringvisible?: boolean(default:true)zIndex?: number(applied to lines; arrows arezIndex + 1)lines?: LinesLayerOptionsarrows?: ArrowsLayerOptions & { enabled?: boolean; visible?: boolean }
Internally creates a
LayerGroup.addLayer()returns that group.
CirclesLayer(options)
positions: Position[](required; Point positions)id?: string(default:"circles")title?: stringvisible?: boolean(default:false)zIndex?: numberradiusProperty?: string(default:"confidence")style?: ol/style/Style(optional custom style)
NumberingLayer(options)
positions: Position[](required; Point positions)id?: string(default:"numbering")title?: stringvisible?: boolean(default:false)zIndex?: numbernumberProperty?: string(default:"sequenceNumber")font?,fillColor?,strokeColor?,strokeWidth?,offsetX?,offsetY?
Note: All layers are currently implemented for the OpenLayers renderer only.
MapLibre support is planned but not yet available.
CSS Hooks
- Host classes toggled by attributes:
.has-rotate-control.has-zoom-slider.has-scale-control.has-location-dms
- CSS custom property:
--em-scale-bar-bottom— bottom offset for scale + location readout.
Example:
em-map {
--em-scale-bar-bottom: 16px;
}Troubleshooting
“No map visible because the map container's width or height are 0.”
Ensure the host container has an explicit height (e.g.450px).CSP errors
Ensure you pass acspNonceand include'nonce-<value>'instyle-src.Vector tiles not loading
Confirm the server middleware is mounted and OS credentials are set. The UI talks to the local proxy automatically.