Skip to content

Commit cd7a6f4

Browse files
authored
Merge pull request #1377 from rcpch/rcpch-imd-library
rcpch-map-component-integration
2 parents ce71d67 + 3b8429d commit cd7a6f4

4 files changed

Lines changed: 169 additions & 784 deletions

File tree

agents.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,63 @@ This wraps `manage.py recalculate_imd --all` / `--cohort N` (`epilepsy12/managem
160160

161161
---
162162

163+
## Organisation Dashboard — Cases Map (`@rcpch/imd-map`)
164+
165+
The organisation dashboard (`epilepsy12/views/organisation_views.py``selected_organisation_summary`) renders a choropleth deprivation map showing case locations against IMD tiles. This is implemented using the [`@rcpch/imd-map`](https://github.com/rcpch/rcpch-mapping-component) browser library, **not** a server-side mapping tool.
166+
167+
### Architecture
168+
169+
The backend prepares a plain JSON payload; the browser library does all tile streaming and WebGL rendering.
170+
171+
```
172+
View (organisation_views.py)
173+
└── builds organisation_cases_imd_payload:
174+
{ initialNation, initialEra, patients: [{id, lat, lon, ...}], leadCentre: {lat, lon, label} }
175+
→ passed to template via Django json_script (XSS-safe)
176+
177+
Template (selected_organisation_summary.html)
178+
└── CDN: @rcpch/imd-map UMD bundle (includes MapLibre GL)
179+
└── <div id="organisation_cases_map"> — mount point
180+
└── <script> IIFE:
181+
RcpchImdMap.createImdMap({ container, tilesBaseUrl, initialNation, initialEra, ... })
182+
map.setPatients(payload.patients)
183+
map.setLeadCentre(payload.leadCentre)
184+
map.fitToData()
185+
stored on window._organisationCasesImdMap for HTMX-safe destroy/recreate
186+
```
187+
188+
### Nation and era rules
189+
190+
The library selects the correct IMD tile era based on `initialNation` + `initialEra` passed from the view:
191+
192+
- England, cohort ≥ 8 → `initialEra: "2021"` (2021 LSOA boundaries, 2025 IMD)
193+
- England, cohort < 8 → `initialEra: "2011"` (2011 LSOA boundaries, 2019 IMD)
194+
- Wales / Scotland / N. Ireland → always `"2011"` (library enforces this regardless of era passed)
195+
196+
`initialNation` is derived from `selected_organisation.country.boundary_identifier` via a lookup dict in the view.
197+
198+
### Boundary overlays
199+
200+
NHS Region, ICB, and LHB boundary overlays are rendered by the library itself via `enableHealthOverlays: true`. The view **no longer** builds or serialises boundary GeoJSON — the old `_build_boundary_overlay()` function and `return_tile_for_region` / `generate_case_counts_for_each_region_in_each_abstraction_level` calls have been removed.
201+
202+
### Tile server
203+
204+
`tilesBaseUrl` is read from `settings.RCPCH_DEPRIVATION_TILES_URL` (env var `RCPCH_DEPRIVATION_TILES_URL`) and passed directly from view context to the template JS. The library also reads `window.RCPCH_DEPRIVATION_TILES_URL` as a fallback.
205+
206+
### Key files
207+
208+
| File | Role |
209+
|---|---|
210+
| `epilepsy12/views/organisation_views.py` | Builds `organisation_cases_imd_payload` context key |
211+
| `templates/epilepsy12/partials/selected_organisation_summary.html` | Map mount point + init script |
212+
| `rcpch-audit-engine/settings.py` | `RCPCH_DEPRIVATION_TILES_URL` setting |
213+
214+
### What was removed
215+
216+
The old hand-rolled `static/js/maps/organisation_cases_map.js` (which exposed `window.RCPCHMaps.initialiseOrganisationCasesMap`) has been deleted. Do not attempt to restore it or reference `window.RCPCHMaps` — use `window.RcpchImdMap.createImdMap` instead.
217+
218+
---
219+
163220
## Areas to Expand
164221

165222
The following sections will be added over time:

epilepsy12/views/organisation_views.py

Lines changed: 50 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# Python imports
22
from datetime import date
3-
import json
43
import math
54

65
# third party libraries
@@ -18,7 +17,7 @@
1817

1918
# E12 imports
2019
from ..decorator import user_may_view_this_organisation, login_and_otp_required
21-
from epilepsy12.constants import INDIVIDUAL_KPI_MEASURES, EnumAbstractionLevel
20+
from epilepsy12.constants import INDIVIDUAL_KPI_MEASURES
2221
from epilepsy12.models import (
2322
Organisation,
2423
KPI,
@@ -54,10 +53,6 @@
5453
from epilepsy12.common_view_functions.aggregate_by import (
5554
update_all_kpi_agg_models,
5655
)
57-
from epilepsy12.common_view_functions.tiles_for_region import return_tile_for_region
58-
from epilepsy12.common_view_functions.map_from_shape_file import (
59-
generate_case_counts_for_each_region_in_each_abstraction_level,
60-
)
6156

6257

6358
def selected_organisation_summary_select(request):
@@ -190,139 +185,72 @@ def selected_organisation_summary(request, organisation_id):
190185
# - Other nations: default to 2011 boundaries
191186
is_england = selected_organisation.country.boundary_identifier == "E92000001"
192187
imd_tile_era = "2021" if (is_england and cohort_number >= 8) else "2011"
188+
nation_by_boundary_identifier = {
189+
"E92000001": "england",
190+
"W92000004": "wales",
191+
"S92000003": "scotland",
192+
"N92000002": "northern_ireland",
193+
}
194+
imd_initial_nation = nation_by_boundary_identifier.get(
195+
selected_organisation.country.boundary_identifier, "all"
196+
)
193197

194-
# Serialise case locations as GeoJSON for the MapLibre deprivation map
195-
_case_features = []
198+
# Build patient list for the IMD map
199+
imd_map_patients = []
196200
if not case_distances_dataframe.empty:
197201
_required = {"latitude", "longitude"}
198202
if _required.issubset(case_distances_dataframe.columns):
199203
for _, _row in case_distances_dataframe.dropna(
200204
subset=["latitude", "longitude"]
201205
).iterrows():
202-
_props = {}
203-
for _col in (
204-
"pk",
205-
"distance_mi",
206-
"distance_km",
207-
"epilepsy12_sites__organisation__name",
208-
):
209-
if _col in case_distances_dataframe.columns:
210-
_val = _row[_col]
211-
_props[_col] = (
212-
None
213-
if (isinstance(_val, float) and math.isnan(_val))
214-
else _val
215-
)
216-
_case_features.append(
217-
{
218-
"type": "Feature",
219-
"geometry": {
220-
"type": "Point",
221-
"coordinates": [
222-
float(_row["longitude"]),
223-
float(_row["latitude"]),
224-
],
225-
},
226-
"properties": _props,
227-
}
206+
patient_lat = float(_row["latitude"])
207+
patient_lon = float(_row["longitude"])
208+
patient_id = (
209+
_row["pk"] if "pk" in case_distances_dataframe.columns else None
228210
)
229-
cases_geojson = json.dumps(
230-
{"type": "FeatureCollection", "features": _case_features}
231-
)
232-
233-
def _build_boundary_overlay(
234-
*,
235-
overlay_id,
236-
boundary_type,
237-
region_key,
238-
abstraction_enum,
239-
identifier_property,
240-
line_color,
211+
if patient_id is None or (
212+
isinstance(patient_id, float) and math.isnan(patient_id)
213+
):
214+
patient_id = f"case-{len(imd_map_patients) + 1}"
215+
216+
imd_map_patient = {
217+
"id": str(patient_id),
218+
"lat": patient_lat,
219+
"lon": patient_lon,
220+
}
221+
if "distance_mi" in case_distances_dataframe.columns:
222+
distance_mi = _row["distance_mi"]
223+
if not (isinstance(distance_mi, float) and math.isnan(distance_mi)):
224+
imd_map_patient["distance_mi"] = float(distance_mi)
225+
if "distance_km" in case_distances_dataframe.columns:
226+
distance_km = _row["distance_km"]
227+
if not (isinstance(distance_km, float) and math.isnan(distance_km)):
228+
imd_map_patient["distance_km"] = float(distance_km)
229+
imd_map_patients.append(imd_map_patient)
230+
imd_map_lead_centre = None
231+
if (
232+
selected_organisation.latitude is not None
233+
and selected_organisation.longitude is not None
241234
):
242-
"""Create a boundary overlay payload for the frontend map."""
243-
region_geojson = json.loads(return_tile_for_region(region_key))
244-
case_counts_df = generate_case_counts_for_each_region_in_each_abstraction_level(
245-
abstraction_level=abstraction_enum,
246-
cohort=cohort_number,
247-
organisation=selected_organisation,
248-
)
249-
250-
case_count_by_identifier = {}
251-
if not case_counts_df.empty:
252-
for _, count_row in case_counts_df.iterrows():
253-
identifier = count_row.get("identifier")
254-
if identifier is None:
255-
continue
256-
raw_cases = count_row.get("cases", 0)
257-
case_count_by_identifier[str(identifier)] = int(raw_cases or 0)
258-
259-
for feature in region_geojson.get("features", []):
260-
props = feature.setdefault("properties", {})
261-
identifier = props.get(identifier_property)
262-
props["boundary_type"] = boundary_type
263-
props["boundary_name"] = props.get("name", "Unknown boundary")
264-
props["boundary_code"] = identifier if identifier is not None else "N/A"
265-
props["case_count"] = case_count_by_identifier.get(str(identifier), 0)
266-
267-
return {
268-
"id": overlay_id,
269-
"sourceType": "geojson",
270-
"data": region_geojson,
271-
"beforeLayerId": "cases-layer",
272-
"linePaint": {
273-
"line-color": line_color,
274-
"line-width": 2,
275-
"line-opacity": 0.85,
276-
},
277-
"fillPaint": {
278-
"fill-color": "#000000",
279-
"fill-opacity": 0.0,
280-
},
235+
imd_map_lead_centre = {
236+
"lat": float(selected_organisation.latitude),
237+
"lon": float(selected_organisation.longitude),
238+
"label": selected_organisation.name,
281239
}
282240

283-
boundary_overlays = []
284-
285-
# Always provide Welsh LHB boundaries so they are visible when zooming out from any centre.
286-
boundary_overlays.append(
287-
_build_boundary_overlay(
288-
overlay_id="lhb-boundaries",
289-
boundary_type="Local Health Board",
290-
region_key="lhb",
291-
abstraction_enum=EnumAbstractionLevel.LOCAL_HEALTH_BOARD,
292-
identifier_property="ods_code",
293-
line_color="#2e7d32",
294-
)
295-
)
241+
organisation_cases_imd_payload = {
242+
"initialNation": imd_initial_nation,
243+
"initialEra": imd_tile_era,
244+
"patients": imd_map_patients,
245+
"leadCentre": imd_map_lead_centre,
246+
}
296247

297248
# Use centre geography to choose trust/LHB denominator for completion summary cards.
298249
if selected_organisation.country.boundary_identifier == "W92000004": # Wales
299250
abstraction_level = "local_health_board"
300251
else:
301252
abstraction_level = "trust"
302253

303-
# Add English hierarchy overlays for contextual tooltips (not applicable to Jersey).
304-
if selected_organisation.ods_code != "RGT1W":
305-
boundary_overlays.append(
306-
_build_boundary_overlay(
307-
overlay_id="icb-boundaries",
308-
boundary_type="Integrated Care Board",
309-
region_key="icb",
310-
abstraction_enum=EnumAbstractionLevel.ICB,
311-
identifier_property="ods_code",
312-
line_color="#7a1f2b",
313-
)
314-
)
315-
boundary_overlays.append(
316-
_build_boundary_overlay(
317-
overlay_id="nhs-region-boundaries",
318-
boundary_type="NHS England Region",
319-
region_key="nhs_england_region",
320-
abstraction_enum=EnumAbstractionLevel.NHS_ENGLAND_REGION,
321-
identifier_property="region_code",
322-
line_color="#6b7280",
323-
)
324-
)
325-
326254
# query to return all completed E12 cases in the current cohort in this organisation
327255
count_of_current_cohort_registered_completed_cases_in_this_organisation = (
328256
all_registered_cases_for_cohort_and_abstraction_level(
@@ -460,13 +388,8 @@ def _build_boundary_overlay(
460388
"count_of_all_current_cohort_registered_cases_in_this_trust": count_of_all_current_cohort_registered_cases_in_this_trust,
461389
"count_of_current_cohort_registered_completed_cases_in_this_trust": count_of_current_cohort_registered_completed_cases_in_this_trust,
462390
"individual_kpi_choices": INDIVIDUAL_KPI_MEASURES,
463-
"cases_geojson": cases_geojson,
464-
"boundary_overlays_geojson": json.dumps(boundary_overlays),
465-
"imd_tile_era": imd_tile_era,
466391
"deprivation_tiles_url": settings.RCPCH_DEPRIVATION_TILES_URL,
467-
"org_lat": selected_organisation.latitude,
468-
"org_lng": selected_organisation.longitude,
469-
"org_name": selected_organisation.name,
392+
"organisation_cases_imd_payload": organisation_cases_imd_payload,
470393
"aggregated_distances": aggregated_distances,
471394
"organisational_audit_submission_period": organisational_audit_submission_period,
472395
}

0 commit comments

Comments
 (0)