Skip to content

Instantly share code, notes, and snippets.

@vahid-ahmadi
Created November 29, 2025 16:27
Show Gist options
  • Select an option

  • Save vahid-ahmadi/b73dea80207de167490b0f1b8026ed68 to your computer and use it in GitHub Desktop.

Select an option

Save vahid-ahmadi/b73dea80207de167490b0f1b8026ed68 to your computer and use it in GitHub Desktop.
Generate standalone UK constituency impact heatmap HTML
"""
Generate a standalone HTML constituency map with embedded data.
This script creates an interactive UK constituency map showing policy impacts.
The output is a single HTML file with all data embedded (no external dependencies
except D3.js from CDN).
Usage:
python scripts/generate_constituency_map.py
Output:
public/constituency_map_standalone.html
"""
import json
import csv
from pathlib import Path
# Configuration
YEAR = 2026
INPUT_CSV = "public/data/constituency.csv"
INPUT_GEOJSON = "public/data/uk_constituencies_2024.geojson"
OUTPUT_HTML = "public/constituency_map_standalone.html"
def load_constituency_data(csv_path: str, year: int) -> dict:
"""Load constituency impact data from CSV for a specific year."""
data = {}
with open(csv_path, "r") as f:
reader = csv.DictReader(f)
for row in reader:
if row["year"] == str(year):
data[row["constituency_code"]] = {
"name": row["constituency_name"],
"avg_gain": float(row["average_gain"]),
"rel_change": float(row["relative_change"]),
}
return data
def load_geojson(geojson_path: str) -> dict:
"""Load GeoJSON boundary data."""
with open(geojson_path, "r") as f:
return json.load(f)
def generate_html(constituency_data: dict, geojson: dict) -> str:
"""Generate standalone HTML with embedded data."""
return f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>UK Constituency Map</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body {{ margin: 0; overflow: hidden; background: #f8f9fa; }}
svg {{ display: block; }}
.tooltip {{
position: fixed;
background: white;
padding: 10px 14px;
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
pointer-events: none;
opacity: 0;
font-family: -apple-system, sans-serif;
font-size: 13px;
}}
.tooltip strong {{ display: block; margin-bottom: 4px; }}
.tooltip .value {{ font-weight: 600; }}
.gain {{ color: #14B8A6; }}
.loss {{ color: #DC2626; }}
</style>
</head>
<body>
<svg id="map"></svg>
<div class="tooltip" id="tooltip"></div>
<script>
const data = {json.dumps(constituency_data)};
const geo = {json.dumps(geojson)};
const w = window.innerWidth, h = window.innerHeight;
const svg = d3.select("#map").attr("width", w).attr("height", h);
const g = svg.append("g");
const tip = d3.select("#tooltip");
// Calculate bounds from GeoJSON
let xMin=Infinity,xMax=-Infinity,yMin=Infinity,yMax=-Infinity;
geo.features.forEach(f=>{{
const t=c=>{{
if(typeof c[0]==='number'){{
xMin=Math.min(xMin,c[0]);xMax=Math.max(xMax,c[0]);
yMin=Math.min(yMin,c[1]);yMax=Math.max(yMax,c[1]);
}} else c.forEach(t);
}};
if(f.geometry?.coordinates)t(f.geometry.coordinates);
}});
const s=Math.min((w-40)/(xMax-xMin),(h-40)/(yMax-yMin));
const ox=(w-(xMax-xMin)*s)/2,oy=(h-(yMax-yMin)*s)/2;
const proj=d3.geoTransform({{
point:function(x,y){{
this.stream.point((x-xMin)*s+ox,h-((y-yMin)*s+oy));
}}
}});
const path=d3.geoPath().projection(proj);
// Color scale: red (#DC2626) -> grey (#E5E7EB) -> teal (#14B8A6)
const vals=Object.values(data).map(d=>d.rel_change);
const mx=Math.max(Math.abs(Math.min(...vals)),Math.abs(Math.max(...vals)))||0.01;
const colorScale = (value) => {{
const t = (value + mx) / (2 * mx);
if (t < 0.5) {{
const ratio = t * 2;
return d3.interpolateRgb("#DC2626", "#E5E7EB")(ratio);
}} else {{
const ratio = (t - 0.5) * 2;
return d3.interpolateRgb("#E5E7EB", "#14B8A6")(ratio);
}}
}};
// Draw constituencies
g.selectAll("path").data(geo.features).join("path")
.attr("d", path)
.attr("fill", d => {{
const v = data[d.properties.GSScode];
return v ? colorScale(v.rel_change) : "#ddd";
}})
.attr("stroke", "#fff")
.attr("stroke-width", 0.2)
.style("cursor", "pointer")
.on("mouseover", function(e, d) {{
d3.select(this).attr("stroke", "#333").attr("stroke-width", 1);
const v = data[d.properties.GSScode];
if (v) {{
const cls = v.avg_gain >= 0 ? 'gain' : 'loss';
const sign = v.avg_gain >= 0 ? '+' : '';
tip.style("opacity", 1).html(
'<strong>' + v.name + '</strong>' +
'<span class="value ' + cls + '">' + sign + '£' + v.avg_gain.toFixed(0) + '</span> per household<br>' +
'<span class="value ' + cls + '">' + sign + v.rel_change.toFixed(3) + '%</span> relative'
);
}}
}})
.on("mousemove", e => tip.style("left", (e.pageX + 12) + "px").style("top", (e.pageY - 12) + "px"))
.on("mouseout", function() {{
d3.select(this).attr("stroke", "#fff").attr("stroke-width", 0.2);
tip.style("opacity", 0);
}});
// Zoom with mouse wheel
const zoom = d3.zoom().scaleExtent([1, 8]).on("zoom", e => g.attr("transform", e.transform));
svg.call(zoom);
</script>
</body>
</html>'''
def main():
print(f"Loading constituency data for {YEAR}...")
constituency_data = load_constituency_data(INPUT_CSV, YEAR)
print(f" Found {len(constituency_data)} constituencies")
print(f"Loading GeoJSON from {INPUT_GEOJSON}...")
geojson = load_geojson(INPUT_GEOJSON)
print(f" Found {len(geojson['features'])} features")
print("Generating HTML...")
html = generate_html(constituency_data, geojson)
print(f"Writing to {OUTPUT_HTML}...")
Path(OUTPUT_HTML).parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_HTML, "w") as f:
f.write(html)
file_size = len(html) / 1024 / 1024
print(f"Done! File size: {file_size:.2f} MB")
print(f"\nOpen with: open {OUTPUT_HTML}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment