About
This prototype data explorer tool provides provisional weekly death statistics for England and Wales from the Office for National Statistics (2025 onwards). This tool uses data from the Weekly Deaths Datasets.
A death occurrence is the date someone has died. A death registration is when that death is registered. The time it takes for a death to be registered can vary for multiple reasons. Currently, mortality statistics are registration-based.
If you like this prototype data explorer and would like it to be maintained or developed please let us know!
For more information please see the Weekly Deaths Dashboard and user guide on the ONS website.
Time Series
Plotly = require("https://cdn.plot.ly/plotly-2.27.0.min.js")
// `transpose()` essentially turns a "column-oriented" table into a row-wise array of objects.
death_data_raw = transpose(death_data)
function layoutScale(w) {
const isNarrow = w < 600;
return {
isNarrow: isNarrow,
titleFont: { size: isNarrow ? 14 : 16 },
axisTitle: { size: isNarrow ? 12 : 14 },
// time Series Margins
marginsTS: {
l: isNarrow ? 40 : 80,
r: 15,
t: isNarrow ? 30 : 60,
b: isNarrow ? 85 : 44
},
// bar chart margins
marginsBar: {
l: 60,
r: 20,
t: isNarrow ? 25 : 40,
b: isNarrow ? 80 : 40
},
standoffX: isNarrow ? 12 : 16,
standoffY: isNarrow ? 5 : 16,
tickAngleX: isNarrow ? -45 : 0
};
}
death_data_parsed = death_data_raw.map(d => ({
series: d.series,
week_ending: new Date(d.week_ending_str),
week_number: +d.week_number,
year: +d.year,
area_of_usual_residence: d.area_of_usual_residence,
sex: d.sex,
age_band: d.age_band,
age_order: +d.age_order,
number_of_deaths: +d.number_of_deaths
}));
// unique values
unique_series = ["Registrations", "Occurrences"]
unique_weeks = Array.from(new Set(death_data_parsed.map(d => d.week_number))).sort((a,b)=>a-b)
unique_regions = Array.from(new Set(death_data_parsed.map(d => d.area_of_usual_residence))).sort()
unique_sex = ["Male", "Female"]
unique_ages = ["0-14 years","15-44 years","45-64 years","65-74 years","75-84 years","85+ years"]
// colours - hex codes copied from ons website
color_primary = "#003C57"
color_secondary = "#a8bd3a"
color_accent = "#00a3a6"
color_warning = "#206095"
// logic to switch colour based on sex selection
current_sex_color = {
// if both are selected (length 2) or nothing selected (length 0), use default
if (selected_sex.length === 2 || selected_sex.length === 0) {
return color_primary;
}
// if only Male is selected
else if (selected_sex.includes("Male")) {
return "#206095";
}
// if only Female is selected
else {
return "#00a3a6";
}
}
// create the default state: automatically show latest week selected for bar charts.
// we compute the most recent week number and use it as the default selection.
// NOTE: This affects the bar charts (which read from `filtered_data` below).
latestWeek = d3.max(unique_weeks)
filtered_data = death_data_parsed.filter(d =>
d.series === selected_series &&
d.year === selected_year &&
d.week_number === selected_week &&
selected_sex.includes(d.sex)
);
// format a date as "17 Oct 2025" and ensure UK style
formatWeekEnding = date =>
date?.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) ?? "–"
// for the chart titles find the latest week-ending date present in the current selection.
// if multiple weeks are selected, this picks the most recent to show in the title
selected_week_end_date =
(filtered_data.length
? d3.max(filtered_data, d => d.week_ending)
: null)
selected_week_title = `Week ending ${formatWeekEnding(selected_week_end_date)}`
// dynamic titles for selected sex
// determine sex label for title
sexTitle = (() => {
if (selected_sex.length === 2 || selected_sex.length === 0) {
return "";
} else if (selected_sex.includes("Male")) {
return "(Male)";
} else {
return "(Female)";
}
})();
// label: timeseries-chart
{
// build a df that that IGNORES selected_weeks so TS always shows all weeks
// rolling 12 months window based on the latest available week in the dataset
const latestDate = d3.max(death_data_parsed, d => d.week_ending);
const cutoffDate = d3.timeMonth.offset(latestDate, -12);
const ts_rows = death_data_parsed.filter(d =>
d.series === selected_series &&
selected_sex.includes(d.sex) &&
d.week_ending >= cutoffDate
);
// aggregate by week (sum over all selected sexes)
let ts_map = d3.rollup(
ts_rows,
v => d3.sum(v, d => d.number_of_deaths),
d => d.week_ending.toISOString()
);
let ts_array = Array.from(ts_map, ([date, deaths]) => ({ date: new Date(date), deaths }))
.sort((a,b) => a.date - b.date);
//if (selected_series === "Occurrences" && ts_array.length > 0) ts_array = ts_array.slice(0, -1);
const trace = {
x: ts_array.map(d => d.date),
y: ts_array.map(d => d.deaths),
type: 'scatter',
mode: 'lines+markers',
name: selected_series,
marker: { size: 8, color: selected_series === "Registrations" ? current_sex_color : color_secondary },
line: { width: 4, color: selected_series === "Registrations" ? current_sex_color : color_secondary },
hovertemplate: 'Week ending: %{x|%d %b %Y}<br>Deaths: %{y:,.0f}<extra></extra>'
};
const div = (this && this.style) ? this : DOM.element('div');
// hard-stop any transient overflow inside the card
div.style.width = '100%';
div.style.height = '100%';
// measure the container and pin the layout size to integer pixels
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const scale = layoutScale(w);
const layout = {
title: {
text: `<b>Weekly Deaths - ${selected_series} ${sexTitle}</b>`,
// Use dynamic font size
font: { ...scale.titleFont, color: '#333', family: 'Open Sans, sans-serif' },
x: 0, xanchor: 'left'
},
xaxis: {
title: {
text: 'Week ending',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' },
standoff: scale.standoffX
},
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
showgrid: true,
gridcolor: '#f0f0f0',
automargin: false, // turn off automargin so our manual margins take precedence
tickangle: scale.tickAngleX
},
yaxis: {
title: {
text: 'Number of deaths',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' },
standoff: scale.standoffY
},
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
showgrid: true,
gridcolor: '#f0f0f0',
tickformat: ',d',
automargin: true
},
plot_bgcolor: '#ffffff',
paper_bgcolor: '#ffffff',
margin: scale.marginsTS,
autosize: false,
width: w,
height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [trace], layout, config);
// keep responsiveness: if the card resizes, update the plot size to the new integer pixels
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}viewof selected_series = Inputs.radio(unique_series, {
label: html`Data Type`,
value: "Registrations"
});
viewof selected_sex = Inputs.checkbox(unique_sex, {
label: html`Sex`,
value: unique_sex
});
// year
defaultYear = d3.max(unique_years);
unique_years = Array.from(new Set(death_data_parsed.map(d => d.year)))
.sort((a, b) => a - b);
viewof selected_year = Inputs.select(unique_years, {
label: html`Year`,
value: defaultYear,
format: (y) => String(y)
});
// week depends on selected_year
weeks_for_year = Array.from(new Set(
death_data_parsed
.filter(d => d.year === selected_year)
.map(d => d.week_number)
)).sort((a, b) => a - b);
defaultWeekForYear = d3.max(weeks_for_year);
viewof selected_week = Inputs.select(weeks_for_year, {
label: html`Week number`,
value: defaultWeekForYear
});Deaths by Region
regional_totals = d3.rollup(filtered_data, v => d3.sum(v, d => d.number_of_deaths), d => d.area_of_usual_residence)
regional_array = Array.from(regional_totals, ([region, deaths]) => ({ region, deaths }))
.sort((a, b) => b.deaths - a.deaths)
{
const trace = {
y: regional_array.map(d => d.region),
x: regional_array.map(d => d.deaths),
type: 'bar', orientation: 'h',
marker: { color: current_sex_color, line: { color: '#333', width: 1 } },
hovertemplate: '%{y}<br>Deaths: %{x:,.0f}<extra></extra>'
};
const div = (this && this.style) ? this : DOM.element('div');
div.style.width = '100%';
div.style.height = '100%';
div.style.overflow = 'hidden';
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
// estimate left margin from longest label (characters × 8px + base)
const longest = regional_array.reduce((m, d) => Math.max(m, d.region.length), 0);
const leftMargin = Math.min(180, Math.max(60, 8 * longest + 28));
const scale = layoutScale(w);
const layout = {
title: {
text: `<b>${selected_series} for ${selected_week_title} (Week ${selected_week}, ${selected_year})</b>`,
font: { ...scale.titleFont, family: 'Open Sans, sans-serif', color: '#333333' },
x: 0, xanchor: 'left'
},
xaxis: {
title: {
text: 'Number of deaths',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' }
},
tickfont: { family: 'Open Sans, sans-serif', size: 11},
showgrid: true,
gridcolor: '#f0f0f0'
},
yaxis: {
title: '',
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
automargin: true
},
margin: {
t: scale.marginsBar.t,
b: scale.isNarrow ? 45 : scale.marginsBar.b,
l: leftMargin,
r: scale.marginsBar.r
},
plot_bgcolor: '#ffffff',
paper_bgcolor: '#ffffff',
autosize: false,
width: w,
height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [trace], layout, config);
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}Deaths by Age and Sex
age_order_key = age => {
const m = /(\d+)/.exec(age || "");
return m ? +m[1] : Number.POSITIVE_INFINITY;
}
// determine the order of age bands on the Y axis.
// if a global list `unique_ages` exists (defined elsewhere), use that order;
// otherwise, derive the distinct set of age bands from the current data and
// sort them using the numeric key above (youngest first).
ordered_ages = (typeof unique_ages !== "undefined" && Array.isArray(unique_ages) && unique_ages.length)
? unique_ages.slice()
: Array.from(new Set((filtered_data || []).map(d => d.age_band))).sort((a,b) => age_order_key(a) - age_order_key(b))
// we aggregate deaths by age band and sex for the current selection.
// `filtered_data` already applies the filters (series, sex, and the selected week).
age_sex_pyramid_data = {
// defensive default: if filtered_data is missing then fall back to an empty array.
const rows = filtered_data || [];
// d3.rollup essentially groups rows first by age band, then by sex, and sums deaths
// the result is a nested map: Map(age_band -> Map(sex -> total_deaths))
const rolled = d3.rollup(rows, v => d3.sum(v, d => +(d.number_of_deaths || 0)), d => d.age_band, d => d.sex);
// now we populate two arrays for plotly:
// - `male` with negative values (so bars appear on the left),
// - `female` with positive values (bars to the right)
// here also "track" totals to compute percentages in tooltips
const displayLabel = age => age.replace('-', ' to ');
const order = ordered_ages;
const male = []; const female = [];
let total = 0; const totalBySex = new Map([["Male",0],["Female",0]]);
// now loop through age bands in display order and pull out male/female totals
for (const age of order) {
// get the inner map for this age band, or an empty one if absent
const bySex = rolled.get(age) || new Map();
const m = +(bySex.get("Male") || 0);
const f = +(bySex.get("Female") || 0);
// update totals used for percentage calculations
total += (m+f);
totalBySex.set("Male", totalBySex.get("Male") + m);
totalBySex.set("Female", totalBySex.get("Female") + f);
male.push({ age, value: -m, abs: m });
female.push({ age, value: f, abs: f });
}
return { order, male, female, total, totalBySex };
}
{
const { order, male, female, total, totalBySex } = age_sex_pyramid_data;
const div = (this && this.style) ? this : DOM.element('div');
div.style.width = '100%';
div.style.height = '100%';
// early exit remains unchanged
const maxAbs = Math.max(
d3.max(male, d => d?.abs || 0) || 0,
d3.max(female, d => d?.abs || 0) || 0
);
if (!order.length || maxAbs === 0) {
div.innerHTML = "<div style='display:flex;align-items:center;justify-content:center;height:100%;color:#666;'><em>selection empty.</em></div>";
return div;
}
if (div.innerHTML.includes("selection empty")) div.innerHTML = "";
const niceMax = d3.ticks(0, maxAbs * 1.1, 5).slice(-1)[0] || maxAbs;
const posTicks = d3.ticks(0, niceMax, 5).slice(1);
const tickvals = [...posTicks.map(t => -t), 0, ...posTicks];
const ticktext = tickvals.map(v => d3.format(",d")(Math.abs(v)));
const mTotal = totalBySex.get("Male") || 0;
const fTotal = totalBySex.get("Female") || 0;
const mkCD = d => [d.abs, total ? (d.abs/total*100) : 0, mTotal ? (d.abs/mTotal*100) : 0];
const fkCD = d => [d.abs, total ? (d.abs/total*100) : 0, fTotal ? (d.abs/fTotal*100) : 0];
const displayLabel = age => age.replace('-', ' to ');
const maleTrace = {
y: order.map(displayLabel), x: male.map(d => d.value), customdata: male.map(mkCD),
name: "Male", type: "bar", orientation: "h", marker: { color: "#206095" },
hovertemplate: "%{y}<br>Male deaths: %{customdata[0]:,.0f}<br>Share of total: %{customdata[1]:.1f}%<br>Within male: %{customdata[2]:.1f}%<extra></extra>"
};
const femaleTrace = {
y: order.map(displayLabel), x: female.map(d => d.value), customdata: female.map(fkCD),
name: "Female", type: "bar", orientation: "h", marker: { color: "#00a3a6" },
hovertemplate: "%{y}<br>Female deaths: %{customdata[0]:,.0f}<br>Share of total: %{customdata[1]:.1f}%<br>Within female: %{customdata[2]:.1f}%<extra></extra>"
};
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const scale = layoutScale(w);
const layout = {
title: {
text: `<b>${selected_series} for ${selected_week_title} (Week ${selected_week}, ${selected_year})</b>`,
font: { color: '#333333', size: scale.titleFont.size }, // use scaled font size
x: 0,
xanchor: 'left'
},
barmode: "overlay",
xaxis: {
title: {
text: 'Number of deaths',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' }
},
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
range: [-niceMax, niceMax],
tickvals,
ticktext,
zeroline: true,
zerolinecolor: "#555",
zerolinewidth: 1.5,
gridcolor: "rgba(0,0,0,0.06)",
automargin: true
},
yaxis: {
title: "",
tickfont: { family: 'Open Sans, sans-serif', size: 13 },
categoryorder: "array",
categoryarray: order.map(displayLabel),
automargin: true
},
legend: {
orientation: "h",
x: 0.5,
xanchor: 'center', // center the legend horizontally
y: -0.18, // push lower on small screens
yanchor: 'top'
},
// use the dynamic margins defined earlier
margin: {
t: scale.marginsBar.t,
b: 20,
l: 40,
r: 40
},
plot_bgcolor: "rgba(0,0,0,0)",
paper_bgcolor: "rgba(0,0,0,0)",
autosize: false,
width: w,
height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [maleTrace, femaleTrace], layout, config);
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}