UK Constituency Explorer
  • Hex Map
  • Geographic Map
  • Scatterplot
  • Histogram
  • Box Plot
  • Heatmap
  • Constituency Profile
  • Data Table

Geographic Map

rawData = FileAttachment("data/constituencies.json").json()

partyColors = ({
  "Con": "#0087DC", "Lab": "#DC241f", "LD": "#FAA61A",
  "Green": "#6AB023", "RUK": "#12B6CF", "SNP": "#FDF38E",
  "PC": "#005B54", "Ind": "#999999", "Other": "#AAAAAA"
})

allVarsMap = [
  {label: "Conservative 2024",       value: "Con24"},
  {label: "Labour 2024",             value: "Lab24"},
  {label: "Lib Dem 2024",            value: "LD24"},
  {label: "Reform 2024",             value: "RUK24"},
  {label: "Green 2024",              value: "Green24"},
  {label: "SNP 2024",                value: "SNP24"},
  {label: "Plaid Cymru 2024",        value: "PC24"},
  {label: "Other 2024",              value: "Other24"},
  {label: "Conservative 2019",       value: "Con19"},
  {label: "Labour 2019",             value: "Lab19"},
  {label: "Lib Dem 2019",            value: "LD19"},
  {label: "Reform/Brexit 2019",      value: "Brexit19"},
  {label: "Green 2019",              value: "Green19"},
  {label: "SNP 2019",                value: "SNP19"},
  {label: "Plaid Cymru 2019",        value: "PC19"},
  {label: "Turnout 2024",            value: "Turnout24"},
  {label: "Turnout 2019",            value: "Turnout19"},
  {label: "Majority 2024",           value: "Majority24"},
  {label: "Brexit Leave (Hanretty)", value: "HanrettyLeave"},
  {label: "Population Density",      value: "c21PopulationDensity"},
  {label: "Age: Under 15",           value: "AgeUnder15"},
  {label: "Age: 16-24",              value: "Age16to24"},
  {label: "Age: 25-34",              value: "Age25to34"},
  {label: "Age: 35-44",              value: "Age35to44"},
  {label: "Age: 45-54",              value: "Age45to54"},
  {label: "Age: 55-64",              value: "Age55to64"},
  {label: "Age: Over 65",            value: "AgeOver65"},
  {label: "Ethnicity: White",        value: "c21EthnicityWhite"},
  {label: "Ethnicity: Asian",        value: "c21EthnicityAsian"},
  {label: "Ethnicity: Black",        value: "c21EthnicityBlack"},
  {label: "Ethnicity: Mixed",        value: "c21EthnicityMixed"},
  {label: "Born in UK",              value: "born_uk"},
  {label: "Religion: Christian",     value: "c21Christian"},
  {label: "Religion: Muslim",        value: "c21Muslim"},
  {label: "Religion: No Religion",   value: "c21NoReligion"},
  {label: "Qualification: None",     value: "c21QualNone"},
  {label: "Qualification: Level 4+", value: "c21QualLevel4"},
  {label: "Housing: Owned Outright", value: "c21HouseOutright"},
  {label: "Housing: Mortgage",       value: "c21HouseMortgage"},
  {label: "Housing: Social Rent",    value: "c21HouseSocialLA"},
  {label: "Housing: Private Rent",   value: "c21HousePrivateLandlord"},
  {label: "No Car",                  value: "c21CarsNone"},
  {label: "Health: Very Good",       value: "c21HealthVeryGood"},
  {label: "Health: Bad/Very Bad",    value: "c21HealthBad"},
  {label: "Employment: Unemployed",  value: "c21Unemployed"},
  {label: "Deprivation: None",       value: "c21DeprivedNone"},
  {label: "Deprivation: 3+ dims",    value: "c21Deprived3"}
]

mapVarsMap = [
  {label: "2024 Winner", value: "Winner24"},
  {label: "2019 Winner", value: "Winner19"},
  ...allVarsMap
]

allVarLabels = Object.fromEntries(allVarsMap.map(v => [v.value, v.label]))
mapVarLabels = Object.fromEntries(mapVarsMap.map(v => [v.value, v.label]))
allRegions   = [...new Set(rawData.map(d => d.Region))].filter(Boolean).sort()
allWinners   = [...new Set(rawData.map(d => d.Winner24))].filter(Boolean).sort()
allConsts    = rawData.map(d => d.ConstituencyName).filter(Boolean).sort()
leafletCss = {
  if (!document.getElementById("leaflet-css-link")) {
    const link = document.createElement("link");
    link.id = "leaflet-css-link"; link.rel = "stylesheet";
    link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
    document.head.appendChild(link);
  }
}

async function buildLeafletMap(div, geojsonData, filteredData, selectedVar, onConstClick) {
  const L = await require("leaflet@1.9.4");
  const map = L.map(div).setView([54, -2.5], 6);
  L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
    attribution: "© OpenStreetMap contributors © CARTO", maxZoom: 19
  }).addTo(map);

  const byID = new Map(filteredData.map(d => [d.ONSConstID, d]));
  const filteredIDs = new Set(filteredData.map(d => d.ONSConstID));
  const isWinner = selectedVar === "Winner24" || selectedVar === "Winner19";
  const numVals = filteredData.map(d => +d[selectedVar]).filter(v => isFinite(v));
  const minV = Math.min(...numVals), maxV = Math.max(...numVals);

  function getColor(onsId) {
    const d = byID.get(onsId);
    if (!d) return "#cccccc";
    if (isWinner) return partyColors[d[selectedVar]] || "#cccccc";
    const v = +d[selectedVar];
    if (!isFinite(v)) return "#cccccc";
    const t = maxV === minV ? 0.5 : (v - minV) / (maxV - minV);
    return d3.interpolateViridis(1 - t);
  }

  L.geoJSON(geojsonData, {
    filter: f => filteredIDs.has(f.properties.ONSConstID),
    style: f => ({fillColor: getColor(f.properties.ONSConstID), fillOpacity: 0.85, weight: 0.5, color: "#555", opacity: 1}),
    onEachFeature: (f, layer) => {
      const d = byID.get(f.properties.ONSConstID);
      if (!d) return;
      const vLabel = mapVarLabels[selectedVar] || selectedVar;
      const tip = isWinner
        ? `<strong>${d.ConstituencyName}</strong><br>2024 Winner: ${d.Winner24}<br>2019 Winner: ${d.Winner19 || "—"}<br>Con: ${(+d.Con24||0).toFixed(1)}% | Lab: ${(+d.Lab24||0).toFixed(1)}% | LD: ${(+d.LD24||0).toFixed(1)}%<br>Ref: ${(+d.RUK24||0).toFixed(1)}% | Grn: ${(+d.Green24||0).toFixed(1)}%<br><em style="font-size:11px">Click for full profile</em>`
        : `<strong>${d.ConstituencyName}</strong><br>${vLabel}: ${(+d[selectedVar]||0).toFixed(2)}<br>2024 Winner: ${d.Winner24}<br><em style="font-size:11px">Click for full profile</em>`;
      layer.bindTooltip(tip, {sticky: true, maxWidth: 260});
      layer.on("mouseover", () => layer.setStyle({weight: 1.5, color: "#222", fillOpacity: 0.95}));
      layer.on("mouseout",  () => layer.setStyle({weight: 0.5, color: "#555", fillOpacity: 0.85}));
      layer.on("click", () => { if (onConstClick) onConstClick(d.ConstituencyName); });
    }
  }).addTo(map);

  const legend = L.control({position: "bottomright"});
  legend.onAdd = () => {
    const el = L.DomUtil.create("div", "info legend");
    if (isWinner) {
      el.innerHTML = `<strong>${mapVarLabels[selectedVar]}</strong><br>` +
        Object.entries(partyColors).map(([p,c]) => `<span class="legend-swatch" style="background:${c}"></span>${p}`).join("<br>");
    } else {
      const rows = Array.from({length:5}, (_,i) => {
        const t = i/4; const v = (minV + t*(maxV-minV)).toFixed(1);
        return `<span class="legend-swatch" style="background:${d3.interpolateViridis(1-t)}"></span>${v}`;
      });
      el.innerHTML = `<strong>${mapVarLabels[selectedVar]}</strong><br>` + rows.reverse().join("<br>");
    }
    return el;
  };
  legend.addTo(map);
  return map;
}
geoJson = FileAttachment("data/geo_boundaries.geojson").json()
viewof geoMapVar = Inputs.select(
  new Map(mapVarsMap.map(v => [v.label, v.value])),
  {label: "Colour by:", value: "Winner24"}
)

Region:

viewof geoRegions = Inputs.checkbox(allRegions, {value: allRegions})

2024 Winner:

viewof geoWinners = Inputs.checkbox(allWinners, {value: allWinners})
filteredGeo = rawData.filter(d =>
  geoRegions.includes(d.Region) && geoWinners.includes(d.Winner24)
)
geoMapEl = {
  leafletCss;
  const div = DOM.element("div", {style: "height:700px;width:100%"});
  yield div;
  const map = await buildLeafletMap(div, geoJson, filteredGeo, geoMapVar, name => {
    window.location.href = `profile.html?const=${encodeURIComponent(name)}`;
  });
  const obs = new ResizeObserver(() => map.invalidateSize());
  obs.observe(div);
  invalidation.then(() => { obs.disconnect(); map.remove(); });
}