Created
November 29, 2025 16:27
-
-
Save vahid-ahmadi/b73dea80207de167490b0f1b8026ed68 to your computer and use it in GitHub Desktop.
Generate standalone UK constituency impact heatmap HTML
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| 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