raw = FileAttachment("police_salary_ranks.csv").csv({typed: true})
rankMap = new Map([
["PC National", {code: "PC", label: "PC (National)"}],
["PC Metropolitan", {code: "Met_PC", label: "PC (Metropolitan)"}],
["Sergeant", {code: "Sgt", label: "Sergeant"}],
["Inspector", {code: "Insp", label: "Inspector"}],
["Superintendent", {code: "Supt", label: "Superintendent"}],
])
data = {
const rows = [];
for (const d of raw) {
const year = +d.Year;
const minWage = realTerms ? +d.MinWage_Annual_Real2025 : +d.MinWage_Annual_Nominal;
const medianWage = realTerms ? +d.Median_FT_Annual_Real2025 : +d.Median_FT_Annual_Nominal;
for (const sel of selectedRanks) {
const info = rankMap.get(sel);
if (payPoint === "Starting salary" || payPoint === "Both") {
let val = realTerms ? +d[info.code + "_Start_Real2025"] : +d[info.code + "_Start_Nominal"];
if (indexBy === "Minimum wage multiple") val = val / minWage;
if (indexBy === "Median wage multiple") val = val / medianWage;
rows.push({
Year: year,
Rank: info.label,
Point: "Start",
Salary: val
});
}
if (payPoint === "Top of scale" || payPoint === "Both") {
let val = realTerms ? +d[info.code + "_Top_Real2025"] : +d[info.code + "_Top_Nominal"];
if (indexBy === "Minimum wage multiple") val = val / minWage;
if (indexBy === "Median wage multiple") val = val / medianWage;
rows.push({
Year: year,
Rank: info.label,
Point: "Top",
Salary: val
});
}
}
}
return rows;
}
minWageLine = raw.map(d => {
const minWage = realTerms ? +d.MinWage_Annual_Real2025 : +d.MinWage_Annual_Nominal;
const medianWage = realTerms ? +d.Median_FT_Annual_Real2025 : +d.Median_FT_Annual_Nominal;
let val = minWage;
if (indexBy === "Minimum wage multiple") val = 1;
if (indexBy === "Median wage multiple") val = minWage / medianWage;
return { Year: +d.Year, Salary: val };
})
medianLine = raw.map(d => {
const minWage = realTerms ? +d.MinWage_Annual_Real2025 : +d.MinWage_Annual_Nominal;
const medianWage = realTerms ? +d.Median_FT_Annual_Real2025 : +d.Median_FT_Annual_Nominal;
let val = medianWage;
if (indexBy === "Minimum wage multiple") val = medianWage / minWage;
if (indexBy === "Median wage multiple") val = 1;
return { Year: +d.Year, Salary: val };
})
yLabel = indexBy === "Absolute salary" ? (realTerms ? "Salary (2025 £)" : "Salary (nominal £)") :
indexBy === "Minimum wage multiple" ? "Multiple of minimum wage" :
"Multiple of median wage"
tickFormat = indexBy === "Absolute salary" ? "~s" : ".2f"
chart = Plot.plot({
width,
height: 500,
style: {
background: "transparent",
color: "currentColor"
},
y: {grid: true, label: yLabel, tickFormat: tickFormat},
x: {label: "Year", tickFormat: "d", grid: true},
color: {legend: true},
marks: [
Plot.ruleY([0]),
showBenchmarks ? Plot.lineY(minWageLine, {
x: "Year",
y: "Salary",
stroke: "#ff6b6b",
strokeWidth: 1.5,
strokeDasharray: "4,4",
tip: true,
title: d => `Min wage: ${d.Salary.toFixed(indexBy === "Absolute salary" ? 0 : 2)}`
}) : null,
showBenchmarks ? Plot.text([minWageLine[minWageLine.length - 1]], {
x: "Year",
y: "Salary",
text: () => "Min wage",
dx: 5,
fill: "#ff6b6b",
fontSize: 11,
textAnchor: "start"
}) : null,
showBenchmarks ? Plot.lineY(medianLine, {
x: "Year",
y: "Salary",
stroke: "#00d4ff",
strokeWidth: 1.5,
strokeDasharray: "4,4",
tip: true,
title: d => `Median wage: ${d.Salary.toFixed(indexBy === "Absolute salary" ? 0 : 2)}`
}) : null,
showBenchmarks ? Plot.text([medianLine[medianLine.length - 1]], {
x: "Year",
y: "Salary",
text: () => "Median wage",
dx: 5,
fill: "#00d4ff",
fontSize: 11,
textAnchor: "start"
}) : null,
Plot.lineY(data, {
x: "Year",
y: "Salary",
stroke: "Rank",
strokeWidth: 2.5,
tip: true
}),
Plot.dot(data, {
x: "Year",
y: "Salary",
stroke: "Rank",
fill: "Rank",
r: 2.5,
tip: true
})
].filter(Boolean)
})