// =============================================================================
// 5. WEBGL MAPLIBRE MAP CONTAINER & EVENT BINDINGS
// =============================================================================
// Creates and initializes the MapLibre GL map instance once, attaching GeoJSON layers,
// navigation controls, pointer hover tooltips, and responsive resize observers.
viewof map = {
// --- 5.1 DOM Markup & Floating Overlays ---
// Outer wrapper containing the map viewport, floating navigation bar, guardrail, and loading spinner
const container = html`
<div class="map-outer-wrapper" style="position: relative; width: 100%; height: 100%; min-height: 550px; flex: 1 1 auto; display: flex; flex-direction: column;">
<div id="map-viewport" style="position: absolute; inset: 0; width: 100%; height: 100%;"></div>
<div id="map-scope-bar" class="map-scope-bar"></div>
<div id="map-guardrail" class="map-guardrail"></div>
<div id="map-legend" class="map-legend"></div>
<div id="map-spinner" style="position: absolute; inset: 0; background: rgba(255,255,255,0.88); display: flex; align-items: center; justify-content: center; z-index: 80;">
<div style="text-align: center; font-family: var(--font-primary); padding: 16px 24px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
<div id="map-spinner-title" style="font-size: 0.95rem; font-weight: 700; color: #990000; margin-bottom: 4px;">Loading Geography Layers...</div>
<div style="font-size: 0.8rem; color: #334155;">Local Authority Districts & LSOA Small Areas</div>
</div>
</div>
</div>
`;
const mapDiv = container.querySelector("#map-viewport");
const spinner = container.querySelector("#map-spinner");
// --- 5.2 MapLibre Instance Setup ---
// Uses Carto Positron basemap tiled vector styling bounded to England & Wales
const m = new maplibregl.Map({
container: mapDiv,
style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
bounds: [[-5.8, 50.0], [1.9, 55.85]],
fitBoundsOptions: { padding: 20 }
});
// Top-right navigation controls (+ / - zoom buttons)
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
// Shared reusable hover popup instance
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
maxWidth: "300px"
});
// State tracker to avoid redundant camera animations
m._currentLad = null;
// --- 5.3 Layer & Source Registration on Map Load ---
m.on("load", () => {
// Add spatial GeoJSON sources (LAD boundaries and fine-grained LSOAs)
m.addSource("lad_src", { type: "geojson", data: "app_data/geo_lad.geojson" });
m.addSource("lsoa_src", { type: "geojson", data: "app_data/geo_lsoa.geojson" });
const initPal = helpers.getMapPalette("lad", "rate");
const initFillExpr = helpers.buildFillExpr("rate_study_period", initPal);
// LSOA polygon fill layer (hidden until an LAD is active)
m.addLayer({
id: "lsoa_fill_layer",
type: "fill",
source: "lsoa_src",
layout: { visibility: "none" },
paint: {
"fill-color": "#fef0d9",
"fill-opacity": 0.88,
"fill-outline-color": "rgba(15, 23, 42, 0.15)"
}
});
// LAD polygon fill layer (national choropleth or muted regional context)
m.addLayer({
id: "lad_fill_layer",
type: "fill",
source: "lad_src",
layout: { visibility: "visible" },
paint: {
"fill-color": initFillExpr,
"fill-opacity": 0.85,
"fill-outline-color": "#475569"
}
});
// Selected LAD high-contrast boundary casing (white halo + dark inner stroke)
m.addLayer({
id: "lad_selected_casing",
type: "line",
source: "lad_src",
paint: { "line-color": "#ffffff", "line-width": 4.5, "line-opacity": 0.95 },
filter: ["==", ["get", "lad11cd"], ""]
});
m.addLayer({
id: "lad_selected_outline",
type: "line",
source: "lad_src",
paint: { "line-color": "#0f172a", "line-width": 2.5, "line-opacity": 1.0 },
filter: ["==", ["get", "lad11cd"], ""]
});
// --- 5.4 Spatial Interaction Event Listeners ---
// Click listener on LAD polygons to activate Small Area Focus
m.on("click", "lad_fill_layer", (e) => {
if (!e.features || !e.features.length) return;
const props = e.features[0].properties;
const code = props.LAD11CD || props.lad11cd;
if (code) {
mutable selected_lad = code;
}
});
// LAD hover tooltip listeners
m.on("mousemove", "lad_fill_layer", (e) => {
if (!e.features || !e.features.length) return;
m.getCanvas().style.cursor = "pointer";
const props = e.features[0].properties;
const content = helpers.formatLadTooltip(props, helpers.activeMapState.selected_lad, helpers.activeMapState.metric);
popup.setLngLat(e.lngLat).setHTML(content).addTo(m);
});
m.on("mouseleave", "lad_fill_layer", () => {
m.getCanvas().style.cursor = "";
popup.remove();
});
// LSOA hover tooltip listeners
m.on("mousemove", "lsoa_fill_layer", (e) => {
if (!e.features || !e.features.length) return;
m.getCanvas().style.cursor = "pointer";
const props = e.features[0].properties;
const content = helpers.formatLsoaTooltip(props, helpers.activeMapState.metric, helpers.activeMapState.active_period);
popup.setLngLat(e.lngLat).setHTML(content).addTo(m);
});
m.on("mouseleave", "lsoa_fill_layer", () => {
m.getCanvas().style.cursor = "";
popup.remove();
});
// Hide loading spinner and dispatch input event so Observable views recognize the map
spinner.style.display = "none";
container.value = m;
container.dispatchEvent(new CustomEvent("input"));
// 1. Force the canvas to stretch to the new 100% CSS height
m.resize();
// 2. Wait 150ms for the DOM to settle, resize again, and snap to the national bounds
setTimeout(() => {
m.resize();
m.fitBounds([[-5.8, 50.0], [1.9, 55.85]], { padding: 20, duration: 0 });
}, 150);
// 3. Final safety resize
setTimeout(() => m.resize(), 350);
});
// --- 5.5 Viewport & Resize Handling ---
// Ensure the map canvas adapts seamlessly to window resize and container flex changes
const onResize = () => m.resize();
window.addEventListener("resize", onResize);
let resizeObs = null;
if (typeof ResizeObserver !== "undefined") {
resizeObs = new ResizeObserver(() => m.resize());
resizeObs.observe(container);
resizeObs.observe(mapDiv);
}
// Teardown listener to prevent WebGL context leaks on cell re-execution
invalidation.then(() => {
window.removeEventListener("resize", onResize);
if (resizeObs) resizeObs.disconnect();
m.remove();
});
return container;
}// =============================================================================
// 6. REACTIVE MAP SYNCHRONIZATION
// =============================================================================
// Reactively adjusts camera bounds, layer filters, choropleth color expressions,
// floating navigation breadcrumbs, and legends without reinitializing the map canvas.
{
const m = map;
const lad = selected_lad;
const pStr = active_period;
const met = metric;
const ladGeo = lad_geojson;
const lsoas = active_lsoa_data;
// Update cached state for hover tooltip formatters
helpers.activeMapState.selected_lad = lad;
helpers.activeMapState.active_period = pStr;
helpers.activeMapState.metric = met;
// --- 6.1 Floating Map Scope Overlay (Top-Left) ---
// In national mode: shows overview hint. In focus mode: shows district name, LSOA count, and "Back" button.
const scopeBarEl = document.getElementById("map-scope-bar");
if (scopeBarEl) {
if (lad === null) {
scopeBarEl.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e;"></span>
<span><strong>Viewing:</strong> England & Wales (348 LADs) — <span style="color: #cbd5e1;">Click any district to inspect LSOAs</span></span>
</div>
`;
} else {
const ladName = selected_lad_meta ? selected_lad_meta.LAD11NM : lad;
const numLsoas = (lsoas && Array.isArray(lsoas)) ? lsoas.length : 0;
const lsoaCountText = numLsoas > 0 ? `${numLsoas}` : "";
const lsoaLabel = lsoaCountText ? `(${lsoaCountText} LSOAs)` : "(LSOAs)";
scopeBarEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 14px;">
<div style="display: flex; align-items: center; gap: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #f59e0b;"></span>
<span><strong>Viewing:</strong> ${ladName} ${lsoaLabel} | <span style="color: #fca5a5; font-weight: 600;">${pStr}</span></span>
</div>
<button id="btn-back-to-lads" class="btn-back-to-lads" title="Return to All England & Wales (348 Local Authorities)">
× Back to All LADs
</button>
</div>
`;
const backBtn = scopeBarEl.querySelector("#btn-back-to-lads");
if (backBtn) {
backBtn.onclick = () => {
mutable selected_lad = null;
};
}
}
}
// --- 6.2 Viewport & Layer Synchronization ---
if (m && m.getLayer("lad_fill_layer") && m.getLayer("lsoa_fill_layer")) {
const isNational = (lad === null);
// Smoothly fly camera to England & Wales or the selected district's bounding box
if (m._currentLad !== lad) {
m._currentLad = lad;
if (isNational) {
m.fitBounds([[-5.8, 50.0], [1.9, 55.85]], { padding: 20, duration: 1000 });
} else if (ladGeo && ladGeo.features) {
const feat = ladGeo.features.find(
f => (f.properties.lad11cd || f.properties.LAD11CD || "").toLowerCase() === lad.toLowerCase()
);
if (feat) {
const bbox = helpers.getFeatureBBox(feat);
m.fitBounds(bbox, { padding: 45, maxZoom: 13, duration: 1200 });
}
}
}
// --- 6.3 Dynamic Choropleth Filtering & Layer Styling ---
if (isNational) {
// National Overview Mode: 348 LADs colored by study period metric
const pal = helpers.getMapPalette("lad", met);
const colName = (met === "rate") ? "rate_study_period" : "estimate_study_period";
const fillExpr = helpers.buildFillExpr(colName, pal);
m.setLayoutProperty("lad_fill_layer", "visibility", "visible");
m.setFilter("lad_fill_layer", null);
m.setPaintProperty("lad_fill_layer", "fill-color", fillExpr);
m.setPaintProperty("lad_fill_layer", "fill-opacity", 0.85);
m.setPaintProperty("lad_fill_layer", "fill-outline-color", "#475569");
// Hide LSOAs and deselect outlines
m.setLayoutProperty("lsoa_fill_layer", "visibility", "none");
m.setFilter("lsoa_fill_layer", ["==", ["get", "lad11cd"], ""]);
m.setFilter("lad_selected_casing", ["==", ["get", "lad11cd"], ""]);
m.setFilter("lad_selected_outline", ["==", ["get", "lad11cd"], ""]);
} else {
// Small Area Focus Mode:
// (a) Render fine-grained LSOAs inside the active LAD, colored by heatwave-specific estimates
const hwCode = helpers.toPeriodKey(pStr);
const lsoaPal = helpers.getMapPalette("lsoa", met);
const lsoaCol = (met === "rate") ? `rate_${hwCode}` : `estimate_${hwCode}`;
const lsoaFillExpr = helpers.buildFillExpr(lsoaCol, lsoaPal);
m.setLayoutProperty("lsoa_fill_layer", "visibility", "visible");
m.setFilter("lsoa_fill_layer", ["==", ["downcase", ["get", "lad11cd"]], lad.toLowerCase()]);
m.setPaintProperty("lsoa_fill_layer", "fill-color", lsoaFillExpr);
m.setPaintProperty("lsoa_fill_layer", "fill-opacity", 0.88);
m.setPaintProperty("lsoa_fill_layer", "fill-outline-color", "rgba(15, 23, 42, 0.15)");
// (b) Retain remaining 347 LADs as a muted, clickable context background layer
m.setLayoutProperty("lad_fill_layer", "visibility", "visible");
m.setFilter("lad_fill_layer", ["!=", ["downcase", ["get", "lad11cd"]], lad.toLowerCase()]);
m.setPaintProperty("lad_fill_layer", "fill-color", "rgba(241, 245, 249, 0.08)");
m.setPaintProperty("lad_fill_layer", "fill-opacity", 1.0);
m.setPaintProperty("lad_fill_layer", "fill-outline-color", "#94a3b8");
// (c) Distinctive perimeter boundary outline around the active LAD
m.setFilter("lad_selected_casing", ["==", ["downcase", ["get", "lad11cd"]], lad.toLowerCase()]);
m.setFilter("lad_selected_outline", ["==", ["downcase", ["get", "lad11cd"]], lad.toLowerCase()]);
}
}
// --- 6.4 Floating Map Legend ---
// Dynamically renders the color gradient bar and numerical scale corresponding to active scale & metric
const isNational = (lad === null);
const pal = helpers.getMapPalette(isNational ? "lad" : "lsoa", met);
const gradStops = pal.stops.map((c, i) => `${c} ${Math.round((i / (pal.stops.length - 1)) * 100)}%`).join(", ");
const legendEl = document.getElementById("map-legend");
if (legendEl) {
legendEl.innerHTML = `
<div style="font-weight: 700; color: #0f172a; margin-bottom: 2px;">${pal.title}</div>
<div style="font-size: 10.5px; color: #334155; margin-bottom: 5px;">
${isNational ? "1 May–30 Sep (Full Season) · 348 LADs" : `${pStr} · LSOA Small Areas`}
</div>
<div style="height: 12px; border-radius: 3px; background: linear-gradient(to right, ${gradStops}); border: 1px solid #475569;"></div>
<div style="display: flex; justify-content: space-between; color: #0f172a; font-size: 11px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums;">
<span>${pal.values[0]}</span>
<span>${pal.values[1]}</span>
<span>${pal.values[2]}</span>
<span>${pal.values[3]}</span>
<span>${pal.values[4]}+</span>
</div>
`;
}
// --- 6.5 Small-Area Methodology Guardrail Banner ---
// Reminds users of modeling nuances when viewing localized non-negative truncated estimates
const guardrailEl = document.getElementById("map-guardrail");
if (guardrailEl) {
if (isNational) {
guardrailEl.innerHTML = "";
} else {
const ladName = selected_lad_meta ? selected_lad_meta.LAD11NM : lad;
guardrailEl.innerHTML = `<div class="guardrail-pill"><i class="bi bi-info-circle me-1"></i><strong>Small-Area Methodology:</strong> LSOA values for <strong>${ladName}</strong> are non-negative truncated point estimates; 95% eCIs are modelled at Regional and National scales.</div>`;
}
}
}// =============================================================================
// 10. INTERACTIVE SORTABLE SUMMARY DATA TABLE & CSV EXPORT
// =============================================================================
// Presents detailed regional and national epidemiological estimates with inline bars,
// multi-column sorting (retaining national benchmark row pinned at the top), and one-click CSV export.
{
const { period: p, age_group: targetAge, region: sel_rgn, regionName } = state;
const sortCol = table_sort_col;
const sortAsc = table_sort_asc;
// --- 10.1 Filter Data by Period, Age, and Regional Focus ---
let data = summary_data.filter(x => x.period === p && x.agegr === targetAge);
if (sel_rgn !== "All") {
data = data.filter(x => x.geo_level === "national" || (x.geo_level === "region" && x.rgn11cd === sel_rgn));
}
// --- 10.2 Multi-Column Sorting with Pinned National Row ---
data = data.slice().sort((a, b) => {
// Keep National benchmark row pinned at the top when viewing all regions
if (a.geo_level !== b.geo_level) return a.geo_level === "national" ? -1 : 1;
const va = a[sortCol], vb = b[sortCol];
const cmp = (typeof va === "string") ? va.localeCompare(vb) : (va - vb);
return sortAsc ? cmp : -cmp;
});
// Calculate maximum values for inline proportional bar chart widths
const maxEst = Math.max(...data.map(d => d.estimate || 0), 1);
const maxRate = Math.max(...data.map(d => d.rate || 0), 1);
// --- 10.3 Column Header Click-Sort Helper ---
const setSort = (col) => () => {
if (table_sort_col === col) {
mutable table_sort_asc = !table_sort_asc;
} else {
mutable table_sort_col = col;
mutable table_sort_asc = (col === "rgn11nm" || col === "age");
}
};
const sortIndicator = (col) => (sortCol === col) ? (sortAsc ? " ▲" : " ▼") : "";
// --- 10.4 In-Browser Dynamic CSV Exporter ---
// Constructs a CSV file from current filtered table rows and triggers an immediate browser download
const downloadCsv = () => {
const headers = ["geo_level", "rgn11cd", "rgn11nm", "period", "period_label", "agegr", "age", "population", "estimate", "lower", "upper", "rate", "ratelow", "ratehigh"];
const csvRows = data.map(row => headers.map(h => {
let v = row[h];
if (typeof v === "string" && v.includes(",")) v = `"${v}"`;
return v ?? "";
}).join(","));
const blob = new Blob([[headers.join(","), ...csvRows].join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `UK_Heatwave_2026_${p}_${targetAge}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const exportBtn = html`<button style="padding: 3px 10px; font-size: 11px; background: #ffffff; border: 1px solid #990000; border-radius: 4px; cursor: pointer; color: #990000; font-weight: 700;">⬇ Export CSV</button>`;
exportBtn.onclick = downloadCsv;
// --- 10.5 Clickable Table Headers ---
const thGeo = html`<th>Geography${sortIndicator("rgn11nm")}</th>`; thGeo.onclick = setSort("rgn11nm");
const thPop = html`<th style="text-align: right;">Population${sortIndicator("population")}</th>`; thPop.onclick = setSort("population");
const thEst = html`<th style="min-width: 155px;">Excess Deaths [95% eCI]${sortIndicator("estimate")}</th>`; thEst.onclick = setSort("estimate");
const thRate = html`<th style="min-width: 155px;">Rate / 1M [95% eCI]${sortIndicator("rate")}</th>`; thRate.onclick = setSort("rate");
// --- 10.6 Assemble Final HTML Table with Inline Bars ---
return html`
<div style="display: flex; flex-direction: column; height: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 11.5px; color: #0f172a; font-weight: 600;">
<strong>${helpers.periodLabelMap[p]}</strong> · ${helpers.ageLabelMap[targetAge]} (${data.length} rows)
</span>
${exportBtn}
</div>
<div style="overflow-y: auto; flex: 1; border: 1px solid #cbd5e1; border-radius: 6px;">
<table class="custom-table">
<thead>
<tr>
${thGeo}
${thPop}
${thEst}
${thRate}
</tr>
</thead>
<tbody>
${data.map(r => {
const isNat = r.geo_level === "national";
const estPct = Math.min(100, Math.max(0, ((r.estimate || 0) / maxEst) * 100)).toFixed(1);
const ratePct = Math.min(100, Math.max(0, ((r.rate || 0) / maxRate) * 100)).toFixed(1);
const estText = `${helpers.fmtInt(r.estimate)} [${helpers.fmtInt(r.lower)} – ${helpers.fmtInt(r.upper)}]`;
const rateText = `${helpers.fmtRate(r.rate, 1)} [${helpers.fmtRate(r.ratelow, 1)} – ${helpers.fmtRate(r.ratehigh, 1)}]`;
const rowEl = html`
<tr class="${isNat ? "row-national" : ""}" style="cursor: pointer;" title="Click to select ${r.rgn11nm}">
<td>
<span class="scope-badge ${isNat ? "scope-national" : "scope-region"}">${isNat ? "NAT" : "RGN"}</span>
<strong>${r.rgn11nm}</strong>
</td>
<td style="text-align: right; font-variant-numeric: tabular-nums;">${helpers.fmtInt(r.population)}</td>
<td>
<div class="inline-bar-bg">
<div class="inline-bar-fill" style="width: ${estPct}%;"></div>
<span class="inline-bar-text">${estText}</span>
</div>
</td>
<td>
<div class="inline-bar-bg">
<div class="inline-bar-fill" style="width: ${ratePct}%;"></div>
<span class="inline-bar-text">${rateText}</span>
</div>
</td>
</tr>
`;
rowEl.onclick = () => {
if (isNat) {
mutable selected_region = "All";
mutable selected_region_name = "England & Wales";
} else {
mutable selected_region = r.rgn11cd;
mutable selected_region_name = r.rgn11nm;
}
};
return rowEl;
})}
</tbody>
</table>
</div>
</div>
`;
}// =============================================================================
// 8. REGIONAL COMPARISON HORIZONTAL BAR CHART
// =============================================================================
// Displays ranked excess mortality across all 10 ONS English & Welsh regions,
// complete with asymmetrical 95% eCI error bars, national benchmark line, and click selection.
{
const { period: p, metric: m, age_group: targetAge, region: rgn, regionName } = state;
// --- 8.1 Container Setup & Plotly Guard ---
const wrapper = html`
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; color: #334155; margin-bottom: 4px; padding: 0 2px;">
<span><strong>10 ONS Regions</strong> (${helpers.ageLabelMap[targetAge]}) · Error bars = 95% eCI</span>
<span style="font-weight: 700; color: #990000;">${rgn === "All" ? "All Regions Active" : `Highlighted: ${regionName}`}</span>
</div>
<div class="plot-host" style="flex: 1; min-height: 380px; width: 100%;"></div>
</div>
`;
const plotEl = wrapper.querySelector(".plot-host");
if (!window.Plotly) return wrapper;
// --- 8.2 Regional Data Slicing & Ranking ---
const isRate = (m === "rate");
const d = summary_data
.filter(x => x.geo_level === "region" && x.period === p && x.agegr === targetAge)
.slice()
.sort((a, b) => isRate ? a.rate - b.rate : a.estimate - b.estimate);
// Retrieve matching national row for benchmark line
const natRow = summary_data.find(x => x.geo_level === "national" && x.period === p && x.agegr === targetAge);
// --- 8.3 Error Bar Offsets & Dynamic Bar Highlighting ---
const xVal = d.map(x => isRate ? x.rate : x.estimate);
const arrMax = d.map(x => isRate ? (x.ratehigh - x.rate) : (x.upper - x.estimate));
const arrMin = d.map(x => isRate ? (x.rate - x.ratelow) : (x.estimate - x.lower));
const barColors = d.map(x => (rgn === "All" || x.rgn11cd === rgn) ? "#990000" : "#94a3b8");
const hoverText = d.map(x =>
`<b>${x.rgn11nm}</b> (${helpers.ageLabelMap[targetAge]})<br>` +
`Rate / 1M: <b>${helpers.fmtRate(x.rate, 1)}</b> [95% eCI: ${helpers.fmtRate(x.ratelow, 1)} – ${helpers.fmtRate(x.ratehigh, 1)}]<br>` +
`Excess Deaths: <b>${helpers.fmtInt(x.estimate)}</b> [95% eCI: ${helpers.fmtInt(x.lower)} – ${helpers.fmtInt(x.upper)}]`
);
const trace = {
y: d.map(x => x.rgn11nm),
x: xVal,
customdata: d.map(x => {
return { code: x.rgn11cd, name: x.rgn11nm };
}),
type: "bar",
orientation: "h",
marker: { color: barColors, line: { color: "#1e293b", width: 0.75 } },
error_x: { type: "data", symmetric: false, array: arrMax, arrayminus: arrMin, color: "#0f172a", thickness: 1.4, width: 3 },
hoverinfo: "text",
text: hoverText,
textposition: "none"
};
// --- 8.4 National Benchmark Reference Line ---
// Overlays a dashed reference line representing the England & Wales rate average
const shapes = [];
const annotations = [];
if (isRate && natRow && natRow.rate != null) {
shapes.push({
type: "line",
x0: natRow.rate, x1: natRow.rate, xref: "x",
y0: 0, y1: 1, yref: "paper",
line: { color: "#0f172a", width: 1.5, dash: "dash" }
});
annotations.push({
x: natRow.rate, y: 1.04, xref: "x", yref: "paper",
text: `E&W Rate: ${helpers.fmtRate(natRow.rate, 1)}/M`,
showarrow: false,
font: { size: 10.5, color: "#0f172a", weight: "bold" }
});
}
// --- 8.5 Chart Layout & Interaction Listeners ---
const layout = {
font: { family: "Inter, system-ui, sans-serif", color: "#0f172a", size: 11 },
shapes,
annotations,
xaxis: { title: isRate ? "Excess Deaths per Million (with 95% eCI)" : "Excess Deaths (with 95% eCI)", gridcolor: "#e2e8f0", automargin: true },
yaxis: { title: "", automargin: true, tickfont: { color: "#0f172a" } },
margin: { l: 135, r: 20, t: 18, b: 32 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)"
};
Plotly.react(plotEl, [trace], layout, { responsive: true, displayModeBar: false });
// Click on a region bar to filter the regional scope throughout the dashboard
plotEl.on("plotly_click", (ev) => {
const pt = ev?.points?.[0]?.customdata;
if (pt && pt.code) {
mutable selected_region = pt.code;
mutable selected_region_name = pt.name;
}
});
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => {
if (plotEl.clientWidth > 0 && plotEl.clientHeight > 0) {
Plotly.Plots.resize(plotEl);
}
});
ro.observe(plotEl);
}
return wrapper;
}// =============================================================================
// 9. DEMOGRAPHIC VULNERABILITY GRADIENT CHART
// =============================================================================
// Demonstrates exponential increase in heat-related excess mortality with advancing age,
// covering the 4 discrete age categories (0-64, 65-74, 75-84, 85 and above) with 95% eCIs.
{
const { period: p, metric: m, region: rgn, regionName } = state;
// --- 9.1 Container Setup & Plotly Guard ---
const wrapper = html`
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; color: #334155; margin-bottom: 4px; padding: 0 2px;">
<span><strong>Demographic Gradient</strong> · Steep age-related risk escalation</span>
<span style="font-weight: 700; color: #990000;">Scope: ${rgn === "All" ? "England & Wales" : regionName}</span>
</div>
<div class="plot-host" style="flex: 1; min-height: 380px; width: 100%;"></div>
</div>
`;
const plotEl = wrapper.querySelector(".plot-host");
if (!window.Plotly) return wrapper;
// --- 9.2 Filter Across Standard Discrete Age Groups ---
const ageKeys = ["age064", "age6574", "age7584", "age85plus"];
const d = summary_data
.filter(x =>
(rgn === "All" ? x.geo_level === "national" : (x.geo_level === "region" && x.rgn11cd === rgn)) &&
x.period === p &&
ageKeys.includes(x.agegr)
)
.slice()
.sort((a, b) => ageKeys.indexOf(a.agegr) - ageKeys.indexOf(b.agegr));
// --- 9.3 Asymmetric Credible Interval Offsets ---
const isRate = (m === "rate");
const yVal = d.map(x => isRate ? x.rate : x.estimate);
const arrMax = d.map(x => isRate ? (x.ratehigh - x.rate) : (x.upper - x.estimate));
const arrMin = d.map(x => isRate ? (x.rate - x.ratelow) : (x.estimate - x.lower));
const hoverText = d.map(x =>
`<b>Age ${x.age}</b> (${rgn === "All" ? "England & Wales" : regionName})<br>` +
`Rate / 1M: <b>${helpers.fmtRate(x.rate, 1)}</b> [95% eCI: ${helpers.fmtRate(x.ratelow, 1)} – ${helpers.fmtRate(x.ratehigh, 1)}]<br>` +
`Excess Deaths: <b>${helpers.fmtInt(x.estimate)}</b> [95% eCI: ${helpers.fmtInt(x.lower)} – ${helpers.fmtInt(x.upper)}]<br>` +
`Population: ${helpers.fmtInt(x.population)}`
);
// --- 9.4 Escalating Color-Coded Bar Trace ---
const trace = {
x: d.map(x => x.age),
y: yVal,
type: "bar",
marker: {
color: ["#fdcc8a", "#fc8d59", "#e34a33", "#990000"],
line: { color: "#0f172a", width: 1 }
},
error_y: { type: "data", symmetric: false, array: arrMax, arrayminus: arrMin, color: "#0f172a", thickness: 1.5, width: 4 },
hoverinfo: "text",
text: hoverText,
textposition: "none"
};
// --- 9.5 Layout & Plotly Render ---
const layout = {
font: { family: "Inter, system-ui, sans-serif", color: "#0f172a", size: 11 },
xaxis: { title: "Age Group (Years)", automargin: true, tickfont: { color: "#0f172a" } },
yaxis: { title: isRate ? "Excess Deaths per Million" : "Excess Deaths (Count)", gridcolor: "#e2e8f0", automargin: true },
margin: { t: 15, r: 20, b: 32, l: 55 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)"
};
Plotly.react(plotEl, [trace], layout, { responsive: true, displayModeBar: false });
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => {
if (plotEl.clientWidth > 0 && plotEl.clientHeight > 0) {
Plotly.Plots.resize(plotEl);
}
});
ro.observe(plotEl);
}
return wrapper;
}// =============================================================================
// 7. DAILY TIME SERIES & ERA5 MEAN TEMPERATURE CHART
// =============================================================================
// Dual-axis interactive Plotly chart combining daily excess mortality bars
// with ERA5 daily mean temperature line, highlighted heatwave shading, and 95% eCIs.
{
const { period: p, metric: m, age_group: age, region: rgn, regionName } = state;
// --- 7.1 Container Markup & Plotly Guard ---
const wrapper = html`
<div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 11.5px; color: #334155; margin-bottom: 4px; padding: 0 2px;">
<span><strong>National Daily Trajectory</strong> (${helpers.ageLabelMap[age]}) · Shaded bands mark HW1–HW5</span>
<span style="font-weight: 700; color: #990000;">${p === "study_period" ? "Full Summer (153 days)" : `Zoomed to ${p.toUpperCase()}`}</span>
</div>
<div class="plot-host" style="flex: 1; min-height: 380px; width: 100%;"></div>
</div>
`;
const plotEl = wrapper.querySelector(".plot-host");
if (!window.Plotly) return wrapper;
// --- 7.2 Data Filtering ---
// Select daily series rows matching the active age category
const d = daily_data.filter(x => x.agegr === age);
// --- 7.3 Heatwave Episode Shading & Dynamic X-Axis Zoom ---
// Defines calendar date boundaries for the 5 discrete heatwave episodes
const hwWindows = {
hw1: ["2026-05-22", "2026-05-28"],
hw2: ["2026-06-18", "2026-06-28"],
hw3: ["2026-07-06", "2026-07-17"],
hw4: ["2026-07-29", "2026-08-05"],
hw5: ["2026-08-11", "2026-08-15"]
};
// Build rectangular background shapes highlighting heatwave windows
const shapes = Object.entries(hwWindows).map(([hw, bounds]) => {
const isActive = (p === hw);
return {
type: "rect",
fillcolor: isActive ? "rgba(153, 0, 0, 0.22)" : "rgba(153, 0, 0, 0.08)",
line: { color: isActive ? "#990000" : "rgba(153, 0, 0, 0.25)", width: isActive ? 1.5 : 1 },
x0: bounds[0], x1: bounds[1], xref: "x", y0: 0, y1: 1, yref: "paper"
};
});
// Zoom x-axis range dynamically if an individual heatwave is selected (+/- 4 day buffer)
let xRange = ["2026-05-01", "2026-09-30"];
if (hwWindows[p]) {
const [b0, b1] = hwWindows[p];
const d0 = new Date(`${b0}T00:00:00Z`); d0.setUTCDate(d0.getUTCDate() - 4);
const d1 = new Date(`${b1}T00:00:00Z`); d1.setUTCDate(d1.getUTCDate() + 4);
xRange = [d0.toISOString().slice(0, 10), d1.toISOString().slice(0, 10)];
}
// --- 7.4 Value Extraction & Rich Hover Text ---
const isRate = (m === "rate");
const yVals = d.map(x => isRate ? x.rate : x.estimate);
const yTitle = isRate ? "Daily Rate / 1M" : "Daily Excess Deaths";
const hoverText = d.map(x => {
const hasCi = x.lower != null && !isNaN(x.lower);
const estStr = hasCi
? `${helpers.fmtInt(x.estimate)} [95% eCI: ${helpers.fmtInt(x.lower)} – ${helpers.fmtInt(x.upper)}]`
: `${helpers.fmtInt(x.estimate)}`;
const rateStr = hasCi
? `${helpers.fmtRate(x.rate, 2)} [95% eCI: ${helpers.fmtRate(x.ratelow, 2)} – ${helpers.fmtRate(x.ratehigh, 2)}]`
: `${helpers.fmtRate(x.rate, 2)}`;
return `<b>${x.dateStr}</b> (${helpers.ageLabelMap[age]})<br>Excess Deaths: <b>${estStr}</b><br>Rate / 1M: <b>${rateStr}</b><br>ERA5 Mean Temp: <b>${helpers.fmtRate(x.meantemp, 1)} °C</b>`;
});
// --- 7.5 Dual-Axis Plotly Traces ---
// Trace 1: Primary Y-axis excess mortality bars
const traceBars = {
x: d.map(x => x.dateStr),
y: yVals,
type: "bar",
name: isRate ? "Excess Rate / 1M" : "Excess Deaths",
marker: { color: "#990000", opacity: 0.88 },
hoverinfo: "text",
text: hoverText,
textposition: "none",
yaxis: "y"
};
// Trace 2: Secondary Y-axis daily ERA5 mean temperature line
const traceLine = {
x: d.map(x => x.dateStr),
y: d.map(x => x.meantemp),
type: "scatter",
mode: "lines",
name: "ERA5 Mean Temp (°C)",
line: { color: "#0f172a", width: 2 },
hoverinfo: "skip",
yaxis: "y2"
};
// --- 7.6 Chart Layout & Plotly Render ---
const layout = {
font: { family: "Inter, system-ui, sans-serif", color: "#0f172a", size: 11 },
shapes,
xaxis: { title: "", range: xRange, showgrid: false, tickfont: { color: "#334155" } },
yaxis: { title: yTitle, zeroline: true, zerolinecolor: "#94a3b8", gridcolor: "#e2e8f0", automargin: true },
yaxis2: { title: "Mean Temp (°C)", overlaying: "y", side: "right", showgrid: false, automargin: true },
legend: { orientation: "h", x: 0.5, xanchor: "center", y: 1.14, font: { size: 11 } },
margin: { t: 24, r: 42, b: 28, l: 48 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)"
};
Plotly.react(plotEl, [traceBars, traceLine], layout, { responsive: true, displayModeBar: false });
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => {
if (plotEl.clientWidth > 0 && plotEl.clientHeight > 0) {
Plotly.Plots.resize(plotEl);
}
});
ro.observe(plotEl);
}
return wrapper;
}Public Health Notice:
Figures presented are modelled statistical estimates of heat-related excess mortality derived by LSHTM from historical temperature–mortality relationships (distributed lag non-linear models, lags 0–21 days) and ECMWF ERA5/ERA5-Land reanalysis temperatures across 34,753 LSOAs—not counts from registered death certificates.
Key Epidemiological Findings (1 May – 30 September 2026): * Total Burden: 5,827 excess deaths (95% eCI: 4,349–6,897) across England & Wales (97.6 per million). * Episodic Concentration: 91.4% of total summer heat mortality (5,325 deaths) occurred during five discrete heatwaves, peaking in HW2 (18–28 June: 1,808 deaths) and HW3 (6–17 July: 1,468 deaths). * Demographic Vulnerability: Older adults in the 75–84 and 85 and above age groups experienced the steepest excess mortality rates across all regions. * Geographic Inequality: Excess mortality rates per million were highest in the West Midlands (133.9), South East (133.0), and London (132.4), and lowest in the North East (41.7). :::